feat: reconexão do disco, pausa por desconexão, sessões automáticas e organização (0.4.0)
- Reconhece o mesmo disco por tamanho + impressão digital quando volta com outro nome. - Varredura pausa sozinha (sem perder nada) se o dispositivo parar de responder. - Sessões salvas automaticamente (atômico) e listadas em Recentes. - Organização ao recuperar (pastas, tipo, data, tudo junto) e checagem de espaço livre. - Botão de escolher a origem também em sessões pausadas. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
97f940f261
commit
fd662d2813
19 files changed
+1011
-97
No files matched your search
@@ -2,6 +2,18 @@
|
||||
|
||||
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
||||
|
||||
## [0.4.0] - 2026-10-18
|
||||
|
||||
### Adicionado
|
||||
- **Reconexão do disco:** se o disco foi desconectado e voltou com outro nome (por exemplo `/dev/sda` virando `/dev/sdb`), o Guará o reconhece sozinho pelo tamanho e pela impressão digital do conteúdo (primeiro e último MiB) e religa a sessão. Se não conseguir, a tela mostra "Reconectar o disco" e "Escolher a origem…", com a lista dos discos conectados e o selo "mesmo disco". Uma origem com conteúdo diferente é recusada, a menos que você marque "usar mesmo assim" (útil para cópias).
|
||||
- **Desconexão no meio da varredura:** se as leituras falharem várias vezes seguidas, a varredura pausa sozinha com o ponto exato salvo e avisa para reconectar e continuar, em vez de seguir pulando blocos e terminar com buracos.
|
||||
- **Sessões salvas automaticamente** de tempos em tempos durante a varredura e ao terminar ou pausar, na pasta de configuração do usuário, e listadas em "Recentes" na barra lateral (`guara sessions` no terminal). Gravação atômica, sem risco de corromper a sessão se o programa fechar no meio.
|
||||
- **Organização ao recuperar:** pastas originais, por tipo, por data (ano e mês) ou tudo na mesma pasta (`--organize` no terminal).
|
||||
- **Verificação de espaço livre** no destino antes de copiar, com mensagem clara se não couber.
|
||||
|
||||
### Corrigido
|
||||
- Em sessões pausadas não havia como escolher outra origem; só "Continuar", que falhava sem o disco.
|
||||
|
||||
## [0.3.2] - 2026-10-18
|
||||
|
||||
### Corrigido
|
||||
|
||||
Generated
+2
-2
@@ -317,7 +317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "guara-cli"
|
||||
version = "0.3.2"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ctrlc",
|
||||
@@ -329,7 +329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "guara-core"
|
||||
version = "0.3.2"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
[package]
|
||||
name = "guara-gui"
|
||||
description = "Guará: interface gráfica de recuperação de arquivos"
|
||||
version = "0.3.2"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/lucasrguerra/guara"
|
||||
|
||||
@@ -1,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"
|
||||
|
||||
@@ -460,3 +460,7 @@ dialog.lightbox[open] { display: grid; }
|
||||
.pv-card b { overflow-wrap: anywhere; font-size: 15px; }
|
||||
.pv-bigicon { font-size: 52px; line-height: 1; }
|
||||
.pv-why { max-width: 38ch; font-size: 12.5px; color: var(--text-2); }
|
||||
|
||||
.cand { display: grid; gap: 4px; margin: 6px 0 12px; max-height: 240px; overflow: auto; }
|
||||
.cand .dev[aria-current="true"] { outline: 2px solid var(--accent); }
|
||||
.cand .s { white-space: normal; }
|
||||
+90
-14
@@ -108,8 +108,16 @@ function askForm({ title, text, fields, ok = 'OK' }) {
|
||||
return new Promise((resolve) => {
|
||||
const d = $('#dlg');
|
||||
d.innerHTML = `<form method="dialog"><h2>${esc(title)}</h2><p>${text || ''}</p>
|
||||
${fields.map((f) => `<label class="fld"><span>${esc(f.label)}</span>${f.multiline ? `<textarea class="input" name="${f.name}" rows="3" placeholder="${esc(f.placeholder || '')}">${esc(f.value || '')}</textarea>` : `<input class="input" name="${f.name}" value="${esc(f.value || '')}" placeholder="${esc(f.placeholder || '')}" autocomplete="off">`}</label>`).join('')}
|
||||
${fields.map((f) => `<label class="fld"><span>${esc(f.label)}</span>${
|
||||
f.options ? `<select class="select" name="${f.name}">${f.options.map(([v, l]) => `<option value="${esc(v)}" ${v === f.value ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select>`
|
||||
: f.multiline ? `<textarea class="input" name="${f.name}" rows="3" placeholder="${esc(f.placeholder || '')}">${esc(f.value || '')}</textarea>`
|
||||
: `<span class="row" style="flex-wrap:nowrap"><input class="input" name="${f.name}" value="${esc(f.value || '')}" placeholder="${esc(f.placeholder || '')}" autocomplete="off">${f.pick && tauri?.dialog ? `<button type="button" class="btn" data-pick="${f.name}">Escolher…</button>` : ''}</span>`}</label>`).join('')}
|
||||
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">${esc(ok)}</button></menu></form>`;
|
||||
d.querySelectorAll('[data-pick]').forEach((b) => (b.onclick = async () => {
|
||||
const f = fields.find((x) => x.name === b.dataset.pick);
|
||||
const r = await tauri.dialog.open(f.pick);
|
||||
if (r) d.querySelector(`[name="${f.name}"]`).value = Array.isArray(r) ? r[0] : r;
|
||||
}));
|
||||
d.onclose = () => {
|
||||
if (d.returnValue !== 'ok') return resolve(null);
|
||||
const out = {};
|
||||
@@ -180,6 +188,28 @@ function renderSide() {
|
||||
<li><button class="dev" data-j="${i}" aria-current="${S.job === s.job}"><span class="ico">${icon('default')}</span>
|
||||
<span><span class="t">${esc(s.label)}</span><span class="s">${esc(s.target)}</span></span></button></li>`).join('');
|
||||
sl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => { openJob(S.sessions[+b.dataset.j]); setMenu(false); }));
|
||||
const rl = $('#recentList');
|
||||
rl.dataset.empty = 'Nada salvo ainda';
|
||||
rl.innerHTML = (S.recent || []).map((r, i) => `
|
||||
<li><button class="dev" data-r="${i}" title="${esc(r.path)}"><span class="ico">${icon('default')}</span>
|
||||
<span><span class="t">${esc((r.target || '').split(/[\\/]/).pop() || 'Sessão')}</span><span class="s">${r.found.toLocaleString('pt-BR')} arquivos · ${esc(r.status)} · ${new Date(r.saved_at * 1000).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' })}</span></span></button></li>`).join('');
|
||||
rl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => openRecent(S.recent[+b.dataset.r])));
|
||||
}
|
||||
|
||||
async function loadRecent() {
|
||||
try { S.recent = await rpc('sessions_list'); } catch { S.recent = []; }
|
||||
renderSide();
|
||||
}
|
||||
|
||||
async function openRecent(r) {
|
||||
setMenu(false);
|
||||
try {
|
||||
const { job } = await rpc('session_load', { path: r.path });
|
||||
const st = await rpc('status', { job });
|
||||
const s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() };
|
||||
S.sessions.unshift(s);
|
||||
openJob(s);
|
||||
} catch (e) { info({ title: 'Não foi possível abrir a sessão', html: `<p>${esc(e.message)}</p>` }); }
|
||||
}
|
||||
|
||||
$('#openImageBtn').onclick = async () => {
|
||||
@@ -415,6 +445,7 @@ async function tick() {
|
||||
// continua consultando enquanto a varredura roda ou as datas/EXIF ainda estão sendo lidas
|
||||
if (st.status !== 'running' && !st.enriching) {
|
||||
stopPoll();
|
||||
loadRecent();
|
||||
if (st.status === 'error') toast(st.error);
|
||||
} else if (!S.poll) {
|
||||
S.poll = setInterval(tick, 700);
|
||||
@@ -446,7 +477,7 @@ function paintStatus() {
|
||||
const eta = rate && running ? dur((p.total - p.done) / rate) : null;
|
||||
const enr = st.enriching ? `<span class="sub">Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString('pt-BR')}/${st.enriching.total.toLocaleString('pt-BR')}</span>` : '';
|
||||
const buttons = running ? '<button class="btn sm" data-act="pause">Pausar</button><button class="btn sm danger" data-act="stop">Parar</button>'
|
||||
: st.status === 'paused' ? '<button class="btn sm primary" data-act="resume">Continuar</button>' : '';
|
||||
: st.status === 'paused' ? `<button class="btn sm primary" data-act="${st.source === false ? 'reconnect' : 'resume'}">${st.source === false ? 'Reconectar o disco' : 'Continuar'}</button><button class="btn sm" data-act="relink">Escolher a origem…</button>` : '';
|
||||
const relink = st.source === false ? '<button class="btn sm primary" data-act="relink">Escolher a origem…</button>' : '';
|
||||
const tools = relink + (!running && st.found ? '<button class="btn sm" data-act="csv">Exportar CSV</button><button class="btn sm" data-act="report">Relatório</button><button class="btn sm" data-act="save">Salvar sessão…</button>' : '');
|
||||
if (running || st.status === 'paused') {
|
||||
@@ -468,17 +499,58 @@ function paintStatus() {
|
||||
+ (st.error ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : ''));
|
||||
}
|
||||
|
||||
async function chooseSource() {
|
||||
let cands = [];
|
||||
try { cands = await rpc('session_candidates', { job: S.job }); } catch { /* segue só com o campo manual */ }
|
||||
const st = S.status || {};
|
||||
const size = st.layout?.size;
|
||||
const d = $('#dlg');
|
||||
const badge = (c) => (c.same_content === true ? '<span class="pill ok">mesmo disco</span>'
|
||||
: c.same_content === false ? '<span class="pill warn">tamanho igual, conteúdo diferente</span>'
|
||||
: c.same_size ? '<span class="pill accent">mesmo tamanho (precisa de administrador para conferir)</span>' : '');
|
||||
d.innerHTML = `<form method="dialog"><h2>Escolher a origem</h2>
|
||||
<p>Esta sessão foi feita sobre <code>${esc(st.target || '')}</code>${size ? ` (${human(size)})` : ''}. Escolha o disco que voltou ou uma imagem dele.</p>
|
||||
<div class="cand">${cands.map((c, i) => `<button type="button" class="dev" data-c="${i}"><span class="ico">${icon(c.device.kind)}</span><span><span class="t">${esc(c.device.name)}</span><span class="s">${esc(c.device.path)} · ${human(c.device.size)} ${badge(c)}</span></span></button>`).join('') || '<p class="sub">Nenhum disco encontrado. Informe o caminho abaixo.</p>'}</div>
|
||||
<label class="fld"><span>Ou o caminho de um dispositivo ou imagem</span><input class="input" id="srcPath" name="path" placeholder="/dev/sdb ou /caminho/imagem.img" value="${esc(st.source_path || st.target || '')}"></label>
|
||||
<label class="check"><input type="checkbox" name="force"><span>Usar mesmo assim, mesmo que o conteúdo inicial seja diferente (por exemplo, uma cópia editada)</span></label>
|
||||
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">Usar esta origem</button></menu></form>`;
|
||||
d.querySelectorAll('.cand .dev').forEach((b) => (b.onclick = () => {
|
||||
d.querySelectorAll('.cand .dev').forEach((x) => x.setAttribute('aria-current', 'false'));
|
||||
b.setAttribute('aria-current', 'true');
|
||||
$('#srcPath', d).value = cands[+b.dataset.c].device.path;
|
||||
}));
|
||||
d.onclose = async () => {
|
||||
if (d.returnValue !== 'ok') return;
|
||||
const path = $('#srcPath', d).value.trim();
|
||||
const force = d.querySelector('[name=force]').checked;
|
||||
if (!path) return;
|
||||
try {
|
||||
await rpc('session_relink', { job: S.job, path, force });
|
||||
toast('Origem reconectada');
|
||||
const cur = { job: S.job, target: st.target, label: S.sessions.find((x) => x.job === S.job)?.label || 'Sessão' };
|
||||
openJob(cur);
|
||||
} catch (err) { info({ title: 'Não foi possível usar essa origem', html: `<p>${esc(err.message)}</p>` }); }
|
||||
};
|
||||
d.showModal();
|
||||
}
|
||||
|
||||
async function onProgressClick(e) {
|
||||
const act = e.target.closest('[data-act]')?.dataset.act;
|
||||
if (!act) return;
|
||||
if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); }
|
||||
else if (act === 'stop') await rpc('scan_stop', { job: S.job });
|
||||
else if (act === 'resume') { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); }
|
||||
else if (act === 'relink') {
|
||||
const path = await ask({ title: 'Escolher a origem', text: `Esta sessão foi feita sobre <code>${esc(S.status?.target || '')}</code>. Informe o dispositivo ou a imagem do mesmo disco para pré-visualizar e recuperar.`, value: S.status?.target || '', ok: 'Usar esta origem' });
|
||||
if (path) {
|
||||
try { await rpc('session_relink', { job: S.job, path }); toast('Origem reconectada'); const cur = { job: S.job, target: S.status.target, label: S.sessions.find((x) => x.job === S.job)?.label || 'Sessão' }; openJob(cur); } catch (err) { info({ title: 'Não foi possível usar essa origem', html: `<p>${esc(err.message)}</p>` }); }
|
||||
}
|
||||
else if (act === 'resume') {
|
||||
try { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); }
|
||||
catch (err) { toast(err.message); chooseSource(); }
|
||||
} else if (act === 'reconnect') {
|
||||
try {
|
||||
const r = await rpc('session_reconnect', { job: S.job });
|
||||
toast(r.renamed ? `Disco encontrado como ${r.renamed}` : 'Disco reconectado');
|
||||
const cur = { job: S.job, target: S.status.target, label: S.sessions.find((x) => x.job === S.job)?.label || 'Sessão' };
|
||||
openJob(cur);
|
||||
} catch { chooseSource(); }
|
||||
}
|
||||
else if (act === 'relink') { chooseSource();
|
||||
} else if (act === 'csv') {
|
||||
const path = await ask({ title: 'Exportar para CSV', text: 'Grava os arquivos da vista atual (com os filtros aplicados). O arquivo abre direto no Excel.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-achados.csv', ok: 'Exportar' });
|
||||
if (path) { try { const r = await rpc('export_csv', { job: S.job, path, query: EX?.query() }); toast(`${r.rows.toLocaleString('pt-BR')} linhas gravadas`); } catch (err) { toast(err.message); } }
|
||||
@@ -501,22 +573,25 @@ async function onProgressClick(e) {
|
||||
}
|
||||
|
||||
async function recover(sel) {
|
||||
const dest = await ask({
|
||||
const f = await askForm({
|
||||
title: 'Recuperar para…',
|
||||
text: 'Escolha uma pasta em <b>outro disco</b>. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar.',
|
||||
value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados',
|
||||
ok: 'Recuperar',
|
||||
pick: { directory: true, multiple: false },
|
||||
fields: [
|
||||
{ name: 'dest', label: 'Pasta de destino', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados', pick: { directory: true, multiple: false } },
|
||||
{ name: 'layout', label: 'Organizar os arquivos', value: 'folders', options: [['folders', 'Pastas originais (e por tipo quando não há pasta)'], ['type', 'Por tipo (Fotos, Documentos, Vídeos...)'], ['date', 'Por data (ano e mês)'], ['flat', 'Tudo na mesma pasta']] },
|
||||
],
|
||||
});
|
||||
if (!dest) return;
|
||||
if (!f || !f.dest) return;
|
||||
const dest = f.dest;
|
||||
try {
|
||||
const { job } = await rpc('recover_start', { job: S.job, dest, ...sel });
|
||||
const { job } = await rpc('recover_start', { job: S.job, dest, layout: f.layout, ...sel });
|
||||
watchAux(job, 'Recuperando arquivos', (st) => {
|
||||
const r = st.report || {};
|
||||
const ok = (r.recovered || []).length, bad = (r.failed || []).length;
|
||||
return `<p><b>${ok}</b> arquivo(s) salvos em <code>${esc(dest)}</code> (${human(r.total_bytes)}).</p>
|
||||
${bad ? `<div class="notice">${warnSvg}<div>${bad} arquivo(s) não puderam ser gravados.</div></div>` : ''}
|
||||
<p class="sub">O arquivo <code>guara-manifesto.json</code> lista o SHA-256 de cada arquivo para você conferir a integridade.</p>`;
|
||||
<p class="sub">O arquivo <code>guara-manifesto.json</code> lista MD5, SHA-1 e SHA-256 de cada arquivo para você conferir a integridade.</p>`;
|
||||
});
|
||||
} catch (e) { info({ title: 'Não foi possível recuperar', html: `<p>${esc(e.message)}</p>` }); }
|
||||
}
|
||||
@@ -563,5 +638,6 @@ async function openJobFromList(s) { openJob(s); }
|
||||
return;
|
||||
}
|
||||
await loadDevices();
|
||||
await loadRecent();
|
||||
renderHome();
|
||||
})();
|
||||
@@ -24,6 +24,8 @@
|
||||
<button class="side-action" id="openImageBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M4 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" stroke="currentColor" stroke-width="1.8" fill="none"/></svg>Abrir arquivo de imagem…</button>
|
||||
<div class="side-title">Sessões</div>
|
||||
<ul id="sessionList" class="list"></ul>
|
||||
<div class="side-title">Recentes <small style="font-weight:400;text-transform:none;letter-spacing:0">(salvas sozinhas)</small></div>
|
||||
<ul id="recentList" class="list"></ul>
|
||||
<button class="side-action" id="loadSessionBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>Abrir sessão salva…</button>
|
||||
</nav>
|
||||
<footer class="side-foot"><div class="theme-switch" role="group" aria-label="Tema"><button data-theme-pick="auto" aria-pressed="true">Auto</button><button data-theme-pick="light" aria-pressed="false">Claro</button><button data-theme-pick="dark" aria-pressed="false">Escuro</button></div><span id="versionTag"></span><span>Somente leitura na origem</span></footer>
|
||||
|
||||
@@ -94,6 +94,8 @@ enum Cmd {
|
||||
},
|
||||
/// Calcula MD5, SHA-1 e SHA-256 do dispositivo ou imagem (verificação de integridade)
|
||||
Hash { path: String },
|
||||
/// Lista as sessões salvas automaticamente
|
||||
Sessions,
|
||||
/// Abre a interface gráfica no navegador (modo local)
|
||||
Ui {
|
||||
#[arg(long, default_value_t = 0)]
|
||||
@@ -114,6 +116,9 @@ struct FilterArgs {
|
||||
/// Só nomes/caminhos que contenham este texto
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
/// Como organizar o que for recuperado: folders (pastas originais), type, date ou flat
|
||||
#[arg(long, default_value = "folders")]
|
||||
organize: String,
|
||||
}
|
||||
|
||||
#[derive(clap::Args, Clone)]
|
||||
@@ -230,6 +235,7 @@ fn run(cli: Cli) -> Result<(), String> {
|
||||
category: vec![],
|
||||
min_confidence: "low".into(),
|
||||
name: None,
|
||||
organize: "folders".into(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
@@ -367,6 +373,23 @@ fn run(cli: Cli) -> Result<(), String> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Sessions => {
|
||||
let list = eng.rpc("sessions_list", json!({})).map_err(s)?;
|
||||
let items = list.as_array().cloned().unwrap_or_default();
|
||||
if items.is_empty() {
|
||||
println!("Nenhuma sessão salva automaticamente ainda.");
|
||||
}
|
||||
for i in items {
|
||||
println!(
|
||||
"{:<10} {:>8} arquivos {:<22} {}",
|
||||
i["status"].as_str().unwrap_or(""),
|
||||
i["found"],
|
||||
i["target"].as_str().unwrap_or(""),
|
||||
i["path"].as_str().unwrap_or("")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Ui { port, no_open } => serve::run(port, !no_open),
|
||||
}
|
||||
}
|
||||
@@ -483,7 +506,13 @@ fn recover_job(
|
||||
}
|
||||
ids = Some(all);
|
||||
}
|
||||
let rj = eng.start_recover(scan_job, ids, Some(q), dest).map_err(s)?;
|
||||
let layout = guara_core::recover::RecoverLayout::parse(&filter.organize).ok_or(format!(
|
||||
"organização inválida: {} (use folders, type, date ou flat)",
|
||||
filter.organize
|
||||
))?;
|
||||
let rj = eng
|
||||
.start_recover(scan_job, ids, Some(q), dest, layout)
|
||||
.map_err(s)?;
|
||||
let st = wait(eng, &rj, None)?;
|
||||
eprintln!();
|
||||
if st["status"] == "error" {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Reencontra o disco de uma sessão quando ele foi desconectado e voltou com
|
||||
//! outro nome (por exemplo `/dev/sda` virando `/dev/sdb`).
|
||||
|
||||
use crate::device;
|
||||
use crate::source::{fingerprint, open_path, Source};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A origem é compatível com o que a sessão espera?
|
||||
/// `size == 0` ou `fp == None` significam "não sei" e não reprovam.
|
||||
pub fn matches(src: &dyn Source, size: u64, fp: Option<&str>) -> bool {
|
||||
(size == 0 || src.size() == size) && fp.map_or(true, |f| fingerprint(src).as_deref() == Some(f))
|
||||
}
|
||||
|
||||
/// Procura entre os discos conectados o que corresponde à sessão.
|
||||
/// Com impressão digital, exige igualdade; sem ela, só aceita se houver um único disco do mesmo tamanho.
|
||||
pub fn find_matching_source(
|
||||
size: u64,
|
||||
fp: Option<&str>,
|
||||
exclude: &[String],
|
||||
) -> Option<(String, Arc<dyn Source>)> {
|
||||
if size == 0 && fp.is_none() {
|
||||
return None;
|
||||
}
|
||||
let mut found: Vec<(String, Arc<dyn Source>)> = vec![];
|
||||
for d in device::list_devices() {
|
||||
if !d.readable || exclude.contains(&d.path) || (size != 0 && d.size != size) {
|
||||
continue;
|
||||
}
|
||||
let Ok(src) = open_path(Path::new(&d.path)) else {
|
||||
continue;
|
||||
};
|
||||
if matches(src.as_ref(), size, fp) {
|
||||
found.push((d.path, src));
|
||||
}
|
||||
}
|
||||
if fp.is_some() {
|
||||
found.into_iter().next()
|
||||
} else if found.len() == 1 {
|
||||
found.pop()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -72,12 +72,57 @@ pub fn check_destination(source_path: Option<&Path>, dest: &Path) -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Como organizar os arquivos recuperados dentro do destino.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RecoverLayout {
|
||||
/// Pastas originais quando conhecidas; o resto separado por tipo (padrão).
|
||||
#[default]
|
||||
Folders,
|
||||
/// Uma pasta por tipo (Fotos, Documentos, Vídeos...).
|
||||
Type,
|
||||
/// Pastas por ano e mês (data da foto, ou a de modificação).
|
||||
Date,
|
||||
/// Tudo direto na pasta de destino.
|
||||
Flat,
|
||||
}
|
||||
|
||||
impl RecoverLayout {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"folders" | "pastas" => Self::Folders,
|
||||
"type" | "tipo" => Self::Type,
|
||||
"date" | "data" => Self::Date,
|
||||
"flat" | "plano" => Self::Flat,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recover(
|
||||
src: &dyn Source,
|
||||
files: &[&Found],
|
||||
dest: &Path,
|
||||
cancelled: &(dyn Fn() -> bool + Sync),
|
||||
progress: &mut dyn FnMut(usize, usize, u64),
|
||||
) -> Result<RecoverReport> {
|
||||
recover_with(
|
||||
src,
|
||||
files,
|
||||
dest,
|
||||
RecoverLayout::Folders,
|
||||
cancelled,
|
||||
progress,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn recover_with(
|
||||
src: &dyn Source,
|
||||
files: &[&Found],
|
||||
dest: &Path,
|
||||
layout: RecoverLayout,
|
||||
cancelled: &(dyn Fn() -> bool + Sync),
|
||||
progress: &mut dyn FnMut(usize, usize, u64),
|
||||
) -> Result<RecoverReport> {
|
||||
crate::owner::create_dir_all_owned(dest)?;
|
||||
let mut report = RecoverReport {
|
||||
@@ -91,7 +136,7 @@ pub fn recover(
|
||||
break;
|
||||
}
|
||||
progress(i, files.len(), report.total_bytes);
|
||||
let rel = target_rel(f);
|
||||
let rel = target_rel(f, layout);
|
||||
let mut path = dest.join(&rel);
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = crate::owner::create_dir_all_owned(parent) {
|
||||
@@ -151,25 +196,75 @@ pub fn recover(
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn target_rel(f: &Found) -> PathBuf {
|
||||
fn target_rel(f: &Found, layout: RecoverLayout) -> PathBuf {
|
||||
let mut p = PathBuf::new();
|
||||
match &f.dir {
|
||||
Some(d) if !d.is_empty() => {
|
||||
p.push("Recuperados");
|
||||
for c in d.split('/') {
|
||||
if !c.is_empty() {
|
||||
p.push(sanitize_name(c));
|
||||
match layout {
|
||||
RecoverLayout::Folders => match &f.dir {
|
||||
Some(d) if !d.is_empty() => {
|
||||
p.push("Recuperados");
|
||||
for c in d.split('/') {
|
||||
if !c.is_empty() {
|
||||
p.push(sanitize_name(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
p.push(f.category.label_pt());
|
||||
}
|
||||
_ => p.push(f.category.label_pt()),
|
||||
},
|
||||
RecoverLayout::Type => p.push(f.category.label_pt()),
|
||||
RecoverLayout::Date => match f.when() {
|
||||
Some(t) => {
|
||||
let (y, m, _) = crate::meta::ymd(t);
|
||||
p.push(format!("{y:04}"));
|
||||
p.push(format!("{y:04}-{m:02}"));
|
||||
}
|
||||
None => p.push("Sem data"),
|
||||
},
|
||||
RecoverLayout::Flat => {}
|
||||
}
|
||||
p.push(sanitize_name(&f.name));
|
||||
p
|
||||
}
|
||||
|
||||
/// Espaço livre (bytes) no volume onde `path` está (ou estaria).
|
||||
pub fn free_space(path: &Path) -> Option<u64> {
|
||||
let mut p = path;
|
||||
while !p.exists() {
|
||||
p = p.parent()?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let c = std::ffi::CString::new(p.as_os_str().as_bytes()).ok()?;
|
||||
// SAFETY: `c` é uma string C válida e `st` é uma estrutura de saída local.
|
||||
let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||
if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 {
|
||||
return None;
|
||||
}
|
||||
Some((st.f_bavail as u64).saturating_mul(st.f_frsize as u64))
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW;
|
||||
let wide: Vec<u16> = p
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let mut free = 0u64;
|
||||
// SAFETY: `wide` termina em NUL e o ponteiro de saída é válido.
|
||||
let ok = unsafe {
|
||||
GetDiskFreeSpaceExW(
|
||||
wide.as_ptr(),
|
||||
&mut free,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
(ok != 0).then_some(free)
|
||||
}
|
||||
}
|
||||
|
||||
/// Escreve o conteúdo e devolve o SHA-256.
|
||||
pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<Hashes> {
|
||||
let mut out = std::fs::OpenOptions::new()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Orquestra a varredura: layout → metadados dos sistemas de arquivos →
|
||||
//! carving no espaço livre/desconhecido → fusão sem duplicatas.
|
||||
|
||||
use crate::carve::{carve_range, CarveCfg, CarveEvents, HitAction};
|
||||
use crate::carve::{carve_range, CarveCfg, CarveEnd, CarveEvents, HitAction};
|
||||
use crate::fs::{self, merge_extents, overlaps, Ctx};
|
||||
use crate::partition;
|
||||
use crate::signatures::category_for_ext;
|
||||
@@ -42,6 +42,9 @@ pub struct Checkpoint {
|
||||
pub ranges: Vec<RangeState>,
|
||||
pub next_id: u64,
|
||||
pub layout: Option<DiskLayout>,
|
||||
/// A varredura parou porque o dispositivo parou de responder (desconectado?).
|
||||
#[serde(default)]
|
||||
pub io_lost: bool,
|
||||
}
|
||||
|
||||
pub enum Outcome {
|
||||
@@ -189,9 +192,10 @@ pub fn run(
|
||||
if next >= end {
|
||||
continue;
|
||||
}
|
||||
let finished = carve_range(src, start, end, &mut next, &cfg, cancelled, &mut handler);
|
||||
let end_state = carve_range(src, start, end, &mut next, &cfg, cancelled, &mut handler);
|
||||
cp.ranges[ri].next = next;
|
||||
if !finished {
|
||||
if end_state != CarveEnd::Done {
|
||||
cp.io_lost = end_state == CarveEnd::IoLost;
|
||||
cp.next_id = next_id.load(std::sync::atomic::Ordering::Relaxed);
|
||||
return Outcome::Interrupted(cp);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,12 @@ pub struct Job {
|
||||
cancel: AtomicBool,
|
||||
stop_requested: AtomicBool,
|
||||
src: Mutex<Option<Arc<dyn Source>>>,
|
||||
/// Caminho atualmente usado para ler a origem (pode mudar se o disco voltar com outro nome).
|
||||
src_path: Mutex<String>,
|
||||
/// Impressão digital do disco (reconhecer o mesmo disco depois de reconectar).
|
||||
fingerprint: Mutex<Option<String>>,
|
||||
/// Arquivo de sessão salvo automaticamente.
|
||||
session_file: Mutex<Option<PathBuf>>,
|
||||
st: Mutex<State>,
|
||||
}
|
||||
|
||||
@@ -78,11 +84,14 @@ impl Job {
|
||||
Arc::new(Job {
|
||||
id,
|
||||
kind,
|
||||
target,
|
||||
target: target.clone(),
|
||||
opts,
|
||||
cancel: AtomicBool::new(false),
|
||||
stop_requested: AtomicBool::new(false),
|
||||
src: Mutex::new(src),
|
||||
src_path: Mutex::new(target.clone()),
|
||||
fingerprint: Mutex::new(None),
|
||||
session_file: Mutex::new(None),
|
||||
st: Mutex::new(State {
|
||||
status: Status::Running,
|
||||
progress: Progress::default(),
|
||||
@@ -197,7 +206,10 @@ impl Engine {
|
||||
|
||||
pub fn start_scan(&self, path: &str, opts: ScanOptions) -> Result<String> {
|
||||
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
|
||||
let fp = crate::source::fingerprint(src.as_ref());
|
||||
let job = self.register(JobKind::Scan, path.to_string(), opts, Some(src));
|
||||
*job.fingerprint.lock().unwrap() = fp;
|
||||
*job.session_file.lock().unwrap() = Some(new_session_path(path));
|
||||
spawn_scan(job.clone(), None);
|
||||
Ok(job.id.clone())
|
||||
}
|
||||
@@ -217,17 +229,35 @@ impl Engine {
|
||||
|
||||
pub fn resume(&self, id: &str) -> Result<()> {
|
||||
let j = self.job(id)?;
|
||||
if j.source().is_none() {
|
||||
return Err(no_source());
|
||||
}
|
||||
let cp = {
|
||||
let mut s = j.st.lock().unwrap();
|
||||
let io_lost = {
|
||||
let s = j.st.lock().unwrap();
|
||||
if s.status != Status::Paused {
|
||||
return Err(Error::msg("a varredura não está pausada"));
|
||||
}
|
||||
s.status = Status::Running;
|
||||
s.checkpoint.take()
|
||||
s.checkpoint.as_ref().is_some_and(|c| c.io_lost)
|
||||
};
|
||||
// Sem origem, ou depois de uma desconexão: reabre o disco (mesmo com outro nome).
|
||||
if j.source().is_none() || io_lost {
|
||||
self.reconnect(&j).map_err(|e| {
|
||||
Error::msg(format!(
|
||||
"A origem desta sessão não está disponível ({e}). Reconecte o disco ou escolha a origem para continuar."
|
||||
))
|
||||
})?;
|
||||
}
|
||||
let cp = {
|
||||
let mut s = j.st.lock().unwrap();
|
||||
s.status = Status::Running;
|
||||
s.warnings
|
||||
.retain(|w| !w.starts_with("A leitura do dispositivo"));
|
||||
let mut cp = s.checkpoint.take();
|
||||
if let Some(c) = cp.as_mut() {
|
||||
c.io_lost = false;
|
||||
}
|
||||
cp
|
||||
};
|
||||
if j.session_file.lock().unwrap().is_none() {
|
||||
*j.session_file.lock().unwrap() = Some(new_session_path(&j.target));
|
||||
}
|
||||
j.cancel.store(false, Ordering::Relaxed);
|
||||
j.stop_requested.store(false, Ordering::Relaxed);
|
||||
spawn_scan(j, cp);
|
||||
@@ -261,6 +291,8 @@ impl Engine {
|
||||
"report": s.report,
|
||||
"elapsed": elapsed,
|
||||
"source": j.source().is_some(),
|
||||
"source_path": j.src_path.lock().unwrap().clone(),
|
||||
"session_file": j.session_file.lock().unwrap().as_ref().map(|p| p.to_string_lossy().to_string()),
|
||||
"hits": s.hits.len(),
|
||||
"enriching": s.enrich.map(|(d, t)| json!({ "done": d, "total": t })),
|
||||
}))
|
||||
@@ -337,6 +369,7 @@ impl Engine {
|
||||
ids: Option<Vec<u64>>,
|
||||
filter: Option<Query>,
|
||||
dest: &str,
|
||||
layout: recover::RecoverLayout,
|
||||
) -> Result<String> {
|
||||
let sj = self.job(scan_id)?;
|
||||
let src = sj.source().ok_or_else(no_source)?;
|
||||
@@ -355,6 +388,16 @@ impl Engine {
|
||||
return Err(Error::msg("nenhum arquivo selecionado"));
|
||||
}
|
||||
let dest_path = PathBuf::from(dest);
|
||||
let total: u64 = files.iter().map(|f| f.size).sum();
|
||||
if let Some(free) = recover::free_space(&dest_path) {
|
||||
if total.saturating_add(64 * 1024 * 1024) > free {
|
||||
return Err(Error::msg(format!(
|
||||
"Espaço insuficiente no destino: os arquivos somam {} e há {} livres. Escolha outro disco ou recupere menos arquivos.",
|
||||
human_bytes(total),
|
||||
human_bytes(free)
|
||||
)));
|
||||
}
|
||||
}
|
||||
recover::check_destination(Some(Path::new(&sj.target)), &dest_path)?;
|
||||
let job = self.register(
|
||||
JobKind::Recover,
|
||||
@@ -375,7 +418,14 @@ impl Engine {
|
||||
};
|
||||
};
|
||||
let refs: Vec<&Found> = files.iter().collect();
|
||||
let res = recover::recover(src.as_ref(), &refs, &dest_path, &cancelled, &mut prog);
|
||||
let res = recover::recover_with(
|
||||
src.as_ref(),
|
||||
&refs,
|
||||
&dest_path,
|
||||
layout,
|
||||
&cancelled,
|
||||
&mut prog,
|
||||
);
|
||||
let mut s = j2.st.lock().unwrap();
|
||||
match res {
|
||||
Ok(rep) => {
|
||||
@@ -597,21 +647,7 @@ impl Engine {
|
||||
|
||||
pub fn save_session(&self, id: &str, path: &str) -> Result<()> {
|
||||
let j = self.job(id)?;
|
||||
let s = j.st.lock().unwrap();
|
||||
let data = SessionFile {
|
||||
version: 1,
|
||||
target: j.target.clone(),
|
||||
options: j.opts.clone(),
|
||||
found: s.found.clone(),
|
||||
checkpoint: s.checkpoint.clone(),
|
||||
layout: s.layout.clone(),
|
||||
warnings: s.warnings.clone(),
|
||||
done: s.status == Status::Done,
|
||||
recoveries: s.recoveries.clone(),
|
||||
started_unix: s.started_unix,
|
||||
};
|
||||
crate::owner::write_owned(Path::new(path), serde_json::to_vec(&data)?)?;
|
||||
Ok(())
|
||||
write_session(&j, Path::new(path))
|
||||
}
|
||||
|
||||
pub fn load_session(&self, path: &str) -> Result<String> {
|
||||
@@ -629,18 +665,17 @@ impl Engine {
|
||||
"O arquivo {path} não é uma sessão do Guará válida (ou foi criado por uma versão incompatível): {e}"
|
||||
))
|
||||
})?;
|
||||
// A sessão abre mesmo sem a origem: dá para navegar, exportar e gerar relatório.
|
||||
// Pré-visualização e recuperação voltam quando a origem for reconectada.
|
||||
let (src, missing) = match open_path(Path::new(&data.target)) {
|
||||
Ok(s) => (Some(s), None),
|
||||
Err(e) => (None, Some(open_error(&data.target, e).to_string())),
|
||||
};
|
||||
let job = self.register(
|
||||
JobKind::Scan,
|
||||
data.target.clone(),
|
||||
data.options.clone(),
|
||||
src,
|
||||
None,
|
||||
);
|
||||
*job.fingerprint.lock().unwrap() = data.fingerprint.clone();
|
||||
*job.src_path.lock().unwrap() = data
|
||||
.source_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| data.target.clone());
|
||||
{
|
||||
let mut s = job.st.lock().unwrap();
|
||||
for (i, f) in data.found.iter().enumerate() {
|
||||
@@ -658,45 +693,181 @@ impl Engine {
|
||||
Status::Paused
|
||||
};
|
||||
}
|
||||
if let Some(m) = missing {
|
||||
job.st.lock().unwrap().warnings.push(format!(
|
||||
"A origem {} não está disponível agora ({m}). Você pode navegar, exportar e gerar relatório; para pré-visualizar e recuperar, reconecte o disco ou escolha a origem.",
|
||||
// A sessão sempre abre. Tenta religar o disco (inclusive se ele voltou com outro nome);
|
||||
// sem a origem ainda dá para navegar, exportar e gerar relatório.
|
||||
match self.reconnect(&job) {
|
||||
Ok(Some(nome)) => job.st.lock().unwrap().warnings.push(format!(
|
||||
"O disco foi reconhecido de novo como {nome} (o nome mudou desde a varredura)."
|
||||
)),
|
||||
Ok(None) => {}
|
||||
Err(e) => job.st.lock().unwrap().warnings.push(format!(
|
||||
"A origem {} não está disponível agora ({e}). Você pode navegar, exportar e gerar relatório; para pré-visualizar e recuperar, reconecte o disco ou escolha a origem.",
|
||||
data.target
|
||||
));
|
||||
)),
|
||||
}
|
||||
// continua salvando automaticamente no mesmo arquivo se a varredura for retomada
|
||||
*job.session_file.lock().unwrap() = Some(PathBuf::from(path));
|
||||
if data.done {
|
||||
spawn_enrich(job.clone());
|
||||
}
|
||||
Ok(job.id.clone())
|
||||
}
|
||||
|
||||
/// Aponta uma sessão carregada para a origem (disco ou imagem) atual.
|
||||
pub fn relink(&self, id: &str, path: &str) -> Result<()> {
|
||||
let j = self.job(id)?;
|
||||
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
|
||||
{
|
||||
let s = j.st.lock().unwrap();
|
||||
if let Some(l) = &s.layout {
|
||||
if l.size != 0 && l.size != src.size() {
|
||||
return Err(Error::msg(format!(
|
||||
"Essa origem tem {} bytes, mas a sessão foi feita sobre um disco de {} bytes. Escolha o mesmo disco ou uma imagem dele.",
|
||||
src.size(),
|
||||
l.size
|
||||
)));
|
||||
/// Reabre a origem da sessão: tenta o caminho atual e o original e, se o disco voltou com
|
||||
/// outro nome, procura entre os dispositivos conectados o que tem o mesmo conteúdo.
|
||||
/// `Ok(Some(caminho))` quando achou o disco num caminho diferente do esperado.
|
||||
fn reconnect(&self, j: &Arc<Job>) -> Result<Option<String>> {
|
||||
let fp = j.fingerprint.lock().unwrap().clone();
|
||||
let size =
|
||||
j.st.lock()
|
||||
.unwrap()
|
||||
.layout
|
||||
.as_ref()
|
||||
.map(|l| l.size)
|
||||
.unwrap_or(0);
|
||||
let current = j.src_path.lock().unwrap().clone();
|
||||
let mut tried = vec![current.clone()];
|
||||
if j.target != current {
|
||||
tried.push(j.target.clone());
|
||||
}
|
||||
let mut last_err = None;
|
||||
for path in &tried {
|
||||
match open_path(Path::new(path)) {
|
||||
Ok(src) if crate::reconnect::matches(src.as_ref(), size, fp.as_deref()) => {
|
||||
*j.src.lock().unwrap() = Some(src);
|
||||
*j.src_path.lock().unwrap() = path.clone();
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(_) => last_err = Some(format!("{path} agora é outro disco")),
|
||||
Err(e) => last_err = Some(open_error(path, e).to_string()),
|
||||
}
|
||||
}
|
||||
if let Some((path, src)) =
|
||||
crate::reconnect::find_matching_source(size, fp.as_deref(), &tried)
|
||||
{
|
||||
*j.src.lock().unwrap() = Some(src);
|
||||
*j.src_path.lock().unwrap() = path.clone();
|
||||
return Ok(Some(path));
|
||||
}
|
||||
Err(Error::msg(
|
||||
last_err.unwrap_or_else(|| "disco não encontrado".into()),
|
||||
))
|
||||
}
|
||||
|
||||
/// Aponta uma sessão carregada para a origem (disco ou imagem) atual.
|
||||
/// Recusa uma origem que não seja o mesmo disco, a menos que `force`.
|
||||
pub fn relink(&self, id: &str, path: &str, force: bool) -> Result<()> {
|
||||
let j = self.job(id)?;
|
||||
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
|
||||
let fp = j.fingerprint.lock().unwrap().clone();
|
||||
let size =
|
||||
j.st.lock()
|
||||
.unwrap()
|
||||
.layout
|
||||
.as_ref()
|
||||
.map(|l| l.size)
|
||||
.unwrap_or(0);
|
||||
if size != 0 && src.size() != size && !force {
|
||||
return Err(Error::msg(format!(
|
||||
"Essa origem tem {} bytes, mas a sessão foi feita sobre um disco de {} bytes. Escolha o mesmo disco ou uma imagem dele.",
|
||||
src.size(),
|
||||
size
|
||||
)));
|
||||
}
|
||||
if !force && fp.is_some() && !crate::reconnect::matches(src.as_ref(), 0, fp.as_deref()) {
|
||||
return Err(Error::msg(
|
||||
"O conteúdo inicial dessa origem é diferente do disco da sessão (outra tabela de partições ou outro disco). Se tem certeza, use a opção de usar assim mesmo.",
|
||||
));
|
||||
}
|
||||
if fp.is_none() {
|
||||
*j.fingerprint.lock().unwrap() = crate::source::fingerprint(src.as_ref());
|
||||
}
|
||||
*j.src.lock().unwrap() = Some(src);
|
||||
*j.src_path.lock().unwrap() = path.to_string();
|
||||
j.st.lock()
|
||||
.unwrap()
|
||||
.warnings
|
||||
.retain(|w| !w.starts_with("A origem "));
|
||||
.retain(|w| !w.starts_with("A origem ") && !w.starts_with("O disco foi reconhecido"));
|
||||
if j.st.lock().unwrap().status == Status::Done {
|
||||
spawn_enrich(j);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discos conectados que podem ser a origem desta sessão (mesmo tamanho primeiro).
|
||||
pub fn candidates(&self, id: &str) -> Result<Value> {
|
||||
let j = self.job(id)?;
|
||||
let fp = j.fingerprint.lock().unwrap().clone();
|
||||
let size =
|
||||
j.st.lock()
|
||||
.unwrap()
|
||||
.layout
|
||||
.as_ref()
|
||||
.map(|l| l.size)
|
||||
.unwrap_or(0);
|
||||
let mut out: Vec<Value> = vec![];
|
||||
for d in device::list_devices() {
|
||||
let same_size = size != 0 && d.size == size;
|
||||
let same_content = if same_size && d.readable {
|
||||
open_path(Path::new(&d.path))
|
||||
.ok()
|
||||
.map(|s| crate::reconnect::matches(s.as_ref(), size, fp.as_deref()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
out.push(json!({ "device": d, "same_size": same_size, "same_content": same_content }));
|
||||
}
|
||||
out.sort_by_key(|v| {
|
||||
std::cmp::Reverse((
|
||||
v["same_content"].as_bool() == Some(true),
|
||||
v["same_size"].as_bool() == Some(true),
|
||||
))
|
||||
});
|
||||
Ok(json!(out))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sessões automáticas
|
||||
|
||||
/// Sessões salvas automaticamente (mais recentes primeiro).
|
||||
pub fn sessions_list(&self) -> Value {
|
||||
let dir = sessions_dir();
|
||||
let mut items: Vec<Value> = vec![];
|
||||
if let Ok(rd) = std::fs::read_dir(&dir) {
|
||||
for e in rd.flatten() {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if let Some(base) = name.strip_suffix(".meta.json") {
|
||||
let sess = dir.join(format!("{base}.json"));
|
||||
if !sess.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(mut v) = std::fs::read(e.path())
|
||||
.map_err(|_| ())
|
||||
.and_then(|b| serde_json::from_slice::<Value>(&b).map_err(|_| ()))
|
||||
{
|
||||
v["path"] = json!(sess.to_string_lossy());
|
||||
items.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items.sort_by_key(|v| std::cmp::Reverse(v["saved_at"].as_i64().unwrap_or(0)));
|
||||
items.truncate(30);
|
||||
json!(items)
|
||||
}
|
||||
|
||||
/// Apaga uma sessão automática (e a miniatura de metadados).
|
||||
pub fn session_delete(&self, path: &str) -> Result<()> {
|
||||
let p = Path::new(path);
|
||||
if p.parent() != Some(sessions_dir().as_path()) {
|
||||
return Err(Error::msg(
|
||||
"só sessões automáticas podem ser apagadas por aqui",
|
||||
));
|
||||
}
|
||||
let _ = std::fs::remove_file(p.with_extension("meta.json"));
|
||||
std::fs::remove_file(p)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- RPC
|
||||
|
||||
pub fn rpc(&self, method: &str, p: Value) -> Result<Value> {
|
||||
@@ -815,7 +986,12 @@ impl Engine {
|
||||
.filter(|v| !v.is_null())
|
||||
.map(serde_json::from_value)
|
||||
.transpose()?;
|
||||
json!({ "job": self.start_recover(&s("job")?, ids, filter, &s("dest")?)? })
|
||||
let layout = p
|
||||
.get("layout")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(recover::RecoverLayout::parse)
|
||||
.unwrap_or_default();
|
||||
json!({ "job": self.start_recover(&s("job")?, ids, filter, &s("dest")?, layout)? })
|
||||
}
|
||||
"image_start" => json!({ "job": self.start_image(&s("path")?, &s("dest")?)? }),
|
||||
"session_save" => {
|
||||
@@ -823,7 +999,18 @@ impl Engine {
|
||||
json!({})
|
||||
}
|
||||
"session_relink" => {
|
||||
self.relink(&s("job")?, &s("path")?)?;
|
||||
let force = p.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
self.relink(&s("job")?, &s("path")?, force)?;
|
||||
json!({})
|
||||
}
|
||||
"session_reconnect" => {
|
||||
let j = self.job(&s("job")?)?;
|
||||
json!({ "renamed": self.reconnect(&j)? })
|
||||
}
|
||||
"session_candidates" => self.candidates(&s("job")?)?,
|
||||
"sessions_list" => self.sessions_list(),
|
||||
"session_delete" => {
|
||||
self.session_delete(&s("path")?)?;
|
||||
json!({})
|
||||
}
|
||||
"session_load" => json!({ "job": self.load_session(&s("path")?)? }),
|
||||
@@ -855,6 +1042,10 @@ struct SessionFile {
|
||||
recoveries: Vec<recover::RecoverReport>,
|
||||
#[serde(default)]
|
||||
started_unix: Option<i64>,
|
||||
#[serde(default)]
|
||||
fingerprint: Option<String>,
|
||||
#[serde(default)]
|
||||
source_path: Option<String>,
|
||||
}
|
||||
|
||||
use crate::explorer::cat_key;
|
||||
@@ -883,6 +1074,9 @@ fn open_error(path: &str, e: std::io::Error) -> Error {
|
||||
}
|
||||
|
||||
fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
|
||||
// Salva a sessão sozinho enquanto a varredura roda (e ao terminar/pausar).
|
||||
let saver = job.clone();
|
||||
std::thread::spawn(move || autosave_loop(saver));
|
||||
std::thread::spawn(move || {
|
||||
let Some(src) = job.source() else {
|
||||
let mut s = job.st.lock().unwrap();
|
||||
@@ -912,6 +1106,9 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
|
||||
s.status = Status::Stopped;
|
||||
} else {
|
||||
s.status = Status::Paused;
|
||||
if cp.io_lost {
|
||||
s.warnings.push("A leitura do dispositivo falhou repetidamente: ele provavelmente foi desconectado. A varredura foi pausada sem perder nada. Reconecte o disco e clique em Continuar.".into());
|
||||
}
|
||||
s.checkpoint = Some(cp);
|
||||
}
|
||||
}
|
||||
@@ -1167,3 +1364,116 @@ impl Engine {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sessões automáticas
|
||||
|
||||
fn sessions_dir() -> PathBuf {
|
||||
crate::settings::config_dir().join("sessoes")
|
||||
}
|
||||
|
||||
fn new_session_path(target: &str) -> PathBuf {
|
||||
let name: String = Path::new(target)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "disco".into())
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let t = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
sessions_dir().join(format!("{name}-{t}.json"))
|
||||
}
|
||||
|
||||
fn snapshot(j: &Job) -> SessionFile {
|
||||
let s = j.st.lock().unwrap();
|
||||
SessionFile {
|
||||
version: 1,
|
||||
target: j.target.clone(),
|
||||
options: j.opts.clone(),
|
||||
found: s.found.clone(),
|
||||
checkpoint: s.checkpoint.clone(),
|
||||
layout: s.layout.clone(),
|
||||
warnings: s.warnings.clone(),
|
||||
done: s.status == Status::Done,
|
||||
recoveries: s.recoveries.clone(),
|
||||
started_unix: s.started_unix,
|
||||
fingerprint: j.fingerprint.lock().unwrap().clone(),
|
||||
source_path: Some(j.src_path.lock().unwrap().clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Grava a sessão de forma atômica (arquivo temporário + renomear), com o dono certo.
|
||||
fn write_session(j: &Job, path: &Path) -> Result<()> {
|
||||
let data = snapshot(j);
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
crate::owner::write_owned(&tmp, serde_json::to_vec(&data)?)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
crate::owner::give_to_user(path);
|
||||
// metadados pequenos para listar as sessões recentes sem ler o arquivo inteiro
|
||||
let status = if data.done {
|
||||
"concluída"
|
||||
} else if data.checkpoint.is_some() {
|
||||
"pausada"
|
||||
} else {
|
||||
"incompleta"
|
||||
};
|
||||
let meta = json!({
|
||||
"target": data.target, "found": data.found.len(), "status": status,
|
||||
"saved_at": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0),
|
||||
"bytes": std::fs::metadata(path).map(|m| m.len()).unwrap_or(0),
|
||||
});
|
||||
let _ = crate::owner::write_owned(
|
||||
&path.with_extension("meta.json"),
|
||||
serde_json::to_vec(&meta)?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Salva de tempos em tempos (o intervalo cresce se a sessão for grande) e no fim.
|
||||
fn autosave_loop(job: Arc<Job>) {
|
||||
let mut last = Instant::now();
|
||||
let mut interval = std::time::Duration::from_secs(45);
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
let running = job.st.lock().unwrap().status == Status::Running;
|
||||
if running && last.elapsed() < interval {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = job.session_file.lock().unwrap().clone() else {
|
||||
return;
|
||||
};
|
||||
let t0 = Instant::now();
|
||||
if write_session(&job, &path).is_err() {
|
||||
return;
|
||||
}
|
||||
// nunca gasta mais de ~10% do tempo salvando
|
||||
interval = (t0.elapsed() * 10).max(std::time::Duration::from_secs(45));
|
||||
last = Instant::now();
|
||||
if !running {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn human_bytes(n: u64) -> String {
|
||||
const U: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
|
||||
let mut v = n as f64;
|
||||
let mut i = 0;
|
||||
while v >= 1024.0 && i < 4 {
|
||||
v /= 1024.0;
|
||||
i += 1;
|
||||
}
|
||||
if i == 0 {
|
||||
format!("{n} B")
|
||||
} else {
|
||||
format!("{v:.1} {}", U[i])
|
||||
}
|
||||
}
|
||||
@@ -274,3 +274,34 @@ pub fn open_path(path: &Path) -> io::Result<Arc<dyn Source>> {
|
||||
}
|
||||
Ok(Arc::new(FileSource::open(path)?))
|
||||
}
|
||||
|
||||
/// Identidade do conteúdo de um disco: SHA-256 do tamanho + primeiro e último MiB
|
||||
/// (tabela de partições, cabeçalhos e backup). Serve para reconhecer o mesmo disco
|
||||
/// quando ele volta com outro nome (`/dev/sda` virando `/dev/sdb`).
|
||||
pub fn fingerprint(src: &dyn Source) -> Option<String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
const MIB: u64 = 1024 * 1024;
|
||||
let size = src.size();
|
||||
let mut h = Sha256::new();
|
||||
h.update(size.to_le_bytes());
|
||||
let mut feed = |off: u64, len: u64| -> bool {
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
let mut got = 0;
|
||||
while got < buf.len() {
|
||||
match src.read_at(off + got as u64, &mut buf[got..]) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => got += n,
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
h.update(&buf[..got]);
|
||||
true
|
||||
};
|
||||
if !feed(0, size.min(MIB)) {
|
||||
return None;
|
||||
}
|
||||
if size > 2 * MIB && !feed(size - MIB, MIB) {
|
||||
return None;
|
||||
}
|
||||
Some(h.finalize().iter().map(|b| format!("{b:02x}")).collect())
|
||||
}
|
||||
@@ -31,6 +31,13 @@ impl Sink for Collect {
|
||||
}
|
||||
}
|
||||
|
||||
/// Isola as configurações/sessões automáticas dos testes numa pasta temporária.
|
||||
pub fn init() {
|
||||
static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
|
||||
let d = DIR.get_or_init(|| tempfile::tempdir().unwrap());
|
||||
std::env::set_var("GUARA_CONFIG_DIR", d.path());
|
||||
}
|
||||
|
||||
pub fn scan_path(path: &Path, opts: &ScanOptions) -> Collect {
|
||||
let src = source::open_path(path).unwrap();
|
||||
let mut sink = Collect::default();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
use guara_core::source::MemSource;
|
||||
use guara_core::*;
|
||||
use std::io::Write;
|
||||
|
||||
@@ -235,6 +236,7 @@ fn pausa_e_retoma_encontra_o_mesmo() {
|
||||
/// Streaming de pré-visualização: Range devolve exatamente os bytes do arquivo recuperado.
|
||||
#[test]
|
||||
fn file_slice_com_range_bate_com_o_arquivo_recuperado() {
|
||||
init();
|
||||
use std::io::Read;
|
||||
let Some(dir) = fixtures() else { return };
|
||||
let path = dir.join("ntfs.img");
|
||||
@@ -293,8 +295,8 @@ fn file_slice_com_range_bate_com_o_arquivo_recuperado() {
|
||||
|
||||
#[test]
|
||||
fn configuracoes_de_tema_persistem() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::env::set_var("GUARA_CONFIG_DIR", dir.path());
|
||||
init();
|
||||
let _ = std::fs::remove_file(settings::config_dir().join("settings.json"));
|
||||
assert_eq!(settings::load().theme, "auto");
|
||||
settings::save(&settings::Settings {
|
||||
theme: "dark".into(),
|
||||
@@ -316,6 +318,7 @@ fn wait_done(eng: &service::Engine, job: &str) {
|
||||
/// Gera uma sessão no formato da v0.1.0 (sem os campos que vieram depois) a partir de uma
|
||||
/// varredura real da imagem, com o alvo informado.
|
||||
fn sessao_formato_v0_1_0(img: &std::path::Path, alvo: &str) -> tempfile::NamedTempFile {
|
||||
init();
|
||||
let eng = service::Engine::new();
|
||||
let job = eng
|
||||
.start_scan(img.to_str().unwrap(), ScanOptions::default())
|
||||
@@ -329,6 +332,8 @@ fn sessao_formato_v0_1_0(img: &std::path::Path, alvo: &str) -> tempfile::NamedTe
|
||||
let o = v.as_object_mut().unwrap();
|
||||
o.remove("recoveries");
|
||||
o.remove("started_unix");
|
||||
o.remove("fingerprint");
|
||||
o.remove("source_path");
|
||||
o.insert("target".into(), alvo.into());
|
||||
for f in o["found"].as_array_mut().unwrap() {
|
||||
let f = f.as_object_mut().unwrap();
|
||||
@@ -343,6 +348,7 @@ fn sessao_formato_v0_1_0(img: &std::path::Path, alvo: &str) -> tempfile::NamedTe
|
||||
/// Uma sessão gravada pela v0.1.0 continua abrindo nas versões novas.
|
||||
#[test]
|
||||
fn sessao_da_v0_1_0_abre_e_funciona() {
|
||||
init();
|
||||
let Some(dir) = fixtures() else { return };
|
||||
let img = dir.join("ntfs.img");
|
||||
let tmp = sessao_formato_v0_1_0(&img, img.to_str().unwrap());
|
||||
@@ -363,6 +369,7 @@ fn sessao_da_v0_1_0_abre_e_funciona() {
|
||||
None,
|
||||
Some(service::Query::default()),
|
||||
out.path().to_str().unwrap(),
|
||||
recover::RecoverLayout::Folders,
|
||||
)
|
||||
.unwrap();
|
||||
wait_done(&eng, &rj);
|
||||
@@ -375,9 +382,15 @@ fn sessao_da_v0_1_0_abre_e_funciona() {
|
||||
/// Origem indisponível não impede abrir a sessão; reapontar a origem religa tudo.
|
||||
#[test]
|
||||
fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
|
||||
init();
|
||||
let Some(dir) = fixtures() else { return };
|
||||
let img = dir.join("ntfs.img");
|
||||
let tmp = sessao_formato_v0_1_0(&img, "/dev/disco-que-nao-existe");
|
||||
// sessão pausada (como as de uma varredura interrompida)
|
||||
let mut v: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(tmp.path()).unwrap()).unwrap();
|
||||
v["done"] = serde_json::json!(false);
|
||||
std::fs::write(tmp.path(), serde_json::to_vec(&v).unwrap()).unwrap();
|
||||
let eng = service::Engine::new();
|
||||
let job = eng.load_session(tmp.path().to_str().unwrap()).unwrap();
|
||||
let st = eng.status(&job).unwrap();
|
||||
@@ -407,16 +420,28 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
|
||||
.contains("não está disponível"));
|
||||
let out = tempfile::tempdir().unwrap();
|
||||
let e = eng
|
||||
.start_recover(&job, None, None, out.path().to_str().unwrap())
|
||||
.start_recover(
|
||||
&job,
|
||||
None,
|
||||
None,
|
||||
out.path().to_str().unwrap(),
|
||||
recover::RecoverLayout::Folders,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(e.to_string().contains("não está disponível"), "{e}");
|
||||
// reconecta
|
||||
eng.relink(&job, img.to_str().unwrap()).unwrap();
|
||||
eng.relink(&job, img.to_str().unwrap(), false).unwrap();
|
||||
let st = eng.status(&job).unwrap();
|
||||
assert_eq!(st["source"], true);
|
||||
assert!(st["warnings"].as_array().unwrap().is_empty());
|
||||
let rj = eng
|
||||
.start_recover(&job, None, None, out.path().to_str().unwrap())
|
||||
.start_recover(
|
||||
&job,
|
||||
None,
|
||||
None,
|
||||
out.path().to_str().unwrap(),
|
||||
recover::RecoverLayout::Folders,
|
||||
)
|
||||
.unwrap();
|
||||
wait_done(&eng, &rj);
|
||||
assert_eq!(hashes_in(out.path()).len(), 7);
|
||||
@@ -428,3 +453,256 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
|
||||
"{e}"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ desconexão e reconexão do disco
|
||||
|
||||
#[test]
|
||||
fn reconexao_so_aceita_o_mesmo_disco_ou_um_clone() {
|
||||
init();
|
||||
let Some(dir) = fixtures() else { return };
|
||||
let img = dir.join("ntfs.img");
|
||||
let tmp = sessao_formato_v0_1_0(&img, img.to_str().unwrap());
|
||||
let eng = service::Engine::new();
|
||||
let job = eng.load_session(tmp.path().to_str().unwrap()).unwrap();
|
||||
// sessão antiga não tem impressão digital: o relink aprende a do primeiro disco aceito
|
||||
eng.relink(&job, img.to_str().unwrap(), false).unwrap();
|
||||
let work = tempfile::tempdir().unwrap();
|
||||
// clone idêntico (por exemplo uma imagem do disco): aceito
|
||||
let clone = work.path().join("clone.img");
|
||||
std::fs::copy(&img, &clone).unwrap();
|
||||
eng.relink(&job, clone.to_str().unwrap(), false).unwrap();
|
||||
// mesmo tamanho, mas outro conteúdo inicial: recusado, a menos que force
|
||||
let outro = work.path().join("outro.img");
|
||||
let mut bytes = std::fs::read(&img).unwrap();
|
||||
bytes[0..16].copy_from_slice(b"OUTRO DISCO....!");
|
||||
std::fs::write(&outro, &bytes).unwrap();
|
||||
let e = eng
|
||||
.relink(&job, outro.to_str().unwrap(), false)
|
||||
.unwrap_err();
|
||||
assert!(e.to_string().contains("conteúdo inicial"), "{e}");
|
||||
eng.relink(&job, outro.to_str().unwrap(), true).unwrap();
|
||||
// tamanho diferente: recusado
|
||||
let menor = work.path().join("menor.img");
|
||||
std::fs::write(&menor, &bytes[..bytes.len() / 2]).unwrap();
|
||||
let e = eng
|
||||
.relink(&job, menor.to_str().unwrap(), false)
|
||||
.unwrap_err();
|
||||
assert!(e.to_string().contains("bytes"), "{e}");
|
||||
}
|
||||
|
||||
/// Fonte que "some" a partir de certo ponto, como um disco puxado da porta USB.
|
||||
struct Flaky {
|
||||
inner: MemSource,
|
||||
broken_from: u64,
|
||||
}
|
||||
|
||||
impl source::Source for Flaky {
|
||||
fn size(&self) -> u64 {
|
||||
self.inner.size()
|
||||
}
|
||||
fn read_at(&self, off: u64, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
if off + buf.len() as u64 > self.broken_from {
|
||||
return Err(std::io::Error::other("dispositivo removido"));
|
||||
}
|
||||
self.inner.read_at(off, buf)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn varredura_pausa_quando_o_disco_some_e_retoma_do_ponto_certo() {
|
||||
use guara_core::scan::{self, Outcome};
|
||||
let mib = 1024 * 1024usize;
|
||||
let mut disk = vec![0u8; 64 * mib];
|
||||
let png = |extra: usize| {
|
||||
let mut v = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
for (ty, data) in [
|
||||
(&b"IHDR"[..], vec![0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]),
|
||||
(&b"IDAT"[..], vec![5u8; extra]),
|
||||
(&b"IEND"[..], vec![]),
|
||||
] {
|
||||
v.extend((data.len() as u32).to_be_bytes());
|
||||
v.extend(ty);
|
||||
v.extend(&data);
|
||||
v.extend([0, 0, 0, 0]);
|
||||
}
|
||||
v
|
||||
};
|
||||
let (a, b) = (png(3000), png(4000));
|
||||
disk[2 * mib..2 * mib + a.len()].copy_from_slice(&a); // antes da falha
|
||||
disk[50 * mib..50 * mib + b.len()].copy_from_slice(&b); // depois da falha
|
||||
let flaky = Flaky {
|
||||
inner: MemSource(disk.clone()),
|
||||
broken_from: 16 * mib as u64,
|
||||
};
|
||||
let mut sink = Collect::default();
|
||||
let opts = ScanOptions::default();
|
||||
let out = scan::run(&flaky, &opts, None, &[], &|| false, &mut sink);
|
||||
let Outcome::Interrupted(cp) = out else {
|
||||
panic!("deveria ter parado por perda do dispositivo")
|
||||
};
|
||||
assert!(cp.io_lost, "deveria marcar perda de E/S");
|
||||
assert_eq!(sink.found.len(), 1, "só o PNG anterior à falha");
|
||||
// o ponto de retomada não pula os blocos que falharam
|
||||
assert!(cp.ranges[0].next <= 16 * mib as u64 + 8 * mib as u64);
|
||||
// disco volta: retoma e acha o resto, sem duplicar o primeiro
|
||||
let healthy = MemSource(disk);
|
||||
let before = sink.found.clone();
|
||||
let out = scan::run(&healthy, &opts, Some(cp), &before, &|| false, &mut sink);
|
||||
assert!(matches!(out, Outcome::Completed));
|
||||
let mut offs: Vec<_> = sink.found.iter().filter_map(|f| f.first_offset()).collect();
|
||||
offs.sort();
|
||||
assert_eq!(offs, vec![2 * mib as u64, 50 * mib as u64]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn varredura_salva_sessao_sozinha_e_ela_reabre() {
|
||||
init();
|
||||
let Some(dir) = fixtures() else { return };
|
||||
let img = dir.join("ntfs.img");
|
||||
let eng = service::Engine::new();
|
||||
let job = eng
|
||||
.start_scan(img.to_str().unwrap(), ScanOptions::default())
|
||||
.unwrap();
|
||||
wait_done(&eng, &job);
|
||||
// o salvamento final roda numa thread própria
|
||||
let mut item = None;
|
||||
for _ in 0..200 {
|
||||
let list = eng.sessions_list();
|
||||
item = list
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|v| v["target"] == img.to_str().unwrap() && v["status"] == "concluída")
|
||||
.cloned();
|
||||
if item.is_some() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(25));
|
||||
}
|
||||
let item = item.expect("sessão automática listada");
|
||||
assert_eq!(item["found"], 7);
|
||||
let path = item["path"].as_str().unwrap().to_string();
|
||||
assert!(std::path::Path::new(&path).exists());
|
||||
// reabre como qualquer sessão
|
||||
let job2 = eng.load_session(&path).unwrap();
|
||||
let st = eng.status(&job2).unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
st["status"].as_str().unwrap(),
|
||||
st["found"].as_u64().unwrap(),
|
||||
st["source"].as_bool().unwrap()
|
||||
),
|
||||
("done", 7, true)
|
||||
);
|
||||
// apagar
|
||||
eng.session_delete(&path).unwrap();
|
||||
assert!(!std::path::Path::new(&path).exists());
|
||||
assert!(eng.session_delete("/etc/passwd").is_err());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ organização e espaço
|
||||
|
||||
#[test]
|
||||
fn recuperar_organiza_por_tipo_data_ou_tudo_junto() {
|
||||
let Some(dir) = fixtures() else { return };
|
||||
let img = dir.join("fat32.img");
|
||||
let res = scan_path(
|
||||
&img,
|
||||
&ScanOptions {
|
||||
mode: ScanMode::Quick,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let src = source::open_path(&img).unwrap();
|
||||
let files: Vec<&Found> = res.found.iter().collect();
|
||||
for (layout, esperado) in [
|
||||
(
|
||||
recover::RecoverLayout::Folders,
|
||||
"Recuperados/Documentos/Trabalho/_elatorio.pdf",
|
||||
),
|
||||
(recover::RecoverLayout::Type, "Documentos/_elatorio.pdf"),
|
||||
(recover::RecoverLayout::Flat, "_elatorio.pdf"),
|
||||
] {
|
||||
let out = tempfile::tempdir().unwrap();
|
||||
recover::recover_with(
|
||||
src.as_ref(),
|
||||
&files,
|
||||
out.path(),
|
||||
layout,
|
||||
&|| false,
|
||||
&mut |_, _, _| {},
|
||||
)
|
||||
.unwrap();
|
||||
let achou = std::fs::read_dir(out.path()).is_ok()
|
||||
&& walk_names(out.path()).iter().any(|p| {
|
||||
p.ends_with(esperado) || p.ends_with(&esperado.replace("_elatorio", "relatorio"))
|
||||
});
|
||||
assert!(achou, "{layout:?}: {:?}", walk_names(out.path()));
|
||||
}
|
||||
// por data: usa a data de modificação (ano/mês)
|
||||
let out = tempfile::tempdir().unwrap();
|
||||
recover::recover_with(
|
||||
src.as_ref(),
|
||||
&files,
|
||||
out.path(),
|
||||
recover::RecoverLayout::Date,
|
||||
&|| false,
|
||||
&mut |_, _, _| {},
|
||||
)
|
||||
.unwrap();
|
||||
let nomes = walk_names(out.path());
|
||||
assert!(
|
||||
nomes
|
||||
.iter()
|
||||
.any(|p| p.matches('/').count() >= 2 && p.chars().take(4).all(|c| c.is_ascii_digit())),
|
||||
"{nomes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn walk_names(root: &std::path::Path) -> Vec<String> {
|
||||
let mut v = vec![];
|
||||
fn go(d: &std::path::Path, root: &std::path::Path, v: &mut Vec<String>) {
|
||||
for e in std::fs::read_dir(d).unwrap().flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
go(&p, root, v);
|
||||
} else {
|
||||
v.push(
|
||||
p.strip_prefix(root)
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
go(root, root, &mut v);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn espaco_insuficiente_no_destino_e_avisado_antes_de_copiar() {
|
||||
init();
|
||||
let Some(dir) = fixtures() else { return };
|
||||
let img = dir.join("ntfs.img");
|
||||
let tmp = sessao_formato_v0_1_0(&img, img.to_str().unwrap());
|
||||
// sessão com um arquivo "enorme" (1 EB)
|
||||
let mut v: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(tmp.path()).unwrap()).unwrap();
|
||||
v["found"][0]["size"] = serde_json::json!(1u64 << 60);
|
||||
std::fs::write(tmp.path(), serde_json::to_vec(&v).unwrap()).unwrap();
|
||||
let eng = service::Engine::new();
|
||||
let job = eng.load_session(tmp.path().to_str().unwrap()).unwrap();
|
||||
let out = tempfile::tempdir().unwrap();
|
||||
let e = eng
|
||||
.start_recover(
|
||||
&job,
|
||||
None,
|
||||
None,
|
||||
out.path().to_str().unwrap(),
|
||||
recover::RecoverLayout::Folders,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(e.to_string().contains("Espaço insuficiente"), "{e}");
|
||||
assert!(recover::free_space(out.path()).unwrap_or(0) > 0);
|
||||
}
|
||||
Reference in new issue
Block a user