diff --git a/CHANGELOG.md b/CHANGELOG.md index 441ccc4..fa6c8d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer. +## [0.3.1] - 2026-10-18 + +### Corrigido +- Abrir uma sessão salva falhava por inteiro quando a origem (por exemplo `/dev/sdb`) não estava acessível, e a mensagem de erro era vaga. Agora a sessão sempre abre: dá para navegar, exportar e gerar relatório, e o botão "Escolher a origem…" reconecta o disco ou uma imagem para voltar a pré-visualizar e recuperar. +- Mensagens claras quando o arquivo de sessão não pode ser lido (por exemplo, criado como administrador em `/root`) ou não é uma sessão válida. +- Teste automático garante que sessões gravadas pela v0.1.0 continuam abrindo. + ## [0.3.0] - 2026-10-18 ### Adicionado diff --git a/Cargo.lock b/Cargo.lock index 48edef1..25bf418 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -317,7 +317,7 @@ dependencies = [ [[package]] name = "guara-cli" -version = "0.3.0" +version = "0.3.1" dependencies = [ "clap", "ctrlc", @@ -329,7 +329,7 @@ dependencies = [ [[package]] name = "guara-core" -version = "0.3.0" +version = "0.3.1" dependencies = [ "flate2", "image", diff --git a/Cargo.toml b/Cargo.toml index b32f758..982cbe1 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.0" +version = "0.3.1" edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/lucasrguerra/guara" diff --git a/apps/gui/src-tauri/Cargo.toml b/apps/gui/src-tauri/Cargo.toml index 3b38d2a..95450de 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.0" +version = "0.3.1" 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 39a0e99..055a9d5 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.0", + "version": "0.3.1", "identifier": "dev.guara.app", "build": { "frontendDist": "../ui" diff --git a/apps/gui/ui/app.js b/apps/gui/ui/app.js index 102cf3f..6ccaa65 100644 --- a/apps/gui/ui/app.js +++ b/apps/gui/ui/app.js @@ -210,7 +210,7 @@ $('#loadSessionBtn').onclick = async () => { const s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() }; S.sessions.unshift(s); openJob(s); - } catch (e) { toast(e.message); } + } catch (e) { info({ title: 'Não foi possível abrir a sessão', html: `

${esc(e.message)}

