1 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
19 changed files with 1011 additions and 97 deletions

No files matched your search

+12
View File
@@ -2,6 +2,18 @@
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
Generated
+2 -2
View File
@@ -317,7 +317,7 @@ dependencies = [
[[package]]
name = "guara-cli"
version = "0.3.2"
version = "0.4.0"
dependencies = [
"clap",
"ctrlc",
@@ -329,7 +329,7 @@ dependencies = [
[[package]]
name = "guara-core"
version = "0.3.2"
version = "0.4.0"
dependencies = [
"flate2",
"image",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/guara-core", "crates/guara-cli"]
[workspace.package]
version = "0.3.2"
version = "0.4.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara"
+3 -1
View File
@@ -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.2"
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.2",
"version": "0.4.0",
"identifier": "dev.guara.app",
"build": {
"frontendDist": "../ui"
+4
View File
@@ -460,3 +460,7 @@ dialog.lightbox[open] { display: grid; }
.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; }
+90 -14
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 () => {
@@ -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);
@@ -446,7 +477,7 @@ 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>' : '';
: 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') {
@@ -468,17 +499,58 @@ 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 === 'relink') {
const path = await ask({ title: 'Escolher a origem', text: `Esta sessão foi feita sobre <code>${esc(S.status?.target || '')}</code>. Informe o dispositivo ou a imagem do mesmo disco para pré-visualizar e recuperar.`, value: S.status?.target || '', ok: 'Usar esta origem' });
if (path) {
try { await rpc('session_relink', { job: S.job, path }); toast('Origem reconectada'); const cur = { job: S.job, target: S.status.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>` }); }
}
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); } }
@@ -501,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>` }); }
}
@@ -563,5 +638,6 @@ async function openJobFromList(s) { openJob(s); }
return;
}
await loadDevices();
await loadRecent();
renderHome();
})();
+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>
+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" {
+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.
+1
View File
@@ -15,6 +15,7 @@ pub mod image;
pub mod meta;
pub mod owner;
pub mod partition;
pub mod reconnect;
pub mod recover;
pub mod report;
pub mod scan;
+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
}
}
+107 -12
View File
@@ -72,12 +72,57 @@ 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],
dest: &Path,
cancelled: &(dyn Fn() -> bool + Sync),
progress: &mut dyn FnMut(usize, usize, u64),
) -> Result<RecoverReport> {
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 {
@@ -91,7 +136,7 @@ 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) = crate::owner::create_dir_all_owned(parent) {
@@ -151,25 +196,75 @@ pub fn recover(
Ok(report)
}
fn target_rel(f: &Found) -> PathBuf {
fn target_rel(f: &Found, layout: RecoverLayout) -> PathBuf {
let mut p = PathBuf::new();
match &f.dir {
Some(d) if !d.is_empty() => {
p.push("Recuperados");
for c in d.split('/') {
if !c.is_empty() {
p.push(sanitize_name(c));
match layout {
RecoverLayout::Folders => match &f.dir {
Some(d) if !d.is_empty() => {
p.push("Recuperados");
for c in d.split('/') {
if !c.is_empty() {
p.push(sanitize_name(c));
}
}
}
}
_ => {
p.push(f.category.label_pt());
}
_ => p.push(f.category.label_pt()),
},
RecoverLayout::Type => p.push(f.category.label_pt()),
RecoverLayout::Date => match f.when() {
Some(t) => {
let (y, m, _) = crate::meta::ymd(t);
p.push(format!("{y:04}"));
p.push(format!("{y:04}-{m:02}"));
}
None => p.push("Sem data"),
},
RecoverLayout::Flat => {}
}
p.push(sanitize_name(&f.name));
p
}
/// Espaço livre (bytes) no volume onde `path` está (ou estaria).
pub fn free_space(path: &Path) -> Option<u64> {
let mut p = path;
while !p.exists() {
p = p.parent()?;
}
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
let c = std::ffi::CString::new(p.as_os_str().as_bytes()).ok()?;
// SAFETY: `c` é uma string C válida e `st` é uma estrutura de saída local.
let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 {
return None;
}
Some((st.f_bavail as u64).saturating_mul(st.f_frsize as u64))
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW;
let wide: Vec<u16> = p
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut free = 0u64;
// SAFETY: `wide` termina em NUL e o ponteiro de saída é válido.
let ok = unsafe {
GetDiskFreeSpaceExW(
wide.as_ptr(),
&mut free,
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
(ok != 0).then_some(free)
}
}
/// Escreve o conteúdo e devolve o SHA-256.
pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<Hashes> {
let mut out = std::fs::OpenOptions::new()
+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);
}
+361 -51
View File
@@ -64,6 +64,12 @@ pub struct Job {
cancel: AtomicBool,
stop_requested: AtomicBool,
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: 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(),
@@ -197,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())
}
@@ -217,17 +229,35 @@ impl Engine {
pub fn resume(&self, id: &str) -> Result<()> {
let j = self.job(id)?;
if j.source().is_none() {
return Err(no_source());
}
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);
@@ -261,6 +291,8 @@ impl Engine {
"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 })),
}))
@@ -337,6 +369,7 @@ 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.source().ok_or_else(no_source)?;
@@ -355,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,
@@ -375,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) => {
@@ -597,21 +647,7 @@ 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,
};
crate::owner::write_owned(Path::new(path), serde_json::to_vec(&data)?)?;
Ok(())
write_session(&j, Path::new(path))
}
pub fn load_session(&self, path: &str) -> Result<String> {
@@ -629,18 +665,17 @@ impl Engine {
"O arquivo {path} não é uma sessão do Guará válida (ou foi criado por uma versão incompatível): {e}"
))
})?;
// A sessão abre mesmo sem a origem: dá para navegar, exportar e gerar relatório.
// Pré-visualização e recuperação voltam quando a origem for reconectada.
let (src, missing) = match open_path(Path::new(&data.target)) {
Ok(s) => (Some(s), None),
Err(e) => (None, Some(open_error(&data.target, e).to_string())),
};
let job = self.register(
JobKind::Scan,
data.target.clone(),
data.options.clone(),
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() {
@@ -658,45 +693,181 @@ impl Engine {
Status::Paused
};
}
if let Some(m) = missing {
job.st.lock().unwrap().warnings.push(format!(
"A origem {} não está disponível agora ({m}). Você pode navegar, exportar e gerar relatório; para pré-visualizar e recuperar, reconecte o disco ou escolha a origem.",
// 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())
}
/// Aponta uma sessão carregada para a origem (disco ou imagem) atual.
pub fn relink(&self, id: &str, path: &str) -> Result<()> {
let j = self.job(id)?;
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
{
let s = j.st.lock().unwrap();
if let Some(l) = &s.layout {
if l.size != 0 && l.size != src.size() {
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(),
l.size
)));
/// 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 "));
.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> {
@@ -815,7 +986,12 @@ 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" => {
@@ -823,7 +999,18 @@ impl Engine {
json!({})
}
"session_relink" => {
self.relink(&s("job")?, &s("path")?)?;
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")?)? }),
@@ -855,6 +1042,10 @@ 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;
@@ -883,6 +1074,9 @@ 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 Some(src) = job.source() else {
let mut s = job.st.lock().unwrap();
@@ -912,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);
}
}
@@ -1167,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])
}
}
+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();
+283 -5
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(),
@@ -316,6 +318,7 @@ fn wait_done(eng: &service::Engine, job: &str) {
/// 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())
@@ -329,6 +332,8 @@ fn sessao_formato_v0_1_0(img: &std::path::Path, alvo: &str) -> tempfile::NamedTe
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();
@@ -343,6 +348,7 @@ fn sessao_formato_v0_1_0(img: &std::path::Path, alvo: &str) -> tempfile::NamedTe
/// 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());
@@ -363,6 +369,7 @@ fn sessao_da_v0_1_0_abre_e_funciona() {
None,
Some(service::Query::default()),
out.path().to_str().unwrap(),
recover::RecoverLayout::Folders,
)
.unwrap();
wait_done(&eng, &rj);
@@ -375,9 +382,15 @@ fn sessao_da_v0_1_0_abre_e_funciona() {
/// 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();
@@ -407,16 +420,28 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
.contains("não está disponível"));
let out = tempfile::tempdir().unwrap();
let e = eng
.start_recover(&job, None, None, out.path().to_str().unwrap())
.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()).unwrap();
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())
.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);
@@ -428,3 +453,256 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
"{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);
}