diff --git a/CHANGELOG.md b/CHANGELOG.md index d5c9446..107612a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index d3e529b..99a7163 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index c6429c7..f680b5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 3d414cb..686e513 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/apps/gui/src-tauri/Cargo.toml b/apps/gui/src-tauri/Cargo.toml index 275644b..7e0e333 100644 --- a/apps/gui/src-tauri/Cargo.toml +++ b/apps/gui/src-tauri/Cargo.toml @@ -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" diff --git a/apps/gui/src-tauri/tauri.conf.json b/apps/gui/src-tauri/tauri.conf.json index 13478bc..8c7b442 100644 --- a/apps/gui/src-tauri/tauri.conf.json +++ b/apps/gui/src-tauri/tauri.conf.json @@ -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" diff --git a/apps/gui/ui/app.css b/apps/gui/ui/app.css index bd87465..40595c7 100644 --- a/apps/gui/ui/app.css +++ b/apps/gui/ui/app.css @@ -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; } diff --git a/apps/gui/ui/app.js b/apps/gui/ui/app.js index 023f0b1..d239dda 100644 --- a/apps/gui/ui/app.js +++ b/apps/gui/ui/app.js @@ -108,8 +108,16 @@ function askForm({ title, text, fields, ok = 'OK' }) { return new Promise((resolve) => { const d = $('#dlg'); d.innerHTML = `

${esc(title)}

${text || ''}

- ${fields.map((f) => ``).join('')} + ${fields.map((f) => ``).join('')}
`; + 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() {
  • `).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) => ` +
  • `).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: `

    ${esc(e.message)}

    ` }); } } $('#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 ? `Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString('pt-BR')}/${st.enriching.total.toLocaleString('pt-BR')}` : ''; const buttons = running ? '' - : st.status === 'paused' ? '' : ''; + : st.status === 'paused' ? `` : ''; const relink = st.source === false ? '' : ''; const tools = relink + (!running && st.found ? '' : ''); if (running || st.status === 'paused') { @@ -468,17 +499,58 @@ function paintStatus() { + (st.error ? `
    ${warnSvg}
    ${esc(st.error)}
    ` : '')); } +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 ? 'mesmo disco' + : c.same_content === false ? 'tamanho igual, conteúdo diferente' + : c.same_size ? 'mesmo tamanho (precisa de administrador para conferir)' : ''); + d.innerHTML = `

    Escolher a origem

    +

    Esta sessão foi feita sobre ${esc(st.target || '')}${size ? ` (${human(size)})` : ''}. Escolha o disco que voltou ou uma imagem dele.

    +
    ${cands.map((c, i) => ``).join('') || '

    Nenhum disco encontrado. Informe o caminho abaixo.

    '}
    + + +
    `; + 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: `

    ${esc(err.message)}

    ` }); } + }; + 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 ${esc(S.status?.target || '')}. 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: `

    ${esc(err.message)}

    ` }); } - } + 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 outro disco. 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 `

    ${ok} arquivo(s) salvos em ${esc(dest)} (${human(r.total_bytes)}).

    ${bad ? `
    ${warnSvg}
    ${bad} arquivo(s) não puderam ser gravados.
    ` : ''} -

    O arquivo guara-manifesto.json lista o SHA-256 de cada arquivo para você conferir a integridade.

    `; +

    O arquivo guara-manifesto.json lista MD5, SHA-1 e SHA-256 de cada arquivo para você conferir a integridade.

    `; }); } catch (e) { info({ title: 'Não foi possível recuperar', html: `

    ${esc(e.message)}

    ` }); } } @@ -563,5 +638,6 @@ async function openJobFromList(s) { openJob(s); } return; } await loadDevices(); + await loadRecent(); renderHome(); })(); diff --git a/apps/gui/ui/index.html b/apps/gui/ui/index.html index df1cdee..936478a 100644 --- a/apps/gui/ui/index.html +++ b/apps/gui/ui/index.html @@ -24,6 +24,8 @@
    Sessões
    +
    Recentes (salvas sozinhas)
    + diff --git a/crates/guara-cli/src/main.rs b/crates/guara-cli/src/main.rs index a03cd77..060cc51 100644 --- a/crates/guara-cli/src/main.rs +++ b/crates/guara-cli/src/main.rs @@ -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, + /// 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" { diff --git a/crates/guara-core/src/carve.rs b/crates/guara-core/src/carve.rs index b7bea4c..c894f57 100644 --- a/crates/guara-core/src/carve.rs +++ b/crates/guara-core/src/carve.rs @@ -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. diff --git a/crates/guara-core/src/lib.rs b/crates/guara-core/src/lib.rs index 56a19e2..656bdb5 100644 --- a/crates/guara-core/src/lib.rs +++ b/crates/guara-core/src/lib.rs @@ -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; diff --git a/crates/guara-core/src/reconnect.rs b/crates/guara-core/src/reconnect.rs new file mode 100644 index 0000000..7d0dc90 --- /dev/null +++ b/crates/guara-core/src/reconnect.rs @@ -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)> { + if size == 0 && fp.is_none() { + return None; + } + let mut found: Vec<(String, Arc)> = 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 + } +} diff --git a/crates/guara-core/src/recover.rs b/crates/guara-core/src/recover.rs index 3a0ff19..7a0acf5 100644 --- a/crates/guara-core/src/recover.rs +++ b/crates/guara-core/src/recover.rs @@ -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 { + 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 { + 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 { 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 { + 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 = 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 { let mut out = std::fs::OpenOptions::new() diff --git a/crates/guara-core/src/scan.rs b/crates/guara-core/src/scan.rs index 0c54e0d..17d412b 100644 --- a/crates/guara-core/src/scan.rs +++ b/crates/guara-core/src/scan.rs @@ -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, pub next_id: u64, pub layout: Option, + /// 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); } diff --git a/crates/guara-core/src/service.rs b/crates/guara-core/src/service.rs index 7c61ab0..f3cf57b 100644 --- a/crates/guara-core/src/service.rs +++ b/crates/guara-core/src/service.rs @@ -64,6 +64,12 @@ pub struct Job { cancel: AtomicBool, stop_requested: AtomicBool, src: Mutex>>, + /// Caminho atualmente usado para ler a origem (pode mudar se o disco voltar com outro nome). + src_path: Mutex, + /// Impressão digital do disco (reconhecer o mesmo disco depois de reconectar). + fingerprint: Mutex>, + /// Arquivo de sessão salvo automaticamente. + session_file: Mutex>, st: Mutex, } @@ -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 { 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>, filter: Option, dest: &str, + layout: recover::RecoverLayout, ) -> Result { 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 { @@ -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) -> Result> { + 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 { + 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 = 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 = 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::(&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 { @@ -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, #[serde(default)] started_unix: Option, + #[serde(default)] + fingerprint: Option, + #[serde(default)] + source_path: Option, } use crate::explorer::cat_key; @@ -883,6 +1074,9 @@ fn open_error(path: &str, e: std::io::Error) -> Error { } fn spawn_scan(job: Arc, cp: Option) { + // 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, cp: Option) { 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) { + 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]) + } +} diff --git a/crates/guara-core/src/source.rs b/crates/guara-core/src/source.rs index 326f688..646854d 100644 --- a/crates/guara-core/src/source.rs +++ b/crates/guara-core/src/source.rs @@ -274,3 +274,34 @@ pub fn open_path(path: &Path) -> io::Result> { } 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 { + 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()) +} diff --git a/crates/guara-core/tests/common/mod.rs b/crates/guara-core/tests/common/mod.rs index cdf9097..a45cb7b 100644 --- a/crates/guara-core/tests/common/mod.rs +++ b/crates/guara-core/tests/common/mod.rs @@ -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 = 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(); diff --git a/crates/guara-core/tests/disk.rs b/crates/guara-core/tests/disk.rs index f107343..ce911aa 100644 --- a/crates/guara-core/tests/disk.rs +++ b/crates/guara-core/tests/disk.rs @@ -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 { + 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 { + let mut v = vec![]; + fn go(d: &std::path::Path, root: &std::path::Path, v: &mut Vec) { + 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); +}