Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bb2397e3b | ||
|
|
97f940f261 | ||
|
|
282161d297 | ||
|
|
b9bef85bc7 | ||
|
|
354caaf8f0 | ||
|
|
682ba8b943 |
No files matched your search
@@ -2,6 +2,39 @@
|
||||
|
||||
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
||||
|
||||
## [0.4.0] - 2026-10-18
|
||||
|
||||
### Adicionado
|
||||
- **Reconexão do disco:** se o disco foi desconectado e voltou com outro nome (por exemplo `/dev/sda` virando `/dev/sdb`), o Guará o reconhece sozinho pelo tamanho e pela impressão digital do conteúdo (primeiro e último MiB) e religa a sessão. Se não conseguir, a tela mostra "Reconectar o disco" e "Escolher a origem…", com a lista dos discos conectados e o selo "mesmo disco". Uma origem com conteúdo diferente é recusada, a menos que você marque "usar mesmo assim" (útil para cópias).
|
||||
- **Desconexão no meio da varredura:** se as leituras falharem várias vezes seguidas, a varredura pausa sozinha com o ponto exato salvo e avisa para reconectar e continuar, em vez de seguir pulando blocos e terminar com buracos.
|
||||
- **Sessões salvas automaticamente** de tempos em tempos durante a varredura e ao terminar ou pausar, na pasta de configuração do usuário, e listadas em "Recentes" na barra lateral (`guara sessions` no terminal). Gravação atômica, sem risco de corromper a sessão se o programa fechar no meio.
|
||||
- **Organização ao recuperar:** pastas originais, por tipo, por data (ano e mês) ou tudo na mesma pasta (`--organize` no terminal).
|
||||
- **Verificação de espaço livre** no destino antes de copiar, com mensagem clara se não couber.
|
||||
|
||||
### Corrigido
|
||||
- Em sessões pausadas não havia como escolher outra origem; só "Continuar", que falhava sem o disco.
|
||||
|
||||
## [0.3.2] - 2026-10-18
|
||||
|
||||
### Corrigido
|
||||
- **Travamento ao clicar em um vídeo no app para Linux.** O AppImage traz o WebKitGTK sem os plugins de mídia do GStreamer: ao criar um `<video>` ou `<audio>` o processo da página morria e a janela ficava congelada. Agora o app para Linux nunca cria esses elementos; vídeo e áudio têm o botão "Abrir no player do sistema", que copia o arquivo para uma pasta temporária do usuário e o abre no player padrão (inclusive quando o Guará roda como administrador). No navegador (`guara ui`) e nos demais sistemas o vídeo continua tocando dentro do app, mas só na tela grande, nunca no painel pequeno.
|
||||
|
||||
### Alterado
|
||||
- Clicar na pré-visualização do painel de detalhes abre a visualização grande (imagem, PDF, documento e demais tipos), agora maior (até 98% da largura e 95% da altura da janela).
|
||||
|
||||
## [0.3.1] - 2026-10-18
|
||||
|
||||
### Adicionado
|
||||
- Filtros "Ocultar arquivos pequenos (menos de 30 KB)" e "Mais de 1 megapixel" no explorador, para separar fotos de ícones e miniaturas quando há milhares de imagens.
|
||||
|
||||
### Corrigido
|
||||
- Abrir uma sessão salva falhava por inteiro quando a origem (por exemplo `/dev/sdb`) não estava acessível, e a mensagem de erro era vaga. Agora a sessão sempre abre: dá para navegar, exportar e gerar relatório, e o botão "Escolher a origem…" reconecta o disco ou uma imagem para voltar a pré-visualizar e recuperar.
|
||||
- Mensagens claras quando o arquivo de sessão não pode ser lido (por exemplo, criado como administrador em `/root`) ou não é uma sessão válida.
|
||||
- Teste automático garante que sessões gravadas pela v0.1.0 continuam abrindo.
|
||||
- Carving de PNG: texto ASCII logo após um PNG truncado era lido como um bloco de ~1,9 GB e gerava "fotos" gigantes de confiança baixa. Agora o bloco precisa ser plausível (tipo válido, tamanho razoável, dentro do disco) e o arquivo termina no último bloco válido.
|
||||
- Retomar uma varredura de sessão sem a origem disponível agora avisa na hora, em vez de falhar depois.
|
||||
- Rodando como administrador (sudo/pkexec), as pastas sugeridas passam a ser as do usuário que abriu o Guará e tudo que ele grava (sessões, CSV, relatórios, imagens e arquivos recuperados, com as pastas criadas) fica com o dono e as permissões desse usuário, e não do root.
|
||||
|
||||
## [0.3.0] - 2026-10-18
|
||||
|
||||
### Adicionado
|
||||
|
||||
Generated
+3
-2
@@ -317,7 +317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "guara-cli"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ctrlc",
|
||||
@@ -329,7 +329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "guara-core"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -343,6 +343,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tiny_http",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/guara-core", "crates/guara-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/lucasrguerra/guara"
|
||||
|
||||
@@ -34,7 +34,7 @@ Cada arquivo tem um `.sha256` ao lado. Compilar do código: `cargo build --relea
|
||||
|
||||
> Os binários ainda **não são assinados**: o Windows SmartScreen e o Gatekeeper do macOS vão avisar. Confira o SHA-256.
|
||||
|
||||
Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux/macOS).
|
||||
Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux/macOS). Mesmo como root, o Guará grava sessões, relatórios e arquivos recuperados com o dono e as permissões do usuário que o abriu.
|
||||
|
||||
## Uso (terminal)
|
||||
|
||||
@@ -46,7 +46,8 @@ sudo guara scan /dev/sdb --quick # só metadados (segundos)
|
||||
guara scan disco.img --category image,document --min-confidence medium
|
||||
guara scan disco.img --recover /mnt/outro-disco/Recuperados
|
||||
guara recover s.json /mnt/outro-disco/Recuperados --category image
|
||||
guara resume s.json # continua após Ctrl+C
|
||||
guara resume s.json # continua após Ctrl+C (religa o disco mesmo com outro nome)
|
||||
guara sessions # sessões salvas automaticamente
|
||||
sudo guara image /dev/sdb /mnt/outro-disco/sdb.img # imagem com mapa de erros
|
||||
sudo guara hash /dev/sdb # MD5, SHA-1 e SHA-256 do dispositivo
|
||||
sudo guara search /dev/sdb "contrato" "cpf" # texto em todo o disco (UTF-8 e UTF-16)
|
||||
@@ -76,6 +77,7 @@ Pensados para quem precisa documentar o que fez:
|
||||
- **Hashes MD5, SHA-1 e SHA-256** de cada arquivo recuperado, da imagem forense e do dispositivo inteiro (`guara hash`).
|
||||
- **Metadados:** EXIF (câmera, lente, número de série, data, **GPS**), informações de PDF (autor, programa, datas), propriedades de documentos Office e OpenDocument (autor, última modificação por, empresa) e dados de vídeo MP4/MOV (data de gravação, duração, resolução).
|
||||
- **Linha do tempo:** datas de criação, modificação e acesso lidas do NTFS, FAT e exFAT.
|
||||
- **Sessões salvas sozinhas e disco que volta com outro nome:** a varredura é salva automaticamente, pausa se o disco for desconectado e o Guará reconhece o mesmo disco ao reconectar.
|
||||
- **Pesquisa de texto no disco inteiro** (`guara search`), inclusive espaço livre e fora de partições, em UTF-8 e UTF-16, mostrando posição, partição e contexto.
|
||||
- **Exportação CSV** (abre no Excel) e **relatório HTML** pronto para imprimir (`guara report`), com identificação do caso, origem, partições, resumo, hashes e lista dos arquivos.
|
||||
- **Detecção de duplicadas** por assinatura de conteúdo.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
[package]
|
||||
name = "guara-gui"
|
||||
description = "Guará: interface gráfica de recuperação de arquivos"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/lucasrguerra/guara"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Guara",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"identifier": "dev.guara.app",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
|
||||
+11
-1
@@ -323,7 +323,7 @@ code, .mono { overflow-wrap: anywhere; font-family: var(--mono); font-size: .92e
|
||||
.pv-pdfbar .pg { font-size: 13px; color: var(--text-2); min-width: 110px; text-align: center; }
|
||||
.pv-pdfwrap { padding: 8px; } .pv-canvas { display: block; background: #fff; box-shadow: 0 2px 14px rgba(0, 0, 0, .25); }
|
||||
|
||||
dialog.lightbox { width: min(1180px, 96vw); height: min(90vh, 920px); padding: 0; display: none; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; }
|
||||
dialog.lightbox { width: min(1760px, 98vw); height: min(95vh, 1100px); padding: 0; display: none; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; }
|
||||
dialog.lightbox[open] { display: grid; }
|
||||
.lb-head, .lb-foot { display: flex; gap: 12px; align-items: center; padding: 12px 16px; }
|
||||
.lb-head { border-bottom: 1px solid var(--line); } .lb-foot { border-top: 1px solid var(--line); flex-wrap: wrap; justify-content: space-between; }
|
||||
@@ -454,3 +454,13 @@ dialog.lightbox[open] { display: grid; }
|
||||
.hit .ctx { font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; color: var(--text-2); }
|
||||
.hit mark { background: var(--accent-soft); color: var(--accent); padding: 0 2px; border-radius: 3px; font-weight: 600; }
|
||||
@media (max-width: 860px) { .hit { grid-template-columns: 100px 1fr; } .hit span:nth-child(2), .hit span:nth-child(3) { display: none; } }
|
||||
|
||||
.xp-insp #xpPv { cursor: zoom-in; }
|
||||
.pv-card { width: 100%; min-height: 220px; display: grid; place-content: center; justify-items: center; gap: 10px; padding: 24px; text-align: center; }
|
||||
.pv-card b { overflow-wrap: anywhere; font-size: 15px; }
|
||||
.pv-bigicon { font-size: 52px; line-height: 1; }
|
||||
.pv-why { max-width: 38ch; font-size: 12.5px; color: var(--text-2); }
|
||||
|
||||
.cand { display: grid; gap: 4px; margin: 6px 0 12px; max-height: 240px; overflow: auto; }
|
||||
.cand .dev[aria-current="true"] { outline: 2px solid var(--accent); }
|
||||
.cand .s { white-space: normal; }
|
||||
+95
-13
@@ -108,8 +108,16 @@ function askForm({ title, text, fields, ok = 'OK' }) {
|
||||
return new Promise((resolve) => {
|
||||
const d = $('#dlg');
|
||||
d.innerHTML = `<form method="dialog"><h2>${esc(title)}</h2><p>${text || ''}</p>
|
||||
${fields.map((f) => `<label class="fld"><span>${esc(f.label)}</span>${f.multiline ? `<textarea class="input" name="${f.name}" rows="3" placeholder="${esc(f.placeholder || '')}">${esc(f.value || '')}</textarea>` : `<input class="input" name="${f.name}" value="${esc(f.value || '')}" placeholder="${esc(f.placeholder || '')}" autocomplete="off">`}</label>`).join('')}
|
||||
${fields.map((f) => `<label class="fld"><span>${esc(f.label)}</span>${
|
||||
f.options ? `<select class="select" name="${f.name}">${f.options.map(([v, l]) => `<option value="${esc(v)}" ${v === f.value ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select>`
|
||||
: f.multiline ? `<textarea class="input" name="${f.name}" rows="3" placeholder="${esc(f.placeholder || '')}">${esc(f.value || '')}</textarea>`
|
||||
: `<span class="row" style="flex-wrap:nowrap"><input class="input" name="${f.name}" value="${esc(f.value || '')}" placeholder="${esc(f.placeholder || '')}" autocomplete="off">${f.pick && tauri?.dialog ? `<button type="button" class="btn" data-pick="${f.name}">Escolher…</button>` : ''}</span>`}</label>`).join('')}
|
||||
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">${esc(ok)}</button></menu></form>`;
|
||||
d.querySelectorAll('[data-pick]').forEach((b) => (b.onclick = async () => {
|
||||
const f = fields.find((x) => x.name === b.dataset.pick);
|
||||
const r = await tauri.dialog.open(f.pick);
|
||||
if (r) d.querySelector(`[name="${f.name}"]`).value = Array.isArray(r) ? r[0] : r;
|
||||
}));
|
||||
d.onclose = () => {
|
||||
if (d.returnValue !== 'ok') return resolve(null);
|
||||
const out = {};
|
||||
@@ -180,6 +188,28 @@ function renderSide() {
|
||||
<li><button class="dev" data-j="${i}" aria-current="${S.job === s.job}"><span class="ico">${icon('default')}</span>
|
||||
<span><span class="t">${esc(s.label)}</span><span class="s">${esc(s.target)}</span></span></button></li>`).join('');
|
||||
sl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => { openJob(S.sessions[+b.dataset.j]); setMenu(false); }));
|
||||
const rl = $('#recentList');
|
||||
rl.dataset.empty = 'Nada salvo ainda';
|
||||
rl.innerHTML = (S.recent || []).map((r, i) => `
|
||||
<li><button class="dev" data-r="${i}" title="${esc(r.path)}"><span class="ico">${icon('default')}</span>
|
||||
<span><span class="t">${esc((r.target || '').split(/[\\/]/).pop() || 'Sessão')}</span><span class="s">${r.found.toLocaleString('pt-BR')} arquivos · ${esc(r.status)} · ${new Date(r.saved_at * 1000).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' })}</span></span></button></li>`).join('');
|
||||
rl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => openRecent(S.recent[+b.dataset.r])));
|
||||
}
|
||||
|
||||
async function loadRecent() {
|
||||
try { S.recent = await rpc('sessions_list'); } catch { S.recent = []; }
|
||||
renderSide();
|
||||
}
|
||||
|
||||
async function openRecent(r) {
|
||||
setMenu(false);
|
||||
try {
|
||||
const { job } = await rpc('session_load', { path: r.path });
|
||||
const st = await rpc('status', { job });
|
||||
const s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() };
|
||||
S.sessions.unshift(s);
|
||||
openJob(s);
|
||||
} catch (e) { info({ title: 'Não foi possível abrir a sessão', html: `<p>${esc(e.message)}</p>` }); }
|
||||
}
|
||||
|
||||
$('#openImageBtn').onclick = async () => {
|
||||
@@ -210,7 +240,7 @@ $('#loadSessionBtn').onclick = async () => {
|
||||
const s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() };
|
||||
S.sessions.unshift(s);
|
||||
openJob(s);
|
||||
} catch (e) { toast(e.message); }
|
||||
} catch (e) { info({ title: 'Não foi possível abrir a sessão', html: `<p>${esc(e.message)}</p>` }); }
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ início
|
||||
@@ -415,6 +445,7 @@ async function tick() {
|
||||
// continua consultando enquanto a varredura roda ou as datas/EXIF ainda estão sendo lidas
|
||||
if (st.status !== 'running' && !st.enriching) {
|
||||
stopPoll();
|
||||
loadRecent();
|
||||
if (st.status === 'error') toast(st.error);
|
||||
} else if (!S.poll) {
|
||||
S.poll = setInterval(tick, 700);
|
||||
@@ -431,7 +462,7 @@ function renderScan() {
|
||||
</div>`;
|
||||
$('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome());
|
||||
$('#progressArea').onclick = onProgressClick;
|
||||
EX = createExplorer({ host: $('#xpHost'), job: S.job, rpc, esc, human, fileUrl, thumbUrl, onRecover: recover, toast });
|
||||
EX = createExplorer({ host: $('#xpHost'), job: S.job, rpc, esc, human, fileUrl, thumbUrl, onRecover: recover, toast, noInlineMedia: !!tauri && /Linux/i.test(navigator.userAgent) });
|
||||
}
|
||||
|
||||
function paintStatus() {
|
||||
@@ -446,8 +477,9 @@ function paintStatus() {
|
||||
const eta = rate && running ? dur((p.total - p.done) / rate) : null;
|
||||
const enr = st.enriching ? `<span class="sub">Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString('pt-BR')}/${st.enriching.total.toLocaleString('pt-BR')}</span>` : '';
|
||||
const buttons = running ? '<button class="btn sm" data-act="pause">Pausar</button><button class="btn sm danger" data-act="stop">Parar</button>'
|
||||
: st.status === 'paused' ? '<button class="btn sm primary" data-act="resume">Continuar</button>' : '';
|
||||
const tools = !running && st.found ? '<button class="btn sm" data-act="csv">Exportar CSV</button><button class="btn sm" data-act="report">Relatório</button><button class="btn sm" data-act="save">Salvar sessão…</button>' : '';
|
||||
: st.status === 'paused' ? `<button class="btn sm primary" data-act="${st.source === false ? 'reconnect' : 'resume'}">${st.source === false ? 'Reconectar o disco' : 'Continuar'}</button><button class="btn sm" data-act="relink">Escolher a origem…</button>` : '';
|
||||
const relink = st.source === false ? '<button class="btn sm primary" data-act="relink">Escolher a origem…</button>' : '';
|
||||
const tools = relink + (!running && st.found ? '<button class="btn sm" data-act="csv">Exportar CSV</button><button class="btn sm" data-act="report">Relatório</button><button class="btn sm" data-act="save">Salvar sessão…</button>' : '');
|
||||
if (running || st.status === 'paused') {
|
||||
setHTML($('#progressArea'), `<div class="progress-card slim">
|
||||
<div class="row spread"><div><b>${esc(p.phase || 'Iniciando')}</b>${pct != null ? ` <span class="sub">· ${pct}%</span>` : ''}</div><div class="row">${buttons}</div></div>
|
||||
@@ -467,13 +499,59 @@ function paintStatus() {
|
||||
+ (st.error ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : ''));
|
||||
}
|
||||
|
||||
async function chooseSource() {
|
||||
let cands = [];
|
||||
try { cands = await rpc('session_candidates', { job: S.job }); } catch { /* segue só com o campo manual */ }
|
||||
const st = S.status || {};
|
||||
const size = st.layout?.size;
|
||||
const d = $('#dlg');
|
||||
const badge = (c) => (c.same_content === true ? '<span class="pill ok">mesmo disco</span>'
|
||||
: c.same_content === false ? '<span class="pill warn">tamanho igual, conteúdo diferente</span>'
|
||||
: c.same_size ? '<span class="pill accent">mesmo tamanho (precisa de administrador para conferir)</span>' : '');
|
||||
d.innerHTML = `<form method="dialog"><h2>Escolher a origem</h2>
|
||||
<p>Esta sessão foi feita sobre <code>${esc(st.target || '')}</code>${size ? ` (${human(size)})` : ''}. Escolha o disco que voltou ou uma imagem dele.</p>
|
||||
<div class="cand">${cands.map((c, i) => `<button type="button" class="dev" data-c="${i}"><span class="ico">${icon(c.device.kind)}</span><span><span class="t">${esc(c.device.name)}</span><span class="s">${esc(c.device.path)} · ${human(c.device.size)} ${badge(c)}</span></span></button>`).join('') || '<p class="sub">Nenhum disco encontrado. Informe o caminho abaixo.</p>'}</div>
|
||||
<label class="fld"><span>Ou o caminho de um dispositivo ou imagem</span><input class="input" id="srcPath" name="path" placeholder="/dev/sdb ou /caminho/imagem.img" value="${esc(st.source_path || st.target || '')}"></label>
|
||||
<label class="check"><input type="checkbox" name="force"><span>Usar mesmo assim, mesmo que o conteúdo inicial seja diferente (por exemplo, uma cópia editada)</span></label>
|
||||
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">Usar esta origem</button></menu></form>`;
|
||||
d.querySelectorAll('.cand .dev').forEach((b) => (b.onclick = () => {
|
||||
d.querySelectorAll('.cand .dev').forEach((x) => x.setAttribute('aria-current', 'false'));
|
||||
b.setAttribute('aria-current', 'true');
|
||||
$('#srcPath', d).value = cands[+b.dataset.c].device.path;
|
||||
}));
|
||||
d.onclose = async () => {
|
||||
if (d.returnValue !== 'ok') return;
|
||||
const path = $('#srcPath', d).value.trim();
|
||||
const force = d.querySelector('[name=force]').checked;
|
||||
if (!path) return;
|
||||
try {
|
||||
await rpc('session_relink', { job: S.job, path, force });
|
||||
toast('Origem reconectada');
|
||||
const cur = { job: S.job, target: st.target, label: S.sessions.find((x) => x.job === S.job)?.label || 'Sessão' };
|
||||
openJob(cur);
|
||||
} catch (err) { info({ title: 'Não foi possível usar essa origem', html: `<p>${esc(err.message)}</p>` }); }
|
||||
};
|
||||
d.showModal();
|
||||
}
|
||||
|
||||
async function onProgressClick(e) {
|
||||
const act = e.target.closest('[data-act]')?.dataset.act;
|
||||
if (!act) return;
|
||||
if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); }
|
||||
else if (act === 'stop') await rpc('scan_stop', { job: S.job });
|
||||
else if (act === 'resume') { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); }
|
||||
else if (act === 'csv') {
|
||||
else if (act === 'resume') {
|
||||
try { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); }
|
||||
catch (err) { toast(err.message); chooseSource(); }
|
||||
} else if (act === 'reconnect') {
|
||||
try {
|
||||
const r = await rpc('session_reconnect', { job: S.job });
|
||||
toast(r.renamed ? `Disco encontrado como ${r.renamed}` : 'Disco reconectado');
|
||||
const cur = { job: S.job, target: S.status.target, label: S.sessions.find((x) => x.job === S.job)?.label || 'Sessão' };
|
||||
openJob(cur);
|
||||
} catch { chooseSource(); }
|
||||
}
|
||||
else if (act === 'relink') { chooseSource();
|
||||
} else if (act === 'csv') {
|
||||
const path = await ask({ title: 'Exportar para CSV', text: 'Grava os arquivos da vista atual (com os filtros aplicados). O arquivo abre direto no Excel.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-achados.csv', ok: 'Exportar' });
|
||||
if (path) { try { const r = await rpc('export_csv', { job: S.job, path, query: EX?.query() }); toast(`${r.rows.toLocaleString('pt-BR')} linhas gravadas`); } catch (err) { toast(err.message); } }
|
||||
} else if (act === 'report') {
|
||||
@@ -495,22 +573,25 @@ async function onProgressClick(e) {
|
||||
}
|
||||
|
||||
async function recover(sel) {
|
||||
const dest = await ask({
|
||||
const f = await askForm({
|
||||
title: 'Recuperar para…',
|
||||
text: 'Escolha uma pasta em <b>outro disco</b>. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar.',
|
||||
value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados',
|
||||
ok: 'Recuperar',
|
||||
pick: { directory: true, multiple: false },
|
||||
fields: [
|
||||
{ name: 'dest', label: 'Pasta de destino', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados', pick: { directory: true, multiple: false } },
|
||||
{ name: 'layout', label: 'Organizar os arquivos', value: 'folders', options: [['folders', 'Pastas originais (e por tipo quando não há pasta)'], ['type', 'Por tipo (Fotos, Documentos, Vídeos...)'], ['date', 'Por data (ano e mês)'], ['flat', 'Tudo na mesma pasta']] },
|
||||
],
|
||||
});
|
||||
if (!dest) return;
|
||||
if (!f || !f.dest) return;
|
||||
const dest = f.dest;
|
||||
try {
|
||||
const { job } = await rpc('recover_start', { job: S.job, dest, ...sel });
|
||||
const { job } = await rpc('recover_start', { job: S.job, dest, layout: f.layout, ...sel });
|
||||
watchAux(job, 'Recuperando arquivos', (st) => {
|
||||
const r = st.report || {};
|
||||
const ok = (r.recovered || []).length, bad = (r.failed || []).length;
|
||||
return `<p><b>${ok}</b> arquivo(s) salvos em <code>${esc(dest)}</code> (${human(r.total_bytes)}).</p>
|
||||
${bad ? `<div class="notice">${warnSvg}<div>${bad} arquivo(s) não puderam ser gravados.</div></div>` : ''}
|
||||
<p class="sub">O arquivo <code>guara-manifesto.json</code> lista o SHA-256 de cada arquivo para você conferir a integridade.</p>`;
|
||||
<p class="sub">O arquivo <code>guara-manifesto.json</code> lista MD5, SHA-1 e SHA-256 de cada arquivo para você conferir a integridade.</p>`;
|
||||
});
|
||||
} catch (e) { info({ title: 'Não foi possível recuperar', html: `<p>${esc(e.message)}</p>` }); }
|
||||
}
|
||||
@@ -557,5 +638,6 @@ async function openJobFromList(s) { openJob(s); }
|
||||
return;
|
||||
}
|
||||
await loadDevices();
|
||||
await loadRecent();
|
||||
renderHome();
|
||||
})();
|
||||
+27
-6
@@ -31,8 +31,13 @@ const I = {
|
||||
|
||||
export function createExplorer(deps) {
|
||||
const { host, job, rpc, esc, human, fileUrl, thumbUrl, onRecover, toast } = deps;
|
||||
const inlineMedia = !deps.noInlineMedia;
|
||||
const openExternal = (it) => {
|
||||
toast('Preparando para abrir no player do sistema…');
|
||||
rpc('open_external', { job, id: it.id }).then(() => toast('Aberto no player do sistema')).catch((err) => toast(err.message));
|
||||
};
|
||||
const E = {
|
||||
filters: { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, query: '' },
|
||||
filters: { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, bigOnly: false, mp1: false, query: '' },
|
||||
sort: 'date',
|
||||
view: 'grid',
|
||||
tile: 150,
|
||||
@@ -95,6 +100,8 @@ export function createExplorer(deps) {
|
||||
if (f.minConf) q.min_confidence = f.minConf;
|
||||
if (f.gps) q.has_gps = true;
|
||||
if (f.dedupe) q.dedupe = true;
|
||||
if (f.bigOnly) q.min_size = 30 * 1024;
|
||||
if (f.mp1) q.min_mp = 1;
|
||||
if (f.query) q.query = f.query;
|
||||
return q;
|
||||
};
|
||||
@@ -279,7 +286,10 @@ export function createExplorer(deps) {
|
||||
if (img.tagName !== 'IMG' || !img.dataset.th) return;
|
||||
const wrap = img.closest('.xp-thumb, .xp-lth');
|
||||
img.remove();
|
||||
wrap?.insertAdjacentHTML('afterbegin', `<div class="xp-ico c-bad" title="Não foi possível gerar a miniatura (arquivo possivelmente danificado)"><span>${I.warn}</span><b>?</b></div>`);
|
||||
const offline = E.status && E.status.source === false;
|
||||
wrap?.insertAdjacentHTML('afterbegin', offline
|
||||
? '<div class="xp-ico c-image" title="Origem desconectada: sem miniatura"><span>🖼</span><b>IMG</b></div>'
|
||||
: `<div class="xp-ico c-bad" title="Não foi possível gerar a miniatura (arquivo possivelmente danificado)"><span>${I.warn}</span><b>?</b></div>`);
|
||||
}, true);
|
||||
|
||||
// ------------------------------------------------------------ seleção
|
||||
@@ -421,6 +431,8 @@ export function createExplorer(deps) {
|
||||
const cams = f.cameras.map((c) => node(esc(c.name), c.count, `data-f="camera" data-v="${esc(c.name)}"`, F.camera === c.name)).join('');
|
||||
const conf = ['high', 'medium', 'low'].map((k) => node(`Confiança ${CONF[k][0]}`, f.confidence[k], `data-f="minConf" data-v="${k}"`, F.minConf === k)).join('');
|
||||
const extra = (f.gps ? node('Com localização (GPS)', f.gps, 'data-f="gps" data-v="1"', F.gps) : '')
|
||||
+ (f.small && f.small < f.total ? node('Ocultar arquivos pequenos (menos de 30 KB)', f.total - f.small, 'data-f="bigOnly" data-v="1"', F.bigOnly) : '')
|
||||
+ (f.mp1 ? node('Mais de 1 megapixel', f.mp1, 'data-f="mp1" data-v="1"', F.mp1) : '')
|
||||
+ (f.duplicates ? node('Ocultar duplicadas', f.duplicates, 'data-f="dedupe" data-v="1"', F.dedupe) : '');
|
||||
|
||||
const html = `<div class="xp-sec">${node('Tudo', f.total, 'data-f="all"', !anyFilter())}</div>`
|
||||
@@ -447,6 +459,8 @@ export function createExplorer(deps) {
|
||||
else if (f === 'undated') { F.undated = !F.undated; F.year = F.month = null; }
|
||||
else if (f === 'gps') F.gps = !F.gps;
|
||||
else if (f === 'dedupe') F.dedupe = !F.dedupe;
|
||||
else if (f === 'bigOnly') F.bigOnly = !F.bigOnly;
|
||||
else if (f === 'mp1') F.mp1 = !F.mp1;
|
||||
else F[f] = F[f] === v ? null : v;
|
||||
if (f === 'folder' && F.folder && !F.folder.startsWith('@')) E.expanded.add('d' + F.folder);
|
||||
nav._h = null;
|
||||
@@ -455,7 +469,7 @@ export function createExplorer(deps) {
|
||||
});
|
||||
|
||||
function clearFilters(refresh = true) {
|
||||
E.filters = { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, query: '' };
|
||||
E.filters = { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, bigOnly: false, mp1: false, query: '' };
|
||||
$('#xpQ').value = '';
|
||||
if (refresh) changed(); else changed();
|
||||
}
|
||||
@@ -490,6 +504,8 @@ export function createExplorer(deps) {
|
||||
if (F.minConf) add(`Confiança ${CONF[F.minConf][0]}`, 'minConf');
|
||||
if (F.gps) add('Com GPS', 'gps');
|
||||
if (F.dedupe) add('Sem duplicadas', 'dedupe');
|
||||
if (F.bigOnly) add('Sem arquivos pequenos', 'bigOnly');
|
||||
if (F.mp1) add('Mais de 1 MP', 'mp1');
|
||||
if (F.query) add(`“${esc(F.query)}”`, 'query');
|
||||
$('#xpChips').innerHTML = chips.length ? chips.join('') + '<button class="btn sm" data-x="all">Limpar filtros</button>' : '';
|
||||
}
|
||||
@@ -497,7 +513,7 @@ export function createExplorer(deps) {
|
||||
const k = e.target.closest('[data-x]')?.dataset.x;
|
||||
if (!k) return;
|
||||
if (k === 'all') return clearFilters();
|
||||
E.filters[k] = k === 'query' ? '' : k === 'undated' || k === 'gps' || k === 'dedupe' ? false : null;
|
||||
E.filters[k] = k === 'query' ? '' : k === 'undated' || k === 'gps' || k === 'dedupe' || k === 'bigOnly' || k === 'mp1' ? false : null;
|
||||
if (k === 'year') E.filters.month = null;
|
||||
if (k === 'query') $('#xpQ').value = '';
|
||||
nav._h = null;
|
||||
@@ -559,7 +575,12 @@ export function createExplorer(deps) {
|
||||
<dl class="kv"><dt>Pasta</dt><dd>${esc(it.dir || (it.origin === 'carved' ? 'Encontrado por assinatura' : '/'))}</dd>${timesHtml(it)}${it.camera ? `<dt>Câmera</dt><dd>${esc(it.camera)}</dd>` : ''}${it.px ? `<dt>Resolução</dt><dd>${it.px[0]} × ${it.px[1]} px</dd>` : ''}</dl>
|
||||
<div class="xp-meta" id="xpMeta"></div>
|
||||
<div class="row"><button class="btn sm" data-i="full">Tela cheia</button><button class="btn primary sm" data-i="rec1">Recuperar este arquivo</button></div>`;
|
||||
stopPv = renderPreview($('#xpPv'), it, { url, thumb: kindOf(it) === 'image' });
|
||||
stopPv = renderPreview($('#xpPv'), it, { url, thumb: kindOf(it) === 'image', inlineMedia, onOpen: () => openLightbox(idxOf(it.id)), onExternal: () => openExternal(it) });
|
||||
// clicar na miniatura abre o modal grande
|
||||
$('#xpPv').addEventListener('click', (ev) => {
|
||||
if (ev.target.closest('button, a, .pv-pdfbar')) return;
|
||||
openLightbox(idxOf(it.id));
|
||||
});
|
||||
const my = ++metaToken;
|
||||
rpc('metadata', { job, id: it.id }).then((m) => {
|
||||
if (my !== metaToken || !$('#xpMeta')) return;
|
||||
@@ -591,7 +612,7 @@ export function createExplorer(deps) {
|
||||
<div class="lb-body"><div class="viewer large" id="lbHost"></div></div>
|
||||
<div class="lb-foot"><span class="sub">${it.note ? esc(it.note) : '← → para navegar · Esc para fechar'}</span><button class="btn primary sm" id="lbRec">Recuperar este arquivo</button></div>`;
|
||||
stopLb?.();
|
||||
stopLb = renderPreview(d.querySelector('#lbHost'), it, { url: fileUrl(job, it.id), large: true });
|
||||
stopLb = renderPreview(d.querySelector('#lbHost'), it, { url: fileUrl(job, it.id), large: true, inlineMedia, onExternal: () => openExternal(it) });
|
||||
const go = (n) => { const t = i + n; if (t >= 0 && t < E.items.length) { setCursor(t, { reveal: true }); openLightbox(t); } };
|
||||
d.querySelector('#lbPrev').onclick = () => go(-1);
|
||||
d.querySelector('#lbNext').onclick = () => go(1);
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
<button class="side-action" id="openImageBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M4 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" stroke="currentColor" stroke-width="1.8" fill="none"/></svg>Abrir arquivo de imagem…</button>
|
||||
<div class="side-title">Sessões</div>
|
||||
<ul id="sessionList" class="list"></ul>
|
||||
<div class="side-title">Recentes <small style="font-weight:400;text-transform:none;letter-spacing:0">(salvas sozinhas)</small></div>
|
||||
<ul id="recentList" class="list"></ul>
|
||||
<button class="side-action" id="loadSessionBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>Abrir sessão salva…</button>
|
||||
</nav>
|
||||
<footer class="side-foot"><div class="theme-switch" role="group" aria-label="Tema"><button data-theme-pick="auto" aria-pressed="true">Auto</button><button data-theme-pick="light" aria-pressed="false">Claro</button><button data-theme-pick="dark" aria-pressed="false">Escuro</button></div><span id="versionTag"></span><span>Somente leitura na origem</span></footer>
|
||||
|
||||
+28
-3
@@ -131,8 +131,10 @@ async function image(host, item, url, state, thumb) {
|
||||
if (state.dead) return resolve();
|
||||
host.innerHTML = '';
|
||||
host.appendChild(img);
|
||||
host.classList.add('zoomable');
|
||||
img.onclick = () => host.classList.toggle('actual');
|
||||
if (!thumb) {
|
||||
host.classList.add('zoomable');
|
||||
img.onclick = () => host.classList.toggle('actual');
|
||||
}
|
||||
if (!thumb) note(host, `${img.naturalWidth} × ${img.naturalHeight} px. Clique para alternar entre ajustar e tamanho real.`);
|
||||
resolve();
|
||||
};
|
||||
@@ -272,12 +274,35 @@ async function zip(host, item, url, state) {
|
||||
|
||||
// ------------------------------------------------------------------ API
|
||||
/** Desenha a pré-visualização de `item` dentro de `host`. Devolve uma função de limpeza. */
|
||||
export function renderPreview(host, item, { url, large = false, thumb = false }) {
|
||||
function mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal }) {
|
||||
const ico = kind === 'video' ? '🎞' : '🎵';
|
||||
const canInline = inlineMedia && large;
|
||||
host.innerHTML = `<div class="pv-card"><div class="pv-bigicon" aria-hidden="true">${ico}</div>
|
||||
<b>${esc(item.name)}</b><span class="sub">${esc(KIND_LABEL[kind])} · ${human(item.size)}</span>
|
||||
${inlineMedia
|
||||
? '<button class="btn primary" data-pv="open">Reproduzir em tela grande</button>'
|
||||
: '<button class="btn primary" data-pv="ext">Abrir no player do sistema</button><span class="pv-why">Neste app o ' + (kind === 'video' ? 'vídeo' : 'áudio') + ' abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.</span>'}
|
||||
</div>`;
|
||||
host.onclick = (e) => {
|
||||
const a = e.target.closest('[data-pv]')?.dataset.pv;
|
||||
if (a === 'open') onOpen?.();
|
||||
else if (a === 'ext') onExternal?.();
|
||||
};
|
||||
void canInline;
|
||||
}
|
||||
|
||||
export function renderPreview(host, item, { url, large = false, thumb = false, inlineMedia = true, onOpen = null, onExternal = null }) {
|
||||
const state = { dead: false, cleanup: [] };
|
||||
host.dataset.url = url;
|
||||
host.classList.remove('zoomable', 'actual');
|
||||
host.innerHTML = '<div class="pv-loading"><i></i></div>';
|
||||
const kind = kindOf(item);
|
||||
// Vídeo e áudio: nunca criamos <video>/<audio> no painel pequeno, e no Linux do app desktop
|
||||
// (WebKitGTK sem plugins de mídia) nem na tela grande, pois o processo da página morre.
|
||||
if ((kind === 'video' || kind === 'audio') && (!large || !inlineMedia)) {
|
||||
mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal });
|
||||
return () => { state.dead = true; };
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
if (!item.size) { host.innerHTML = ''; note(host, 'Arquivo vazio.'); return; }
|
||||
|
||||
@@ -94,6 +94,8 @@ enum Cmd {
|
||||
},
|
||||
/// Calcula MD5, SHA-1 e SHA-256 do dispositivo ou imagem (verificação de integridade)
|
||||
Hash { path: String },
|
||||
/// Lista as sessões salvas automaticamente
|
||||
Sessions,
|
||||
/// Abre a interface gráfica no navegador (modo local)
|
||||
Ui {
|
||||
#[arg(long, default_value_t = 0)]
|
||||
@@ -114,6 +116,9 @@ struct FilterArgs {
|
||||
/// Só nomes/caminhos que contenham este texto
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
/// Como organizar o que for recuperado: folders (pastas originais), type, date ou flat
|
||||
#[arg(long, default_value = "folders")]
|
||||
organize: String,
|
||||
}
|
||||
|
||||
#[derive(clap::Args, Clone)]
|
||||
@@ -230,6 +235,7 @@ fn run(cli: Cli) -> Result<(), String> {
|
||||
category: vec![],
|
||||
min_confidence: "low".into(),
|
||||
name: None,
|
||||
organize: "folders".into(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
@@ -367,6 +373,23 @@ fn run(cli: Cli) -> Result<(), String> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Sessions => {
|
||||
let list = eng.rpc("sessions_list", json!({})).map_err(s)?;
|
||||
let items = list.as_array().cloned().unwrap_or_default();
|
||||
if items.is_empty() {
|
||||
println!("Nenhuma sessão salva automaticamente ainda.");
|
||||
}
|
||||
for i in items {
|
||||
println!(
|
||||
"{:<10} {:>8} arquivos {:<22} {}",
|
||||
i["status"].as_str().unwrap_or(""),
|
||||
i["found"],
|
||||
i["target"].as_str().unwrap_or(""),
|
||||
i["path"].as_str().unwrap_or("")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Ui { port, no_open } => serve::run(port, !no_open),
|
||||
}
|
||||
}
|
||||
@@ -483,7 +506,13 @@ fn recover_job(
|
||||
}
|
||||
ids = Some(all);
|
||||
}
|
||||
let rj = eng.start_recover(scan_job, ids, Some(q), dest).map_err(s)?;
|
||||
let layout = guara_core::recover::RecoverLayout::parse(&filter.organize).ok_or(format!(
|
||||
"organização inválida: {} (use folders, type, date ou flat)",
|
||||
filter.organize
|
||||
))?;
|
||||
let rj = eng
|
||||
.start_recover(scan_job, ids, Some(q), dest, layout)
|
||||
.map_err(s)?;
|
||||
let st = wait(eng, &rj, None)?;
|
||||
eprintln!();
|
||||
if st["status"] == "error" {
|
||||
|
||||
@@ -4,7 +4,7 @@ use guara_core::service::Engine;
|
||||
use serde_json::json;
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
||||
use tiny_http::{Header, Method, Request, Response, Server};
|
||||
|
||||
const INDEX: &str = include_str!("../../../apps/gui/ui/index.html");
|
||||
const CSS: &str = include_str!("../../../apps/gui/ui/app.css");
|
||||
@@ -52,7 +52,7 @@ fn header(name: &str, value: &str) -> Header {
|
||||
Header::from_bytes(name.as_bytes(), value.as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
fn handle(mut req: Request, eng: &Engine, token: &str, hosts: &[String]) {
|
||||
fn handle(req: Request, eng: &Engine, token: &str, hosts: &[String]) {
|
||||
let host_ok = req
|
||||
.headers()
|
||||
.iter()
|
||||
@@ -61,8 +61,11 @@ fn handle(mut req: Request, eng: &Engine, token: &str, hosts: &[String]) {
|
||||
let _ = req.respond(Response::from_string("host inválido").with_status_code(403));
|
||||
return;
|
||||
}
|
||||
let Some(mut req) = guara_core::http::handle_media(eng, token, req) else {
|
||||
return;
|
||||
};
|
||||
let full = req.url().to_string();
|
||||
let (path, query) = full.split_once('?').unwrap_or((full.as_str(), ""));
|
||||
let (path, _query) = full.split_once('?').unwrap_or((full.as_str(), ""));
|
||||
let ct = |v: &str| header("Content-Type", v);
|
||||
match (req.method().clone(), path) {
|
||||
(Method::Get, "/") | (Method::Get, "/index.html") => {
|
||||
@@ -105,78 +108,6 @@ fn handle(mut req: Request, eng: &Engine, token: &str, hosts: &[String]) {
|
||||
Response::from_string(PDF_WORKER).with_header(ct("text/javascript; charset=utf-8")),
|
||||
);
|
||||
}
|
||||
(Method::Get, p) if p.starts_with("/thumb/") => {
|
||||
let tok_ok = query
|
||||
.split('&')
|
||||
.any(|kv| kv.strip_prefix("t=") == Some(token));
|
||||
if !tok_ok {
|
||||
let _ = req.respond(Response::from_string("token inválido").with_status_code(401));
|
||||
return;
|
||||
}
|
||||
let mut it = p.trim_start_matches("/thumb/").split('/');
|
||||
let (job, id) = (
|
||||
it.next().unwrap_or(""),
|
||||
it.next().and_then(|s| s.parse::<u64>().ok()),
|
||||
);
|
||||
let size = query
|
||||
.split('&')
|
||||
.find_map(|kv| kv.strip_prefix("s="))
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(256);
|
||||
let Some(id) = id else {
|
||||
let _ = req.respond(Response::from_string("id inválido").with_status_code(400));
|
||||
return;
|
||||
};
|
||||
match eng.thumb(job, id, size) {
|
||||
Ok(bytes) => {
|
||||
let resp = Response::from_data(bytes.as_ref().clone())
|
||||
.with_header(ct("image/jpeg"))
|
||||
.with_header(header("Cache-Control", "private, max-age=3600"));
|
||||
let _ = req.respond(resp);
|
||||
}
|
||||
Err(code) => {
|
||||
let _ =
|
||||
req.respond(Response::from_string("sem miniatura").with_status_code(code));
|
||||
}
|
||||
}
|
||||
}
|
||||
(Method::Get, p) if p.starts_with("/file/") => {
|
||||
let tok_ok = query
|
||||
.split('&')
|
||||
.any(|kv| kv.strip_prefix("t=") == Some(token));
|
||||
if !tok_ok {
|
||||
let _ = req.respond(Response::from_string("token inválido").with_status_code(401));
|
||||
return;
|
||||
}
|
||||
let mut it = p.trim_start_matches("/file/").split('/');
|
||||
let (job, id) = (
|
||||
it.next().unwrap_or(""),
|
||||
it.next().and_then(|s| s.parse::<u64>().ok()),
|
||||
);
|
||||
let range = req
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("Range"))
|
||||
.map(|h| h.value.as_str().to_string());
|
||||
let Some(id) = id else {
|
||||
let _ = req.respond(Response::from_string("id inválido").with_status_code(400));
|
||||
return;
|
||||
};
|
||||
match eng.file_slice(job, id, range.as_deref()) {
|
||||
Ok(slice) => {
|
||||
let (status, len) = (slice.status(), slice.len() as usize);
|
||||
let headers: Vec<Header> =
|
||||
slice.headers().iter().map(|(k, v)| header(k, v)).collect();
|
||||
let resp =
|
||||
Response::new(StatusCode(status), headers, slice.reader(), Some(len), None);
|
||||
let _ = req.respond(resp);
|
||||
}
|
||||
Err(code) => {
|
||||
let _ =
|
||||
req.respond(Response::from_string("indisponível").with_status_code(code));
|
||||
}
|
||||
}
|
||||
}
|
||||
(Method::Post, p) if p.starts_with("/rpc/") => {
|
||||
let ok = req
|
||||
.headers()
|
||||
|
||||
@@ -18,6 +18,7 @@ jpeg-encoder = "0.6"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "gif", "bmp", "webp"] }
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
thiserror = "2"
|
||||
tiny_http = "0.12"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -25,23 +25,33 @@ pub fn carve_range(
|
||||
cfg: &CarveCfg,
|
||||
cancelled: &(dyn Fn() -> bool + Sync),
|
||||
ev: &mut dyn CarveEvents,
|
||||
) -> bool {
|
||||
) -> CarveEnd {
|
||||
let end = end.min(src.size());
|
||||
let mut pos = (*next).max(start);
|
||||
// Alinha ao setor.
|
||||
pos = pos.div_ceil(SECTOR) * SECTOR;
|
||||
let mut buf = vec![0u8; CHUNK + LOOKAHEAD];
|
||||
let mut skip_until = 0u64;
|
||||
let mut fails = 0u32;
|
||||
while pos < end {
|
||||
if cancelled() {
|
||||
*next = pos;
|
||||
return false;
|
||||
return CarveEnd::Cancelled;
|
||||
}
|
||||
let want = ((end - pos) as usize).min(CHUNK + LOOKAHEAD);
|
||||
let got = match read_full(src, pos, &mut buf[..want]) {
|
||||
Ok(n) => n,
|
||||
Ok(n) => {
|
||||
fails = 0;
|
||||
n
|
||||
}
|
||||
Err(_) => {
|
||||
// Setor ruim: pula um bloco inteiro e segue (a imagem forense trata isso melhor).
|
||||
// Várias falhas seguidas = dispositivo sumiu: para com o ponto de retomada salvo.
|
||||
fails += 1;
|
||||
if fails >= 4 {
|
||||
*next = pos - (fails as u64 - 1) * CHUNK as u64;
|
||||
return CarveEnd::IoLost;
|
||||
}
|
||||
// Setor ruim isolado: pula um bloco inteiro e segue (a imagem forense trata isso melhor).
|
||||
pos += CHUNK as u64;
|
||||
ev.advance(CHUNK as u64);
|
||||
*next = pos;
|
||||
@@ -84,7 +94,16 @@ pub fn carve_range(
|
||||
*next = pos;
|
||||
}
|
||||
*next = end;
|
||||
true
|
||||
CarveEnd::Done
|
||||
}
|
||||
|
||||
/// Como o carving de um intervalo terminou.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CarveEnd {
|
||||
Done,
|
||||
Cancelled,
|
||||
/// Várias leituras seguidas falharam: o dispositivo provavelmente foi desconectado.
|
||||
IoLost,
|
||||
}
|
||||
|
||||
/// Receptor de eventos do carving.
|
||||
|
||||
@@ -180,7 +180,14 @@ pub fn facets(found: &[Found]) -> Value {
|
||||
let mut conf = [0usize; 3];
|
||||
let mut seen_sig = std::collections::HashSet::new();
|
||||
let mut dups = 0usize;
|
||||
let (mut small, mut mp1) = (0usize, 0usize);
|
||||
for f in found {
|
||||
if f.size < 30 * 1024 {
|
||||
small += 1;
|
||||
}
|
||||
if f.px.is_some_and(|(w, h)| w as u64 * h as u64 >= 1_000_000) {
|
||||
mp1 += 1;
|
||||
}
|
||||
if let Some(g) = f.sig {
|
||||
if !seen_sig.insert((g, f.size)) {
|
||||
dups += 1;
|
||||
@@ -242,6 +249,8 @@ pub fn facets(found: &[Found]) -> Value {
|
||||
"root": root,
|
||||
"gps": gps,
|
||||
"duplicates": dups,
|
||||
"small": small,
|
||||
"mp1": mp1,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Abre um arquivo achado no aplicativo padrão do sistema (player de vídeo, leitor
|
||||
//! de PDF, etc.). Usado quando o WebView do app não consegue reproduzir a mídia:
|
||||
//! o WebKitGTK empacotado no AppImage não traz os plugins de mídia do GStreamer e
|
||||
//! o processo da página morre ao criar um `<video>`.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::fs::sanitize_name;
|
||||
use crate::types::Found;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Pasta temporária própria do usuário (nunca compartilhada).
|
||||
fn temp_dir() -> PathBuf {
|
||||
let uid = crate::owner::invoking_user()
|
||||
.map(|u| u.uid.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// SAFETY: getuid não tem pré-condições.
|
||||
unsafe { libc::getuid() }.to_string()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
"user".to_string()
|
||||
}
|
||||
});
|
||||
std::env::temp_dir().join(format!("guara-abrir-{uid}"))
|
||||
}
|
||||
|
||||
/// Remove cópias com mais de um dia.
|
||||
fn cleanup(dir: &Path) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
let limit = std::time::Duration::from_secs(24 * 3600);
|
||||
for e in rd.flatten() {
|
||||
if let Ok(age) = e
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.and_then(|t| t.elapsed().map_err(std::io::Error::other))
|
||||
{
|
||||
if age > limit {
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copia o arquivo achado para uma pasta temporária do usuário e o abre no aplicativo padrão.
|
||||
pub fn open_found(src: &dyn crate::source::Source, f: &Found) -> Result<PathBuf> {
|
||||
const MAX: u64 = 8 * 1024 * 1024 * 1024;
|
||||
if f.size > MAX {
|
||||
return Err(Error::msg("Arquivo grande demais para abrir direto. Recupere-o e abra pelo seu gerenciador de arquivos."));
|
||||
}
|
||||
let dir = temp_dir();
|
||||
crate::owner::create_dir_all_owned(&dir)?;
|
||||
cleanup(&dir);
|
||||
let path = dir.join(format!("{}-{}", f.id, sanitize_name(&f.name)));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
crate::recover::write_file(src, f, &path)?;
|
||||
open_path(&path)
|
||||
.map_err(|e| Error::msg(format!("Não foi possível abrir {}: {e}", path.display())))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Abre o caminho com o aplicativo padrão, na sessão do usuário real mesmo se formos root.
|
||||
pub fn open_path(path: &Path) -> std::io::Result<()> {
|
||||
use std::process::{Command, Stdio};
|
||||
#[cfg(target_os = "linux")]
|
||||
let mut cmd = if let Some(u) = crate::owner::invoking_user() {
|
||||
let mut c = Command::new("runuser");
|
||||
c.args(["-u", &u.name, "--", "env"]);
|
||||
for k in ["DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY"] {
|
||||
if let Ok(v) = std::env::var(k) {
|
||||
c.arg(format!("{k}={v}"));
|
||||
}
|
||||
}
|
||||
c.arg(format!("XDG_RUNTIME_DIR=/run/user/{}", u.uid));
|
||||
c.arg(format!(
|
||||
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{}/bus",
|
||||
u.uid
|
||||
));
|
||||
c.arg("xdg-open").arg(path);
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new("xdg-open");
|
||||
c.arg(path);
|
||||
c
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut cmd = {
|
||||
let mut c = Command::new("open");
|
||||
c.arg(path);
|
||||
c
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let mut cmd = {
|
||||
let mut c = Command::new("cmd");
|
||||
c.args(["/C", "start", ""]).arg(path);
|
||||
c
|
||||
};
|
||||
let mut child = cmd
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Rotas HTTP de mídia (`/file` com Range e `/thumb`) compartilhadas pelo modo `guara ui`.
|
||||
|
||||
use crate::service::Engine;
|
||||
use tiny_http::{Header, Method, Request, Response, StatusCode};
|
||||
|
||||
pub fn header(name: &str, value: &str) -> Header {
|
||||
Header::from_bytes(name.as_bytes(), value.as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
/// O cabeçalho Host bate com um dos endereços esperados (defesa contra DNS rebinding)?
|
||||
pub fn host_ok(req: &Request, hosts: &[String]) -> bool {
|
||||
req.headers()
|
||||
.iter()
|
||||
.any(|h| h.field.equiv("Host") && hosts.iter().any(|x| x == h.value.as_str()))
|
||||
}
|
||||
|
||||
/// Responde `/file/<job>/<id>` (com Range) e `/thumb/<job>/<id>?s=NNN`.
|
||||
/// Devolve a requisição quando ela não é uma rota de mídia.
|
||||
pub fn handle_media(eng: &Engine, token: &str, req: Request) -> Option<Request> {
|
||||
let full = req.url().to_string();
|
||||
let (path, query) = full.split_once('?').unwrap_or((full.as_str(), ""));
|
||||
let is_file = path.starts_with("/file/");
|
||||
let is_thumb = path.starts_with("/thumb/");
|
||||
if !(is_file || is_thumb) {
|
||||
return Some(req);
|
||||
}
|
||||
if req.method() == &Method::Options {
|
||||
let r = Response::empty(204)
|
||||
.with_header(header("Access-Control-Allow-Origin", "*"))
|
||||
.with_header(header("Access-Control-Allow-Headers", "Range"))
|
||||
.with_header(header("Access-Control-Allow-Methods", "GET, OPTIONS"));
|
||||
let _ = req.respond(r);
|
||||
return None;
|
||||
}
|
||||
if req.method() != &Method::Get {
|
||||
let _ = req.respond(Response::from_string("método não permitido").with_status_code(405));
|
||||
return None;
|
||||
}
|
||||
if !query
|
||||
.split('&')
|
||||
.any(|kv| kv.strip_prefix("t=") == Some(token))
|
||||
{
|
||||
let _ = req.respond(Response::from_string("token inválido").with_status_code(401));
|
||||
return None;
|
||||
}
|
||||
let mut it = path
|
||||
.trim_start_matches("/file/")
|
||||
.trim_start_matches("/thumb/")
|
||||
.split('/');
|
||||
let (job, id) = (
|
||||
it.next().unwrap_or(""),
|
||||
it.next().and_then(|s| s.parse::<u64>().ok()),
|
||||
);
|
||||
let Some(id) = id else {
|
||||
let _ = req.respond(Response::from_string("id inválido").with_status_code(400));
|
||||
return None;
|
||||
};
|
||||
if is_thumb {
|
||||
let size = query
|
||||
.split('&')
|
||||
.find_map(|kv| kv.strip_prefix("s="))
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(256);
|
||||
match eng.thumb(job, id, size) {
|
||||
Ok(bytes) => {
|
||||
let r = Response::from_data(bytes.as_ref().clone())
|
||||
.with_header(header("Content-Type", "image/jpeg"))
|
||||
.with_header(header("Cache-Control", "private, max-age=3600"))
|
||||
.with_header(header("Access-Control-Allow-Origin", "*"));
|
||||
let _ = req.respond(r);
|
||||
}
|
||||
Err(code) => {
|
||||
let _ = req.respond(
|
||||
Response::from_string("sem miniatura")
|
||||
.with_status_code(code)
|
||||
.with_header(header("Access-Control-Allow-Origin", "*")),
|
||||
);
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let range = req
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("Range"))
|
||||
.map(|h| h.value.as_str().to_string());
|
||||
match eng.file_slice(job, id, range.as_deref()) {
|
||||
Ok(slice) => {
|
||||
let (status, len) = (slice.status(), slice.len() as usize);
|
||||
let headers: Vec<Header> = slice.headers().iter().map(|(k, v)| header(k, v)).collect();
|
||||
let resp = Response::new(StatusCode(status), headers, slice.reader(), Some(len), None);
|
||||
let _ = req.respond(resp);
|
||||
}
|
||||
Err(code) => {
|
||||
let _ = req.respond(
|
||||
Response::from_string("indisponível")
|
||||
.with_status_code(code)
|
||||
.with_header(header("Access-Control-Allow-Origin", "*")),
|
||||
);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -40,6 +40,7 @@ pub fn create_image(
|
||||
dest.display()
|
||||
))
|
||||
})?;
|
||||
crate::owner::give_to_user(dest);
|
||||
let size = src.size();
|
||||
let mut rep = ImageReport::default();
|
||||
let mut hasher = MultiHasher::new();
|
||||
@@ -81,7 +82,7 @@ pub fn create_image(
|
||||
rep.md5 = h.md5;
|
||||
rep.sha1 = h.sha1;
|
||||
let map = dest.with_extension("map.json");
|
||||
std::fs::write(map, serde_json::to_vec_pretty(&rep)?)?;
|
||||
crate::owner::write_owned(&map, serde_json::to_vec_pretty(&rep)?)?;
|
||||
Ok(rep)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,15 @@ pub mod carve;
|
||||
pub mod device;
|
||||
pub mod error;
|
||||
pub mod explorer;
|
||||
pub mod external;
|
||||
pub mod fs;
|
||||
pub mod hashing;
|
||||
pub mod http;
|
||||
pub mod image;
|
||||
pub mod meta;
|
||||
pub mod owner;
|
||||
pub mod partition;
|
||||
pub mod reconnect;
|
||||
pub mod recover;
|
||||
pub mod report;
|
||||
pub mod scan;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Quando o Guará roda como administrador (sudo/pkexec), tudo que ele grava para
|
||||
//! o usuário (sessões, relatórios, arquivos recuperados) pertence ao usuário que
|
||||
//! o abriu, e não ao root, e as pastas sugeridas ficam na pasta desse usuário.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct User {
|
||||
pub name: String,
|
||||
pub uid: u32,
|
||||
pub gid: u32,
|
||||
pub home: PathBuf,
|
||||
}
|
||||
|
||||
/// O usuário real por trás de um processo root iniciado por sudo/pkexec.
|
||||
#[cfg(unix)]
|
||||
pub fn invoking_user() -> Option<User> {
|
||||
// SAFETY: geteuid não tem pré-condições.
|
||||
if unsafe { libc::geteuid() } != 0 {
|
||||
return None;
|
||||
}
|
||||
let uid = std::env::var("SUDO_UID")
|
||||
.ok()
|
||||
.or_else(|| std::env::var("PKEXEC_UID").ok())
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.filter(|&u| u != 0);
|
||||
let name = std::env::var("SUDO_USER").ok().filter(|u| u != "root");
|
||||
let passwd = std::fs::read_to_string("/etc/passwd").ok()?;
|
||||
lookup_user(&passwd, uid, name.as_deref())
|
||||
}
|
||||
|
||||
/// Procura o usuário (por uid ou, na falta dele, por nome) num conteúdo no formato do /etc/passwd.
|
||||
pub fn lookup_user(passwd: &str, uid: Option<u32>, name: Option<&str>) -> Option<User> {
|
||||
for l in passwd.lines() {
|
||||
let f: Vec<&str> = l.split(':').collect();
|
||||
if f.len() < 6 {
|
||||
continue;
|
||||
}
|
||||
let (Ok(fuid), Ok(fgid)) = (f[2].parse::<u32>(), f[3].parse::<u32>()) else {
|
||||
continue;
|
||||
};
|
||||
if uid == Some(fuid) || (uid.is_none() && name == Some(f[0])) {
|
||||
return Some(User {
|
||||
name: f[0].to_string(),
|
||||
uid: fuid,
|
||||
gid: fgid,
|
||||
home: PathBuf::from(f[5]),
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn invoking_user() -> Option<User> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Pasta pessoal para sugerir ao usuário (a dele, mesmo se estivermos como root).
|
||||
pub fn user_home() -> PathBuf {
|
||||
if let Some(u) = invoking_user() {
|
||||
return u.home;
|
||||
}
|
||||
PathBuf::from(
|
||||
std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Entrega o arquivo/pasta ao usuário que abriu o Guará (não faz nada fora do modo root).
|
||||
pub fn give_to_user(path: &Path) {
|
||||
#[cfg(unix)]
|
||||
if let Some(u) = invoking_user() {
|
||||
let _ = std::os::unix::fs::lchown(path, Some(u.uid), Some(u.gid));
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = path;
|
||||
}
|
||||
|
||||
/// Como `create_dir_all`, mas as pastas criadas ficam com o dono certo.
|
||||
pub fn create_dir_all_owned(path: &Path) -> io::Result<()> {
|
||||
let mut missing = vec![];
|
||||
let mut cur = Some(path);
|
||||
while let Some(p) = cur {
|
||||
if p.as_os_str().is_empty() || p.exists() {
|
||||
break;
|
||||
}
|
||||
missing.push(p.to_path_buf());
|
||||
cur = p.parent();
|
||||
}
|
||||
std::fs::create_dir_all(path)?;
|
||||
for p in missing.iter().rev() {
|
||||
give_to_user(p);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Como `fs::write`, mas o arquivo fica com o dono certo.
|
||||
pub fn write_owned(path: &Path, bytes: impl AsRef<[u8]>) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
create_dir_all_owned(parent)?;
|
||||
}
|
||||
std::fs::write(path, bytes)?;
|
||||
give_to_user(path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PASSWD: &str = "root:x:0:0:root:/root:/bin/bash\nlucas:x:1000:1000:Lucas:/home/lucas:/bin/bash\nmaria:x:1001:1002::/home/maria:/bin/zsh\nlinha-quebrada\n";
|
||||
|
||||
#[test]
|
||||
fn acha_o_usuario_por_uid_ou_nome() {
|
||||
let u = lookup_user(PASSWD, Some(1000), None).unwrap();
|
||||
assert_eq!(
|
||||
(u.uid, u.gid, u.home.to_str().unwrap()),
|
||||
(1000, 1000, "/home/lucas")
|
||||
);
|
||||
let m = lookup_user(PASSWD, None, Some("maria")).unwrap();
|
||||
assert_eq!(
|
||||
(m.uid, m.gid, m.home.to_str().unwrap()),
|
||||
(1001, 1002, "/home/maria")
|
||||
);
|
||||
assert!(lookup_user(PASSWD, Some(4242), None).is_none());
|
||||
// o uid manda sobre o nome
|
||||
assert_eq!(
|
||||
lookup_user(PASSWD, Some(1001), Some("lucas")).unwrap().uid,
|
||||
1001
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fora_do_modo_root_nao_muda_dono_e_grava_normalmente() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let f = d.path().join("a/b/sessao.json");
|
||||
write_owned(&f, b"{}").unwrap();
|
||||
assert_eq!(std::fs::read(&f).unwrap(), b"{}");
|
||||
assert!(create_dir_all_owned(&d.path().join("x/y")).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Reencontra o disco de uma sessão quando ele foi desconectado e voltou com
|
||||
//! outro nome (por exemplo `/dev/sda` virando `/dev/sdb`).
|
||||
|
||||
use crate::device;
|
||||
use crate::source::{fingerprint, open_path, Source};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A origem é compatível com o que a sessão espera?
|
||||
/// `size == 0` ou `fp == None` significam "não sei" e não reprovam.
|
||||
pub fn matches(src: &dyn Source, size: u64, fp: Option<&str>) -> bool {
|
||||
(size == 0 || src.size() == size) && fp.map_or(true, |f| fingerprint(src).as_deref() == Some(f))
|
||||
}
|
||||
|
||||
/// Procura entre os discos conectados o que corresponde à sessão.
|
||||
/// Com impressão digital, exige igualdade; sem ela, só aceita se houver um único disco do mesmo tamanho.
|
||||
pub fn find_matching_source(
|
||||
size: u64,
|
||||
fp: Option<&str>,
|
||||
exclude: &[String],
|
||||
) -> Option<(String, Arc<dyn Source>)> {
|
||||
if size == 0 && fp.is_none() {
|
||||
return None;
|
||||
}
|
||||
let mut found: Vec<(String, Arc<dyn Source>)> = vec![];
|
||||
for d in device::list_devices() {
|
||||
if !d.readable || exclude.contains(&d.path) || (size != 0 && d.size != size) {
|
||||
continue;
|
||||
}
|
||||
let Ok(src) = open_path(Path::new(&d.path)) else {
|
||||
continue;
|
||||
};
|
||||
if matches(src.as_ref(), size, fp) {
|
||||
found.push((d.path, src));
|
||||
}
|
||||
}
|
||||
if fp.is_some() {
|
||||
found.into_iter().next()
|
||||
} else if found.len() == 1 {
|
||||
found.pop()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ pub struct RecoverReport {
|
||||
|
||||
/// Recusa destinos que ficam no mesmo dispositivo da origem.
|
||||
pub fn check_destination(source_path: Option<&Path>, dest: &Path) -> Result<()> {
|
||||
std::fs::create_dir_all(dest)?;
|
||||
crate::owner::create_dir_all_owned(dest)?;
|
||||
let dest = dest.canonicalize()?;
|
||||
let Some(sp) = source_path else { return Ok(()) };
|
||||
let sp_str = sp.to_string_lossy().to_string();
|
||||
@@ -72,6 +72,33 @@ pub fn check_destination(source_path: Option<&Path>, dest: &Path) -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Como organizar os arquivos recuperados dentro do destino.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RecoverLayout {
|
||||
/// Pastas originais quando conhecidas; o resto separado por tipo (padrão).
|
||||
#[default]
|
||||
Folders,
|
||||
/// Uma pasta por tipo (Fotos, Documentos, Vídeos...).
|
||||
Type,
|
||||
/// Pastas por ano e mês (data da foto, ou a de modificação).
|
||||
Date,
|
||||
/// Tudo direto na pasta de destino.
|
||||
Flat,
|
||||
}
|
||||
|
||||
impl RecoverLayout {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"folders" | "pastas" => Self::Folders,
|
||||
"type" | "tipo" => Self::Type,
|
||||
"date" | "data" => Self::Date,
|
||||
"flat" | "plano" => Self::Flat,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recover(
|
||||
src: &dyn Source,
|
||||
files: &[&Found],
|
||||
@@ -79,7 +106,25 @@ pub fn recover(
|
||||
cancelled: &(dyn Fn() -> bool + Sync),
|
||||
progress: &mut dyn FnMut(usize, usize, u64),
|
||||
) -> Result<RecoverReport> {
|
||||
std::fs::create_dir_all(dest)?;
|
||||
recover_with(
|
||||
src,
|
||||
files,
|
||||
dest,
|
||||
RecoverLayout::Folders,
|
||||
cancelled,
|
||||
progress,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn recover_with(
|
||||
src: &dyn Source,
|
||||
files: &[&Found],
|
||||
dest: &Path,
|
||||
layout: RecoverLayout,
|
||||
cancelled: &(dyn Fn() -> bool + Sync),
|
||||
progress: &mut dyn FnMut(usize, usize, u64),
|
||||
) -> Result<RecoverReport> {
|
||||
crate::owner::create_dir_all_owned(dest)?;
|
||||
let mut report = RecoverReport {
|
||||
destination: dest.display().to_string(),
|
||||
..Default::default()
|
||||
@@ -91,10 +136,10 @@ pub fn recover(
|
||||
break;
|
||||
}
|
||||
progress(i, files.len(), report.total_bytes);
|
||||
let rel = target_rel(f);
|
||||
let rel = target_rel(f, layout);
|
||||
let mut path = dest.join(&rel);
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
if let Err(e) = crate::owner::create_dir_all_owned(parent) {
|
||||
report.failed.push((f.id, e.to_string()));
|
||||
continue;
|
||||
}
|
||||
@@ -144,38 +189,89 @@ pub fn recover(
|
||||
"algoritmos_hash": ["MD5", "SHA-1", "SHA-256"],
|
||||
"relatorio": &report,
|
||||
});
|
||||
std::fs::write(
|
||||
dest.join("guara-manifesto.json"),
|
||||
crate::owner::write_owned(
|
||||
&dest.join("guara-manifesto.json"),
|
||||
serde_json::to_vec_pretty(&manifest)?,
|
||||
)?;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn target_rel(f: &Found) -> PathBuf {
|
||||
fn target_rel(f: &Found, layout: RecoverLayout) -> PathBuf {
|
||||
let mut p = PathBuf::new();
|
||||
match &f.dir {
|
||||
Some(d) if !d.is_empty() => {
|
||||
p.push("Recuperados");
|
||||
for c in d.split('/') {
|
||||
if !c.is_empty() {
|
||||
p.push(sanitize_name(c));
|
||||
match layout {
|
||||
RecoverLayout::Folders => match &f.dir {
|
||||
Some(d) if !d.is_empty() => {
|
||||
p.push("Recuperados");
|
||||
for c in d.split('/') {
|
||||
if !c.is_empty() {
|
||||
p.push(sanitize_name(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
p.push(f.category.label_pt());
|
||||
}
|
||||
_ => p.push(f.category.label_pt()),
|
||||
},
|
||||
RecoverLayout::Type => p.push(f.category.label_pt()),
|
||||
RecoverLayout::Date => match f.when() {
|
||||
Some(t) => {
|
||||
let (y, m, _) = crate::meta::ymd(t);
|
||||
p.push(format!("{y:04}"));
|
||||
p.push(format!("{y:04}-{m:02}"));
|
||||
}
|
||||
None => p.push("Sem data"),
|
||||
},
|
||||
RecoverLayout::Flat => {}
|
||||
}
|
||||
p.push(sanitize_name(&f.name));
|
||||
p
|
||||
}
|
||||
|
||||
/// Espaço livre (bytes) no volume onde `path` está (ou estaria).
|
||||
pub fn free_space(path: &Path) -> Option<u64> {
|
||||
let mut p = path;
|
||||
while !p.exists() {
|
||||
p = p.parent()?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let c = std::ffi::CString::new(p.as_os_str().as_bytes()).ok()?;
|
||||
// SAFETY: `c` é uma string C válida e `st` é uma estrutura de saída local.
|
||||
let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||
if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 {
|
||||
return None;
|
||||
}
|
||||
Some((st.f_bavail as u64).saturating_mul(st.f_frsize as u64))
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW;
|
||||
let wide: Vec<u16> = p
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let mut free = 0u64;
|
||||
// SAFETY: `wide` termina em NUL e o ponteiro de saída é válido.
|
||||
let ok = unsafe {
|
||||
GetDiskFreeSpaceExW(
|
||||
wide.as_ptr(),
|
||||
&mut free,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
(ok != 0).then_some(free)
|
||||
}
|
||||
}
|
||||
|
||||
/// Escreve o conteúdo e devolve o SHA-256.
|
||||
pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<Hashes> {
|
||||
let mut out = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)?;
|
||||
crate::owner::give_to_user(path);
|
||||
let mut hasher = MultiHasher::new();
|
||||
match &f.data {
|
||||
DataLoc::Resident { bytes } => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Orquestra a varredura: layout → metadados dos sistemas de arquivos →
|
||||
//! carving no espaço livre/desconhecido → fusão sem duplicatas.
|
||||
|
||||
use crate::carve::{carve_range, CarveCfg, CarveEvents, HitAction};
|
||||
use crate::carve::{carve_range, CarveCfg, CarveEnd, CarveEvents, HitAction};
|
||||
use crate::fs::{self, merge_extents, overlaps, Ctx};
|
||||
use crate::partition;
|
||||
use crate::signatures::category_for_ext;
|
||||
@@ -42,6 +42,9 @@ pub struct Checkpoint {
|
||||
pub ranges: Vec<RangeState>,
|
||||
pub next_id: u64,
|
||||
pub layout: Option<DiskLayout>,
|
||||
/// A varredura parou porque o dispositivo parou de responder (desconectado?).
|
||||
#[serde(default)]
|
||||
pub io_lost: bool,
|
||||
}
|
||||
|
||||
pub enum Outcome {
|
||||
@@ -189,9 +192,10 @@ pub fn run(
|
||||
if next >= end {
|
||||
continue;
|
||||
}
|
||||
let finished = carve_range(src, start, end, &mut next, &cfg, cancelled, &mut handler);
|
||||
let end_state = carve_range(src, start, end, &mut next, &cfg, cancelled, &mut handler);
|
||||
cp.ranges[ri].next = next;
|
||||
if !finished {
|
||||
if end_state != CarveEnd::Done {
|
||||
cp.io_lost = end_state == CarveEnd::IoLost;
|
||||
cp.next_id = next_id.load(std::sync::atomic::Ordering::Relaxed);
|
||||
return Outcome::Interrupted(cp);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,13 @@ pub struct Job {
|
||||
opts: ScanOptions,
|
||||
cancel: AtomicBool,
|
||||
stop_requested: AtomicBool,
|
||||
src: Option<Arc<dyn Source>>,
|
||||
src: Mutex<Option<Arc<dyn Source>>>,
|
||||
/// Caminho atualmente usado para ler a origem (pode mudar se o disco voltar com outro nome).
|
||||
src_path: Mutex<String>,
|
||||
/// Impressão digital do disco (reconhecer o mesmo disco depois de reconectar).
|
||||
fingerprint: Mutex<Option<String>>,
|
||||
/// Arquivo de sessão salvo automaticamente.
|
||||
session_file: Mutex<Option<PathBuf>>,
|
||||
st: Mutex<State>,
|
||||
}
|
||||
|
||||
@@ -78,11 +84,14 @@ impl Job {
|
||||
Arc::new(Job {
|
||||
id,
|
||||
kind,
|
||||
target,
|
||||
target: target.clone(),
|
||||
opts,
|
||||
cancel: AtomicBool::new(false),
|
||||
stop_requested: AtomicBool::new(false),
|
||||
src,
|
||||
src: Mutex::new(src),
|
||||
src_path: Mutex::new(target.clone()),
|
||||
fingerprint: Mutex::new(None),
|
||||
session_file: Mutex::new(None),
|
||||
st: Mutex::new(State {
|
||||
status: Status::Running,
|
||||
progress: Progress::default(),
|
||||
@@ -105,6 +114,9 @@ impl Job {
|
||||
}),
|
||||
})
|
||||
}
|
||||
fn source(&self) -> Option<Arc<dyn Source>> {
|
||||
self.src.lock().unwrap().clone()
|
||||
}
|
||||
fn cancelled(&self) -> bool {
|
||||
self.cancel.load(Ordering::Relaxed)
|
||||
}
|
||||
@@ -194,7 +206,10 @@ impl Engine {
|
||||
|
||||
pub fn start_scan(&self, path: &str, opts: ScanOptions) -> Result<String> {
|
||||
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
|
||||
let fp = crate::source::fingerprint(src.as_ref());
|
||||
let job = self.register(JobKind::Scan, path.to_string(), opts, Some(src));
|
||||
*job.fingerprint.lock().unwrap() = fp;
|
||||
*job.session_file.lock().unwrap() = Some(new_session_path(path));
|
||||
spawn_scan(job.clone(), None);
|
||||
Ok(job.id.clone())
|
||||
}
|
||||
@@ -214,14 +229,35 @@ impl Engine {
|
||||
|
||||
pub fn resume(&self, id: &str) -> Result<()> {
|
||||
let j = self.job(id)?;
|
||||
let cp = {
|
||||
let mut s = j.st.lock().unwrap();
|
||||
let io_lost = {
|
||||
let s = j.st.lock().unwrap();
|
||||
if s.status != Status::Paused {
|
||||
return Err(Error::msg("a varredura não está pausada"));
|
||||
}
|
||||
s.status = Status::Running;
|
||||
s.checkpoint.take()
|
||||
s.checkpoint.as_ref().is_some_and(|c| c.io_lost)
|
||||
};
|
||||
// Sem origem, ou depois de uma desconexão: reabre o disco (mesmo com outro nome).
|
||||
if j.source().is_none() || io_lost {
|
||||
self.reconnect(&j).map_err(|e| {
|
||||
Error::msg(format!(
|
||||
"A origem desta sessão não está disponível ({e}). Reconecte o disco ou escolha a origem para continuar."
|
||||
))
|
||||
})?;
|
||||
}
|
||||
let cp = {
|
||||
let mut s = j.st.lock().unwrap();
|
||||
s.status = Status::Running;
|
||||
s.warnings
|
||||
.retain(|w| !w.starts_with("A leitura do dispositivo"));
|
||||
let mut cp = s.checkpoint.take();
|
||||
if let Some(c) = cp.as_mut() {
|
||||
c.io_lost = false;
|
||||
}
|
||||
cp
|
||||
};
|
||||
if j.session_file.lock().unwrap().is_none() {
|
||||
*j.session_file.lock().unwrap() = Some(new_session_path(&j.target));
|
||||
}
|
||||
j.cancel.store(false, Ordering::Relaxed);
|
||||
j.stop_requested.store(false, Ordering::Relaxed);
|
||||
spawn_scan(j, cp);
|
||||
@@ -254,6 +290,9 @@ impl Engine {
|
||||
"error": s.error,
|
||||
"report": s.report,
|
||||
"elapsed": elapsed,
|
||||
"source": j.source().is_some(),
|
||||
"source_path": j.src_path.lock().unwrap().clone(),
|
||||
"session_file": j.session_file.lock().unwrap().as_ref().map(|p| p.to_string_lossy().to_string()),
|
||||
"hits": s.hits.len(),
|
||||
"enriching": s.enrich.map(|(d, t)| json!({ "done": d, "total": t })),
|
||||
}))
|
||||
@@ -294,7 +333,7 @@ impl Engine {
|
||||
if let Some(v) = self.thumbs.lock().unwrap().get(&key) {
|
||||
return if v.is_empty() { Err(422) } else { Ok(v) };
|
||||
}
|
||||
let (f, src) = self.open_file(job, id).map_err(|_| 404u16)?;
|
||||
let (f, src) = self.open_file(job, id).map_err(status_of)?;
|
||||
if f.category != Category::Image {
|
||||
return Err(415);
|
||||
}
|
||||
@@ -319,10 +358,7 @@ impl Engine {
|
||||
.index
|
||||
.get(&file)
|
||||
.ok_or_else(|| Error::msg("arquivo não encontrado"))?;
|
||||
Ok((
|
||||
s.found[i].clone(),
|
||||
j.src.clone().ok_or_else(|| Error::msg("sem origem"))?,
|
||||
))
|
||||
Ok((s.found[i].clone(), j.source().ok_or_else(no_source)?))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- recuperação
|
||||
@@ -333,9 +369,10 @@ impl Engine {
|
||||
ids: Option<Vec<u64>>,
|
||||
filter: Option<Query>,
|
||||
dest: &str,
|
||||
layout: recover::RecoverLayout,
|
||||
) -> Result<String> {
|
||||
let sj = self.job(scan_id)?;
|
||||
let src = sj.src.clone().ok_or_else(|| Error::msg("sem origem"))?;
|
||||
let src = sj.source().ok_or_else(no_source)?;
|
||||
let files: Vec<Found> = {
|
||||
let s = sj.st.lock().unwrap();
|
||||
match (&ids, &filter) {
|
||||
@@ -351,6 +388,16 @@ impl Engine {
|
||||
return Err(Error::msg("nenhum arquivo selecionado"));
|
||||
}
|
||||
let dest_path = PathBuf::from(dest);
|
||||
let total: u64 = files.iter().map(|f| f.size).sum();
|
||||
if let Some(free) = recover::free_space(&dest_path) {
|
||||
if total.saturating_add(64 * 1024 * 1024) > free {
|
||||
return Err(Error::msg(format!(
|
||||
"Espaço insuficiente no destino: os arquivos somam {} e há {} livres. Escolha outro disco ou recupere menos arquivos.",
|
||||
human_bytes(total),
|
||||
human_bytes(free)
|
||||
)));
|
||||
}
|
||||
}
|
||||
recover::check_destination(Some(Path::new(&sj.target)), &dest_path)?;
|
||||
let job = self.register(
|
||||
JobKind::Recover,
|
||||
@@ -371,7 +418,14 @@ impl Engine {
|
||||
};
|
||||
};
|
||||
let refs: Vec<&Found> = files.iter().collect();
|
||||
let res = recover::recover(src.as_ref(), &refs, &dest_path, &cancelled, &mut prog);
|
||||
let res = recover::recover_with(
|
||||
src.as_ref(),
|
||||
&refs,
|
||||
&dest_path,
|
||||
layout,
|
||||
&cancelled,
|
||||
&mut prog,
|
||||
);
|
||||
let mut s = j2.st.lock().unwrap();
|
||||
match res {
|
||||
Ok(rep) => {
|
||||
@@ -449,7 +503,7 @@ impl Engine {
|
||||
Some(q) => q.select(&s.found),
|
||||
None => s.found.iter().collect(),
|
||||
};
|
||||
std::fs::write(path, crate::report::csv(&items))?;
|
||||
crate::owner::write_owned(Path::new(path), crate::report::csv(&items))?;
|
||||
Ok(items.len())
|
||||
}
|
||||
|
||||
@@ -478,7 +532,7 @@ impl Engine {
|
||||
recoveries: &s.recoveries,
|
||||
source_hashes: hashes.as_ref(),
|
||||
});
|
||||
std::fs::write(path, html)?;
|
||||
crate::owner::write_owned(Path::new(path), html)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -593,32 +647,35 @@ impl Engine {
|
||||
|
||||
pub fn save_session(&self, id: &str, path: &str) -> Result<()> {
|
||||
let j = self.job(id)?;
|
||||
let s = j.st.lock().unwrap();
|
||||
let data = SessionFile {
|
||||
version: 1,
|
||||
target: j.target.clone(),
|
||||
options: j.opts.clone(),
|
||||
found: s.found.clone(),
|
||||
checkpoint: s.checkpoint.clone(),
|
||||
layout: s.layout.clone(),
|
||||
warnings: s.warnings.clone(),
|
||||
done: s.status == Status::Done,
|
||||
recoveries: s.recoveries.clone(),
|
||||
started_unix: s.started_unix,
|
||||
};
|
||||
std::fs::write(path, serde_json::to_vec(&data)?)?;
|
||||
Ok(())
|
||||
write_session(&j, Path::new(path))
|
||||
}
|
||||
|
||||
pub fn load_session(&self, path: &str) -> Result<String> {
|
||||
let data: SessionFile = serde_json::from_slice(&std::fs::read(path)?)?;
|
||||
let src = open_path(Path::new(&data.target)).map_err(|e| open_error(&data.target, e))?;
|
||||
let bytes = std::fs::read(path).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
Error::msg(format!(
|
||||
"Sem permissão para ler a sessão {path}. Se ela foi criada como administrador (por exemplo em /root), abra o Guará como administrador ou copie o arquivo para a sua pasta."
|
||||
))
|
||||
} else {
|
||||
Error::msg(format!("Não foi possível ler a sessão {path}: {e}"))
|
||||
}
|
||||
})?;
|
||||
let data: SessionFile = serde_json::from_slice(&bytes).map_err(|e| {
|
||||
Error::msg(format!(
|
||||
"O arquivo {path} não é uma sessão do Guará válida (ou foi criado por uma versão incompatível): {e}"
|
||||
))
|
||||
})?;
|
||||
let job = self.register(
|
||||
JobKind::Scan,
|
||||
data.target.clone(),
|
||||
data.options.clone(),
|
||||
Some(src),
|
||||
None,
|
||||
);
|
||||
*job.fingerprint.lock().unwrap() = data.fingerprint.clone();
|
||||
*job.src_path.lock().unwrap() = data
|
||||
.source_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| data.target.clone());
|
||||
{
|
||||
let mut s = job.st.lock().unwrap();
|
||||
for (i, f) in data.found.iter().enumerate() {
|
||||
@@ -636,12 +693,181 @@ impl Engine {
|
||||
Status::Paused
|
||||
};
|
||||
}
|
||||
// A sessão sempre abre. Tenta religar o disco (inclusive se ele voltou com outro nome);
|
||||
// sem a origem ainda dá para navegar, exportar e gerar relatório.
|
||||
match self.reconnect(&job) {
|
||||
Ok(Some(nome)) => job.st.lock().unwrap().warnings.push(format!(
|
||||
"O disco foi reconhecido de novo como {nome} (o nome mudou desde a varredura)."
|
||||
)),
|
||||
Ok(None) => {}
|
||||
Err(e) => job.st.lock().unwrap().warnings.push(format!(
|
||||
"A origem {} não está disponível agora ({e}). Você pode navegar, exportar e gerar relatório; para pré-visualizar e recuperar, reconecte o disco ou escolha a origem.",
|
||||
data.target
|
||||
)),
|
||||
}
|
||||
// continua salvando automaticamente no mesmo arquivo se a varredura for retomada
|
||||
*job.session_file.lock().unwrap() = Some(PathBuf::from(path));
|
||||
if data.done {
|
||||
spawn_enrich(job.clone());
|
||||
}
|
||||
Ok(job.id.clone())
|
||||
}
|
||||
|
||||
/// Reabre a origem da sessão: tenta o caminho atual e o original e, se o disco voltou com
|
||||
/// outro nome, procura entre os dispositivos conectados o que tem o mesmo conteúdo.
|
||||
/// `Ok(Some(caminho))` quando achou o disco num caminho diferente do esperado.
|
||||
fn reconnect(&self, j: &Arc<Job>) -> Result<Option<String>> {
|
||||
let fp = j.fingerprint.lock().unwrap().clone();
|
||||
let size =
|
||||
j.st.lock()
|
||||
.unwrap()
|
||||
.layout
|
||||
.as_ref()
|
||||
.map(|l| l.size)
|
||||
.unwrap_or(0);
|
||||
let current = j.src_path.lock().unwrap().clone();
|
||||
let mut tried = vec![current.clone()];
|
||||
if j.target != current {
|
||||
tried.push(j.target.clone());
|
||||
}
|
||||
let mut last_err = None;
|
||||
for path in &tried {
|
||||
match open_path(Path::new(path)) {
|
||||
Ok(src) if crate::reconnect::matches(src.as_ref(), size, fp.as_deref()) => {
|
||||
*j.src.lock().unwrap() = Some(src);
|
||||
*j.src_path.lock().unwrap() = path.clone();
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(_) => last_err = Some(format!("{path} agora é outro disco")),
|
||||
Err(e) => last_err = Some(open_error(path, e).to_string()),
|
||||
}
|
||||
}
|
||||
if let Some((path, src)) =
|
||||
crate::reconnect::find_matching_source(size, fp.as_deref(), &tried)
|
||||
{
|
||||
*j.src.lock().unwrap() = Some(src);
|
||||
*j.src_path.lock().unwrap() = path.clone();
|
||||
return Ok(Some(path));
|
||||
}
|
||||
Err(Error::msg(
|
||||
last_err.unwrap_or_else(|| "disco não encontrado".into()),
|
||||
))
|
||||
}
|
||||
|
||||
/// Aponta uma sessão carregada para a origem (disco ou imagem) atual.
|
||||
/// Recusa uma origem que não seja o mesmo disco, a menos que `force`.
|
||||
pub fn relink(&self, id: &str, path: &str, force: bool) -> Result<()> {
|
||||
let j = self.job(id)?;
|
||||
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
|
||||
let fp = j.fingerprint.lock().unwrap().clone();
|
||||
let size =
|
||||
j.st.lock()
|
||||
.unwrap()
|
||||
.layout
|
||||
.as_ref()
|
||||
.map(|l| l.size)
|
||||
.unwrap_or(0);
|
||||
if size != 0 && src.size() != size && !force {
|
||||
return Err(Error::msg(format!(
|
||||
"Essa origem tem {} bytes, mas a sessão foi feita sobre um disco de {} bytes. Escolha o mesmo disco ou uma imagem dele.",
|
||||
src.size(),
|
||||
size
|
||||
)));
|
||||
}
|
||||
if !force && fp.is_some() && !crate::reconnect::matches(src.as_ref(), 0, fp.as_deref()) {
|
||||
return Err(Error::msg(
|
||||
"O conteúdo inicial dessa origem é diferente do disco da sessão (outra tabela de partições ou outro disco). Se tem certeza, use a opção de usar assim mesmo.",
|
||||
));
|
||||
}
|
||||
if fp.is_none() {
|
||||
*j.fingerprint.lock().unwrap() = crate::source::fingerprint(src.as_ref());
|
||||
}
|
||||
*j.src.lock().unwrap() = Some(src);
|
||||
*j.src_path.lock().unwrap() = path.to_string();
|
||||
j.st.lock()
|
||||
.unwrap()
|
||||
.warnings
|
||||
.retain(|w| !w.starts_with("A origem ") && !w.starts_with("O disco foi reconhecido"));
|
||||
if j.st.lock().unwrap().status == Status::Done {
|
||||
spawn_enrich(j);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discos conectados que podem ser a origem desta sessão (mesmo tamanho primeiro).
|
||||
pub fn candidates(&self, id: &str) -> Result<Value> {
|
||||
let j = self.job(id)?;
|
||||
let fp = j.fingerprint.lock().unwrap().clone();
|
||||
let size =
|
||||
j.st.lock()
|
||||
.unwrap()
|
||||
.layout
|
||||
.as_ref()
|
||||
.map(|l| l.size)
|
||||
.unwrap_or(0);
|
||||
let mut out: Vec<Value> = vec![];
|
||||
for d in device::list_devices() {
|
||||
let same_size = size != 0 && d.size == size;
|
||||
let same_content = if same_size && d.readable {
|
||||
open_path(Path::new(&d.path))
|
||||
.ok()
|
||||
.map(|s| crate::reconnect::matches(s.as_ref(), size, fp.as_deref()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
out.push(json!({ "device": d, "same_size": same_size, "same_content": same_content }));
|
||||
}
|
||||
out.sort_by_key(|v| {
|
||||
std::cmp::Reverse((
|
||||
v["same_content"].as_bool() == Some(true),
|
||||
v["same_size"].as_bool() == Some(true),
|
||||
))
|
||||
});
|
||||
Ok(json!(out))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sessões automáticas
|
||||
|
||||
/// Sessões salvas automaticamente (mais recentes primeiro).
|
||||
pub fn sessions_list(&self) -> Value {
|
||||
let dir = sessions_dir();
|
||||
let mut items: Vec<Value> = vec![];
|
||||
if let Ok(rd) = std::fs::read_dir(&dir) {
|
||||
for e in rd.flatten() {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if let Some(base) = name.strip_suffix(".meta.json") {
|
||||
let sess = dir.join(format!("{base}.json"));
|
||||
if !sess.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(mut v) = std::fs::read(e.path())
|
||||
.map_err(|_| ())
|
||||
.and_then(|b| serde_json::from_slice::<Value>(&b).map_err(|_| ()))
|
||||
{
|
||||
v["path"] = json!(sess.to_string_lossy());
|
||||
items.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items.sort_by_key(|v| std::cmp::Reverse(v["saved_at"].as_i64().unwrap_or(0)));
|
||||
items.truncate(30);
|
||||
json!(items)
|
||||
}
|
||||
|
||||
/// Apaga uma sessão automática (e a miniatura de metadados).
|
||||
pub fn session_delete(&self, path: &str) -> Result<()> {
|
||||
let p = Path::new(path);
|
||||
if p.parent() != Some(sessions_dir().as_path()) {
|
||||
return Err(Error::msg(
|
||||
"só sessões automáticas podem ser apagadas por aqui",
|
||||
));
|
||||
}
|
||||
let _ = std::fs::remove_file(p.with_extension("meta.json"));
|
||||
std::fs::remove_file(p)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- RPC
|
||||
|
||||
pub fn rpc(&self, method: &str, p: Value) -> Result<Value> {
|
||||
@@ -681,6 +907,15 @@ impl Engine {
|
||||
}
|
||||
"status" => self.status(&s("job")?)?,
|
||||
"facets" => self.facets(&s("job")?)?,
|
||||
"open_external" => {
|
||||
let id = p
|
||||
.get("id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| Error::msg("id ausente"))?;
|
||||
let (f, src) = self.open_file(&s("job")?, id)?;
|
||||
let path = crate::external::open_found(src.as_ref(), &f)?;
|
||||
json!({ "path": path.to_string_lossy() })
|
||||
}
|
||||
"export_csv" => {
|
||||
let q: Option<Query> = p
|
||||
.get("query")
|
||||
@@ -751,18 +986,36 @@ impl Engine {
|
||||
.filter(|v| !v.is_null())
|
||||
.map(serde_json::from_value)
|
||||
.transpose()?;
|
||||
json!({ "job": self.start_recover(&s("job")?, ids, filter, &s("dest")?)? })
|
||||
let layout = p
|
||||
.get("layout")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(recover::RecoverLayout::parse)
|
||||
.unwrap_or_default();
|
||||
json!({ "job": self.start_recover(&s("job")?, ids, filter, &s("dest")?, layout)? })
|
||||
}
|
||||
"image_start" => json!({ "job": self.start_image(&s("path")?, &s("dest")?)? }),
|
||||
"session_save" => {
|
||||
self.save_session(&s("job")?, &s("path")?)?;
|
||||
json!({})
|
||||
}
|
||||
"session_relink" => {
|
||||
let force = p.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
self.relink(&s("job")?, &s("path")?, force)?;
|
||||
json!({})
|
||||
}
|
||||
"session_reconnect" => {
|
||||
let j = self.job(&s("job")?)?;
|
||||
json!({ "renamed": self.reconnect(&j)? })
|
||||
}
|
||||
"session_candidates" => self.candidates(&s("job")?)?,
|
||||
"sessions_list" => self.sessions_list(),
|
||||
"session_delete" => {
|
||||
self.session_delete(&s("path")?)?;
|
||||
json!({})
|
||||
}
|
||||
"session_load" => json!({ "job": self.load_session(&s("path")?)? }),
|
||||
"defaults" => {
|
||||
let home = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.unwrap_or_default();
|
||||
let home = crate::owner::user_home().to_string_lossy().to_string();
|
||||
json!({ "home": home, "sep": std::path::MAIN_SEPARATOR.to_string() })
|
||||
}
|
||||
"version" => {
|
||||
@@ -789,10 +1042,27 @@ struct SessionFile {
|
||||
recoveries: Vec<recover::RecoverReport>,
|
||||
#[serde(default)]
|
||||
started_unix: Option<i64>,
|
||||
#[serde(default)]
|
||||
fingerprint: Option<String>,
|
||||
#[serde(default)]
|
||||
source_path: Option<String>,
|
||||
}
|
||||
|
||||
use crate::explorer::cat_key;
|
||||
|
||||
/// 503 quando a origem da sessão está desconectada; 404 nos demais casos.
|
||||
fn status_of(e: Error) -> u16 {
|
||||
if e.to_string().contains("não está disponível") {
|
||||
503
|
||||
} else {
|
||||
404
|
||||
}
|
||||
}
|
||||
|
||||
fn no_source() -> Error {
|
||||
Error::msg("A origem desta sessão não está disponível. Reconecte o disco ou escolha a origem para pré-visualizar e recuperar.")
|
||||
}
|
||||
|
||||
fn open_error(path: &str, e: std::io::Error) -> Error {
|
||||
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
Error::msg(format!(
|
||||
@@ -804,8 +1074,16 @@ fn open_error(path: &str, e: std::io::Error) -> Error {
|
||||
}
|
||||
|
||||
fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
|
||||
// Salva a sessão sozinho enquanto a varredura roda (e ao terminar/pausar).
|
||||
let saver = job.clone();
|
||||
std::thread::spawn(move || autosave_loop(saver));
|
||||
std::thread::spawn(move || {
|
||||
let src = job.src.clone().expect("origem");
|
||||
let Some(src) = job.source() else {
|
||||
let mut s = job.st.lock().unwrap();
|
||||
s.status = Status::Error;
|
||||
s.error = Some(no_source().to_string());
|
||||
return;
|
||||
};
|
||||
let existing: Vec<Found> = job.st.lock().unwrap().found.clone();
|
||||
let cancelled = || job.cancelled();
|
||||
let mut sink = JobSink(job.clone());
|
||||
@@ -828,6 +1106,9 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
|
||||
s.status = Status::Stopped;
|
||||
} else {
|
||||
s.status = Status::Paused;
|
||||
if cp.io_lost {
|
||||
s.warnings.push("A leitura do dispositivo falhou repetidamente: ele provavelmente foi desconectado. A varredura foi pausada sem perder nada. Reconecte o disco e clique em Continuar.".into());
|
||||
}
|
||||
s.checkpoint = Some(cp);
|
||||
}
|
||||
}
|
||||
@@ -845,7 +1126,7 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
|
||||
/// navegar por data, câmera e resolução.
|
||||
fn spawn_enrich(job: Arc<Job>) {
|
||||
std::thread::spawn(move || {
|
||||
let Some(src) = job.src.clone() else { return };
|
||||
let Some(src) = job.source() else { return };
|
||||
let todo: Vec<(u64, Found)> = {
|
||||
let s = job.st.lock().unwrap();
|
||||
s.found
|
||||
@@ -976,6 +1257,10 @@ impl FileSlice {
|
||||
"sandbox; default-src 'none'".into(),
|
||||
),
|
||||
("Access-Control-Allow-Origin", "*".into()),
|
||||
(
|
||||
"Access-Control-Expose-Headers",
|
||||
"Content-Range, Content-Length, Accept-Ranges".into(),
|
||||
),
|
||||
("Content-Disposition", "inline".into()),
|
||||
];
|
||||
if self.partial {
|
||||
@@ -1045,7 +1330,7 @@ impl Engine {
|
||||
id: u64,
|
||||
range: Option<&str>,
|
||||
) -> std::result::Result<FileSlice, u16> {
|
||||
let (found, src) = self.open_file(job, id).map_err(|_| 404u16)?;
|
||||
let (found, src) = self.open_file(job, id).map_err(status_of)?;
|
||||
let total = found.size;
|
||||
let (mut start, mut end, mut partial) = (0u64, total.saturating_sub(1), false);
|
||||
if let Some(spec) = range.and_then(|r| r.strip_prefix("bytes=")) {
|
||||
@@ -1079,3 +1364,116 @@ impl Engine {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sessões automáticas
|
||||
|
||||
fn sessions_dir() -> PathBuf {
|
||||
crate::settings::config_dir().join("sessoes")
|
||||
}
|
||||
|
||||
fn new_session_path(target: &str) -> PathBuf {
|
||||
let name: String = Path::new(target)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "disco".into())
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let t = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
sessions_dir().join(format!("{name}-{t}.json"))
|
||||
}
|
||||
|
||||
fn snapshot(j: &Job) -> SessionFile {
|
||||
let s = j.st.lock().unwrap();
|
||||
SessionFile {
|
||||
version: 1,
|
||||
target: j.target.clone(),
|
||||
options: j.opts.clone(),
|
||||
found: s.found.clone(),
|
||||
checkpoint: s.checkpoint.clone(),
|
||||
layout: s.layout.clone(),
|
||||
warnings: s.warnings.clone(),
|
||||
done: s.status == Status::Done,
|
||||
recoveries: s.recoveries.clone(),
|
||||
started_unix: s.started_unix,
|
||||
fingerprint: j.fingerprint.lock().unwrap().clone(),
|
||||
source_path: Some(j.src_path.lock().unwrap().clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Grava a sessão de forma atômica (arquivo temporário + renomear), com o dono certo.
|
||||
fn write_session(j: &Job, path: &Path) -> Result<()> {
|
||||
let data = snapshot(j);
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
crate::owner::write_owned(&tmp, serde_json::to_vec(&data)?)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
crate::owner::give_to_user(path);
|
||||
// metadados pequenos para listar as sessões recentes sem ler o arquivo inteiro
|
||||
let status = if data.done {
|
||||
"concluída"
|
||||
} else if data.checkpoint.is_some() {
|
||||
"pausada"
|
||||
} else {
|
||||
"incompleta"
|
||||
};
|
||||
let meta = json!({
|
||||
"target": data.target, "found": data.found.len(), "status": status,
|
||||
"saved_at": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0),
|
||||
"bytes": std::fs::metadata(path).map(|m| m.len()).unwrap_or(0),
|
||||
});
|
||||
let _ = crate::owner::write_owned(
|
||||
&path.with_extension("meta.json"),
|
||||
serde_json::to_vec(&meta)?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Salva de tempos em tempos (o intervalo cresce se a sessão for grande) e no fim.
|
||||
fn autosave_loop(job: Arc<Job>) {
|
||||
let mut last = Instant::now();
|
||||
let mut interval = std::time::Duration::from_secs(45);
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
let running = job.st.lock().unwrap().status == Status::Running;
|
||||
if running && last.elapsed() < interval {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = job.session_file.lock().unwrap().clone() else {
|
||||
return;
|
||||
};
|
||||
let t0 = Instant::now();
|
||||
if write_session(&job, &path).is_err() {
|
||||
return;
|
||||
}
|
||||
// nunca gasta mais de ~10% do tempo salvando
|
||||
interval = (t0.elapsed() * 10).max(std::time::Duration::from_secs(45));
|
||||
last = Instant::now();
|
||||
if !running {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn human_bytes(n: u64) -> String {
|
||||
const U: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
|
||||
let mut v = n as f64;
|
||||
let mut i = 0;
|
||||
while v >= 1024.0 && i < 4 {
|
||||
v /= 1024.0;
|
||||
i += 1;
|
||||
}
|
||||
if i == 0 {
|
||||
format!("{n} B")
|
||||
} else {
|
||||
format!("{v:.1} {}", U[i])
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,8 @@ pub fn config_dir() -> PathBuf {
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Some(home) = invoking_user_home() {
|
||||
return default_dir(home);
|
||||
if let Some(u) = crate::owner::invoking_user() {
|
||||
return default_dir(u.home);
|
||||
}
|
||||
}
|
||||
default_dir(PathBuf::from(
|
||||
@@ -69,25 +69,6 @@ fn default_dir(home: PathBuf) -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// Se somos root por sudo/pkexec, a home de quem invocou.
|
||||
#[cfg(unix)]
|
||||
fn invoking_user_home() -> Option<PathBuf> {
|
||||
// SAFETY: geteuid não tem pré-condições.
|
||||
if unsafe { libc::geteuid() } != 0 {
|
||||
return None;
|
||||
}
|
||||
let user = std::env::var("SUDO_USER").ok().filter(|u| u != "root");
|
||||
let uid = std::env::var("PKEXEC_UID").ok();
|
||||
let passwd = std::fs::read_to_string("/etc/passwd").ok()?;
|
||||
for l in passwd.lines() {
|
||||
let f: Vec<&str> = l.split(':').collect();
|
||||
if f.len() >= 6 && (user.as_deref() == Some(f[0]) || uid.as_deref() == Some(f[2])) {
|
||||
return Some(PathBuf::from(f[5]));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn file() -> PathBuf {
|
||||
config_dir().join("settings.json")
|
||||
}
|
||||
@@ -100,19 +81,6 @@ pub fn load() -> Settings {
|
||||
}
|
||||
|
||||
pub fn save(s: &Settings) -> Result<()> {
|
||||
let dir = config_dir();
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let path = file();
|
||||
std::fs::write(&path, serde_json::to_vec_pretty(s)?)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Como root, devolve a posse ao dono da pasta para o app normal conseguir regravar.
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
if let Ok(m) = std::fs::metadata(&dir) {
|
||||
let _ = std::os::unix::fs::chown(&path, Some(m.uid()), Some(m.gid()));
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::owner::write_owned(&file(), serde_json::to_vec_pretty(s)?)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -219,8 +219,10 @@ fn jpeg(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
// ---------------------------------------------------------------- PNG
|
||||
|
||||
fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
let total = src.size().saturating_sub(off);
|
||||
let mut p = 8u64;
|
||||
let mut first = true;
|
||||
let mut saw_idat = false;
|
||||
for _ in 0..200_000 {
|
||||
let c = read_vec(src, off + p, 8).ok()?;
|
||||
if c.len() < 8 {
|
||||
@@ -228,13 +230,20 @@ fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
}
|
||||
let len = u32::from_be_bytes([c[0], c[1], c[2], c[3]]) as u64;
|
||||
let ty = &c[4..8];
|
||||
if !ty.iter().all(|b| b.is_ascii_alphabetic()) || len > 0x7FFF_FFFF {
|
||||
// Bloco válido: 4 letras, 3ª maiúscula (bit reservado), tamanho plausível e dentro do
|
||||
// disco. Texto ASCII solto parece "tamanho" de ~1,9 GB e não pode virar um PNG gigante.
|
||||
let valid = ty.iter().all(|b| b.is_ascii_alphabetic())
|
||||
&& ty[2].is_ascii_uppercase()
|
||||
&& len <= 128 * 1024 * 1024
|
||||
&& p + 12 + len <= total;
|
||||
if !valid {
|
||||
break;
|
||||
}
|
||||
if first && ty != b"IHDR" {
|
||||
return None;
|
||||
}
|
||||
first = false;
|
||||
saw_idat |= ty == b"IDAT";
|
||||
p += 12 + len;
|
||||
if p > max {
|
||||
break;
|
||||
@@ -247,11 +256,12 @@ fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
});
|
||||
}
|
||||
}
|
||||
if first {
|
||||
// Sem IEND: só vale como imagem truncada se já tem dados e é de tamanho razoável.
|
||||
if first || !saw_idat || p > 64 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
Some(Hit {
|
||||
len: p.min(max).min(src.size().saturating_sub(off)),
|
||||
len: p,
|
||||
ext: "png",
|
||||
confidence: Confidence::Low,
|
||||
})
|
||||
|
||||
@@ -274,3 +274,34 @@ pub fn open_path(path: &Path) -> io::Result<Arc<dyn Source>> {
|
||||
}
|
||||
Ok(Arc::new(FileSource::open(path)?))
|
||||
}
|
||||
|
||||
/// Identidade do conteúdo de um disco: SHA-256 do tamanho + primeiro e último MiB
|
||||
/// (tabela de partições, cabeçalhos e backup). Serve para reconhecer o mesmo disco
|
||||
/// quando ele volta com outro nome (`/dev/sda` virando `/dev/sdb`).
|
||||
pub fn fingerprint(src: &dyn Source) -> Option<String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
const MIB: u64 = 1024 * 1024;
|
||||
let size = src.size();
|
||||
let mut h = Sha256::new();
|
||||
h.update(size.to_le_bytes());
|
||||
let mut feed = |off: u64, len: u64| -> bool {
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
let mut got = 0;
|
||||
while got < buf.len() {
|
||||
match src.read_at(off + got as u64, &mut buf[got..]) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => got += n,
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
h.update(&buf[..got]);
|
||||
true
|
||||
};
|
||||
if !feed(0, size.min(MIB)) {
|
||||
return None;
|
||||
}
|
||||
if size > 2 * MIB && !feed(size - MIB, MIB) {
|
||||
return None;
|
||||
}
|
||||
Some(h.finalize().iter().map(|b| format!("{b:02x}")).collect())
|
||||
}
|
||||
@@ -31,6 +31,13 @@ impl Sink for Collect {
|
||||
}
|
||||
}
|
||||
|
||||
/// Isola as configurações/sessões automáticas dos testes numa pasta temporária.
|
||||
pub fn init() {
|
||||
static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
|
||||
let d = DIR.get_or_init(|| tempfile::tempdir().unwrap());
|
||||
std::env::set_var("GUARA_CONFIG_DIR", d.path());
|
||||
}
|
||||
|
||||
pub fn scan_path(path: &Path, opts: &ScanOptions) -> Collect {
|
||||
let src = source::open_path(path).unwrap();
|
||||
let mut sink = Collect::default();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
use guara_core::source::MemSource;
|
||||
use guara_core::*;
|
||||
use std::io::Write;
|
||||
|
||||
@@ -235,6 +236,7 @@ fn pausa_e_retoma_encontra_o_mesmo() {
|
||||
/// Streaming de pré-visualização: Range devolve exatamente os bytes do arquivo recuperado.
|
||||
#[test]
|
||||
fn file_slice_com_range_bate_com_o_arquivo_recuperado() {
|
||||
init();
|
||||
use std::io::Read;
|
||||
let Some(dir) = fixtures() else { return };
|
||||
let path = dir.join("ntfs.img");
|
||||
@@ -293,8 +295,8 @@ fn file_slice_com_range_bate_com_o_arquivo_recuperado() {
|
||||
|
||||
#[test]
|
||||
fn configuracoes_de_tema_persistem() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::env::set_var("GUARA_CONFIG_DIR", dir.path());
|
||||
init();
|
||||
let _ = std::fs::remove_file(settings::config_dir().join("settings.json"));
|
||||
assert_eq!(settings::load().theme, "auto");
|
||||
settings::save(&settings::Settings {
|
||||
theme: "dark".into(),
|
||||
@@ -302,3 +304,405 @@ fn configuracoes_de_tema_persistem() {
|
||||
.unwrap();
|
||||
assert_eq!(settings::load().theme, "dark");
|
||||
}
|
||||
|
||||
fn wait_done(eng: &service::Engine, job: &str) {
|
||||
for _ in 0..500 {
|
||||
if eng.status(job).unwrap()["status"] != "running" {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
panic!("job não terminou");
|
||||
}
|
||||
|
||||
/// Gera uma sessão no formato da v0.1.0 (sem os campos que vieram depois) a partir de uma
|
||||
/// varredura real da imagem, com o alvo informado.
|
||||
fn sessao_formato_v0_1_0(img: &std::path::Path, alvo: &str) -> tempfile::NamedTempFile {
|
||||
init();
|
||||
let eng = service::Engine::new();
|
||||
let job = eng
|
||||
.start_scan(img.to_str().unwrap(), ScanOptions::default())
|
||||
.unwrap();
|
||||
wait_done(&eng, &job);
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
eng.save_session(&job, tmp.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
let mut v: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(tmp.path()).unwrap()).unwrap();
|
||||
let o = v.as_object_mut().unwrap();
|
||||
o.remove("recoveries");
|
||||
o.remove("started_unix");
|
||||
o.remove("fingerprint");
|
||||
o.remove("source_path");
|
||||
o.insert("target".into(), alvo.into());
|
||||
for f in o["found"].as_array_mut().unwrap() {
|
||||
let f = f.as_object_mut().unwrap();
|
||||
for k in ["created", "accessed", "taken", "camera", "px", "gps", "sig"] {
|
||||
f.remove(k);
|
||||
}
|
||||
}
|
||||
std::fs::write(tmp.path(), serde_json::to_vec(&v).unwrap()).unwrap();
|
||||
tmp
|
||||
}
|
||||
|
||||
/// Uma sessão gravada pela v0.1.0 continua abrindo nas versões novas.
|
||||
#[test]
|
||||
fn sessao_da_v0_1_0_abre_e_funciona() {
|
||||
init();
|
||||
let Some(dir) = fixtures() else { return };
|
||||
let img = dir.join("ntfs.img");
|
||||
let tmp = sessao_formato_v0_1_0(&img, img.to_str().unwrap());
|
||||
let eng = service::Engine::new();
|
||||
let job = eng.load_session(tmp.path().to_str().unwrap()).unwrap();
|
||||
wait_done(&eng, &job);
|
||||
let st = eng.status(&job).unwrap();
|
||||
assert_eq!(st["status"], "done");
|
||||
assert_eq!(st["found"], 7);
|
||||
assert_eq!(st["source"], true);
|
||||
let res = eng.results(&job, &service::Query::default()).unwrap();
|
||||
assert_eq!(res["total"], 7);
|
||||
// recuperar a partir da sessão antiga continua dando o mesmo conteúdo
|
||||
let out = tempfile::tempdir().unwrap();
|
||||
let rj = eng
|
||||
.start_recover(
|
||||
&job,
|
||||
None,
|
||||
Some(service::Query::default()),
|
||||
out.path().to_str().unwrap(),
|
||||
recover::RecoverLayout::Folders,
|
||||
)
|
||||
.unwrap();
|
||||
wait_done(&eng, &rj);
|
||||
let got = hashes_in(out.path());
|
||||
for (h, p) in expected(&dir, "ntfs") {
|
||||
assert!(got.contains_key(&h), "faltou {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Origem indisponível não impede abrir a sessão; reapontar a origem religa tudo.
|
||||
#[test]
|
||||
fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
|
||||
init();
|
||||
let Some(dir) = fixtures() else { return };
|
||||
let img = dir.join("ntfs.img");
|
||||
let tmp = sessao_formato_v0_1_0(&img, "/dev/disco-que-nao-existe");
|
||||
// sessão pausada (como as de uma varredura interrompida)
|
||||
let mut v: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(tmp.path()).unwrap()).unwrap();
|
||||
v["done"] = serde_json::json!(false);
|
||||
std::fs::write(tmp.path(), serde_json::to_vec(&v).unwrap()).unwrap();
|
||||
let eng = service::Engine::new();
|
||||
let job = eng.load_session(tmp.path().to_str().unwrap()).unwrap();
|
||||
let st = eng.status(&job).unwrap();
|
||||
assert_eq!(st["source"], false);
|
||||
assert_eq!(st["found"], 7);
|
||||
assert!(st["warnings"][0]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("não está disponível"));
|
||||
// navegar e exportar funcionam
|
||||
assert_eq!(
|
||||
eng.results(&job, &service::Query::default()).unwrap()["total"],
|
||||
7
|
||||
);
|
||||
let csv = tempfile::NamedTempFile::new().unwrap();
|
||||
assert_eq!(
|
||||
eng.export_csv(&job, csv.path().to_str().unwrap(), None)
|
||||
.unwrap(),
|
||||
7
|
||||
);
|
||||
// pré-visualização, retomada e recuperação avisam com clareza
|
||||
assert_eq!(eng.thumb(&job, 1, 128).err(), Some(503));
|
||||
assert!(eng
|
||||
.resume(&job)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("não está disponível"));
|
||||
let out = tempfile::tempdir().unwrap();
|
||||
let e = eng
|
||||
.start_recover(
|
||||
&job,
|
||||
None,
|
||||
None,
|
||||
out.path().to_str().unwrap(),
|
||||
recover::RecoverLayout::Folders,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(e.to_string().contains("não está disponível"), "{e}");
|
||||
// reconecta
|
||||
eng.relink(&job, img.to_str().unwrap(), false).unwrap();
|
||||
let st = eng.status(&job).unwrap();
|
||||
assert_eq!(st["source"], true);
|
||||
assert!(st["warnings"].as_array().unwrap().is_empty());
|
||||
let rj = eng
|
||||
.start_recover(
|
||||
&job,
|
||||
None,
|
||||
None,
|
||||
out.path().to_str().unwrap(),
|
||||
recover::RecoverLayout::Folders,
|
||||
)
|
||||
.unwrap();
|
||||
wait_done(&eng, &rj);
|
||||
assert_eq!(hashes_in(out.path()).len(), 7);
|
||||
// arquivo que não é sessão
|
||||
std::fs::write(tmp.path(), b"nao e json").unwrap();
|
||||
let e = eng.load_session(tmp.path().to_str().unwrap()).unwrap_err();
|
||||
assert!(
|
||||
e.to_string().contains("não é uma sessão do Guará válida"),
|
||||
"{e}"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ desconexão e reconexão do disco
|
||||
|
||||
#[test]
|
||||
fn reconexao_so_aceita_o_mesmo_disco_ou_um_clone() {
|
||||
init();
|
||||
let Some(dir) = fixtures() else { return };
|
||||
let img = dir.join("ntfs.img");
|
||||
let tmp = sessao_formato_v0_1_0(&img, img.to_str().unwrap());
|
||||
let eng = service::Engine::new();
|
||||
let job = eng.load_session(tmp.path().to_str().unwrap()).unwrap();
|
||||
// sessão antiga não tem impressão digital: o relink aprende a do primeiro disco aceito
|
||||
eng.relink(&job, img.to_str().unwrap(), false).unwrap();
|
||||
let work = tempfile::tempdir().unwrap();
|
||||
// clone idêntico (por exemplo uma imagem do disco): aceito
|
||||
let clone = work.path().join("clone.img");
|
||||
std::fs::copy(&img, &clone).unwrap();
|
||||
eng.relink(&job, clone.to_str().unwrap(), false).unwrap();
|
||||
// mesmo tamanho, mas outro conteúdo inicial: recusado, a menos que force
|
||||
let outro = work.path().join("outro.img");
|
||||
let mut bytes = std::fs::read(&img).unwrap();
|
||||
bytes[0..16].copy_from_slice(b"OUTRO DISCO....!");
|
||||
std::fs::write(&outro, &bytes).unwrap();
|
||||
let e = eng
|
||||
.relink(&job, outro.to_str().unwrap(), false)
|
||||
.unwrap_err();
|
||||
assert!(e.to_string().contains("conteúdo inicial"), "{e}");
|
||||
eng.relink(&job, outro.to_str().unwrap(), true).unwrap();
|
||||
// tamanho diferente: recusado
|
||||
let menor = work.path().join("menor.img");
|
||||
std::fs::write(&menor, &bytes[..bytes.len() / 2]).unwrap();
|
||||
let e = eng
|
||||
.relink(&job, menor.to_str().unwrap(), false)
|
||||
.unwrap_err();
|
||||
assert!(e.to_string().contains("bytes"), "{e}");
|
||||
}
|
||||
|
||||
/// Fonte que "some" a partir de certo ponto, como um disco puxado da porta USB.
|
||||
struct Flaky {
|
||||
inner: MemSource,
|
||||
broken_from: u64,
|
||||
}
|
||||
|
||||
impl source::Source for Flaky {
|
||||
fn size(&self) -> u64 {
|
||||
self.inner.size()
|
||||
}
|
||||
fn read_at(&self, off: u64, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
if off + buf.len() as u64 > self.broken_from {
|
||||
return Err(std::io::Error::other("dispositivo removido"));
|
||||
}
|
||||
self.inner.read_at(off, buf)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn varredura_pausa_quando_o_disco_some_e_retoma_do_ponto_certo() {
|
||||
use guara_core::scan::{self, Outcome};
|
||||
let mib = 1024 * 1024usize;
|
||||
let mut disk = vec![0u8; 64 * mib];
|
||||
let png = |extra: usize| {
|
||||
let mut v = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
for (ty, data) in [
|
||||
(&b"IHDR"[..], vec![0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]),
|
||||
(&b"IDAT"[..], vec![5u8; extra]),
|
||||
(&b"IEND"[..], vec![]),
|
||||
] {
|
||||
v.extend((data.len() as u32).to_be_bytes());
|
||||
v.extend(ty);
|
||||
v.extend(&data);
|
||||
v.extend([0, 0, 0, 0]);
|
||||
}
|
||||
v
|
||||
};
|
||||
let (a, b) = (png(3000), png(4000));
|
||||
disk[2 * mib..2 * mib + a.len()].copy_from_slice(&a); // antes da falha
|
||||
disk[50 * mib..50 * mib + b.len()].copy_from_slice(&b); // depois da falha
|
||||
let flaky = Flaky {
|
||||
inner: MemSource(disk.clone()),
|
||||
broken_from: 16 * mib as u64,
|
||||
};
|
||||
let mut sink = Collect::default();
|
||||
let opts = ScanOptions::default();
|
||||
let out = scan::run(&flaky, &opts, None, &[], &|| false, &mut sink);
|
||||
let Outcome::Interrupted(cp) = out else {
|
||||
panic!("deveria ter parado por perda do dispositivo")
|
||||
};
|
||||
assert!(cp.io_lost, "deveria marcar perda de E/S");
|
||||
assert_eq!(sink.found.len(), 1, "só o PNG anterior à falha");
|
||||
// o ponto de retomada não pula os blocos que falharam
|
||||
assert!(cp.ranges[0].next <= 16 * mib as u64 + 8 * mib as u64);
|
||||
// disco volta: retoma e acha o resto, sem duplicar o primeiro
|
||||
let healthy = MemSource(disk);
|
||||
let before = sink.found.clone();
|
||||
let out = scan::run(&healthy, &opts, Some(cp), &before, &|| false, &mut sink);
|
||||
assert!(matches!(out, Outcome::Completed));
|
||||
let mut offs: Vec<_> = sink.found.iter().filter_map(|f| f.first_offset()).collect();
|
||||
offs.sort();
|
||||
assert_eq!(offs, vec![2 * mib as u64, 50 * mib as u64]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn varredura_salva_sessao_sozinha_e_ela_reabre() {
|
||||
init();
|
||||
let Some(dir) = fixtures() else { return };
|
||||
let img = dir.join("ntfs.img");
|
||||
let eng = service::Engine::new();
|
||||
let job = eng
|
||||
.start_scan(img.to_str().unwrap(), ScanOptions::default())
|
||||
.unwrap();
|
||||
wait_done(&eng, &job);
|
||||
// o salvamento final roda numa thread própria
|
||||
let mut item = None;
|
||||
for _ in 0..200 {
|
||||
let list = eng.sessions_list();
|
||||
item = list
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|v| v["target"] == img.to_str().unwrap() && v["status"] == "concluída")
|
||||
.cloned();
|
||||
if item.is_some() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(25));
|
||||
}
|
||||
let item = item.expect("sessão automática listada");
|
||||
assert_eq!(item["found"], 7);
|
||||
let path = item["path"].as_str().unwrap().to_string();
|
||||
assert!(std::path::Path::new(&path).exists());
|
||||
// reabre como qualquer sessão
|
||||
let job2 = eng.load_session(&path).unwrap();
|
||||
let st = eng.status(&job2).unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
st["status"].as_str().unwrap(),
|
||||
st["found"].as_u64().unwrap(),
|
||||
st["source"].as_bool().unwrap()
|
||||
),
|
||||
("done", 7, true)
|
||||
);
|
||||
// apagar
|
||||
eng.session_delete(&path).unwrap();
|
||||
assert!(!std::path::Path::new(&path).exists());
|
||||
assert!(eng.session_delete("/etc/passwd").is_err());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ organização e espaço
|
||||
|
||||
#[test]
|
||||
fn recuperar_organiza_por_tipo_data_ou_tudo_junto() {
|
||||
let Some(dir) = fixtures() else { return };
|
||||
let img = dir.join("fat32.img");
|
||||
let res = scan_path(
|
||||
&img,
|
||||
&ScanOptions {
|
||||
mode: ScanMode::Quick,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let src = source::open_path(&img).unwrap();
|
||||
let files: Vec<&Found> = res.found.iter().collect();
|
||||
for (layout, esperado) in [
|
||||
(
|
||||
recover::RecoverLayout::Folders,
|
||||
"Recuperados/Documentos/Trabalho/_elatorio.pdf",
|
||||
),
|
||||
(recover::RecoverLayout::Type, "Documentos/_elatorio.pdf"),
|
||||
(recover::RecoverLayout::Flat, "_elatorio.pdf"),
|
||||
] {
|
||||
let out = tempfile::tempdir().unwrap();
|
||||
recover::recover_with(
|
||||
src.as_ref(),
|
||||
&files,
|
||||
out.path(),
|
||||
layout,
|
||||
&|| false,
|
||||
&mut |_, _, _| {},
|
||||
)
|
||||
.unwrap();
|
||||
let achou = std::fs::read_dir(out.path()).is_ok()
|
||||
&& walk_names(out.path()).iter().any(|p| {
|
||||
p.ends_with(esperado) || p.ends_with(&esperado.replace("_elatorio", "relatorio"))
|
||||
});
|
||||
assert!(achou, "{layout:?}: {:?}", walk_names(out.path()));
|
||||
}
|
||||
// por data: usa a data de modificação (ano/mês)
|
||||
let out = tempfile::tempdir().unwrap();
|
||||
recover::recover_with(
|
||||
src.as_ref(),
|
||||
&files,
|
||||
out.path(),
|
||||
recover::RecoverLayout::Date,
|
||||
&|| false,
|
||||
&mut |_, _, _| {},
|
||||
)
|
||||
.unwrap();
|
||||
let nomes = walk_names(out.path());
|
||||
assert!(
|
||||
nomes
|
||||
.iter()
|
||||
.any(|p| p.matches('/').count() >= 2 && p.chars().take(4).all(|c| c.is_ascii_digit())),
|
||||
"{nomes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn walk_names(root: &std::path::Path) -> Vec<String> {
|
||||
let mut v = vec![];
|
||||
fn go(d: &std::path::Path, root: &std::path::Path, v: &mut Vec<String>) {
|
||||
for e in std::fs::read_dir(d).unwrap().flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
go(&p, root, v);
|
||||
} else {
|
||||
v.push(
|
||||
p.strip_prefix(root)
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
go(root, root, &mut v);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn espaco_insuficiente_no_destino_e_avisado_antes_de_copiar() {
|
||||
init();
|
||||
let Some(dir) = fixtures() else { return };
|
||||
let img = dir.join("ntfs.img");
|
||||
let tmp = sessao_formato_v0_1_0(&img, img.to_str().unwrap());
|
||||
// sessão com um arquivo "enorme" (1 EB)
|
||||
let mut v: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(tmp.path()).unwrap()).unwrap();
|
||||
v["found"][0]["size"] = serde_json::json!(1u64 << 60);
|
||||
std::fs::write(tmp.path(), serde_json::to_vec(&v).unwrap()).unwrap();
|
||||
let eng = service::Engine::new();
|
||||
let job = eng.load_session(tmp.path().to_str().unwrap()).unwrap();
|
||||
let out = tempfile::tempdir().unwrap();
|
||||
let e = eng
|
||||
.start_recover(
|
||||
&job,
|
||||
None,
|
||||
None,
|
||||
out.path().to_str().unwrap(),
|
||||
recover::RecoverLayout::Folders,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(e.to_string().contains("Espaço insuficiente"), "{e}");
|
||||
assert!(recover::free_space(out.path()).unwrap_or(0) > 0);
|
||||
}
|
||||
@@ -473,6 +473,7 @@ fn facetas_filtros_e_duplicadas() {
|
||||
mk(3, "c.jpg", Some("Viagens"), Some(jul23), None, Some(7)), // duplicada de a.jpg
|
||||
mk(4, "d.jpg", None, None, None, None),
|
||||
];
|
||||
let q = |json: &str| -> Query { serde_json::from_str(json).unwrap() };
|
||||
let f = explorer::facets(&found);
|
||||
assert_eq!(f["total"], 4);
|
||||
assert_eq!(f["years"][0]["year"], 2024);
|
||||
@@ -481,6 +482,8 @@ fn facetas_filtros_e_duplicadas() {
|
||||
assert_eq!(f["undated"], 1);
|
||||
assert_eq!(f["carved"], 1);
|
||||
assert_eq!(f["duplicates"], 1);
|
||||
assert_eq!(f["small"], 4); // todos têm 10 bytes
|
||||
assert_eq!(q(r#"{"min_size":30720}"#).select(&found).len(), 0);
|
||||
assert_eq!(f["cameras"][0]["name"], "Canon");
|
||||
let dcim = f["folders"]
|
||||
.as_array()
|
||||
@@ -490,7 +493,6 @@ fn facetas_filtros_e_duplicadas() {
|
||||
.unwrap();
|
||||
assert_eq!(dcim["count"], 2);
|
||||
|
||||
let q = |json: &str| -> Query { serde_json::from_str(json).unwrap() };
|
||||
assert_eq!(q(r#"{"year":2023}"#).select(&found).len(), 2);
|
||||
assert_eq!(q(r#"{"year":2023,"month":7}"#).select(&found).len(), 2);
|
||||
assert_eq!(q(r#"{"year":2023,"month":8}"#).select(&found).len(), 0);
|
||||
@@ -505,3 +507,95 @@ fn facetas_filtros_e_duplicadas() {
|
||||
assert_eq!(items[0].id, 2); // mais recente primeiro
|
||||
assert_eq!(items.last().unwrap().id, 4); // sem data por último
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ carving
|
||||
|
||||
fn png_chunk(ty: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut c = (data.len() as u32).to_be_bytes().to_vec();
|
||||
c.extend(ty);
|
||||
c.extend(data);
|
||||
c.extend([0, 0, 0, 0]);
|
||||
c
|
||||
}
|
||||
|
||||
/// Regressão: texto ASCII depois de um PNG truncado vira "tamanho" de ~1,9 GB;
|
||||
/// o carving não pode inventar um PNG gigante.
|
||||
#[test]
|
||||
fn png_truncado_seguido_de_texto_nao_vira_arquivo_gigante() {
|
||||
let mut d = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
d.extend(png_chunk(b"IHDR", &[0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]));
|
||||
d.extend(png_chunk(b"IDAT", &vec![7u8; 5000]));
|
||||
let real_end = d.len();
|
||||
d.extend(b"ualiBase64 texto qualquer que segue no disco, como parte de outro arquivo html ");
|
||||
d.resize(4 * 1024 * 1024, 0x41);
|
||||
let src = MemSource(d.clone());
|
||||
let hit =
|
||||
signatures::detect(&src, 0, &d[..64], 8 << 30).expect("PNG truncado ainda é reconhecido");
|
||||
assert_eq!(
|
||||
hit.len as usize, real_end,
|
||||
"deve parar no fim do último bloco válido"
|
||||
);
|
||||
assert_eq!(hit.confidence, Confidence::Low);
|
||||
|
||||
// sem nenhum dado de imagem (só cabeçalho) não é arquivo
|
||||
let mut d2 = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
d2.extend(png_chunk(b"IHDR", &[0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]));
|
||||
d2.resize(1 << 20, b'e');
|
||||
assert!(signatures::detect(&MemSource(d2.clone()), 0, &d2[..64], 8 << 30).is_none());
|
||||
|
||||
// PNG completo continua com confiança alta e tamanho exato
|
||||
let mut ok = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
ok.extend(png_chunk(b"IHDR", &[0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]));
|
||||
ok.extend(png_chunk(b"IDAT", &[1; 100]));
|
||||
ok.extend(png_chunk(b"IEND", &[]));
|
||||
let n = ok.len();
|
||||
ok.resize(1 << 20, 0);
|
||||
let h = signatures::detect(&MemSource(ok.clone()), 0, &ok[..64], 8 << 30).unwrap();
|
||||
assert_eq!((h.len as usize, h.confidence), (n, Confidence::High));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ abrir no player do sistema
|
||||
|
||||
/// O vídeo é copiado para uma pasta temporária e entregue ao aplicativo padrão do sistema
|
||||
/// (aqui um `xdg-open`/`open` falso grava o que recebeu).
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[test]
|
||||
fn abre_no_player_do_sistema() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let bin = tempfile::tempdir().unwrap();
|
||||
let marker = bin.path().join("recebido");
|
||||
let name = if cfg!(target_os = "linux") {
|
||||
"xdg-open"
|
||||
} else {
|
||||
"open"
|
||||
};
|
||||
let script = bin.path().join(name);
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!("#!/bin/sh\ncp \"$1\" '{}'\n", marker.display()),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let old = std::env::var("PATH").unwrap_or_default();
|
||||
std::env::set_var("PATH", format!("{}:{old}", bin.path().display()));
|
||||
let content: Vec<u8> = (0..5000u32).map(|i| (i * 7) as u8).collect();
|
||||
let mut f = resident("meu video.mp4", content.clone());
|
||||
f.id = 42;
|
||||
let path = external::open_found(&MemSource(vec![]), &f).unwrap();
|
||||
std::env::set_var("PATH", old);
|
||||
assert!(path
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with("42-"));
|
||||
for _ in 0..100 {
|
||||
if marker.exists() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
}
|
||||
assert_eq!(std::fs::read(&marker).unwrap(), content);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), content);
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
Reference in new issue
Block a user