6 Commits
Author SHA1 Message Date
Lucas RayanandClaude Sonnet 5.5 4bb2397e3b feat: reconexão do disco, pausa por desconexão, sessões automáticas e organização (0.4.0)
Release / Cria a release (push) Canceled after 0s
Release / CLI (linux-aarch64) (push) Canceled after 0s
Release / CLI (linux-x86_64) (push) Canceled after 0s
Release / CLI (macos-aarch64) (push) Canceled after 0s
Release / CLI (macos-x86_64) (push) Canceled after 0s
Release / CLI (windows-x86_64) (push) Canceled after 0s
Release / App desktop (linux-x86_64) (push) Canceled after 0s
Release / App desktop (macos-aarch64) (push) Canceled after 0s
Release / App desktop (macos-x86_64) (push) Canceled after 0s
Release / App desktop (windows-x86_64) (push) Canceled after 0s
Release / Publica a release (push) Canceled after 0s
- Reconhece o mesmo disco por tamanho + impressão digital quando volta com outro nome.
- Varredura pausa sozinha (sem perder nada) se o dispositivo parar de responder.
- Sessões salvas automaticamente (atômico) e listadas em Recentes.
- Organização ao recuperar (pastas, tipo, data, tudo junto) e checagem de espaço livre.
- Botão de escolher a origem também em sessões pausadas.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 19:32:00 -03:00
Lucas RayanandClaude Sonnet 5.5 97f940f261 fix: vídeo no app Linux travava a janela; abrir no player do sistema (0.3.2)
Release / Cria a release (push) Canceled after 0s
Release / CLI (linux-x86_64) (push) Canceled after 0s
Release / CLI (macos-aarch64) (push) Canceled after 0s
Release / CLI (macos-x86_64) (push) Canceled after 0s
Release / CLI (windows-x86_64) (push) Canceled after 0s
Release / App desktop (linux-x86_64) (push) Canceled after 0s
Release / CLI (linux-aarch64) (push) Canceled after 0s
Release / App desktop (macos-aarch64) (push) Canceled after 0s
Release / App desktop (macos-x86_64) (push) Canceled after 0s
Release / App desktop (windows-x86_64) (push) Canceled after 0s
Release / Publica a release (push) Canceled after 0s
O AppImage traz o WebKitGTK sem os plugins de mídia do GStreamer; criar um <video>
derrubava o processo da página. O app Linux não cria mais <video>/<audio>: oferece
"Abrir no player do sistema". Clicar no preview abre a visualização grande.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:09:28 -03:00
Lucas RayanandClaude Sonnet 5.5 282161d297 test: sessão no formato da v0.1.0 gerada em tempo de teste
Release / Cria a release (push) Canceled after 0s
Release / CLI (linux-aarch64) (push) Canceled after 0s
Release / CLI (linux-x86_64) (push) Canceled after 0s
Release / CLI (macos-aarch64) (push) Canceled after 0s
Release / CLI (macos-x86_64) (push) Canceled after 0s
Release / CLI (windows-x86_64) (push) Canceled after 0s
Release / App desktop (linux-x86_64) (push) Canceled after 0s
Release / App desktop (macos-aarch64) (push) Canceled after 0s
Release / App desktop (macos-x86_64) (push) Canceled after 0s
Release / App desktop (windows-x86_64) (push) Canceled after 0s
Release / Publica a release (push) Canceled after 0s
O arquivo estático dependia do conteúdo aleatório da imagem local e falhava no CI.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 16:47:56 -03:00
Lucas RayanandClaude Sonnet 5.5 b9bef85bc7 fix: PNG truncado não vira arquivo gigante; filtros de tamanho e megapixels
Descoberto ao abrir uma sessão real: texto ASCII após um PNG truncado era lido
como tamanho de bloco (~1,9 GB). Também adiciona filtros para separar fotos de
ícones e falha cedo ao retomar sem a origem.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 16:44:03 -03:00
Lucas RayanandClaude Sonnet 5.5 354caaf8f0 fix: tudo que o Guará grava como root pertence ao usuário que o abriu
Sessões, CSV, relatórios, imagens e arquivos recuperados (e as pastas criadas)
passam ao dono do sudo/pkexec, e a pasta sugerida é a do usuário, não /root.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 16:40:13 -03:00
Lucas RayanandClaude Sonnet 5.5 682ba8b943 fix: sessões abrem mesmo sem a origem; mensagens claras de erro (0.3.1)
Antes, abrir uma sessão cuja origem não estava acessível (ou cujo arquivo não podia
ser lido, como em /root) falhava por inteiro com erro vago. Agora a sessão abre,
permite navegar/exportar e a origem pode ser reconectada. Testes cobrem sessões
gravadas pela v0.1.0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 16:37:25 -03:00
31 changed files with 1788 additions and 205 deletions

No files matched your search

+33
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+4 -2
View File
@@ -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.
+1 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+2
View File
@@ -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>
+26 -1
View File
@@ -131,8 +131,10 @@ async function image(host, item, url, state, thumb) {
if (state.dead) return resolve();
host.innerHTML = '';
host.appendChild(img);
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; }
+30 -1
View File
@@ -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" {
+6 -75
View File
@@ -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()
+1
View File
@@ -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"
+24 -5
View File
@@ -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.
+9
View File
@@ -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,
})
}
+110
View File
@@ -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(())
}
+103
View File
@@ -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
}
+2 -1
View File
@@ -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)
}
+4
View File
@@ -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;
+144
View File
@@ -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());
}
}
+44
View File
@@ -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
}
}
+106 -10
View File
@@ -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,16 +189,17 @@ 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 {
match layout {
RecoverLayout::Folders => match &f.dir {
Some(d) if !d.is_empty() => {
p.push("Recuperados");
for c in d.split('/') {
@@ -162,20 +208,70 @@ fn target_rel(f: &Found) -> PathBuf {
}
}
}
_ => {
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 } => {
+7 -3
View File
@@ -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);
}
+439 -41
View File
@@ -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])
}
}
+3 -35
View File
@@ -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(())
}
+13 -3
View File
@@ -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,
})
+31
View File
@@ -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())
}
+7
View File
@@ -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();
+406 -2
View File
@@ -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);
}
+95 -1
View File
@@ -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);
}