` }); } }; // ------------------------------------------------------------------ início @@ -447,7 +447,8 @@ function paintStatus() { 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' ? '' : ''; - const tools = !running && st.found ? '' : ''; + const relink = st.source === false ? '' : ''; + const tools = relink + (!running && st.found ? '' : ''); if (running || st.status === 'paused') { setHTML($('#progressArea'), `
${esc(p.phase || 'Iniciando')}${pct != null ? ` · ${pct}%` : ''}
${buttons}
@@ -473,7 +474,12 @@ async function onProgressClick(e) { if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); } else if (act === 'stop') await rpc('scan_stop', { job: S.job }); else if (act === 'resume') { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); } - else if (act === 'csv') { + else if (act === '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 === 'csv') { const path = await ask({ title: 'Exportar para CSV', text: 'Grava os arquivos da vista atual (com os filtros aplicados). O arquivo abre direto no Excel.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-achados.csv', ok: 'Exportar' }); if (path) { try { const r = await rpc('export_csv', { job: S.job, path, query: EX?.query() }); toast(`${r.rows.toLocaleString('pt-BR')} linhas gravadas`); } catch (err) { toast(err.message); } } } else if (act === 'report') { diff --git a/apps/gui/ui/explorer.js b/apps/gui/ui/explorer.js index d84af74..3f2adce 100644 --- a/apps/gui/ui/explorer.js +++ b/apps/gui/ui/explorer.js @@ -279,7 +279,10 @@ export function createExplorer(deps) { if (img.tagName !== 'IMG' || !img.dataset.th) return; const wrap = img.closest('.xp-thumb, .xp-lth'); img.remove(); - wrap?.insertAdjacentHTML('afterbegin', `
${I.warn}?
`); + const offline = E.status && E.status.source === false; + wrap?.insertAdjacentHTML('afterbegin', offline + ? '
🖼IMG
' + : `
${I.warn}?
`); }, true); // ------------------------------------------------------------ seleção diff --git a/crates/guara-core/src/service.rs b/crates/guara-core/src/service.rs index 5767207..43f7d5f 100644 --- a/crates/guara-core/src/service.rs +++ b/crates/guara-core/src/service.rs @@ -63,7 +63,7 @@ pub struct Job { opts: ScanOptions, cancel: AtomicBool, stop_requested: AtomicBool, - src: Option>, + src: Mutex>>, st: Mutex, } @@ -82,7 +82,7 @@ impl Job { opts, cancel: AtomicBool::new(false), stop_requested: AtomicBool::new(false), - src, + src: Mutex::new(src), st: Mutex::new(State { status: Status::Running, progress: Progress::default(), @@ -105,6 +105,9 @@ impl Job { }), }) } + fn source(&self) -> Option> { + self.src.lock().unwrap().clone() + } fn cancelled(&self) -> bool { self.cancel.load(Ordering::Relaxed) } @@ -254,6 +257,7 @@ impl Engine { "error": s.error, "report": s.report, "elapsed": elapsed, + "source": j.source().is_some(), "hits": s.hits.len(), "enriching": s.enrich.map(|(d, t)| json!({ "done": d, "total": t })), })) @@ -294,7 +298,7 @@ impl Engine { if let Some(v) = self.thumbs.lock().unwrap().get(&key) { return if v.is_empty() { Err(422) } else { Ok(v) }; } - let (f, src) = self.open_file(job, id).map_err(|_| 404u16)?; + let (f, src) = self.open_file(job, id).map_err(status_of)?; if f.category != Category::Image { return Err(415); } @@ -319,10 +323,7 @@ impl Engine { .index .get(&file) .ok_or_else(|| Error::msg("arquivo não encontrado"))?; - Ok(( - s.found[i].clone(), - j.src.clone().ok_or_else(|| Error::msg("sem origem"))?, - )) + Ok((s.found[i].clone(), j.source().ok_or_else(no_source)?)) } // ---------------------------------------------------------------- recuperação @@ -335,7 +336,7 @@ impl Engine { dest: &str, ) -> Result { let sj = self.job(scan_id)?; - let src = sj.src.clone().ok_or_else(|| Error::msg("sem origem"))?; + let src = sj.source().ok_or_else(no_source)?; let files: Vec = { let s = sj.st.lock().unwrap(); match (&ids, &filter) { @@ -611,13 +612,31 @@ impl Engine { } pub fn load_session(&self, path: &str) -> Result { - let data: SessionFile = serde_json::from_slice(&std::fs::read(path)?)?; - let src = open_path(Path::new(&data.target)).map_err(|e| open_error(&data.target, e))?; + let bytes = std::fs::read(path).map_err(|e| { + if e.kind() == std::io::ErrorKind::PermissionDenied { + Error::msg(format!( + "Sem permissão para ler a sessão {path}. Se ela foi criada como administrador (por exemplo em /root), abra o Guará como administrador ou copie o arquivo para a sua pasta." + )) + } else { + Error::msg(format!("Não foi possível ler a sessão {path}: {e}")) + } + })?; + let data: SessionFile = serde_json::from_slice(&bytes).map_err(|e| { + Error::msg(format!( + "O arquivo {path} não é uma sessão do Guará válida (ou foi criado por uma versão incompatível): {e}" + )) + })?; + // 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(), - Some(src), + src, ); { let mut s = job.st.lock().unwrap(); @@ -636,12 +655,45 @@ 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.", + data.target + )); + } 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 + ))); + } + } + } + *j.src.lock().unwrap() = Some(src); + j.st.lock() + .unwrap() + .warnings + .retain(|w| !w.starts_with("A origem ")); + if j.st.lock().unwrap().status == Status::Done { + spawn_enrich(j); + } + Ok(()) + } + // ---------------------------------------------------------------- RPC pub fn rpc(&self, method: &str, p: Value) -> Result { @@ -758,6 +810,10 @@ impl Engine { self.save_session(&s("job")?, &s("path")?)?; json!({}) } + "session_relink" => { + self.relink(&s("job")?, &s("path")?)?; + json!({}) + } "session_load" => json!({ "job": self.load_session(&s("path")?)? }), "defaults" => { let home = std::env::var("HOME") @@ -793,6 +849,19 @@ struct SessionFile { use crate::explorer::cat_key; +/// 503 quando a origem da sessão está desconectada; 404 nos demais casos. +fn status_of(e: Error) -> u16 { + if e.to_string().contains("não está disponível") { + 503 + } else { + 404 + } +} + +fn no_source() -> Error { + Error::msg("A origem desta sessão não está disponível. Reconecte o disco ou escolha a origem para pré-visualizar e recuperar.") +} + fn open_error(path: &str, e: std::io::Error) -> Error { if e.kind() == std::io::ErrorKind::PermissionDenied { Error::msg(format!( @@ -805,7 +874,12 @@ fn open_error(path: &str, e: std::io::Error) -> Error { fn spawn_scan(job: Arc, cp: Option) { std::thread::spawn(move || { - let src = job.src.clone().expect("origem"); + let Some(src) = job.source() else { + let mut s = job.st.lock().unwrap(); + s.status = Status::Error; + s.error = Some(no_source().to_string()); + return; + }; let existing: Vec = job.st.lock().unwrap().found.clone(); let cancelled = || job.cancelled(); let mut sink = JobSink(job.clone()); @@ -845,7 +919,7 @@ fn spawn_scan(job: Arc, cp: Option) { /// navegar por data, câmera e resolução. fn spawn_enrich(job: Arc) { std::thread::spawn(move || { - let Some(src) = job.src.clone() else { return }; + let Some(src) = job.source() else { return }; let todo: Vec<(u64, Found)> = { let s = job.st.lock().unwrap(); s.found @@ -1045,7 +1119,7 @@ impl Engine { id: u64, range: Option<&str>, ) -> std::result::Result { - let (found, src) = self.open_file(job, id).map_err(|_| 404u16)?; + let (found, src) = self.open_file(job, id).map_err(status_of)?; let total = found.size; let (mut start, mut end, mut partial) = (0u64, total.saturating_sub(1), false); if let Some(spec) = range.and_then(|r| r.strip_prefix("bytes=")) { diff --git a/crates/guara-core/tests/data/sessao-v0.1.0.json b/crates/guara-core/tests/data/sessao-v0.1.0.json new file mode 100644 index 0000000..3b3ace7 --- /dev/null +++ b/crates/guara-core/tests/data/sessao-v0.1.0.json @@ -0,0 +1 @@ +{"version": 1, "target": "@@ALVO@@", "options": {"mode": "deep", "include_allocated": false, "categories": [], "max_carve_size": 8589934592}, "found": [{"id": 1, "name": "tese.docx", "dir": null, "ext": "docx", "category": "document", "size": 411277, "origin": "ntfs", "confidence": "high", "data": {"kind": "extents", "extents": [{"offset": 52428800, "len": 411277}]}, "modified": 1792346095, "note": null}, {"id": 2, "name": "notas.txt", "dir": null, "ext": "txt", "category": "document", "size": 945617, "origin": "ntfs", "confidence": "medium", "data": {"kind": "extents", "extents": [{"offset": 52842496, "len": 945617}]}, "modified": 1792346095, "note": null}, {"id": 3, "name": "relatorio.pdf", "dir": null, "ext": "pdf", "category": "document", "size": 646931, "origin": "ntfs", "confidence": "high", "data": {"kind": "extents", "extents": [{"offset": 53788672, "len": 646931}]}, "modified": 1792346095, "note": null}, {"id": 4, "name": "dados.bin", "dir": null, "ext": "bin", "category": "other", "size": 3000000, "origin": "ntfs", "confidence": "medium", "data": {"kind": "extents", "extents": [{"offset": 54435840, "len": 3000000}]}, "modified": 1792346095, "note": null}, {"id": 5, "name": "contatos.db", "dir": null, "ext": "db", "category": "database", "size": 573440, "origin": "ntfs", "confidence": "high", "data": {"kind": "extents", "extents": [{"offset": 57438208, "len": 573440}]}, "modified": 1792346095, "note": null}, {"id": 6, "name": "praia.jpg", "dir": null, "ext": "jpg", "category": "image", "size": 511391, "origin": "ntfs", "confidence": "high", "data": {"kind": "extents", "extents": [{"offset": 58011648, "len": 511391}]}, "modified": 1792346096, "note": null}, {"id": 7, "name": "montanha.png", "dir": null, "ext": "png", "category": "image", "size": 3337460, "origin": "ntfs", "confidence": "high", "data": {"kind": "extents", "extents": [{"offset": 58638336, "len": 3337460}]}, "modified": 1792346096, "note": null}], "checkpoint": null, "layout": {"size": 100663296, "table": "none", "partitions": [{"index": 0, "start": 0, "len": 100663296, "fs": "ntfs", "label": null, "lost": false, "encrypted": null, "table_type": "volume sem tabela de partições"}], "gaps": [], "warnings": []}, "warnings": [], "done": true} \ No newline at end of file diff --git a/crates/guara-core/tests/disk.rs b/crates/guara-core/tests/disk.rs index e06a5b6..c9436fc 100644 --- a/crates/guara-core/tests/disk.rs +++ b/crates/guara-core/tests/disk.rs @@ -302,3 +302,102 @@ fn configuracoes_de_tema_persistem() { .unwrap(); assert_eq!(settings::load().theme, "dark"); } + +fn wait_done(eng: &service::Engine, job: &str) { + for _ in 0..500 { + if eng.status(job).unwrap()["status"] != "running" { + return; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + panic!("job não terminou"); +} + +/// Uma sessão gravada pela v0.1.0 continua abrindo nas versões novas. +#[test] +fn sessao_da_v0_1_0_abre_e_funciona() { + let Some(dir) = fixtures() else { return }; + let img = dir.join("ntfs.img"); + let raw = include_str!("data/sessao-v0.1.0.json").replace("@@ALVO@@", img.to_str().unwrap()); + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), raw).unwrap(); + let eng = service::Engine::new(); + let job = eng.load_session(tmp.path().to_str().unwrap()).unwrap(); + wait_done(&eng, &job); + let st = eng.status(&job).unwrap(); + assert_eq!(st["status"], "done"); + assert_eq!(st["found"], 7); + assert_eq!(st["source"], true); + let res = eng.results(&job, &service::Query::default()).unwrap(); + assert_eq!(res["total"], 7); + // recuperar a partir da sessão antiga continua dando o mesmo conteúdo + let out = tempfile::tempdir().unwrap(); + let rj = eng + .start_recover( + &job, + None, + Some(service::Query::default()), + out.path().to_str().unwrap(), + ) + .unwrap(); + wait_done(&eng, &rj); + let got = hashes_in(out.path()); + for (h, p) in expected(&dir, "ntfs") { + assert!(got.contains_key(&h), "faltou {p}"); + } +} + +/// Origem indisponível não impede abrir a sessão; reapontar a origem religa tudo. +#[test] +fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() { + let Some(dir) = fixtures() else { return }; + let img = dir.join("ntfs.img"); + let raw = + include_str!("data/sessao-v0.1.0.json").replace("@@ALVO@@", "/dev/disco-que-nao-existe"); + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), raw).unwrap(); + let eng = service::Engine::new(); + let job = eng.load_session(tmp.path().to_str().unwrap()).unwrap(); + let st = eng.status(&job).unwrap(); + assert_eq!(st["source"], false); + assert_eq!(st["found"], 7); + assert!(st["warnings"][0] + .as_str() + .unwrap() + .contains("não está disponível")); + // navegar e exportar funcionam + assert_eq!( + eng.results(&job, &service::Query::default()).unwrap()["total"], + 7 + ); + let csv = tempfile::NamedTempFile::new().unwrap(); + assert_eq!( + eng.export_csv(&job, csv.path().to_str().unwrap(), None) + .unwrap(), + 7 + ); + // pré-visualização e recuperação avisam com clareza + assert_eq!(eng.thumb(&job, 1, 128).err(), Some(503)); + let out = tempfile::tempdir().unwrap(); + let e = eng + .start_recover(&job, None, None, out.path().to_str().unwrap()) + .unwrap_err(); + assert!(e.to_string().contains("não está disponível"), "{e}"); + // reconecta + eng.relink(&job, img.to_str().unwrap()).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()) + .unwrap(); + wait_done(&eng, &rj); + assert_eq!(hashes_in(out.path()).len(), 7); + // arquivo que não é sessão + std::fs::write(tmp.path(), b"nao e json").unwrap(); + let e = eng.load_session(tmp.path().to_str().unwrap()).unwrap_err(); + assert!( + e.to_string().contains("não é uma sessão do Guará válida"), + "{e}" + ); +}