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 = `
`;
+ 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() {
${icon('default')}
${esc(s.label)} ${esc(s.target)} `).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) => `
+ ${icon('default')}
+ ${esc((r.target || '').split(/[\\/]/).pop() || 'Sessão')} ${r.found.toLocaleString('pt-BR')} arquivos · ${esc(r.status)} · ${new Date(r.saved_at * 1000).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' })} `).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 ? 'Pausar Parar '
- : st.status === 'paused' ? 'Continuar ' : '';
+ : st.status === 'paused' ? `${st.source === false ? 'Reconectar o disco' : 'Continuar'} Escolher a origem… ` : '';
const relink = st.source === false ? 'Escolher a origem… ' : '';
const tools = relink + (!running && st.found ? 'Exportar CSV Relatório Salvar sessão… ' : '');
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) => `
${icon(c.device.kind)} ${esc(c.device.name)} ${esc(c.device.path)} · ${human(c.device.size)} ${badge(c)} `).join('') || '
Nenhum disco encontrado. Informe o caminho abaixo.
'}
+ Ou o caminho de um dispositivo ou imagem
+ Usar mesmo assim, mesmo que o conteúdo inicial seja diferente (por exemplo, uma cópia editada)
+ Cancelar Usar esta origem `;
+ 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 @@
Abrir arquivo de imagem…
Sessões
+ Recentes (salvas sozinhas)
+
Abrir sessão salva…
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);
+}