3 Commits
Author SHA1 Message Date
Lucas RayanandClaude Sonnet 5.5 b9657ea856 Corrige aviso de variável não usada fora do Unix
Release / Cria a release (push) Canceled after 0s
Release / CLI (linux-aarch64) (push) Canceled after 0s
Release / CLI (linux-x86_64) (push) Canceled after 0s
Release / CLI (macos-aarch64) (push) Canceled after 0s
Release / CLI (macos-x86_64) (push) Canceled after 0s
Release / CLI (windows-x86_64) (push) Canceled after 0s
Release / App desktop (linux-x86_64) (push) Canceled after 0s
Release / App desktop (macos-aarch64) (push) Canceled after 0s
Release / App desktop (macos-x86_64) (push) Canceled after 0s
Release / App desktop (windows-x86_64) (push) Canceled after 0s
Release / Publica a release (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 20:05:24 -03:00
Lucas RayanandClaude Sonnet 5.5 bbcb42ae6e Versão 0.5.0: bancos SQLite, saúde do disco, notificações, retomada, mapa GPS, inglês
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 20:01:31 -03:00
Lucas RayanandClaude Sonnet 5.5 4bb2397e3b feat: reconexão do disco, pausa por desconexão, sessões automáticas e organização (0.4.0)
Release / Cria a release (push) Canceled after 0s
Release / CLI (linux-aarch64) (push) Canceled after 0s
Release / CLI (linux-x86_64) (push) Canceled after 0s
Release / CLI (macos-aarch64) (push) Canceled after 0s
Release / CLI (macos-x86_64) (push) Canceled after 0s
Release / CLI (windows-x86_64) (push) Canceled after 0s
Release / App desktop (linux-x86_64) (push) Canceled after 0s
Release / App desktop (macos-aarch64) (push) Canceled after 0s
Release / App desktop (macos-x86_64) (push) Canceled after 0s
Release / App desktop (windows-x86_64) (push) Canceled after 0s
Release / Publica a release (push) Canceled after 0s
- Reconhece o mesmo disco por tamanho + impressão digital quando volta com outro nome.
- Varredura pausa sozinha (sem perder nada) se o dispositivo parar de responder.
- Sessões salvas automaticamente (atômico) e listadas em Recentes.
- Organização ao recuperar (pastas, tipo, data, tudo junto) e checagem de espaço livre.
- Botão de escolher a origem também em sessões pausadas.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 19:32:00 -03:00
32 changed files with 3669 additions and 116 deletions

No files matched your search

+26
View File
@@ -2,6 +2,32 @@
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer. Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
## [0.5.0] - 2026-10-18
### Adicionado
- **Visualizador de bancos de dados:** arquivos SQLite (`.db`, `.sqlite`, `.sqlite3`, `.db3`, `.sdb`, `.sqlitedb`, `.storedata`) abrem na pré-visualização com esquema, tabelas paginadas, blobs e datas reconhecidas. Leitor próprio, só leitura, tolerante a arquivos truncados ou corrompidos. Também **recupera linhas apagadas** a partir do espaço livre das páginas.
- **Saúde do disco (SMART):** antes de varrer, o Guará consulta o `smartctl` (quando instalado) e o contador de erros do kernel. Se o disco parece com defeito, recomenda criar a imagem primeiro e pede confirmação antes da varredura profunda.
- **Notificação do sistema** quando uma varredura longa ou uma recuperação termina, ou pausa por desconexão.
- **Retomar recuperação interrompida:** um diário na pasta de destino faz o Guará pular o que já foi copiado e conferido; o resultado mostra quantos foram pulados.
- **Mapa das fotos com GPS** no painel de detalhes (opcional, usa a internet) e botão para abrir no OpenStreetMap.
- **Interface em inglês**, com escolha de idioma (Auto, Português, English) na nova janela de Configurações, que também controla notificações e mapas online.
- **Fechar a janela durante a varredura** pergunta se você quer pausar e sair, sair sem pausar ou continuar.
### Notas
- Mensagens de erro vindas do núcleo e o terminal continuam em português.
## [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 ## [0.3.2] - 2026-10-18
### Corrigido ### Corrigido
Generated
+2 -2
View File
@@ -317,7 +317,7 @@ dependencies = [
[[package]] [[package]]
name = "guara-cli" name = "guara-cli"
version = "0.3.2" version = "0.5.0"
dependencies = [ dependencies = [
"clap", "clap",
"ctrlc", "ctrlc",
@@ -329,7 +329,7 @@ dependencies = [
[[package]] [[package]]
name = "guara-core" name = "guara-core"
version = "0.3.2" version = "0.5.0"
dependencies = [ dependencies = [
"flate2", "flate2",
"image", "image",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/guara-core", "crates/guara-cli"] members = ["crates/guara-core", "crates/guara-cli"]
[workspace.package] [workspace.package]
version = "0.3.2" version = "0.5.0"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara" repository = "https://github.com/lucasrguerra/guara"
+10 -1
View File
@@ -46,7 +46,8 @@ sudo guara scan /dev/sdb --quick # só metadados (segundos)
guara scan disco.img --category image,document --min-confidence medium guara scan disco.img --category image,document --min-confidence medium
guara scan disco.img --recover /mnt/outro-disco/Recuperados guara scan disco.img --recover /mnt/outro-disco/Recuperados
guara recover s.json /mnt/outro-disco/Recuperados --category image 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 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 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) 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`). - **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). - **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. - **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. - **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. - **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. - **Detecção de duplicadas** por assinatura de conteúdo.
@@ -114,3 +116,10 @@ Veja [CONTRIBUTING.md](CONTRIBUTING.md). Versões seguem [SemVer](https://semver
## Licença ## Licença
[MIT](LICENSE-MIT) ou [Apache-2.0](LICENSE-APACHE), à sua escolha. [MIT](LICENSE-MIT) ou [Apache-2.0](LICENSE-APACHE), à sua escolha.
## Novidades da 0.5.0
- Visualizador de bancos SQLite com recuperação de linhas apagadas.
- Aviso de saúde do disco (SMART) antes da varredura profunda.
- Notificações do sistema, retomada de recuperação interrompida e mapa de fotos com GPS (opcional).
- Interface em inglês e janela de Configurações.
+1 -1
View File
@@ -3,7 +3,7 @@
[package] [package]
name = "guara-gui" name = "guara-gui"
description = "Guará: interface gráfica de recuperação de arquivos" description = "Guará: interface gráfica de recuperação de arquivos"
version = "0.3.2" version = "0.5.0"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara" repository = "https://github.com/lucasrguerra/guara"
+1 -1
View File
@@ -3,5 +3,5 @@
"identifier": "default", "identifier": "default",
"description": "Permissões da janela principal", "description": "Permissões da janela principal",
"windows": ["main"], "windows": ["main"],
"permissions": ["core:default", "dialog:default"] "permissions": ["core:default", "core:window:allow-destroy", "core:window:allow-close", "dialog:default"]
} }
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Guara", "productName": "Guara",
"version": "0.3.2", "version": "0.5.0",
"identifier": "dev.guara.app", "identifier": "dev.guara.app",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
@@ -19,7 +19,7 @@
} }
], ],
"security": { "security": {
"csp": "default-src 'self'; img-src 'self' data: blob: guara: http://guara.localhost; media-src 'self' blob: guara: http://guara.localhost; connect-src 'self' ipc: http://ipc.localhost guara: http://guara.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; frame-src 'none'; object-src 'none'" "csp": "default-src 'self'; img-src 'self' data: blob: guara: http://guara.localhost https://tile.openstreetmap.org; media-src 'self' blob: guara: http://guara.localhost; connect-src 'self' ipc: http://ipc.localhost guara: http://guara.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; frame-src 'none'; object-src 'none'"
} }
}, },
"bundle": { "bundle": {
+34
View File
@@ -460,3 +460,37 @@ dialog.lightbox[open] { display: grid; }
.pv-card b { overflow-wrap: anywhere; font-size: 15px; } .pv-card b { overflow-wrap: anywhere; font-size: 15px; }
.pv-bigicon { font-size: 52px; line-height: 1; } .pv-bigicon { font-size: 52px; line-height: 1; }
.pv-why { max-width: 38ch; font-size: 12.5px; color: var(--text-2); } .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; }
/* ------------------------------------------------------------ visualizador de bancos de dados */
.db-sum { width: 100%; padding: 16px; display: grid; gap: 8px; text-align: left; }
.db-sum ul { list-style: none; margin: 4px 0; padding: 0; display: grid; gap: 2px; font-size: 13px; }
.db-sum li { display: flex; justify-content: space-between; gap: 10px; } .db-sum li small { color: var(--text-3); }
.viewer.db-large { display: block; overflow: hidden; }
.db-wrap { display: grid; grid-template-columns: 220px minmax(0, 1fr); height: 100%; min-height: 0; width: 100%; text-align: left; }
.db-tables { overflow: auto; padding: 10px; border-right: 1px solid var(--line); display: flex; flex-direction: column; gap: 2px; }
.db-t { display: flex; justify-content: space-between; gap: 8px; border: 0; background: transparent; cursor: pointer; padding: 7px 10px; border-radius: 9px; color: var(--text); font-size: 13.5px; text-align: left; }
.db-t:hover { background: color-mix(in srgb, var(--text) 7%, transparent); } .db-t[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.db-t small { color: var(--text-3); font-weight: 400; }
.db-meta { margin-top: 12px; display: grid; gap: 2px; font-size: 12.5px; color: var(--text-2); } .db-meta b { margin-top: 6px; color: var(--text-3); font-size: 11.5px; }
.db-main { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-width: 0; min-height: 0; }
.db-bar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; padding: 10px 14px; border-bottom: 1px solid var(--line); }
.db-bar .input { flex: 0 1 220px; }
.db-grid { overflow: auto; }
.db-foot { display: flex; justify-content: space-between; align-items: center; padding: 8px 14px; border-top: 1px solid var(--line); }
.db-table { border-collapse: collapse; font-size: 12.5px; min-width: 100%; }
.db-table th, .db-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; max-width: 320px; overflow: hidden; text-overflow: ellipsis; vertical-align: top; }
.db-table thead th { position: sticky; top: 0; background: var(--surface); z-index: 1; color: var(--text-3); font-weight: 600; text-align: left; }
.db-table tbody th { color: var(--text-3); font-weight: 400; text-align: right; }
.db-table tr.del td { background: var(--warn-soft); } .db-table tr.del th { color: var(--warn); }
.db-null { color: var(--text-3); font-style: italic; } .db-blob { color: var(--accent); }
.db-ts { margin-left: 6px; font-style: normal; font-size: 11px; color: var(--accent); background: var(--accent-soft); padding: 0 5px; border-radius: 6px; }
@media (max-width: 860px) { .db-wrap { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); } .db-tables { flex-direction: row; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); } }
.xp-map { position: relative; width: 100%; aspect-ratio: 1; overflow: hidden; border-radius: 10px; margin-top: 8px; background: var(--surface-2, #e6eeee); }
.xp-tiles { display: grid; grid-template-columns: repeat(3, 1fr); width: 100%; height: 100%; }
.xp-tiles img { width: 100%; height: 100%; display: block; }
.xp-pin { position: absolute; width: 14px; height: 14px; margin: -7px 0 0 -7px; border-radius: 50%; background: #0f8f8f; border: 3px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.4); }
+205 -25
View File
@@ -2,6 +2,7 @@
// • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/<método> com token. // • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/<método> com token.
import { createExplorer } from './explorer.js'; import { createExplorer } from './explorer.js';
import { setLang, LOC } from './i18n.js';
const tauri = window.__TAURI__; const tauri = window.__TAURI__;
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
@@ -47,9 +48,17 @@ document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async
const t = b.dataset.themePick; const t = b.dataset.themePick;
applyTheme(t); applyTheme(t);
try { localStorage.setItem('guara-theme', t); } catch { /* sem armazenamento */ } try { localStorage.setItem('guara-theme', t); } catch { /* sem armazenamento */ }
try { await rpc('settings_set', { theme: t }); } catch { /* segue só com o armazenamento local */ } saveSettings({ theme: t });
})); }));
// ------------------------------------------------------------------ configurações
// (declarado antes do uso: `S` é definido mais abaixo, por isso o objeto fica aqui)
const SETTINGS = { theme: 'auto', lang: 'auto', notify: true, maps: false };
async function saveSettings(patch) {
Object.assign(SETTINGS, patch);
try { await rpc('settings_set', SETTINGS); } catch { /* segue só com o armazenamento local */ }
}
// ------------------------------------------------------------------ utilidades // ------------------------------------------------------------------ utilidades
const $ = (s, el = document) => el.querySelector(s); const $ = (s, el = document) => el.querySelector(s);
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@ -64,7 +73,7 @@ const dur = (s) => {
s = Math.round(s); s = Math.round(s);
return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}min ${s % 60}s` : `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}min`; return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}min ${s % 60}s` : `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}min`;
}; };
const date = (t) => (t ? new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'medium', timeStyle: 'short' }) : 'sem data'); const date = (t) => (t ? new Date(t * 1000).toLocaleString(LOC(), { dateStyle: 'medium', timeStyle: 'short' }) : 'sem data');
const CATS = [ const CATS = [
['image', 'Fotos'], ['document', 'Documentos'], ['video', 'Vídeos'], ['audio', 'Áudio'], ['image', 'Fotos'], ['document', 'Documentos'], ['video', 'Vídeos'], ['audio', 'Áudio'],
['archive', 'Compactados'], ['database', 'Bancos de dados'], ['other', 'Outros'], ['archive', 'Compactados'], ['database', 'Bancos de dados'], ['other', 'Outros'],
@@ -108,8 +117,16 @@ function askForm({ title, text, fields, ok = 'OK' }) {
return new Promise((resolve) => { return new Promise((resolve) => {
const d = $('#dlg'); const d = $('#dlg');
d.innerHTML = `<form method="dialog"><h2>${esc(title)}</h2><p>${text || ''}</p> 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>`; <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 = () => { d.onclose = () => {
if (d.returnValue !== 'ok') return resolve(null); if (d.returnValue !== 'ok') return resolve(null);
const out = {}; const out = {};
@@ -180,6 +197,28 @@ function renderSide() {
<li><button class="dev" data-j="${i}" aria-current="${S.job === s.job}"><span class="ico">${icon('default')}</span> <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(''); <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); })); 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(LOC())} arquivos · ${esc(r.status)} · ${new Date(r.saved_at * 1000).toLocaleString(LOC(), { 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 () => { $('#openImageBtn').onclick = async () => {
@@ -240,9 +279,36 @@ async function openDevice(d) {
main.innerHTML = `<div class="page"><p class="sub">Lendo ${esc(d.name)}…</p></div>`; main.innerHTML = `<div class="page"><p class="sub">Lendo ${esc(d.name)}…</p></div>`;
main.scrollTop = 0; main.scrollTop = 0;
let layout = null, err = null; let layout = null, err = null;
S.health = null;
try { layout = await rpc('inspect', { path: d.path }); } catch (e) { err = e.message; } try { layout = await rpc('inspect', { path: d.path }); } catch (e) { err = e.message; }
S.layout = layout; S.layout = layout;
renderDevice(err); renderDevice(err);
// saúde (SMART) em segundo plano: não atrasa a tela
rpc('health', { path: d.path }).then((h) => { if (S.device === d) { S.health = h; paintHealth(); } }).catch(() => {});
}
function paintHealth() {
const el = $('#healthArea');
const h = S.health;
if (!el || !h || !h.available) { if (el) el.innerHTML = ''; return; }
const bad = h.status === 'failing' || h.status === 'warning';
const rows = (h.details || []).filter(([k]) => bad || ['Modelo', 'Horas ligado', 'Temperatura'].includes(k)).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('');
el.innerHTML = `<div class="notice ${h.status === 'failing' ? 'bad' : h.status === 'warning' ? '' : 'info'}">${warnSvg}<div><b>${esc(h.summary)}</b>
${rows ? `<dl class="kv" style="margin-top:6px">${rows}</dl>` : ''}${(h.advice || []).map((a) => `<div style="margin-top:6px">${esc(a)}</div>`).join('')}
${bad ? '<div class="row" style="margin-top:8px"><button class="btn sm primary" id="healthImg">Criar a imagem primeiro</button></div>' : ''}</div></div>`;
$('#healthImg')?.addEventListener('click', startImage);
}
/** Disco com sinais de defeito: oferece criar a imagem antes de varrer. */
function confirmRisky() {
return new Promise((resolve) => {
const h = S.health;
const d = $('#dlg');
d.innerHTML = `<form method="dialog"><h2>Este disco parece estar com defeito</h2><p>${esc(h.summary)}</p>${(h.advice || []).map((a) => `<p class="sub">${esc(a)}</p>`).join('')}
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn" value="scan">Varrer mesmo assim</button><button class="btn primary" value="image">Criar a imagem primeiro</button></menu></form>`;
d.onclose = () => resolve(d.returnValue === 'image' ? 'image' : d.returnValue === 'scan' ? 'scan' : null);
d.showModal();
});
} }
function fsClass(p) { function fsClass(p) {
@@ -279,6 +345,7 @@ function renderDevice(err) {
main.innerHTML = `<div class="page"> main.innerHTML = `<div class="page">
<div class="dev-head"><div><h1>${esc(d.name)}</h1><div class="sub">${esc(d.kind)}${d.size ? ' · ' + human(d.size) : l ? ' · ' + human(l.size) : ''} · <span class="mono">${esc(d.path)}</span></div></div></div> <div class="dev-head"><div><h1>${esc(d.name)}</h1><div class="sub">${esc(d.kind)}${d.size ? ' · ' + human(d.size) : l ? ' · ' + human(l.size) : ''} · <span class="mono">${esc(d.path)}</span></div></div></div>
${notes.map(([k, t]) => `<div class="notice ${k === 'bad' ? 'bad' : ''}">${warnSvg}<div>${t}</div></div>`).join('')} ${notes.map(([k, t]) => `<div class="notice ${k === 'bad' ? 'bad' : ''}">${warnSvg}<div>${t}</div></div>`).join('')}
<div id="healthArea"></div>
${l ? `<div class="section"><h2>Mapa do disco</h2>${diskMap(l)} ${l ? `<div class="section"><h2>Mapa do disco</h2>${diskMap(l)}
<div class="legend">${l.partitions.map((p) => `<div><span class="dot ${fsClass(p)}"></span><b>Partição ${p.index + 1}</b> ${esc(FS_NAME[p.fs] || p.fs)} · ${human(p.len)}${p.label ? ' · ' + esc(p.label) : ''} <div class="legend">${l.partitions.map((p) => `<div><span class="dot ${fsClass(p)}"></span><b>Partição ${p.index + 1}</b> ${esc(FS_NAME[p.fs] || p.fs)} · ${human(p.len)}${p.label ? ' · ' + esc(p.label) : ''}
${p.lost ? '<span class="pill accent">encontrada por varredura</span>' : ''}${p.encrypted ? `<span class="pill warn">cifrada (${esc(p.encrypted)}) · só detecção</span>` : ''}</div>`).join('')} ${p.lost ? '<span class="pill accent">encontrada por varredura</span>' : ''}${p.encrypted ? `<span class="pill warn">cifrada (${esc(p.encrypted)}) · só detecção</span>` : ''}</div>`).join('')}
@@ -315,12 +382,19 @@ function renderDevice(err) {
$('#goDeep')?.addEventListener('click', () => startScan(opts('deep'))); $('#goDeep')?.addEventListener('click', () => startScan(opts('deep')));
$('#goQuick')?.addEventListener('click', () => startScan(opts('quick'))); $('#goQuick')?.addEventListener('click', () => startScan(opts('quick')));
$('#goImage')?.addEventListener('click', startImage); $('#goImage')?.addEventListener('click', startImage);
paintHealth();
$('#goSearch')?.addEventListener('click', startSearch); $('#goSearch')?.addEventListener('click', startSearch);
$('#srchTerms')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') startSearch(); }); $('#srchTerms')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') startSearch(); });
$('#goHash')?.addEventListener('click', startHash); $('#goHash')?.addEventListener('click', startHash);
} }
async function startScan(options) { async function startScan(options) {
if (S.health && (S.health.status === 'failing' || S.health.status === 'warning') && options.mode === 'deep' && !S.healthAck?.[S.device.path]) {
const c = await confirmRisky();
if (c === 'image') return startImage();
if (!c) return;
(S.healthAck ??= {})[S.device.path] = true;
}
try { try {
const { job } = await rpc('scan_start', { path: S.device.path, options }); const { job } = await rpc('scan_start', { path: S.device.path, options });
const s = { job, target: S.device.path, label: S.device.name }; const s = { job, target: S.device.path, label: S.device.name };
@@ -379,9 +453,9 @@ function openSearch(job, terms) {
const run = st.status === 'running'; const run = st.status === 'running';
const p = st.progress || {}; const p = st.progress || {};
const pct = p.total ? Math.floor((p.done / p.total) * 100) : 0; const pct = p.total ? Math.floor((p.done / p.total) * 100) : 0;
$('#shTitle').textContent = run ? 'Procurando…' : `${res.total.toLocaleString('pt-BR')} ocorrência${res.total === 1 ? '' : 's'}`; $('#shTitle').textContent = run ? 'Procurando…' : `${res.total.toLocaleString(LOC())} ocorrência${res.total === 1 ? '' : 's'}`;
setHTML($('#shProg'), run ? `<div class="bar"><i style="width:${pct}%"></i></div><div class="sub" style="margin-top:6px">${pct}% · ${res.total.toLocaleString('pt-BR')} até agora</div><button class="btn sm danger" data-stop="1" style="margin-top:8px">Parar</button>` : st.status === 'error' ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : ''); setHTML($('#shProg'), run ? `<div class="bar"><i style="width:${pct}%"></i></div><div class="sub" style="margin-top:6px">${pct}% · ${res.total.toLocaleString(LOC())} até agora</div><button class="btn sm danger" data-stop="1" style="margin-top:8px">Parar</button>` : st.status === 'error' ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : '');
setHTML($('#hits'), res.items.length ? `<div class="hit head"><span>Posição</span><span>Partição</span><span>Codificação</span><span>Contexto</span></div>` + res.items.map((h) => `<div class="hit"><span class="mono" title="${h.offset} bytes (0x${h.offset.toString(16)})">${h.offset.toLocaleString('pt-BR')}</span><span>${h.partition ? '#' + h.partition : 'fora'}</span><span>${esc(h.encoding)}</span><span class="ctx">${esc(h.context).replace(termRe, (m) => `<mark>${m}</mark>`)}</span></div>`).join('') : (run ? '' : '<div class="empty"><h3>Nada encontrado</h3><p>Esses termos não aparecem em nenhum ponto do disco (nem em UTF-8 nem em UTF-16).</p></div>')); setHTML($('#hits'), res.items.length ? `<div class="hit head"><span>Posição</span><span>Partição</span><span>Codificação</span><span>Contexto</span></div>` + res.items.map((h) => `<div class="hit"><span class="mono" title="${h.offset} bytes (0x${h.offset.toString(16)})">${h.offset.toLocaleString(LOC())}</span><span>${h.partition ? '#' + h.partition : 'fora'}</span><span>${esc(h.encoding)}</span><span class="ctx">${esc(h.context).replace(termRe, (m) => `<mark>${m}</mark>`)}</span></div>`).join('') : (run ? '' : '<div class="empty"><h3>Nada encontrado</h3><p>Esses termos não aparecem em nenhum ponto do disco (nem em UTF-8 nem em UTF-16).</p></div>'));
return run; return run;
}; };
$('#shProg').onclick = (e) => { if (e.target.dataset.stop) rpc('job_stop', { job }); }; $('#shProg').onclick = (e) => { if (e.target.dataset.stop) rpc('job_stop', { job }); };
@@ -415,6 +489,7 @@ async function tick() {
// continua consultando enquanto a varredura roda ou as datas/EXIF ainda estão sendo lidas // continua consultando enquanto a varredura roda ou as datas/EXIF ainda estão sendo lidas
if (st.status !== 'running' && !st.enriching) { if (st.status !== 'running' && !st.enriching) {
stopPoll(); stopPoll();
loadRecent();
if (st.status === 'error') toast(st.error); if (st.status === 'error') toast(st.error);
} else if (!S.poll) { } else if (!S.poll) {
S.poll = setInterval(tick, 700); S.poll = setInterval(tick, 700);
@@ -431,7 +506,7 @@ function renderScan() {
</div>`; </div>`;
$('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome()); $('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome());
$('#progressArea').onclick = onProgressClick; $('#progressArea').onclick = onProgressClick;
EX = createExplorer({ host: $('#xpHost'), job: S.job, rpc, esc, human, fileUrl, thumbUrl, onRecover: recover, toast, noInlineMedia: !!tauri && /Linux/i.test(navigator.userAgent) }); EX = createExplorer({ host: $('#xpHost'), job: S.job, rpc, esc, human, fileUrl, thumbUrl, onRecover: recover, toast, noInlineMedia: !!tauri && /Linux/i.test(navigator.userAgent), getSettings: () => SETTINGS });
} }
function paintStatus() { function paintStatus() {
@@ -444,9 +519,9 @@ function paintStatus() {
$('#scanSub').innerHTML = `<span class="mono">${esc(st.target)}</span>`; $('#scanSub').innerHTML = `<span class="mono">${esc(st.target)}</span>`;
const rate = p.total && st.elapsed > 1 && p.done ? p.done / st.elapsed : 0; const rate = p.total && st.elapsed > 1 && p.done ? p.done / st.elapsed : 0;
const eta = rate && running ? dur((p.total - p.done) / rate) : null; 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 enr = st.enriching ? `<span class="sub">Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString(LOC())}/${st.enriching.total.toLocaleString(LOC())}</span>` : '';
const buttons = running ? '<button class="btn sm" data-act="pause">Pausar</button><button class="btn sm danger" data-act="stop">Parar</button>' 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 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>' : ''); 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') { if (running || st.status === 'paused') {
@@ -454,34 +529,75 @@ function paintStatus() {
<div class="row spread"><div><b>${esc(p.phase || 'Iniciando')}</b>${pct != null ? ` <span class="sub">· ${pct}%</span>` : ''}</div><div class="row">${buttons}</div></div> <div class="row spread"><div><b>${esc(p.phase || 'Iniciando')}</b>${pct != null ? ` <span class="sub">· ${pct}%</span>` : ''}</div><div class="row">${buttons}</div></div>
<div class="bar ${running && pct == null ? 'indet' : ''}"><i style="width:${pct ?? (running ? 35 : 100)}%"></i></div> <div class="bar ${running && pct == null ? 'indet' : ''}"><i style="width:${pct ?? (running ? 35 : 100)}%"></i></div>
<div class="stats compact"> <div class="stats compact">
<div class="stat"><b>${st.found.toLocaleString('pt-BR')}</b><span>arquivos</span></div> <div class="stat"><b>${st.found.toLocaleString(LOC())}</b><span>arquivos</span></div>
<div class="stat"><b>${dur(st.elapsed)}</b><span>decorrido${eta ? ' · faltam ~' + eta : ''}</span></div> <div class="stat"><b>${dur(st.elapsed)}</b><span>decorrido${eta ? ' · faltam ~' + eta : ''}</span></div>
<div class="stat"><b style="color:var(--ok)">${st.confidence.high}</b><span>alta</span></div> <div class="stat"><b style="color:var(--ok)">${st.confidence.high}</b><span>alta</span></div>
<div class="stat"><b style="color:var(--warn)">${st.confidence.medium}</b><span>média</span></div> <div class="stat"><b style="color:var(--warn)">${st.confidence.medium}</b><span>média</span></div>
<div class="stat"><b style="color:var(--bad)">${st.confidence.low}</b><span>baixa</span></div> <div class="stat"><b style="color:var(--bad)">${st.confidence.low}</b><span>baixa</span></div>
</div></div>`); </div></div>`);
} else { } else {
setHTML($('#progressArea'), `<div class="done-bar"><span><b>${st.found.toLocaleString('pt-BR')}</b> arquivos em ${dur(st.elapsed)}</span> setHTML($('#progressArea'), `<div class="done-bar"><span><b>${st.found.toLocaleString(LOC())}</b> arquivos em ${dur(st.elapsed)}</span>
<span class="sub">confiança alta ${st.confidence.high} · média ${st.confidence.medium} · baixa ${st.confidence.low}</span>${enr}<span class="grow"></span>${tools}</div>`); <span class="sub">confiança alta ${st.confidence.high} · média ${st.confidence.medium} · baixa ${st.confidence.low}</span>${enr}<span class="grow"></span>${tools}</div>`);
} }
setHTML($('#warnArea'), (st.warnings || []).map((w) => `<div class="notice">${warnSvg}<div>${esc(w)}</div></div>`).join('') setHTML($('#warnArea'), (st.warnings || []).map((w) => `<div class="notice">${warnSvg}<div>${esc(w)}</div></div>`).join('')
+ (st.error ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : '')); + (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) { async function onProgressClick(e) {
const act = e.target.closest('[data-act]')?.dataset.act; const act = e.target.closest('[data-act]')?.dataset.act;
if (!act) return; if (!act) return;
if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); } 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 === '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 === 'resume') {
else if (act === 'relink') { try { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); }
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' }); catch (err) { toast(err.message); chooseSource(); }
if (path) { } else if (act === 'reconnect') {
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>` }); } 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') { } 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' }); 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); } } if (path) { try { const r = await rpc('export_csv', { job: S.job, path, query: EX?.query() }); toast(`${r.rows.toLocaleString(LOC())} linhas gravadas`); } catch (err) { toast(err.message); } }
} else if (act === 'report') { } else if (act === 'report') {
const f = await askForm({ title: 'Relatório de recuperação', text: 'Gera um relatório HTML pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados.', ok: 'Gerar relatório', fields: [ const f = await askForm({ title: 'Relatório de recuperação', text: 'Gera um relatório HTML pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados.', ok: 'Gerar relatório', fields: [
{ name: 'path', label: 'Salvar em', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-relatorio.html' }, { name: 'path', label: 'Salvar em', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-relatorio.html' },
@@ -501,22 +617,26 @@ async function onProgressClick(e) {
} }
async function recover(sel) { async function recover(sel) {
const dest = await ask({ const f = await askForm({
title: 'Recuperar para…', 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.', 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. <span class="sub">Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.</span>',
value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados',
ok: 'Recuperar', 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 { 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) => { watchAux(job, 'Recuperando arquivos', (st) => {
const r = st.report || {}; const r = st.report || {};
const ok = (r.recovered || []).length, bad = (r.failed || []).length; 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> return `<p><b>${ok}</b> arquivo(s) salvos em <code>${esc(dest)}</code> (${human(r.total_bytes)}).</p>
${r.skipped ? `<p class="sub">${r.skipped} já estavam recuperados e conferidos (pulados).</p>` : ''}
${bad ? `<div class="notice">${warnSvg}<div>${bad} arquivo(s) não puderam ser gravados.</div></div>` : ''} ${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>` }); } } catch (e) { info({ title: 'Não foi possível recuperar', html: `<p>${esc(e.message)}</p>` }); }
} }
@@ -550,18 +670,78 @@ function watchAux(job, title, doneHtml) {
async function openJobFromList(s) { openJob(s); } async function openJobFromList(s) { openJob(s); }
// ------------------------------------------------------------------ configurações (janela)
$('#settingsBtn')?.addEventListener('click', () => {
const d = $('#dlg');
d.innerHTML = `<form method="dialog"><h2>Configurações</h2>
<label class="fld"><span>Idioma</span><select class="select" name="lang"><option value="auto">Auto</option><option value="pt">Português</option><option value="en">English</option></select></label>
<label class="check"><input type="checkbox" name="notify" ${SETTINGS.notify ? 'checked' : ''}><span><b>Notificações</b><br><span class="sub">Avisar quando uma tarefa longa terminar</span></span></label>
<label class="check"><input type="checkbox" name="maps" ${SETTINGS.maps ? 'checked' : ''}><span><b>Mapas online</b><br><span class="sub">Mostrar mapas das fotos com GPS (usa a internet: baixa blocos do OpenStreetMap)</span></span></label>
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">Salvar</button></menu></form>`;
d.querySelector('[name=lang]').value = SETTINGS.lang;
d.onclose = () => {
if (d.returnValue !== 'ok') return;
const patch = { lang: d.querySelector('[name=lang]').value, notify: d.querySelector('[name=notify]').checked, maps: d.querySelector('[name=maps]').checked };
saveSettings(patch);
setLang(patch.lang);
toast('Salvo');
};
d.showModal();
});
// ------------------------------------------------------------------ fechar com varredura em andamento
function askClose() {
return new Promise((resolve) => {
const d = $('#dlg');
d.innerHTML = `<form method="dialog"><h2>Há uma varredura em andamento</h2><p>A sessão é salva automaticamente. Pausando agora, você retoma exatamente do ponto em que parou.</p>
<menu><button class="btn" value="stay">Continuar varrendo</button><button class="btn" value="quit">Sair sem pausar</button><button class="btn primary" value="pause">Pausar e sair</button></menu></form>`;
d.onclose = () => resolve(d.returnValue);
d.showModal();
});
}
function watchClose() {
window.addEventListener('beforeunload', (e) => {
if (S.status?.status === 'running' && !tauri) { e.preventDefault(); e.returnValue = ''; }
});
const win = tauri?.window?.getCurrentWindow?.();
if (!win?.onCloseRequested) return;
win.onCloseRequested(async (ev) => {
if (S.status?.status !== 'running') return;
ev.preventDefault();
const c = await askClose();
if (c === 'stay' || !c) return;
if (c === 'pause') {
try {
await rpc('scan_pause', { job: S.job });
for (let i = 0; i < 40; i++) { const st = await rpc('status', { job: S.job }); if (st.status !== 'running') break; await new Promise((r) => setTimeout(r, 250)); }
await new Promise((r) => setTimeout(r, 600)); // dá tempo do salvamento automático
} catch { /* sai mesmo assim */ }
}
win.destroy();
});
}
// ------------------------------------------------------------------ início do app // ------------------------------------------------------------------ início do app
(async function init() { (async function init() {
try { try {
const v = await rpc('version'); const v = await rpc('version');
$('#versionTag').textContent = `v${v.version}`; $('#versionTag').textContent = `v${v.version}`;
S.defaults = await rpc('defaults'); S.defaults = await rpc('defaults');
try { const st = await rpc('settings_get'); if (st?.theme) { applyTheme(st.theme); try { localStorage.setItem('guara-theme', st.theme); } catch { /* */ } } } catch { /* usa o local */ } try {
const st = await rpc('settings_get');
Object.assign(SETTINGS, st);
applyTheme(st.theme);
try { localStorage.setItem('guara-theme', st.theme); } catch { /* */ }
} catch { /* usa o local */ }
setLang(SETTINGS.lang);
watchClose();
tauri?.event?.listen?.('elevate-failed', (e) => { toast(String(e.payload || 'Não foi possível reiniciar como administrador')); }); tauri?.event?.listen?.('elevate-failed', (e) => { toast(String(e.payload || 'Não foi possível reiniciar como administrador')); });
} catch (e) { } catch (e) {
main.innerHTML = `<div class="page"><div class="notice bad">${warnSvg}<div>Não foi possível falar com o núcleo do Guará: ${esc(e.message)}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando <code>guara ui</code> (ele inclui o token).</div></div></div>`; main.innerHTML = `<div class="page"><div class="notice bad">${warnSvg}<div>Não foi possível falar com o núcleo do Guará: ${esc(e.message)}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando <code>guara ui</code> (ele inclui o token).</div></div></div>`;
return; return;
} }
await loadDevices(); await loadDevices();
await loadRecent();
renderHome(); renderHome();
})(); })();
+36 -13
View File
@@ -4,6 +4,7 @@
// visualização rápida em tela cheia. // visualização rápida em tela cheia.
import { renderPreview, kindOf, KIND_LABEL } from './preview.js'; import { renderPreview, kindOf, KIND_LABEL } from './preview.js';
import { LOC } from './i18n.js';
const CAT = { const CAT = {
image: ['Fotos', '🖼'], document: ['Documentos', '📄'], video: ['Vídeos', '🎞'], audio: ['Áudio', '🎵'], image: ['Fotos', '🖼'], document: ['Documentos', '📄'], video: ['Vídeos', '🎞'], audio: ['Áudio', '🎵'],
@@ -32,6 +33,10 @@ const I = {
export function createExplorer(deps) { export function createExplorer(deps) {
const { host, job, rpc, esc, human, fileUrl, thumbUrl, onRecover, toast } = deps; const { host, job, rpc, esc, human, fileUrl, thumbUrl, onRecover, toast } = deps;
const inlineMedia = !deps.noInlineMedia; const inlineMedia = !deps.noInlineMedia;
const dbApi = (it) => ({
info: () => rpc('db_info', { job, id: it.id }),
rows: (table, offset, limit, deleted) => rpc('db_rows', { job, id: it.id, table, offset, limit, deleted }),
});
const openExternal = (it) => { const openExternal = (it) => {
toast('Preparando para abrir no player do sistema…'); toast('Preparando para abrir no player do sistema…');
rpc('open_external', { job, id: it.id }).then(() => toast('Aberto no player do sistema')).catch((err) => toast(err.message)); rpc('open_external', { job, id: it.id }).then(() => toast('Aberto no player do sistema')).catch((err) => toast(err.message));
@@ -141,11 +146,11 @@ export function createExplorer(deps) {
const dayLabel = (k) => { const dayLabel = (k) => {
if (k === 'Sem data') return k; if (k === 'Sem data') return k;
return groupBy === 'day' return groupBy === 'day'
? new Date(k + 'T00:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'UTC', weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }) ? new Date(k + 'T00:00:00Z').toLocaleDateString(LOC(), { timeZone: 'UTC', weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
: new Date(k + '-01T00:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'UTC', month: 'long', year: 'numeric' }); : new Date(k + '-01T00:00:00Z').toLocaleDateString(LOC(), { timeZone: 'UTC', month: 'long', year: 'numeric' });
}; };
const fmtDate = (t) => (t == null ? 'sem data' : new Date(t * 1000).toLocaleString('pt-BR', { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'short' })); const fmtDate = (t) => (t == null ? 'sem data' : new Date(t * 1000).toLocaleString(LOC(), { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'short' }));
const fmtDay = (t) => (t == null ? '' : new Date(t * 1000).toLocaleDateString('pt-BR', { timeZone: 'UTC', day: '2-digit', month: 'short', year: 'numeric' })); const fmtDay = (t) => (t == null ? '' : new Date(t * 1000).toLocaleDateString(LOC(), { timeZone: 'UTC', day: '2-digit', month: 'short', year: 'numeric' }));
function geometry() { function geometry() {
const W = Math.max(200, scroll.clientWidth - PAD * 2); const W = Math.max(200, scroll.clientWidth - PAD * 2);
@@ -383,11 +388,11 @@ export function createExplorer(deps) {
function paintActions() { function paintActions() {
const n = selCount(); const n = selCount();
const html = n const html = n
? `<span class="info"><b>${n.toLocaleString('pt-BR')}</b> selecionado${n === 1 ? '' : 's'}${E.allFiltered ? ' (todos desta vista)' : ''}</span> ? `<span class="info"><b>${n.toLocaleString(LOC())}</b> selecionado${n === 1 ? '' : 's'}${E.allFiltered ? ' (todos desta vista)' : ''}</span>
<button class="btn sm" data-a="clear">Limpar seleção</button> <button class="btn sm" data-a="clear">Limpar seleção</button>
${!E.allFiltered && E.total > n ? `<button class="btn sm" data-a="all">Selecionar todos os ${E.total.toLocaleString('pt-BR')}</button>` : ''} ${!E.allFiltered && E.total > n ? `<button class="btn sm" data-a="all">Selecionar todos os ${E.total.toLocaleString(LOC())}</button>` : ''}
<button class="btn primary" data-a="rec">Recuperar ${n === 1 ? 'selecionado' : 'selecionados'}</button>` <button class="btn primary" data-a="rec">Recuperar ${n === 1 ? 'selecionado' : 'selecionados'}</button>`
: `<span class="info">${E.total.toLocaleString('pt-BR')} ${E.total === 1 ? 'item' : 'itens'} nesta vista${E.total > E.items.length ? ` (mostrando ${E.items.length.toLocaleString('pt-BR')})` : ''}</span> : `<span class="info">${E.total.toLocaleString(LOC())} ${E.total === 1 ? 'item' : 'itens'} nesta vista${E.total > E.items.length ? ` (mostrando ${E.items.length.toLocaleString(LOC())})` : ''}</span>
${E.total ? '<button class="btn sm" data-a="all">Selecionar tudo</button><button class="btn primary" data-a="recall">Recuperar tudo desta vista</button>' : ''}`; ${E.total ? '<button class="btn sm" data-a="all">Selecionar tudo</button><button class="btn primary" data-a="recall">Recuperar tudo desta vista</button>' : ''}`;
if (actions._h !== html) { actions._h = html; actions.innerHTML = html; } if (actions._h !== html) { actions._h = html; actions.innerHTML = html; }
} }
@@ -405,7 +410,7 @@ export function createExplorer(deps) {
if (!f) return; if (!f) return;
const F = E.filters; const F = E.filters;
const node = (label, count, attrs, active, depth = 0, extra = '') => const node = (label, count, attrs, active, depth = 0, extra = '') =>
`<button class="xp-node${active ? ' on' : ''}" ${attrs} style="--d:${depth}" aria-pressed="${!!active}">${extra}<span class="l">${label}</span><small>${count.toLocaleString('pt-BR')}</small></button>`; `<button class="xp-node${active ? ' on' : ''}" ${attrs} style="--d:${depth}" aria-pressed="${!!active}">${extra}<span class="l">${label}</span><small>${count.toLocaleString(LOC())}</small></button>`;
const sec = (title, body) => (body ? `<div class="xp-sec"><h4>${title}</h4>${body}</div>` : ''); const sec = (title, body) => (body ? `<div class="xp-sec"><h4>${title}</h4>${body}</div>` : '');
const cats = Object.keys(CAT).filter((k) => f.categories[k]).map((k) => node(`${CAT[k][1]} ${CAT[k][0]}`, f.categories[k], `data-f="category" data-v="${k}"`, F.category === k)).join(''); const cats = Object.keys(CAT).filter((k) => f.categories[k]).map((k) => node(`${CAT[k][1]} ${CAT[k][0]}`, f.categories[k], `data-f="category" data-v="${k}"`, F.category === k)).join('');
@@ -489,7 +494,7 @@ export function createExplorer(deps) {
if (F.undated) parts.push('Sem data'); if (F.undated) parts.push('Sem data');
if (F.folder) parts.push(F.folder === '@carved' ? 'Por assinatura' : F.folder === '@raiz' ? 'Raiz' : F.folder.split('/').join(' › ')); if (F.folder) parts.push(F.folder === '@carved' ? 'Por assinatura' : F.folder === '@raiz' ? 'Raiz' : F.folder.split('/').join(' › '));
if (F.camera) parts.push(F.camera); if (F.camera) parts.push(F.camera);
$('#xpCrumb').innerHTML = `<b>${parts.length ? esc(parts.join(' › ')) : 'Todos os arquivos'}</b><small>${E.total.toLocaleString('pt-BR')}</small>`; $('#xpCrumb').innerHTML = `<b>${parts.length ? esc(parts.join(' › ')) : 'Todos os arquivos'}</b><small>${E.total.toLocaleString(LOC())}</small>`;
} }
function paintChips() { function paintChips() {
@@ -558,7 +563,7 @@ export function createExplorer(deps) {
const close = `<button class="icon-btn xp-insp-x" data-i="close" aria-label="Fechar">${I.close}</button>`; const close = `<button class="icon-btn xp-insp-x" data-i="close" aria-label="Fechar">${I.close}</button>`;
if (n > 1) { if (n > 1) {
const sizeSum = E.allFiltered ? null : [...E.sel].reduce((a, id) => a + (E.items[idxOf(id)]?.size || 0), 0); const sizeSum = E.allFiltered ? null : [...E.sel].reduce((a, id) => a + (E.items[idxOf(id)]?.size || 0), 0);
insp.innerHTML = `${close}<div class="xp-multi"><b>${n.toLocaleString('pt-BR')} itens selecionados</b>${sizeSum != null ? `<span class="sub">${human(sizeSum)} no total</span>` : ''} insp.innerHTML = `${close}<div class="xp-multi"><b>${n.toLocaleString(LOC())} itens selecionados</b>${sizeSum != null ? `<span class="sub">${human(sizeSum)} no total</span>` : ''}
<div class="row"><button class="btn primary" data-i="rec">Recuperar selecionados</button><button class="btn" data-i="clear">Limpar seleção</button></div></div>`; <div class="row"><button class="btn primary" data-i="rec">Recuperar selecionados</button><button class="btn" data-i="clear">Limpar seleção</button></div></div>`;
return; return;
} }
@@ -575,7 +580,7 @@ export function createExplorer(deps) {
<dl class="kv"><dt>Pasta</dt><dd>${esc(it.dir || (it.origin === 'carved' ? 'Encontrado por assinatura' : '/'))}</dd>${timesHtml(it)}${it.camera ? `<dt>Câmera</dt><dd>${esc(it.camera)}</dd>` : ''}${it.px ? `<dt>Resolução</dt><dd>${it.px[0]} × ${it.px[1]} px</dd>` : ''}</dl> <dl class="kv"><dt>Pasta</dt><dd>${esc(it.dir || (it.origin === 'carved' ? 'Encontrado por assinatura' : '/'))}</dd>${timesHtml(it)}${it.camera ? `<dt>Câmera</dt><dd>${esc(it.camera)}</dd>` : ''}${it.px ? `<dt>Resolução</dt><dd>${it.px[0]} × ${it.px[1]} px</dd>` : ''}</dl>
<div class="xp-meta" id="xpMeta"></div> <div class="xp-meta" id="xpMeta"></div>
<div class="row"><button class="btn sm" data-i="full">Tela cheia</button><button class="btn primary sm" data-i="rec1">Recuperar este arquivo</button></div>`; <div class="row"><button class="btn sm" data-i="full">Tela cheia</button><button class="btn primary sm" data-i="rec1">Recuperar este arquivo</button></div>`;
stopPv = renderPreview($('#xpPv'), it, { url, thumb: kindOf(it) === 'image', inlineMedia, onOpen: () => openLightbox(idxOf(it.id)), onExternal: () => openExternal(it) }); stopPv = renderPreview($('#xpPv'), it, { url, thumb: kindOf(it) === 'image', inlineMedia, db: dbApi(it), onOpen: () => openLightbox(idxOf(it.id)), onExternal: () => openExternal(it) });
// clicar na miniatura abre o modal grande // clicar na miniatura abre o modal grande
$('#xpPv').addEventListener('click', (ev) => { $('#xpPv').addEventListener('click', (ev) => {
if (ev.target.closest('button, a, .pv-pdfbar')) return; if (ev.target.closest('button, a, .pv-pdfbar')) return;
@@ -587,9 +592,27 @@ export function createExplorer(deps) {
const rows = (m.fields || []).filter(([k]) => !['Dimensões'].includes(k)); const rows = (m.fields || []).filter(([k]) => !['Dimensões'].includes(k));
if (!rows.length) return; if (!rows.length) return;
$('#xpMeta').innerHTML = `<h5>Metadados${m.kind ? ` · ${esc(m.kind)}` : ''}</h5><dl class="kv">${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl>` $('#xpMeta').innerHTML = `<h5>Metadados${m.kind ? ` · ${esc(m.kind)}` : ''}</h5><dl class="kv">${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl>`
+ (m.gps ? `<div class="sub" style="font-size:12.5px">Coordenadas: ${m.gps[0].toFixed(5)}, ${m.gps[1].toFixed(5)}</div>` : ''); + (m.gps ? gpsHtml(m.gps) : '');
$('#xpOsm')?.addEventListener('click', () => rpc('open_url', { url: `https://www.openstreetmap.org/?mlat=${m.gps[0]}&mlon=${m.gps[1]}#map=15/${m.gps[0]}/${m.gps[1]}` }).catch(() => {}));
}).catch(() => {}); }).catch(() => {});
} }
/** Coordenadas e, se o usuário permitiu, um mapa (blocos do OpenStreetMap, 3x3 ao redor do ponto). */
function gpsHtml([lat, lon]) {
let map = '';
if (deps.getSettings?.().maps) {
const z = 15, n = 2 ** z;
const fx = (lon + 180) / 360 * n;
const fy = (1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * n;
const tx = Math.floor(fx), ty = Math.floor(fy);
let imgs = '';
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) imgs += `<img src="https://tile.openstreetmap.org/${z}/${tx + dx}/${ty + dy}.png" alt="" loading="lazy" draggable="false">`;
const px = (fx - tx + 1) / 3 * 100, py = (fy - ty + 1) / 3 * 100;
map = `<div class="xp-map"><div class="xp-tiles">${imgs}</div><span class="xp-pin" style="left:${px}%;top:${py}%"></span></div>`;
}
return `<div class="sub" style="font-size:12.5px;margin-top:8px">Coordenadas: ${lat.toFixed(5)}, ${lon.toFixed(5)}</div>${map}
<div class="row" style="margin-top:6px"><button class="btn sm" id="xpOsm">Abrir no OpenStreetMap</button></div>
${map ? '<div class="sub" style="font-size:11px;margin-top:4px">© colaboradores do OpenStreetMap</div>' : ''}`;
}
insp.addEventListener('click', (e) => { insp.addEventListener('click', (e) => {
const a = e.target.closest('[data-i]')?.dataset.i; const a = e.target.closest('[data-i]')?.dataset.i;
if (a === 'full') openLightbox(idxOf(E.curId)); if (a === 'full') openLightbox(idxOf(E.curId));
@@ -612,7 +635,7 @@ export function createExplorer(deps) {
<div class="lb-body"><div class="viewer large" id="lbHost"></div></div> <div class="lb-body"><div class="viewer large" id="lbHost"></div></div>
<div class="lb-foot"><span class="sub">${it.note ? esc(it.note) : '← → para navegar · Esc para fechar'}</span><button class="btn primary sm" id="lbRec">Recuperar este arquivo</button></div>`; <div class="lb-foot"><span class="sub">${it.note ? esc(it.note) : '← → para navegar · Esc para fechar'}</span><button class="btn primary sm" id="lbRec">Recuperar este arquivo</button></div>`;
stopLb?.(); stopLb?.();
stopLb = renderPreview(d.querySelector('#lbHost'), it, { url: fileUrl(job, it.id), large: true, inlineMedia, onExternal: () => openExternal(it) }); stopLb = renderPreview(d.querySelector('#lbHost'), it, { url: fileUrl(job, it.id), large: true, inlineMedia, db: dbApi(it), onExternal: () => openExternal(it) });
const go = (n) => { const t = i + n; if (t >= 0 && t < E.items.length) { setCursor(t, { reveal: true }); openLightbox(t); } }; const go = (n) => { const t = i + n; if (t >= 0 && t < E.items.length) { setCursor(t, { reveal: true }); openLightbox(t); } };
d.querySelector('#lbPrev').onclick = () => go(-1); d.querySelector('#lbPrev').onclick = () => go(-1);
d.querySelector('#lbNext').onclick = () => go(1); d.querySelector('#lbNext').onclick = () => go(1);
+366
View File
@@ -0,0 +1,366 @@
// Dicionário português -> inglês da interface. {n} marca um valor variável.
export const EN = {
// barra lateral e estrutura
'Recuperação de arquivos': 'File recovery',
'Dispositivos': 'Devices',
'Dispositivos e sessões': 'Devices and sessions',
'Atualizar lista': 'Refresh list',
'Lista atualizada': 'List refreshed',
'Abrir arquivo de imagem…': 'Open disk image file…',
'Abrir arquivo de imagem': 'Open disk image file',
'Abrir sessão salva…': 'Open saved session…',
'Abrir sessão salva': 'Open saved session',
'Sessões': 'Sessions',
'Sessão': 'Session',
'Sessão:': 'Session:',
'Recentes': 'Recent',
'(salvas sozinhas)': '(saved automatically)',
'Nenhuma ainda': 'None yet',
'Nada salvo ainda': 'Nothing saved yet',
'Nenhum disco encontrado': 'No disks found',
'Somente leitura na origem': 'Read-only on the source',
'Abrir menu': 'Open menu',
'Tema': 'Theme',
'Auto': 'Auto',
'Claro': 'Light',
'Escuro': 'Dark',
'Configurações': 'Settings',
'· requer admin': '· needs admin',
'requer admin': 'needs admin',
'{n} · requer admin': '{n} · needs admin',
// início
'Encontre o que você achou que tinha perdido.': 'Find what you thought was lost.',
'Escolha um disco, SSD ou pendrive ao lado. O Guará lê tudo: arquivos apagados, partições perdidas e espaço não particionado, sem escrever nada no disco de origem.': 'Pick a disk, SSD or USB drive on the left. Guará reads everything: deleted files, lost partitions and unpartitioned space, without writing anything to the source disk.',
'Uma varredura, tudo junto': 'One scan, everything together',
'Metadados do sistema de arquivos e busca por assinatura na mesma passada, sem duplicatas.': 'File system metadata and signature search in the same pass, without duplicates.',
'Confiança por arquivo': 'Confidence per file',
'Cada achado mostra a chance de abrir corretamente: alta, média ou baixa.': 'Each finding shows the chance of opening correctly: high, medium or low.',
'Origem protegida': 'Protected source',
'Somente leitura. A recuperação sempre vai para outra pasta, com SHA-256 de tudo.': 'Read-only. Recovery always goes to another folder, with SHA-256 of everything.',
'Nenhum disco apareceu.': 'No disks showed up.',
// dispositivo
'Mapa do disco': 'Disk map',
'Como procurar': 'How to search',
'Recomendado': 'Recommended',
'Varredura profunda': 'Deep scan',
'Varredura rápida': 'Quick scan',
'Lê os metadados e examina todo o espaço livre, partições danificadas e áreas não particionadas por assinatura. Encontra mais, leva mais tempo.': 'Reads the metadata and examines all free space, damaged partitions and unpartitioned areas by signature. Finds more, takes longer.',
'Procurar tudo': 'Search everything',
'Só os registros do sistema de arquivos. Em segundos mostra arquivos apagados recentemente, com nome e pasta.': 'Only the file system records. In seconds it shows recently deleted files, with name and folder.',
'Criar imagem do disco': 'Create disk image',
'Copia o disco inteiro para um arquivo, tolerando setores ruins. Ideal para discos com defeito: você trabalha na cópia.': 'Copies the whole disk to a file, tolerating bad sectors. Ideal for failing disks: you work on the copy.',
'Criar imagem…': 'Create image…',
'Ferramentas de perícia': 'Forensic tools',
'Pesquisar texto no disco': 'Search text on the disk',
'Procura palavras em todo o disco, inclusive em espaço livre e fora de partições (UTF-8 e UTF-16). Útil quando não há estrutura de arquivo.': 'Searches words across the whole disk, including free space and outside partitions (UTF-8 and UTF-16). Useful when there is no file structure.',
'termos separados por vírgula': 'terms separated by commas',
'Termos a pesquisar': 'Terms to search',
'Pesquisar': 'Search',
'Hashes da origem': 'Source hashes',
'Calcula MD5, SHA-1 e SHA-256 do disco inteiro para registrar a integridade e a cadeia de custódia no relatório.': 'Computes MD5, SHA-1 and SHA-256 of the whole disk to record integrity and chain of custody in the report.',
'Calcular hashes': 'Compute hashes',
'Opções avançadas': 'Advanced options',
'Incluir áreas ainda em uso': 'Include areas still in use',
'Também procura cópias antigas dentro de arquivos existentes. Bem mais lento.': 'Also looks for old copies inside existing files. Much slower.',
'Sem nenhuma categoria marcada, todos os tipos são procurados.': 'With no category checked, all types are searched.',
'Este disco exige permissão de administrador.': 'This disk requires administrator permission.',
'Reiniciar como administrador': 'Restart as administrator',
'Feche e rode de novo com': 'Close and run again with',
'ou como administrador (Windows).': 'or as administrator (Windows).',
'Espaço não particionado': 'Unpartitioned space',
'Não particionado': 'Unpartitioned',
'encontrada por varredura': 'found by scanning',
'Desconhecido': 'Unknown',
'Este SSD usa TRIM: o sistema avisa o disco quais blocos foram apagados e o próprio SSD pode zerá-los. Arquivos apagados há bastante tempo tendem a não ser recuperáveis.': 'This SSD uses TRIM: the system tells the disk which blocks were deleted and the SSD itself may zero them. Files deleted a long time ago tend to be unrecoverable.',
// saúde do disco
'Saúde do disco': 'Disk health',
'Este disco mostra sinais sérios de defeito.': 'This disk shows serious signs of failure.',
'Este disco mostra sinais de desgaste ou erros.': 'This disk shows signs of wear or errors.',
'Nenhum sinal de defeito encontrado pelo SMART.': 'No signs of failure found by SMART.',
'O sistema já registrou erros de E/S neste disco.': 'The system has already logged I/O errors on this disk.',
'Crie a imagem do disco primeiro (botão "Criar imagem" ou `guara image`) e varra a cópia. Cada leitura extra pode piorar um disco com defeito.': 'Create the disk image first ("Create image" button or `guara image`) and scan the copy. Every extra read can make a failing disk worse.',
'Criar a imagem primeiro': 'Create the image first',
'Varrer mesmo assim': 'Scan anyway',
'Cancelar': 'Cancel',
'Este disco parece estar com defeito': 'This disk seems to be failing',
// varredura
'‹ Dispositivos': '‹ Devices',
'Procurando…': 'Searching…',
'Varredura concluída': 'Scan complete',
'Varredura pausada': 'Scan paused',
'Varredura interrompida': 'Scan stopped',
'Algo deu errado': 'Something went wrong',
'Iniciando': 'Starting',
'Pausar': 'Pause',
'Parar': 'Stop',
'Continuar': 'Continue',
'Pausando…': 'Pausing…',
'Reconectar o disco': 'Reconnect the disk',
'Escolher a origem…': 'Choose the source…',
'Escolher a origem': 'Choose the source',
'Salvar sessão…': 'Save session…',
'Exportar CSV': 'Export CSV',
'Relatório': 'Report',
'arquivos encontrados': 'files found',
'arquivos': 'files',
'decorrido': 'elapsed',
'confiança alta': 'high confidence',
'média': 'medium',
'baixa': 'low',
'alta': 'high',
'· faltam ~{n}': '· ~{n} left',
'· {n}%': '· {n}%',
'{n} arquivos em {n}': '{n} files in {n}',
'confiança alta {n} · média {n} · baixa {n}': 'high confidence {n} · medium {n} · low {n}',
'Lendo datas e metadados das fotos… {n}/{n}': 'Reading dates and metadata from photos… {n}/{n}',
'Lendo tabela de partições': 'Reading partition table',
'Lendo metadados {n} (partição {n})': 'Reading {n} metadata (partition {n})',
'Varredura profunda por assinatura': 'Deep signature scan',
// explorador
'Navegação': 'Navigation',
'Filtros': 'Filters',
'Todos os arquivos': 'All files',
'Tudo': 'All',
'Tipos': 'Types',
'Datas': 'Dates',
'Pastas': 'Folders',
'Câmeras': 'Cameras',
'Confiança': 'Confidence',
'Outros': 'Other',
'Fotos': 'Photos',
'Documentos': 'Documents',
'Vídeos': 'Videos',
'Áudio': 'Audio',
'Compactados': 'Archives',
'Bancos de dados': 'Databases',
'Raiz': 'Root',
'/ (raiz)': '/ (root)',
'Por assinatura': 'By signature',
'Sem data': 'No date',
'sem data': 'no date',
'Confiança {n}': 'Confidence {n}',
'Com localização (GPS)': 'With location (GPS)',
'Com GPS': 'With GPS',
'Ocultar duplicadas': 'Hide duplicates',
'Sem duplicadas': 'Without duplicates',
'Ocultar arquivos pequenos (menos de 30 KB)': 'Hide small files (under 30 KB)',
'Sem arquivos pequenos': 'Without small files',
'Mais de 1 megapixel': 'Over 1 megapixel',
'Mais de 1 MP': 'Over 1 MP',
'Limpar filtros': 'Clear filters',
'Buscar por nome, pasta ou câmera': 'Search by name, folder or camera',
'Buscar': 'Search',
'Ordenar': 'Sort',
'Data (mais recentes)': 'Date (newest)',
'Data (mais antigas)': 'Date (oldest)',
'Nome': 'Name',
'Tamanho': 'Size',
'Modo de exibição': 'View mode',
'Grade': 'Grid',
'Lista': 'List',
'Grade de miniaturas': 'Thumbnail grid',
'Tamanho das miniaturas': 'Thumbnail size',
'Arquivos encontrados': 'Files found',
'Detalhes': 'Details',
'janeiro': 'January', 'fevereiro': 'February', 'março': 'March', 'abril': 'April', 'maio': 'May', 'junho': 'June',
'julho': 'July', 'agosto': 'August', 'setembro': 'September', 'outubro': 'October', 'novembro': 'November', 'dezembro': 'December',
'{n} item': '{n} item',
'{n} itens': '{n} items',
'{n} itens nesta vista': '{n} items in this view',
'{n} item nesta vista': '{n} item in this view',
'{n} itens nesta vista (mostrando {n})': '{n} items in this view (showing {n})',
'{n} selecionado': '{n} selected',
'{n} selecionados': '{n} selected',
'{n} selecionado (todos desta vista)': '{n} selected (all in this view)',
'{n} selecionados (todos desta vista)': '{n} selected (all in this view)',
'{n} itens selecionados': '{n} items selected',
'{n} no total': '{n} in total',
'Selecionar grupo': 'Select group',
'Selecionar este grupo': 'Select this group',
'Selecionar tudo': 'Select all',
'Selecionar todos os {n}': 'Select all {n}',
'Limpar seleção': 'Clear selection',
'Recuperar tudo desta vista': 'Recover everything in this view',
'Recuperar selecionado': 'Recover selected',
'Recuperar selecionados': 'Recover selected',
'Recuperar este arquivo': 'Recover this file',
'Tela cheia': 'Full screen',
'Fechar': 'Close',
'Anterior': 'Previous',
'Próximo': 'Next',
'← → para navegar · Esc para fechar': '← → to navigate · Esc to close',
'Selecione um arquivo para pré-visualizar.': 'Select a file to preview.',
'Selecione um arquivo para pré-visualizar': 'Select a file to preview',
'Setas navegam, Espaço abre em tela cheia.': 'Arrows navigate, Space opens full screen.',
'Pasta': 'Folder',
'Modificado': 'Modified',
'Criado': 'Created',
'Acessado': 'Accessed',
'Tirada em': 'Taken on',
'Câmera': 'Camera',
'Resolução': 'Resolution',
'Metadados': 'Metadata',
'Metadados · {n}': 'Metadata · {n}',
'Coordenadas:': 'Coordinates:',
'Aviso': 'Warning',
'Encontrado por assinatura': 'Found by signature',
'Assinatura de formato': 'Format signature',
'Origem': 'Source',
'Origem desconectada: sem miniatura': 'Source disconnected: no thumbnail',
'Não foi possível gerar a miniatura (arquivo possivelmente danificado)': 'Could not generate the thumbnail (file possibly damaged)',
'Tem localização GPS': 'Has GPS location',
'Confiança {n}': 'Confidence {n}',
'Nada por aqui': 'Nothing here',
// pré-visualização
'Imagem': 'Image',
'Vídeo': 'Video',
'PDF': 'PDF',
'Texto': 'Text',
'Documento (texto extraído)': 'Document (extracted text)',
'Conteúdo do arquivo compactado': 'Archive contents',
'Dados brutos (hexadecimal)': 'Raw data (hexadecimal)',
'Banco de dados SQLite': 'SQLite database',
'Banco SQLite': 'SQLite database',
'Arquivo vazio.': 'Empty file.',
'Não foi possível pré-visualizar.': 'Could not preview.',
'O arquivo pode estar danificado, incompleto ou parcialmente sobrescrito, o que também indica que a chance de recuperação é menor.': 'The file may be damaged, incomplete or partially overwritten, which also means the chance of recovery is lower.',
'A imagem não pôde ser decodificada.': 'The image could not be decoded.',
'Este arquivo não parece ser texto.': 'This file does not look like text.',
'Mostrando o início ({n} de {n}).': 'Showing the beginning ({n} of {n}).',
'Mostrando os primeiros {n} de {n}.': 'Showing the first {n} of {n}.',
'{n} × {n} px. Clique para alternar entre ajustar e tamanho real.': '{n} × {n} px. Click to toggle between fit and actual size.',
'Texto extraído do documento. A formatação, as imagens e os gráficos originais não são exibidos.': 'Text extracted from the document. The original formatting, images and charts are not shown.',
'{n} item(ns) no arquivo compactado.': '{n} item(s) in the archive.',
'Sem texto.': 'No text.',
'Página {n} de {n}': 'Page {n} of {n}',
'Página anterior': 'Previous page',
'Próxima página': 'Next page',
'Reproduzir em tela grande': 'Play full size',
'Abrir no player do sistema': 'Open in the system player',
'Preparando para abrir no player do sistema…': 'Preparing to open in the system player…',
'Aberto no player do sistema': 'Opened in the system player',
'Neste app o vídeo abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.': 'In this app, video opens in your player: the embedded Linux browser does not ship the codecs and would freeze the window.',
'Neste app o áudio abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.': 'In this app, audio opens in your player: the embedded Linux browser does not ship the codecs and would freeze the window.',
'pasta': 'folder',
// banco de dados
'Abrir o visualizador': 'Open the viewer',
'{n} tabela(s) · {n} linhas · páginas de {n}': '{n} table(s) · {n} rows · {n} pages',
'{n} linhas · {n} colunas': '{n} rows · {n} columns',
'Tabelas': 'Tables',
'Views': 'Views',
'Índices': 'Indexes',
'Filtrar esta página': 'Filter this page',
'Filtrar': 'Filter',
'Registros apagados (prováveis)': 'Deleted records (likely)',
'Ver esquema': 'View schema',
'Ver dados': 'View data',
'apagado': 'deleted',
'Nenhuma linha.': 'No rows.',
'Nenhuma tabela.': 'No tables.',
'Nenhum registro apagado reconhecido nesta tabela. Isso não garante que não existam: o conteúdo pode ter sido sobrescrito.': 'No deleted records recognized in this table. That does not guarantee there are none: the content may have been overwritten.',
'{n} registro(s) provável(is) apagado(s)': '{n} likely deleted record(s)',
'{n} a {n} de {n}': '{n} to {n} of {n}',
'‹ Anterior': '‹ Previous',
'Próximas ›': 'Next ›',
// recuperar
'Recuperar para…': 'Recover to…',
'Recuperar': 'Recover',
'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.': 'Choose a folder on <b>another disk</b>. Guará never writes to the source disk, so it does not overwrite what can still be saved.',
'Pasta de destino': 'Destination folder',
'Escolher…': 'Choose…',
'Organizar os arquivos': 'Organize the files',
'Pastas originais (e por tipo quando não há pasta)': 'Original folders (and by type when there is no folder)',
'Por tipo (Fotos, Documentos, Vídeos...)': 'By type (Photos, Documents, Videos...)',
'Por data (ano e mês)': 'By date (year and month)',
'Tudo na mesma pasta': 'Everything in the same folder',
'Recuperando arquivos': 'Recovering files',
'Pronto': 'Done',
'Interrompido': 'Stopped',
'Salvar sessão': 'Save session',
'Guarda os resultados para recuperar depois, sem varrer de novo.': 'Saves the results so you can recover later without scanning again.',
'Sessão salva': 'Session saved',
'Salvar': 'Save',
'Exportar para CSV': 'Export to CSV',
'Exportar': 'Export',
'Grava os arquivos da vista atual (com os filtros aplicados). O arquivo abre direto no Excel.': 'Writes the files in the current view (with filters applied). The file opens directly in Excel.',
'{n} linhas gravadas': '{n} rows written',
'Relatório de recuperação': 'Recovery report',
'Gerar relatório': 'Generate report',
'Gera um relatório HTML pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados.': 'Generates a print-ready HTML report with the examined source, the findings and the hashes of recovered files.',
'Salvar em': 'Save to',
'Número do caso': 'Case number',
'Responsável pelo exame': 'Examiner',
'Organização': 'Organization',
'Observações': 'Notes',
'Relatório gravado': 'Report saved',
'lista MD5, SHA-1 e SHA-256 de cada arquivo para você conferir a integridade.': 'lists the MD5, SHA-1 and SHA-256 of each file so you can verify integrity.',
'O arquivo': 'The file',
'Não foi possível recuperar': 'Could not recover',
'Não foi possível iniciar': 'Could not start',
'Entendi': 'Got it',
'{n} arquivo(s) salvos em {n} ({n}).': '{n} file(s) saved to {n} ({n}).',
'{n} arquivo(s) não puderam ser gravados.': '{n} file(s) could not be written.',
'Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.': 'If the recovery is interrupted, pick the same folder again: Guará skips what was already verified.',
'{n} já estavam recuperados e conferidos (pulados).': '{n} were already recovered and verified (skipped).',
// imagem, hash, busca
'Onde salvar o arquivo de imagem? Use <b>outro disco</b> com espaço suficiente e um nome que ainda não exista.': 'Where to save the image file? Use <b>another disk</b> with enough space and a name that does not exist yet.',
'Criando imagem do disco': 'Creating disk image',
'Nenhum setor ruim encontrado.': 'No bad sectors found.',
'Calculando hashes da origem': 'Computing source hashes',
'Eles serão incluídos nos relatórios gerados nesta sessão.': 'They will be included in reports generated in this session.',
'Digite pelo menos um termo.': 'Type at least one term.',
'Posição': 'Position',
'Partição': 'Partition',
'Codificação': 'Encoding',
'Contexto': 'Context',
'fora': 'outside',
'Nada encontrado': 'Nothing found',
'Esses termos não aparecem em nenhum ponto do disco (nem em UTF-8 nem em UTF-16).': 'These terms do not appear anywhere on the disk (neither in UTF-8 nor UTF-16).',
'{n} ocorrência': '{n} occurrence',
'{n} ocorrências': '{n} occurrences',
'{n} ocorrência{n}': '{n} occurrence{n}',
'Termos: {n}': 'Terms: {n}',
'{n}% · {n} até agora': '{n}% · {n} so far',
// sessões e origem
'Abrir arquivo de imagem': 'Open disk image file',
'Caminho completo de uma imagem de disco (.img, .dd, .iso…).': 'Full path of a disk image (.img, .dd, .iso…).',
'Caminho do arquivo de sessão (.json) criado pelo Guará.': 'Path of the session file (.json) created by Guará.',
'Abrir': 'Open',
'Abrir sessão salva': 'Open saved session',
'Não foi possível abrir a sessão': 'Could not open the session',
'Disco encontrado como {n}': 'Disk found as {n}',
'Disco reconectado': 'Disk reconnected',
'Origem reconectada': 'Source reconnected',
'Não foi possível usar essa origem': 'Could not use this source',
'Esta sessão foi feita sobre': 'This session was made on',
'Escolha o disco que voltou ou uma imagem dele.': 'Choose the disk that came back or an image of it.',
'Nenhum disco encontrado. Informe o caminho abaixo.': 'No disks found. Enter the path below.',
'Ou o caminho de um dispositivo ou imagem': 'Or the path to a device or image',
'Usar mesmo assim, mesmo que o conteúdo inicial seja diferente (por exemplo, uma cópia editada)': 'Use it anyway, even if the initial content differs (for example, an edited copy)',
'Usar esta origem': 'Use this source',
'mesmo disco': 'same disk',
'tamanho igual, conteúdo diferente': 'same size, different content',
'mesmo tamanho (precisa de administrador para conferir)': 'same size (needs administrator to verify)',
// configurações e fechamento
'Idioma': 'Language',
'Notificações': 'Notifications',
'Avisar quando uma tarefa longa terminar': 'Notify when a long task finishes',
'Mapas online': 'Online maps',
'Mostrar mapas das fotos com GPS (usa a internet: baixa blocos do OpenStreetMap)': 'Show maps for photos with GPS (uses the internet: downloads OpenStreetMap tiles)',
'Salvo': 'Saved',
'Mostrar mapa': 'Show map',
'Mostrar mapa (usa a internet)': 'Show map (uses the internet)',
'Abrir no OpenStreetMap': 'Open in OpenStreetMap',
'Há uma varredura em andamento': 'A scan is in progress',
'Pausar e sair': 'Pause and quit',
'Sair sem pausar': 'Quit without pausing',
'Continuar varrendo': 'Keep scanning',
'A sessão é salva automaticamente. Pausando agora, você retoma exatamente do ponto em que parou.': 'The session is saved automatically. If you pause now, you resume exactly where you stopped.',
// erros comuns vindos do núcleo
'Não foi possível falar com o núcleo do Guará: {n}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando': 'Could not talk to the Guará core: {n}. If you opened this page manually, use the full address shown by the command',
'Não foi possível reiniciar como administrador': 'Could not restart as administrator',
'Autorização cancelada.': 'Authorization canceled.',
'Origem desconectada': 'Source disconnected',
'Este disco está em uso (montado em': 'This disk is in use (mounted at',
'). O Guará só lê, mas evite gravar nele enquanto recupera, porque qualquer escrita pode sobrescrever o que você quer salvar.': '). Guará only reads, but avoid writing to it while recovering, because any write can overwrite what you want to save.',
};
+121
View File
@@ -0,0 +1,121 @@
// Idioma da interface. O texto-fonte é em português; no modo inglês um tradutor
// troca os textos exibidos (nós de texto e atributos) usando um dicionário, e
// continua observando a página para traduzir o que for desenhado depois.
// Textos com valores variáveis usam {n} no dicionário (ex.: "{n} arquivos" -> "{n} files").
import { EN } from './i18n-en.js';
let lang = 'pt';
export const isEn = () => lang === 'en';
export const LOC = () => (lang === 'en' ? 'en-US' : 'pt-BR');
export function resolveLang(pref) {
if (pref === 'pt' || pref === 'en') return pref;
return (navigator.language || 'pt').toLowerCase().startsWith('pt') ? 'pt' : 'en';
}
const ATTRS = ['placeholder', 'title', 'aria-label', 'alt', 'data-empty'];
// Dados do usuário (nomes, caminhos, conteúdo) nunca são traduzidos.
const SKIP = '.xp-cap, .xp-lnm, .db-table, .pv-zip, .hit, pre, code, .mono, .xp-ititle b, dd, textarea, input, script, style';
const exact = new Map(Object.entries(EN).filter(([k]) => !k.includes('{n}')));
const patterns = Object.entries(EN)
.filter(([k]) => k.includes('{n}'))
.map(([k, v]) => {
const parts = k.split('{n}').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return { re: new RegExp('^' + parts.join('(.+?)') + '$', 's'), to: v };
})
// padrões mais específicos (mais texto fixo) primeiro
.sort((a, b) => b.re.source.length - a.re.source.length);
const cache = new Map();
export function tr(text) {
if (!isEn()) return text;
const lead = text.match(/^\s*/)[0];
const trail = text.match(/\s*$/)[0];
const core = text.trim();
if (!core) return text;
if (cache.has(core)) return lead + cache.get(core) + trail;
let out = exact.get(core);
if (out === undefined) {
for (const p of patterns) {
const m = core.match(p.re);
if (m) {
let i = 0;
// os valores capturados também podem ter partes traduzíveis (ex.: nomes de tipos)
out = p.to.replace(/\{n\}/g, () => tr(m[++i] ?? ''));
break;
}
}
}
if (out === undefined) out = core;
if (cache.size > 5000) cache.clear();
cache.set(core, out);
return lead + out + trail;
}
const orig = new WeakMap(); // nó -> texto original em português
let observer = null;
function translateNode(n) {
if (n.nodeType === 3) {
if (n.parentElement?.closest(SKIP)) return;
const o = orig.get(n) ?? n.nodeValue;
const t = tr(o);
if (t !== n.nodeValue) {
if (!orig.has(n)) orig.set(n, o);
n.nodeValue = t;
}
} else if (n.nodeType === 1) {
if (n.matches?.(SKIP) && n.tagName !== 'INPUT') return;
for (const a of ATTRS) {
if (n.hasAttribute?.(a)) {
const key = `i18n-${a}`;
const o = n[key] ?? n.getAttribute(a);
const t = tr(o);
if (t !== n.getAttribute(a)) { n[key] = o; n.setAttribute(a, t); }
}
}
for (const c of n.childNodes) translateNode(c);
}
}
function restoreAll(root) {
const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let n;
while ((n = w.nextNode())) { if (orig.has(n)) { n.nodeValue = orig.get(n); orig.delete(n); } }
root.querySelectorAll('*').forEach((el) => {
for (const a of ATTRS) { const key = `i18n-${a}`; if (el[key] !== undefined) { el.setAttribute(a, el[key]); delete el[key]; } }
});
}
function start() {
translateNode(document.body);
document.title = tr(document.title);
if (observer) return;
observer = new MutationObserver((muts) => {
observer.disconnect();
for (const m of muts) {
if (m.type === 'childList') m.addedNodes.forEach(translateNode);
else if (m.type === 'characterData') { orig.delete(m.target); translateNode(m.target); }
else if (m.type === 'attributes') { delete m.target[`i18n-${m.attributeName}`]; translateNode(m.target); }
}
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ATTRS });
});
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ATTRS });
}
function stop() {
observer?.disconnect();
observer = null;
restoreAll(document.body);
}
export function setLang(pref) {
const next = resolveLang(pref);
const changed = next !== lang;
lang = next;
document.documentElement.lang = lang === 'en' ? 'en' : 'pt-BR';
if (lang === 'en') start();
else if (changed || observer) stop();
}
+3 -1
View File
@@ -24,9 +24,11 @@
<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> <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> <div class="side-title">Sessões</div>
<ul id="sessionList" class="list"></ul> <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> <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> </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> <footer class="side-foot"><button class="icon-btn small" id="settingsBtn" title="Configurações" aria-label="Configurações"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg></button><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>
</aside> </aside>
<div class="scrim" id="scrim"></div> <div class="scrim" id="scrim"></div>
<main class="main" id="main" tabindex="-1"></main> <main class="main" id="main" tabindex="-1"></main>
+91 -2
View File
@@ -1,3 +1,4 @@
import { LOC } from './i18n.js';
// Pré-visualização de arquivos recuperados. // Pré-visualização de arquivos recuperados.
// O conteúdo é lido em streaming do núcleo (com Range) pela URL `url`; nada é // O conteúdo é lido em streaming do núcleo (com Range) pela URL `url`; nada é
// gravado em disco. Tipos: imagem, vídeo, áudio, PDF (pdf.js), texto, documentos // gravado em disco. Tipos: imagem, vídeo, áudio, PDF (pdf.js), texto, documentos
@@ -9,6 +10,7 @@ const AUD = new Set(['mp3', 'wav', 'ogg', 'opus', 'm4a', 'aac', 'flac']);
const TXT = new Set(['txt', 'md', 'csv', 'tsv', 'json', 'xml', 'html', 'htm', 'log', 'ini', 'yml', 'yaml', 'js', 'css', 'c', 'h', 'cpp', 'py', 'rs', 'sh', 'bat', 'sql', 'toml', 'cfg', 'conf', 'srt', 'tex', 'svg']); const TXT = new Set(['txt', 'md', 'csv', 'tsv', 'json', 'xml', 'html', 'htm', 'log', 'ini', 'yml', 'yaml', 'js', 'css', 'c', 'h', 'cpp', 'py', 'rs', 'sh', 'bat', 'sql', 'toml', 'cfg', 'conf', 'srt', 'tex', 'svg']);
const OFFICE = new Set(['docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp']); const OFFICE = new Set(['docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp']);
const ZIPS = new Set(['zip', 'jar', 'apk', 'epub']); const ZIPS = new Set(['zip', 'jar', 'apk', 'epub']);
const DBS = new Set(['db', 'sqlite', 'sqlite3', 'db3', 'sdb', 'sqlitedb', 'storedata']);
export function kindOf(item) { export function kindOf(item) {
const e = (item.ext || '').toLowerCase(); const e = (item.ext || '').toLowerCase();
@@ -16,6 +18,7 @@ export function kindOf(item) {
if (VID.has(e)) return 'video'; if (VID.has(e)) return 'video';
if (AUD.has(e)) return 'audio'; if (AUD.has(e)) return 'audio';
if (e === 'pdf') return 'pdf'; if (e === 'pdf') return 'pdf';
if (DBS.has(e)) return 'database';
if (OFFICE.has(e)) return 'office'; if (OFFICE.has(e)) return 'office';
if (ZIPS.has(e)) return 'zip'; if (ZIPS.has(e)) return 'zip';
if (TXT.has(e)) return 'text'; if (TXT.has(e)) return 'text';
@@ -24,7 +27,7 @@ export function kindOf(item) {
export const KIND_LABEL = { export const KIND_LABEL = {
image: 'Imagem', video: 'Vídeo', audio: 'Áudio', pdf: 'PDF', office: 'Documento (texto extraído)', image: 'Imagem', video: 'Vídeo', audio: 'Áudio', pdf: 'PDF', office: 'Documento (texto extraído)',
zip: 'Conteúdo do arquivo compactado', text: 'Texto', hex: 'Dados brutos (hexadecimal)', zip: 'Conteúdo do arquivo compactado', database: 'Banco de dados SQLite', text: 'Texto', hex: 'Dados brutos (hexadecimal)',
}; };
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@ -272,6 +275,91 @@ async function zip(host, item, url, state) {
note(host, `${ents.length} item(ns) no arquivo compactado.`); note(host, `${ents.length} item(ns) no arquivo compactado.`);
} }
// ------------------------------------------------------------------ bancos de dados (SQLite)
/** Valores inteiros que parecem datas (Unix, Chrome/WebKit, macOS) viram uma dica legível. */
function tsHint(v) {
if (!Number.isInteger(v)) return null;
let ms = null, kind = '';
if (v > 9.4e8 && v < 4.2e9) { ms = v * 1000; kind = 'Unix (s)'; }
else if (v > 9.4e11 && v < 4.2e12) { ms = v; kind = 'Unix (ms)'; }
else if (v > 9.4e14 && v < 4.2e15) { ms = v / 1000; kind = 'Unix (µs)'; }
else if (v > 1.2e16 && v < 1.4e16) { ms = v / 1000 - 11644473600000; kind = 'Chrome/WebKit'; }
else if (v > 3e8 && v < 9.4e8) { ms = (v + 978307200) * 1000; kind = 'macOS/CoreData'; }
if (ms == null) return null;
const d = new Date(ms);
return Number.isNaN(d.getTime()) ? null : { kind, text: d.toISOString().replace('T', ' ').slice(0, 19) + ' UTC' };
}
function cellHtml(c) {
switch (c.t) {
case 'null': return '<span class="db-null">NULL</span>';
case 'int': { const h = tsHint(c.v); return h ? `<span title="${esc(h.kind)}: ${esc(h.text)}">${c.v}<i class="db-ts">${esc(h.text.slice(0, 10))}</i></span>` : String(c.v); }
case 'real': return String(c.v);
case 'text': return `<span title="${esc(c.v.slice(0, 600))}">${esc(c.v.length > 240 ? c.v.slice(0, 240) + '…' : c.v)}</span>`;
default: return `<span class="db-blob" title="${esc(c.v.head)}">BLOB ${c.v.mime ? esc(c.v.mime) + ' ' : ''}${human(c.v.len)}</span>`;
}
}
async function database(host, item, state, large, db, onOpen) {
if (!db) throw new Error('visualizador indisponível');
const info = await db.info();
if (state.dead) return;
const tables = info.tables;
const warn = (info.warnings || []).map((w) => `<div class="pv-note bad">${esc(w)}</div>`).join('');
if (!large) {
const total = tables.reduce((a, t) => a + (t.rows || 0), 0);
host.innerHTML = `<div class="db-sum"><b>Banco SQLite</b><span class="sub">${tables.length} tabela(s) · ${total.toLocaleString(LOC())} linhas · páginas de ${human(info.page_size)}</span>
<ul>${tables.slice(0, 10).map((t) => `<li><span>${esc(t.name)}</span><small>${(t.rows || 0).toLocaleString(LOC())}</small></li>`).join('')}</ul>
${tables.length > 10 ? `<span class="sub">e mais ${tables.length - 10}…</span>` : ''}${warn}
<button class="btn primary sm" data-pv="open">Abrir o visualizador</button></div>`;
host.onclick = (e) => { if (e.target.closest('[data-pv]')) onOpen?.(); };
return;
}
const S = { table: tables.find((t) => t.rows > 0)?.name || tables[0]?.name, offset: 0, limit: 100, deleted: false, q: '', schema: false };
host.classList.add('db-large');
host.innerHTML = `<div class="db-wrap"><nav class="db-tables" aria-label="Tabelas"></nav><section class="db-main"><div class="db-bar"></div><div class="db-grid"></div><div class="db-foot"></div></section></div>`;
const $ = (sel) => host.querySelector(sel);
const nav = $('.db-tables'), bar = $('.db-bar'), grid = $('.db-grid'), foot = $('.db-foot');
nav.innerHTML = tables.map((t) => `<button class="db-t" data-t="${esc(t.name)}"><span>${esc(t.name)}</span><small>${(t.rows || 0).toLocaleString(LOC())}</small></button>`).join('')
+ (info.indexes.length || info.views.length ? `<div class="db-meta">${info.views.length ? `<b>Views</b>${info.views.map((v) => `<span>${esc(v)}</span>`).join('')}` : ''}${info.indexes.length ? `<b>Índices</b>${info.indexes.map((v) => `<span>${esc(v)}</span>`).join('')}` : ''}</div>` : '')
+ warn;
async function load() {
if (state.dead) return;
nav.querySelectorAll('.db-t').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.t === S.table)));
const t = tables.find((x) => x.name === S.table);
if (!t) { grid.innerHTML = '<div class="pv-note">Nenhuma tabela.</div>'; return; }
bar.innerHTML = `<b>${esc(t.name)}</b><span class="sub">${(t.rows || 0).toLocaleString(LOC())} linhas · ${t.columns.length} colunas</span>
<input class="input" id="dbq" type="search" placeholder="Filtrar esta página" value="${esc(S.q)}" aria-label="Filtrar">
<label class="check" style="padding:0"><input type="checkbox" id="dbdel" ${S.deleted ? 'checked' : ''}><span>Registros apagados (prováveis)</span></label>
<button class="btn sm" id="dbsch">${S.schema ? 'Ver dados' : 'Ver esquema'}</button>`;
if (S.schema) { grid.innerHTML = `<pre class="pv-text">${esc(t.sql)}</pre>`; foot.innerHTML = ''; wire(); return; }
grid.innerHTML = '<div class="pv-loading"><i></i></div>';
let page;
try { page = await db.rows(S.table, S.offset, S.limit, S.deleted); } catch (e) { grid.innerHTML = `<div class="pv-note bad">${esc(e.message || e)}</div>`; wire(); return; }
if (state.dead) return;
const q = S.q.toLowerCase();
const rows = q ? page.rows.filter((r) => r.cells.some((c) => String(c.t === 'blob' ? '' : c.v).toLowerCase().includes(q))) : page.rows;
const notes = (page.warnings || []).map((w) => `<div class="pv-note bad">${esc(w)}</div>`).join('');
grid.innerHTML = notes + (rows.length ? `<table class="db-table"><thead><tr><th>#</th>${page.columns.map((c) => `<th>${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r, i) => `<tr class="${r.deleted ? 'del' : ''}"><th>${r.deleted ? 'apagado' : (r.rowid ?? S.offset + i + 1)}</th>${r.cells.map((c) => `<td>${cellHtml(c)}</td>`).join('')}</tr>`).join('')}</tbody></table>`
: `<div class="pv-note">${S.deleted ? 'Nenhum registro apagado reconhecido nesta tabela. Isso não garante que não existam: o conteúdo pode ter sido sobrescrito.' : 'Nenhuma linha.'}</div>`);
const total = S.deleted ? page.total : (t.rows || page.total);
foot.innerHTML = `<span class="sub">${S.deleted ? `${total.toLocaleString(LOC())} registro(s) provável(is) apagado(s)` : `${total ? S.offset + 1 : 0} a ${Math.min(S.offset + S.limit, total)} de ${total.toLocaleString(LOC())}`}</span>
<span class="row"><button class="btn sm" id="dbprev" ${S.offset <= 0 ? 'disabled' : ''}>‹ Anterior</button><button class="btn sm" id="dbnext" ${S.offset + S.limit >= total ? 'disabled' : ''}>Próximas ›</button></span>`;
wire();
}
function wire() {
$('#dbq')?.addEventListener('input', (e) => { S.q = e.target.value; clearTimeout(S.t); S.t = setTimeout(load, 200); });
$('#dbdel')?.addEventListener('change', (e) => { S.deleted = e.target.checked; S.offset = 0; load(); });
$('#dbsch')?.addEventListener('click', () => { S.schema = !S.schema; load(); });
$('#dbprev')?.addEventListener('click', () => { S.offset = Math.max(0, S.offset - S.limit); load(); });
$('#dbnext')?.addEventListener('click', () => { S.offset += S.limit; load(); });
}
nav.addEventListener('click', (e) => { const b = e.target.closest('.db-t'); if (b) { S.table = b.dataset.t; S.offset = 0; S.q = ''; S.deleted = false; S.schema = false; load(); } });
load();
}
// ------------------------------------------------------------------ API // ------------------------------------------------------------------ API
/** Desenha a pré-visualização de `item` dentro de `host`. Devolve uma função de limpeza. */ /** Desenha a pré-visualização de `item` dentro de `host`. Devolve uma função de limpeza. */
function mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal }) { function mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal }) {
@@ -291,7 +379,7 @@ function mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal })
void canInline; void canInline;
} }
export function renderPreview(host, item, { url, large = false, thumb = false, inlineMedia = true, onOpen = null, onExternal = null }) { export function renderPreview(host, item, { url, large = false, thumb = false, inlineMedia = true, onOpen = null, onExternal = null, db = null }) {
const state = { dead: false, cleanup: [] }; const state = { dead: false, cleanup: [] };
host.dataset.url = url; host.dataset.url = url;
host.classList.remove('zoomable', 'actual'); host.classList.remove('zoomable', 'actual');
@@ -310,6 +398,7 @@ export function renderPreview(host, item, { url, large = false, thumb = false, i
else if (kind === 'video') media(host, item, url, 'video', state); else if (kind === 'video') media(host, item, url, 'video', state);
else if (kind === 'audio') media(host, item, url, 'audio', state); else if (kind === 'audio') media(host, item, url, 'audio', state);
else if (kind === 'pdf') await pdf(host, item, url, state, large); else if (kind === 'pdf') await pdf(host, item, url, state, large);
else if (kind === 'database') await database(host, item, state, large, db, onOpen);
else if (kind === 'office') await office(host, item, url, state); else if (kind === 'office') await office(host, item, url, state);
else if (kind === 'zip') await zip(host, item, url, state); else if (kind === 'zip') await zip(host, item, url, state);
else if (kind === 'text') { host.innerHTML = ''; await text(host, item, url, large); } else if (kind === 'text') { host.innerHTML = ''; await text(host, item, url, large); }
+30 -1
View File
@@ -94,6 +94,8 @@ enum Cmd {
}, },
/// Calcula MD5, SHA-1 e SHA-256 do dispositivo ou imagem (verificação de integridade) /// Calcula MD5, SHA-1 e SHA-256 do dispositivo ou imagem (verificação de integridade)
Hash { path: String }, Hash { path: String },
/// Lista as sessões salvas automaticamente
Sessions,
/// Abre a interface gráfica no navegador (modo local) /// Abre a interface gráfica no navegador (modo local)
Ui { Ui {
#[arg(long, default_value_t = 0)] #[arg(long, default_value_t = 0)]
@@ -114,6 +116,9 @@ struct FilterArgs {
/// Só nomes/caminhos que contenham este texto /// Só nomes/caminhos que contenham este texto
#[arg(long)] #[arg(long)]
name: Option<String>, 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)] #[derive(clap::Args, Clone)]
@@ -230,6 +235,7 @@ fn run(cli: Cli) -> Result<(), String> {
category: vec![], category: vec![],
min_confidence: "low".into(), min_confidence: "low".into(),
name: None, name: None,
organize: "folders".into(),
}, },
None, None,
) )
@@ -367,6 +373,23 @@ fn run(cli: Cli) -> Result<(), String> {
); );
Ok(()) 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), Cmd::Ui { port, no_open } => serve::run(port, !no_open),
} }
} }
@@ -483,7 +506,13 @@ fn recover_job(
} }
ids = Some(all); 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)?; let st = wait(eng, &rj, None)?;
eprintln!(); eprintln!();
if st["status"] == "error" { if st["status"] == "error" {
+12
View File
@@ -10,6 +10,8 @@ const INDEX: &str = include_str!("../../../apps/gui/ui/index.html");
const CSS: &str = include_str!("../../../apps/gui/ui/app.css"); const CSS: &str = include_str!("../../../apps/gui/ui/app.css");
const JS: &str = include_str!("../../../apps/gui/ui/app.js"); const JS: &str = include_str!("../../../apps/gui/ui/app.js");
const PREVIEW_JS: &str = include_str!("../../../apps/gui/ui/preview.js"); const PREVIEW_JS: &str = include_str!("../../../apps/gui/ui/preview.js");
const I18N_JS: &str = include_str!("../../../apps/gui/ui/i18n.js");
const I18N_EN_JS: &str = include_str!("../../../apps/gui/ui/i18n-en.js");
const LOGO: &str = include_str!("../../../apps/gui/ui/guara-logo.svg"); const LOGO: &str = include_str!("../../../apps/gui/ui/guara-logo.svg");
const EXPLORER_JS: &str = include_str!("../../../apps/gui/ui/explorer.js"); const EXPLORER_JS: &str = include_str!("../../../apps/gui/ui/explorer.js");
const ICON: &str = include_str!("../../../apps/gui/ui/guara.svg"); const ICON: &str = include_str!("../../../apps/gui/ui/guara.svg");
@@ -86,6 +88,16 @@ fn handle(req: Request, eng: &Engine, token: &str, hosts: &[String]) {
Response::from_string(PREVIEW_JS).with_header(ct("text/javascript; charset=utf-8")), Response::from_string(PREVIEW_JS).with_header(ct("text/javascript; charset=utf-8")),
); );
} }
(Method::Get, "/i18n.js") => {
let _ = req.respond(
Response::from_string(I18N_JS).with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Get, "/i18n-en.js") => {
let _ = req.respond(
Response::from_string(I18N_EN_JS).with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Get, "/guara-logo.svg") => { (Method::Get, "/guara-logo.svg") => {
let _ = req.respond(Response::from_string(LOGO).with_header(ct("image/svg+xml"))); let _ = req.respond(Response::from_string(LOGO).with_header(ct("image/svg+xml")));
} }
+24 -5
View File
@@ -25,23 +25,33 @@ pub fn carve_range(
cfg: &CarveCfg, cfg: &CarveCfg,
cancelled: &(dyn Fn() -> bool + Sync), cancelled: &(dyn Fn() -> bool + Sync),
ev: &mut dyn CarveEvents, ev: &mut dyn CarveEvents,
) -> bool { ) -> CarveEnd {
let end = end.min(src.size()); let end = end.min(src.size());
let mut pos = (*next).max(start); let mut pos = (*next).max(start);
// Alinha ao setor. // Alinha ao setor.
pos = pos.div_ceil(SECTOR) * SECTOR; pos = pos.div_ceil(SECTOR) * SECTOR;
let mut buf = vec![0u8; CHUNK + LOOKAHEAD]; let mut buf = vec![0u8; CHUNK + LOOKAHEAD];
let mut skip_until = 0u64; let mut skip_until = 0u64;
let mut fails = 0u32;
while pos < end { while pos < end {
if cancelled() { if cancelled() {
*next = pos; *next = pos;
return false; return CarveEnd::Cancelled;
} }
let want = ((end - pos) as usize).min(CHUNK + LOOKAHEAD); let want = ((end - pos) as usize).min(CHUNK + LOOKAHEAD);
let got = match read_full(src, pos, &mut buf[..want]) { let got = match read_full(src, pos, &mut buf[..want]) {
Ok(n) => n, Ok(n) => {
fails = 0;
n
}
Err(_) => { 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; pos += CHUNK as u64;
ev.advance(CHUNK as u64); ev.advance(CHUNK as u64);
*next = pos; *next = pos;
@@ -84,7 +94,16 @@ pub fn carve_range(
*next = pos; *next = pos;
} }
*next = end; *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. /// Receptor de eventos do carving.
+272
View File
@@ -0,0 +1,272 @@
//! Saúde do disco antes de varrer: lê o SMART (via `smartctl`, quando instalado) e
//! os contadores de erro do kernel. Um disco com defeito deve ser copiado para uma
//! imagem primeiro: cada leitura extra pode piorar o estado dele.
use serde::Serialize;
use serde_json::Value;
use std::process::{Command, Stdio};
#[derive(Debug, Clone, Serialize, Default)]
pub struct Health {
/// Foi possível avaliar? (imagens de disco e dispositivos sem SMART ficam sem avaliação)
pub available: bool,
/// "ok", "warning", "failing" ou "unknown".
pub status: String,
pub summary: String,
pub details: Vec<(String, String)>,
pub advice: Vec<String>,
}
impl Health {
fn unknown(msg: &str) -> Self {
Health {
available: false,
status: "unknown".into(),
summary: msg.into(),
..Default::default()
}
}
}
const IMAGE_ADVICE: &str = "Crie a imagem do disco primeiro (botão \"Criar imagem\" ou `guara image`) e varra a cópia. Cada leitura extra pode piorar um disco com defeito.";
/// Avalia o dispositivo `path`. Para arquivos comuns (imagens) devolve "sem avaliação".
pub fn check(path: &str) -> Health {
#[cfg(unix)]
let is_dev = {
use std::os::unix::fs::FileTypeExt;
std::fs::metadata(path)
.map(|m| m.file_type().is_block_device() || m.file_type().is_char_device())
.unwrap_or(false)
};
#[cfg(not(unix))]
let is_dev = path.starts_with(r"\\.\");
if !is_dev {
return Health::unknown("Imagem de disco: não há SMART para avaliar.");
}
let mut h = match run_smartctl(path) {
Some(v) => parse_smartctl(&v),
None => Health::unknown("O SMART não pôde ser lido. Instale o pacote smartmontools (comando smartctl) e rode como administrador para ver a saúde do disco."),
};
kernel_errors(path, &mut h);
h
}
fn smartctl_name(path: &str) -> String {
// Windows: \\.\PhysicalDriveN -> /dev/sdX (convenção do smartctl)
if let Some(n) = path
.strip_prefix(r"\\.\PhysicalDrive")
.and_then(|n| n.parse::<u8>().ok())
{
return format!("/dev/sd{}", (b'a' + n.min(25)) as char);
}
// macOS: /dev/rdiskN -> /dev/diskN
if let Some(rest) = path.strip_prefix("/dev/rdisk") {
return format!("/dev/disk{rest}");
}
path.to_string()
}
fn run_smartctl(path: &str) -> Option<Value> {
let dev = smartctl_name(path);
let out = Command::new("smartctl")
.args(["--json=o", "-H", "-A", "-i", "-l", "error", &dev])
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
// smartctl usa bits do código de saída para avisos; o JSON vale mesmo assim
let v: Value = serde_json::from_slice(&out.stdout).ok()?;
// sem permissão ou dispositivo não suportado
if v.pointer("/smartctl/messages")
.and_then(|m| m.as_array())
.is_some_and(|m| m.iter().any(|x| x["severity"] == "error"))
&& v.get("smart_status").is_none()
&& v.get("ata_smart_attributes").is_none()
&& v.get("nvme_smart_health_information_log").is_none()
{
return None;
}
Some(v)
}
fn attr(v: &Value, id: u64) -> Option<i64> {
v.pointer("/ata_smart_attributes/table")?
.as_array()?
.iter()
.find(|a| a["id"] == id)?
.pointer("/raw/value")?
.as_i64()
}
/// Interpreta a saída JSON do `smartctl` (testável sem disco).
pub fn parse_smartctl(v: &Value) -> Health {
let mut h = Health {
available: true,
status: "ok".into(),
..Default::default()
};
let mut bad = 0u32; // 2 = falhando, 1 = atenção
let mut note = |h: &mut Health, sev: u32, k: &str, val: String| {
bad = bad.max(sev);
h.details.push((k.to_string(), val));
};
if let Some(m) = v["model_name"].as_str() {
h.details.push(("Modelo".into(), m.to_string()));
}
if let Some(s) = v["serial_number"].as_str() {
h.details.push(("Nº de série".into(), s.to_string()));
}
if let Some(hrs) = v.pointer("/power_on_time/hours").and_then(|x| x.as_i64()) {
h.details.push(("Horas ligado".into(), hrs.to_string()));
}
if let Some(t) = v.pointer("/temperature/current").and_then(|x| x.as_i64()) {
if t >= 60 {
note(&mut h, 1, "Temperatura", format!("{t} °C (alta)"));
} else {
h.details.push(("Temperatura".into(), format!("{t} °C")));
}
}
if v.pointer("/smart_status/passed") == Some(&Value::Bool(false)) {
note(
&mut h,
2,
"Autoavaliação SMART",
"REPROVADO: o próprio disco prevê falha".into(),
);
} else if v.pointer("/smart_status/passed") == Some(&Value::Bool(true)) {
h.details
.push(("Autoavaliação SMART".into(), "aprovado".into()));
}
// ATA (HD e SSD SATA)
if let Some(n) = attr(v, 5) {
if n > 0 {
note(
&mut h,
if n >= 50 { 2 } else { 1 },
"Setores realocados",
n.to_string(),
);
}
}
if let Some(n) = attr(v, 197) {
if n > 0 {
note(&mut h, 2, "Setores pendentes de realocação", n.to_string());
}
}
if let Some(n) = attr(v, 198) {
if n > 0 {
note(&mut h, 2, "Setores incorrigíveis offline", n.to_string());
}
}
if let Some(n) = attr(v, 187) {
if n > 0 {
note(&mut h, 1, "Erros incorrigíveis reportados", n.to_string());
}
}
if let Some(n) = attr(v, 199) {
if n > 0 {
note(
&mut h,
1,
"Erros de CRC na interface (cabo ou porta)",
n.to_string(),
);
}
}
if let Some(n) = v
.pointer("/ata_smart_error_log/summary/count")
.and_then(|x| x.as_i64())
{
if n > 0 {
note(
&mut h,
1,
"Erros registrados no log do disco",
n.to_string(),
);
}
}
// NVMe
if let Some(n) = v.get("nvme_smart_health_information_log") {
if n["critical_warning"].as_i64().unwrap_or(0) != 0 {
note(
&mut h,
2,
"Aviso crítico NVMe",
n["critical_warning"].to_string(),
);
}
if let Some(u) = n["percentage_used"].as_i64() {
if u >= 100 {
note(&mut h, 2, "Vida útil usada", format!("{u}%"));
} else if u >= 90 {
note(&mut h, 1, "Vida útil usada", format!("{u}%"));
} else {
h.details.push(("Vida útil usada".into(), format!("{u}%")));
}
}
if let Some(m) = n["media_errors"].as_i64() {
if m > 0 {
note(&mut h, 2, "Erros de mídia", m.to_string());
}
}
if let (Some(s), Some(t)) = (
n["available_spare"].as_i64(),
n["available_spare_threshold"].as_i64(),
) {
if s < t {
note(
&mut h,
2,
"Reserva de blocos abaixo do limite",
format!("{s}% (limite {t}%)"),
);
}
}
}
match bad {
2 => {
h.status = "failing".into();
h.summary = "Este disco mostra sinais sérios de defeito.".into();
h.advice.push(IMAGE_ADVICE.into());
}
1 => {
h.status = "warning".into();
h.summary = "Este disco mostra sinais de desgaste ou erros.".into();
h.advice.push(IMAGE_ADVICE.into());
}
_ => h.summary = "Nenhum sinal de defeito encontrado pelo SMART.".into(),
}
h
}
/// Contadores de erro de E/S do kernel (Linux): reforçam o aviso do SMART.
fn kernel_errors(path: &str, h: &mut Health) {
#[cfg(target_os = "linux")]
{
let name = path.trim_start_matches("/dev/");
let base = name.trim_end_matches(|c: char| c.is_ascii_digit());
for n in [name, base] {
if let Ok(s) = std::fs::read_to_string(format!("/sys/block/{n}/device/ioerr_cnt")) {
if let Ok(c) = u64::from_str_radix(s.trim().trim_start_matches("0x"), 16) {
if c > 0 {
h.details.push((
"Erros de leitura/gravação vistos pelo sistema".into(),
c.to_string(),
));
if h.status == "ok" || h.status == "unknown" {
h.available = true;
h.status = "warning".into();
h.summary = "O sistema já registrou erros de E/S neste disco.".into();
h.advice.push(IMAGE_ADVICE.into());
}
}
return;
}
}
}
}
#[cfg(not(target_os = "linux"))]
let _ = (path, h);
}
+4
View File
@@ -10,11 +10,14 @@ pub mod explorer;
pub mod external; pub mod external;
pub mod fs; pub mod fs;
pub mod hashing; pub mod hashing;
pub mod health;
pub mod http; pub mod http;
pub mod image; pub mod image;
pub mod meta; pub mod meta;
pub mod notify;
pub mod owner; pub mod owner;
pub mod partition; pub mod partition;
pub mod reconnect;
pub mod recover; pub mod recover;
pub mod report; pub mod report;
pub mod scan; pub mod scan;
@@ -23,6 +26,7 @@ pub mod service;
pub mod settings; pub mod settings;
pub mod signatures; pub mod signatures;
pub mod source; pub mod source;
pub mod sqlite;
pub mod thumb; pub mod thumb;
pub mod types; pub mod types;
+74
View File
@@ -0,0 +1,74 @@
//! Notificações do sistema (varredura concluída, disco desconectado...), usando os
//! comandos nativos de cada sistema, sem dependências extras.
use std::process::{Command, Stdio};
/// Envia uma notificação sem bloquear. Falhas são ignoradas (é só um aviso).
pub fn send(title: &str, body: &str) {
let (title, body) = (title.to_string(), body.to_string());
std::thread::spawn(move || {
if let Some(mut c) = command(&title, &body) {
if let Ok(mut child) = c
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
let _ = child.wait();
}
}
});
}
#[cfg(target_os = "linux")]
fn command(title: &str, body: &str) -> Option<Command> {
// Como root, notifica na sessão do usuário real.
let mut c = if let Some(u) = crate::owner::invoking_user() {
let mut c = Command::new("runuser");
c.args(["-u", &u.name, "--", "env"]);
for k in ["DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY"] {
if let Ok(v) = std::env::var(k) {
c.arg(format!("{k}={v}"));
}
}
c.arg(format!("XDG_RUNTIME_DIR=/run/user/{}", u.uid));
c.arg(format!(
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{}/bus",
u.uid
));
c.arg("notify-send");
c
} else {
Command::new("notify-send")
};
c.args(["-a", "Guará", "-i", "drive-harddisk", title, body]);
Some(c)
}
#[cfg(target_os = "macos")]
fn command(title: &str, body: &str) -> Option<Command> {
let esc = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\"");
let mut c = Command::new("osascript");
c.args([
"-e",
&format!(
"display notification \"{}\" with title \"{}\"",
esc(body),
esc(title)
),
]);
Some(c)
}
#[cfg(windows)]
fn command(title: &str, body: &str) -> Option<Command> {
let esc = |s: &str| s.replace('\'', "''");
let script = format!(
"Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; $n = New-Object System.Windows.Forms.NotifyIcon; $n.Icon = [System.Drawing.SystemIcons]::Information; $n.Visible = $true; $n.ShowBalloonTip(6000, '{}', '{}', [System.Windows.Forms.ToolTipIcon]::None); Start-Sleep -Seconds 7; $n.Dispose()",
esc(title),
esc(body)
);
let mut c = Command::new("powershell");
c.args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", &script]);
Some(c)
}
+44
View File
@@ -0,0 +1,44 @@
//! Reencontra o disco de uma sessão quando ele foi desconectado e voltou com
//! outro nome (por exemplo `/dev/sda` virando `/dev/sdb`).
use crate::device;
use crate::source::{fingerprint, open_path, Source};
use std::path::Path;
use std::sync::Arc;
/// A origem é compatível com o que a sessão espera?
/// `size == 0` ou `fp == None` significam "não sei" e não reprovam.
pub fn matches(src: &dyn Source, size: u64, fp: Option<&str>) -> bool {
(size == 0 || src.size() == size) && fp.map_or(true, |f| fingerprint(src).as_deref() == Some(f))
}
/// Procura entre os discos conectados o que corresponde à sessão.
/// Com impressão digital, exige igualdade; sem ela, só aceita se houver um único disco do mesmo tamanho.
pub fn find_matching_source(
size: u64,
fp: Option<&str>,
exclude: &[String],
) -> Option<(String, Arc<dyn Source>)> {
if size == 0 && fp.is_none() {
return None;
}
let mut found: Vec<(String, Arc<dyn Source>)> = vec![];
for d in device::list_devices() {
if !d.readable || exclude.contains(&d.path) || (size != 0 && d.size != size) {
continue;
}
let Ok(src) = open_path(Path::new(&d.path)) else {
continue;
};
if matches(src.as_ref(), size, fp) {
found.push((d.path, src));
}
}
if fp.is_some() {
found.into_iter().next()
} else if found.len() == 1 {
found.pop()
} else {
None
}
}
+214 -7
View File
@@ -33,6 +33,9 @@ pub struct RecoverReport {
pub failed: Vec<(u64, String)>, pub failed: Vec<(u64, String)>,
pub total_bytes: u64, pub total_bytes: u64,
pub cancelled: bool, pub cancelled: bool,
/// Arquivos já recuperados e conferidos numa execução anterior (pulados ao retomar).
#[serde(default)]
pub skipped: usize,
} }
/// Recusa destinos que ficam no mesmo dispositivo da origem. /// Recusa destinos que ficam no mesmo dispositivo da origem.
@@ -72,6 +75,33 @@ pub fn check_destination(source_path: Option<&Path>, dest: &Path) -> Result<()>
Ok(()) 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( pub fn recover(
src: &dyn Source, src: &dyn Source,
files: &[&Found], files: &[&Found],
@@ -79,19 +109,117 @@ pub fn recover(
cancelled: &(dyn Fn() -> bool + Sync), cancelled: &(dyn Fn() -> bool + Sync),
progress: &mut dyn FnMut(usize, usize, u64), progress: &mut dyn FnMut(usize, usize, u64),
) -> Result<RecoverReport> { ) -> 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> {
recover_resumable(src, files, dest, layout, "", false, cancelled, progress)
}
/// Uma linha do diário de recuperação (`guara-diario.jsonl`): cada arquivo gravado e conferido.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct JournalEntry {
source: String,
id: u64,
name: String,
file: RecoveredFile,
}
pub const JOURNAL: &str = "guara-diario.jsonl";
fn sha256_of_file(p: &Path) -> Option<String> {
use std::io::Read;
let mut f = std::fs::File::open(p).ok()?;
let mut h = MultiHasher::new();
let mut buf = vec![0u8; 1 << 20];
loop {
let n = f.read(&mut buf).ok()?;
if n == 0 {
break;
}
h.update(&buf[..n]);
}
Some(h.finalize().sha256)
}
/// Como `recover_with`, mas mantém um diário e, com `resume`, pula o que já foi
/// recuperado (e conferido por SHA-256) numa execução interrompida.
/// `source_key` identifica a origem para que o diário de outro disco não seja reaproveitado.
#[allow(clippy::too_many_arguments)]
pub fn recover_resumable(
src: &dyn Source,
files: &[&Found],
dest: &Path,
layout: RecoverLayout,
source_key: &str,
resume: bool,
cancelled: &(dyn Fn() -> bool + Sync),
progress: &mut dyn FnMut(usize, usize, u64),
) -> Result<RecoverReport> {
use std::io::Write;
crate::owner::create_dir_all_owned(dest)?; crate::owner::create_dir_all_owned(dest)?;
let mut report = RecoverReport { let mut report = RecoverReport {
destination: dest.display().to_string(), destination: dest.display().to_string(),
..Default::default() ..Default::default()
}; };
let mut used: HashSet<PathBuf> = HashSet::new(); let mut used: HashSet<PathBuf> = HashSet::new();
let journal_path = dest.join(JOURNAL);
let mut prior: std::collections::HashMap<u64, JournalEntry> = std::collections::HashMap::new();
if resume {
if let Ok(text) = std::fs::read_to_string(&journal_path) {
for line in text.lines() {
if let Ok(e) = serde_json::from_str::<JournalEntry>(line) {
if e.source == source_key {
prior.insert(e.id, e);
}
}
}
}
}
let mut journal = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&journal_path)
.ok();
crate::owner::give_to_user(&journal_path);
for (i, f) in files.iter().enumerate() { for (i, f) in files.iter().enumerate() {
if cancelled() { if cancelled() {
report.cancelled = true; report.cancelled = true;
break; break;
} }
progress(i, files.len(), report.total_bytes); progress(i, files.len(), report.total_bytes);
let rel = target_rel(f); // retomada: já está lá e confere?
if let Some(e) = prior.get(&f.id) {
let p = dest.join(&e.file.path);
if e.name == f.name && e.file.size == f.size && p.is_file() {
if std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0) == f.size
&& sha256_of_file(&p).as_deref() == Some(e.file.sha256.as_str())
{
used.insert(p);
report.skipped += 1;
report.total_bytes += f.size;
report.recovered.push(e.file.clone());
continue;
}
// incompleto ou alterado: refaz (é um arquivo nosso)
let _ = std::fs::remove_file(&p);
}
}
let rel = target_rel(f, layout);
let mut path = dest.join(&rel); let mut path = dest.join(&rel);
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
if let Err(e) = crate::owner::create_dir_all_owned(parent) { if let Err(e) = crate::owner::create_dir_all_owned(parent) {
@@ -117,7 +245,7 @@ pub fn recover(
match write_file(src, f, &path) { match write_file(src, f, &path) {
Ok(h) => { Ok(h) => {
report.total_bytes += f.size; report.total_bytes += f.size;
report.recovered.push(RecoveredFile { let rec = RecoveredFile {
id: f.id, id: f.id,
path: path path: path
.strip_prefix(dest) .strip_prefix(dest)
@@ -131,7 +259,21 @@ pub fn recover(
confidence: f.confidence, confidence: f.confidence,
origin: f.origin, origin: f.origin,
partial: f.confidence == Confidence::Low, partial: f.confidence == Confidence::Low,
}); };
if let Some(j) = journal.as_mut() {
let line = JournalEntry {
source: source_key.to_string(),
id: f.id,
name: f.name.clone(),
file: rec.clone(),
};
if let Ok(mut l) = serde_json::to_string(&line) {
l.push('\n');
let _ = j.write_all(l.as_bytes());
let _ = j.flush();
}
}
report.recovered.push(rec);
} }
Err(e) => report.failed.push((f.id, e.to_string())), Err(e) => report.failed.push((f.id, e.to_string())),
} }
@@ -151,9 +293,10 @@ pub fn recover(
Ok(report) Ok(report)
} }
fn target_rel(f: &Found) -> PathBuf { fn target_rel(f: &Found, layout: RecoverLayout) -> PathBuf {
let mut p = PathBuf::new(); let mut p = PathBuf::new();
match &f.dir { match layout {
RecoverLayout::Folders => match &f.dir {
Some(d) if !d.is_empty() => { Some(d) if !d.is_empty() => {
p.push("Recuperados"); p.push("Recuperados");
for c in d.split('/') { for c in d.split('/') {
@@ -162,14 +305,63 @@ fn target_rel(f: &Found) -> PathBuf {
} }
} }
} }
_ => { _ => 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.push(sanitize_name(&f.name));
p 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. /// Escreve o conteúdo e devolve o SHA-256.
pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<Hashes> { pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<Hashes> {
let mut out = std::fs::OpenOptions::new() let mut out = std::fs::OpenOptions::new()
@@ -252,3 +444,18 @@ pub fn read_found_at(
} }
} }
} }
/// Um arquivo achado visto como origem de leitura posicional (para leitores de formato).
pub struct FoundSource<'a> {
pub src: &'a dyn Source,
pub found: &'a Found,
}
impl Source for FoundSource<'_> {
fn size(&self) -> u64 {
self.found.size
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<usize> {
read_found_at(self.src, self.found, offset, buf)
}
}
+7 -3
View File
@@ -1,7 +1,7 @@
//! Orquestra a varredura: layout → metadados dos sistemas de arquivos → //! Orquestra a varredura: layout → metadados dos sistemas de arquivos →
//! carving no espaço livre/desconhecido → fusão sem duplicatas. //! 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::fs::{self, merge_extents, overlaps, Ctx};
use crate::partition; use crate::partition;
use crate::signatures::category_for_ext; use crate::signatures::category_for_ext;
@@ -42,6 +42,9 @@ pub struct Checkpoint {
pub ranges: Vec<RangeState>, pub ranges: Vec<RangeState>,
pub next_id: u64, pub next_id: u64,
pub layout: Option<DiskLayout>, pub layout: Option<DiskLayout>,
/// A varredura parou porque o dispositivo parou de responder (desconectado?).
#[serde(default)]
pub io_lost: bool,
} }
pub enum Outcome { pub enum Outcome {
@@ -189,9 +192,10 @@ pub fn run(
if next >= end { if next >= end {
continue; 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; 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); cp.next_id = next_id.load(std::sync::atomic::Ordering::Relaxed);
return Outcome::Interrupted(cp); return Outcome::Interrupted(cp);
} }
+459 -44
View File
@@ -64,6 +64,12 @@ pub struct Job {
cancel: AtomicBool, cancel: AtomicBool,
stop_requested: AtomicBool, stop_requested: AtomicBool,
src: Mutex<Option<Arc<dyn Source>>>, 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>, st: Mutex<State>,
} }
@@ -78,11 +84,14 @@ impl Job {
Arc::new(Job { Arc::new(Job {
id, id,
kind, kind,
target, target: target.clone(),
opts, opts,
cancel: AtomicBool::new(false), cancel: AtomicBool::new(false),
stop_requested: AtomicBool::new(false), stop_requested: AtomicBool::new(false),
src: Mutex::new(src), src: Mutex::new(src),
src_path: Mutex::new(target.clone()),
fingerprint: Mutex::new(None),
session_file: Mutex::new(None),
st: Mutex::new(State { st: Mutex::new(State {
status: Status::Running, status: Status::Running,
progress: Progress::default(), progress: Progress::default(),
@@ -197,7 +206,10 @@ impl Engine {
pub fn start_scan(&self, path: &str, opts: ScanOptions) -> Result<String> { 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 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)); 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); spawn_scan(job.clone(), None);
Ok(job.id.clone()) Ok(job.id.clone())
} }
@@ -217,17 +229,35 @@ impl Engine {
pub fn resume(&self, id: &str) -> Result<()> { pub fn resume(&self, id: &str) -> Result<()> {
let j = self.job(id)?; let j = self.job(id)?;
if j.source().is_none() { let io_lost = {
return Err(no_source()); let s = j.st.lock().unwrap();
}
let cp = {
let mut s = j.st.lock().unwrap();
if s.status != Status::Paused { if s.status != Status::Paused {
return Err(Error::msg("a varredura não está pausada")); return Err(Error::msg("a varredura não está pausada"));
} }
s.status = Status::Running; s.checkpoint.as_ref().is_some_and(|c| c.io_lost)
s.checkpoint.take()
}; };
// 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.cancel.store(false, Ordering::Relaxed);
j.stop_requested.store(false, Ordering::Relaxed); j.stop_requested.store(false, Ordering::Relaxed);
spawn_scan(j, cp); spawn_scan(j, cp);
@@ -261,6 +291,8 @@ impl Engine {
"report": s.report, "report": s.report,
"elapsed": elapsed, "elapsed": elapsed,
"source": j.source().is_some(), "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(), "hits": s.hits.len(),
"enriching": s.enrich.map(|(d, t)| json!({ "done": d, "total": t })), "enriching": s.enrich.map(|(d, t)| json!({ "done": d, "total": t })),
})) }))
@@ -288,6 +320,55 @@ impl Engine {
Ok(crate::explorer::facets(&s.found)) Ok(crate::explorer::facets(&s.found))
} }
/// Esquema de um banco SQLite achado, com o número de linhas de cada tabela.
pub fn db_info(&self, id: &str, file: u64) -> Result<Value> {
let (f, src) = self.open_file(id, file)?;
let fs = recover::FoundSource {
src: src.as_ref(),
found: &f,
};
let db = crate::sqlite::Db::open(&fs).map_err(Error::msg)?;
let schema = db.schema();
let counts: Vec<u64> = schema.tables.iter().map(|t| db.count_rows(t)).collect();
let mut v = serde_json::to_value(&schema)?;
for (t, c) in v["tables"].as_array_mut().unwrap().iter_mut().zip(counts) {
t["rows"] = json!(c);
}
Ok(v)
}
/// Uma página de linhas de uma tabela (ou os registros apagados prováveis dela).
pub fn db_rows(
&self,
id: &str,
file: u64,
table: &str,
offset: usize,
limit: usize,
deleted: bool,
) -> Result<Value> {
let (f, src) = self.open_file(id, file)?;
let fs = recover::FoundSource {
src: src.as_ref(),
found: &f,
};
let db = crate::sqlite::Db::open(&fs).map_err(Error::msg)?;
let page = if deleted {
let mut p = db.deleted_rows(table).map_err(Error::msg)?;
p.rows = p
.rows
.into_iter()
.skip(offset)
.take(limit.clamp(1, 1000))
.collect();
p
} else {
db.rows(table, offset, limit.clamp(1, 1000))
.map_err(Error::msg)?
};
Ok(serde_json::to_value(&page)?)
}
/// Metadados detalhados (EXIF, PDF, Office, vídeo…) de um arquivo. /// Metadados detalhados (EXIF, PDF, Office, vídeo…) de um arquivo.
pub fn metadata(&self, id: &str, file: u64) -> Result<crate::meta::Meta> { pub fn metadata(&self, id: &str, file: u64) -> Result<crate::meta::Meta> {
let (f, src) = self.open_file(id, file)?; let (f, src) = self.open_file(id, file)?;
@@ -337,6 +418,7 @@ impl Engine {
ids: Option<Vec<u64>>, ids: Option<Vec<u64>>,
filter: Option<Query>, filter: Option<Query>,
dest: &str, dest: &str,
layout: recover::RecoverLayout,
) -> Result<String> { ) -> Result<String> {
let sj = self.job(scan_id)?; let sj = self.job(scan_id)?;
let src = sj.source().ok_or_else(no_source)?; let src = sj.source().ok_or_else(no_source)?;
@@ -355,6 +437,16 @@ impl Engine {
return Err(Error::msg("nenhum arquivo selecionado")); return Err(Error::msg("nenhum arquivo selecionado"));
} }
let dest_path = PathBuf::from(dest); 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)?; recover::check_destination(Some(Path::new(&sj.target)), &dest_path)?;
let job = self.register( let job = self.register(
JobKind::Recover, JobKind::Recover,
@@ -364,6 +456,12 @@ impl Engine {
); );
let j2 = job.clone(); let j2 = job.clone();
let parent = sj.clone(); let parent = sj.clone();
let source_key = sj
.fingerprint
.lock()
.unwrap()
.clone()
.unwrap_or_else(|| sj.target.clone());
std::thread::spawn(move || { std::thread::spawn(move || {
let cancelled = || j2.cancelled(); let cancelled = || j2.cancelled();
let mut prog = |done: usize, total: usize, bytes: u64| { let mut prog = |done: usize, total: usize, bytes: u64| {
@@ -375,12 +473,34 @@ impl Engine {
}; };
}; };
let refs: Vec<&Found> = files.iter().collect(); let refs: Vec<&Found> = files.iter().collect();
let res = recover::recover(src.as_ref(), &refs, &dest_path, &cancelled, &mut prog); let res = recover::recover_resumable(
src.as_ref(),
&refs,
&dest_path,
layout,
&source_key,
true,
&cancelled,
&mut prog,
);
let mut s = j2.st.lock().unwrap(); let mut s = j2.st.lock().unwrap();
match res { match res {
Ok(rep) => { Ok(rep) => {
s.report = serde_json::to_value(&rep).ok(); s.report = serde_json::to_value(&rep).ok();
parent.st.lock().unwrap().recoveries.push(rep.clone()); parent.st.lock().unwrap().recoveries.push(rep.clone());
if !rep.cancelled
&& s.started.elapsed().as_secs() >= 8
&& crate::settings::load().notify
{
crate::notify::send(
"Guará: recuperação concluída",
&format!(
"{} arquivo(s) salvos em {}",
rep.recovered.len(),
rep.destination
),
);
}
s.status = if rep.cancelled { s.status = if rep.cancelled {
Status::Stopped Status::Stopped
} else { } else {
@@ -597,21 +717,7 @@ impl Engine {
pub fn save_session(&self, id: &str, path: &str) -> Result<()> { pub fn save_session(&self, id: &str, path: &str) -> Result<()> {
let j = self.job(id)?; let j = self.job(id)?;
let s = j.st.lock().unwrap(); write_session(&j, Path::new(path))
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(())
} }
pub fn load_session(&self, path: &str) -> Result<String> { pub fn load_session(&self, path: &str) -> Result<String> {
@@ -629,18 +735,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}" "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( let job = self.register(
JobKind::Scan, JobKind::Scan,
data.target.clone(), data.target.clone(),
data.options.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(); let mut s = job.st.lock().unwrap();
for (i, f) in data.found.iter().enumerate() { for (i, f) in data.found.iter().enumerate() {
@@ -658,45 +763,181 @@ impl Engine {
Status::Paused Status::Paused
}; };
} }
if let Some(m) = missing { // A sessão sempre abre. Tenta religar o disco (inclusive se ele voltou com outro nome);
job.st.lock().unwrap().warnings.push(format!( // sem a origem ainda dá para navegar, exportar e gerar relatório.
"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.", 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 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 { if data.done {
spawn_enrich(job.clone()); spawn_enrich(job.clone());
} }
Ok(job.id.clone()) Ok(job.id.clone())
} }
/// 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. /// Aponta uma sessão carregada para a origem (disco ou imagem) atual.
pub fn relink(&self, id: &str, path: &str) -> Result<()> { /// 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 j = self.job(id)?;
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?; let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
{ let fp = j.fingerprint.lock().unwrap().clone();
let s = j.st.lock().unwrap(); let size =
if let Some(l) = &s.layout { j.st.lock()
if l.size != 0 && l.size != src.size() { .unwrap()
.layout
.as_ref()
.map(|l| l.size)
.unwrap_or(0);
if size != 0 && src.size() != size && !force {
return Err(Error::msg(format!( 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.", "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(), src.size(),
l.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.lock().unwrap() = Some(src);
*j.src_path.lock().unwrap() = path.to_string();
j.st.lock() j.st.lock()
.unwrap() .unwrap()
.warnings .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 { if j.st.lock().unwrap().status == Status::Done {
spawn_enrich(j); spawn_enrich(j);
} }
Ok(()) 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 // ---------------------------------------------------------------- RPC
pub fn rpc(&self, method: &str, p: Value) -> Result<Value> { pub fn rpc(&self, method: &str, p: Value) -> Result<Value> {
@@ -736,6 +977,28 @@ impl Engine {
} }
"status" => self.status(&s("job")?)?, "status" => self.status(&s("job")?)?,
"facets" => self.facets(&s("job")?)?, "facets" => self.facets(&s("job")?)?,
"health" => json!(crate::health::check(&s("path")?)),
"open_url" => {
let u = s("url")?;
// só mapas do OpenStreetMap: evita abrir endereços arbitrários a partir de metadados
if !u.starts_with("https://www.openstreetmap.org/") {
return Err(Error::msg("endereço não permitido"));
}
crate::external::open_path(Path::new(&u))?;
json!({})
}
"db_info" => self.db_info(
&s("job")?,
p.get("id").and_then(|v| v.as_u64()).unwrap_or(0),
)?,
"db_rows" => self.db_rows(
&s("job")?,
p.get("id").and_then(|v| v.as_u64()).unwrap_or(0),
&s("table")?,
p.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
p.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize,
p.get("deleted").and_then(|v| v.as_bool()).unwrap_or(false),
)?,
"open_external" => { "open_external" => {
let id = p let id = p
.get("id") .get("id")
@@ -815,7 +1078,12 @@ impl Engine {
.filter(|v| !v.is_null()) .filter(|v| !v.is_null())
.map(serde_json::from_value) .map(serde_json::from_value)
.transpose()?; .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")?)? }), "image_start" => json!({ "job": self.start_image(&s("path")?, &s("dest")?)? }),
"session_save" => { "session_save" => {
@@ -823,7 +1091,18 @@ impl Engine {
json!({}) json!({})
} }
"session_relink" => { "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!({}) json!({})
} }
"session_load" => json!({ "job": self.load_session(&s("path")?)? }), "session_load" => json!({ "job": self.load_session(&s("path")?)? }),
@@ -855,6 +1134,10 @@ struct SessionFile {
recoveries: Vec<recover::RecoverReport>, recoveries: Vec<recover::RecoverReport>,
#[serde(default)] #[serde(default)]
started_unix: Option<i64>, started_unix: Option<i64>,
#[serde(default)]
fingerprint: Option<String>,
#[serde(default)]
source_path: Option<String>,
} }
use crate::explorer::cat_key; use crate::explorer::cat_key;
@@ -883,6 +1166,9 @@ fn open_error(path: &str, e: std::io::Error) -> Error {
} }
fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) { 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 || { std::thread::spawn(move || {
let Some(src) = job.source() else { let Some(src) = job.source() else {
let mut s = job.st.lock().unwrap(); let mut s = job.st.lock().unwrap();
@@ -906,12 +1192,28 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
Outcome::Completed => { Outcome::Completed => {
s.status = Status::Done; s.status = Status::Done;
s.checkpoint = None; s.checkpoint = None;
if s.started.elapsed().as_secs() >= 20 && crate::settings::load().notify {
crate::notify::send(
"Guará: varredura concluída",
&format!(
"{} arquivo(s) encontrados em {}.",
s.found.len(),
job.target
),
);
}
} }
Outcome::Interrupted(cp) => { Outcome::Interrupted(cp) => {
if job.stop_requested.load(Ordering::Relaxed) { if job.stop_requested.load(Ordering::Relaxed) {
s.status = Status::Stopped; s.status = Status::Stopped;
} else { } else {
s.status = Status::Paused; s.status = Status::Paused;
if cp.io_lost {
if crate::settings::load().notify {
crate::notify::send("Guará: disco desconectado?", "A varredura foi pausada sem perder nada. Reconecte o disco e continue.");
}
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); s.checkpoint = Some(cp);
} }
} }
@@ -1167,3 +1469,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])
}
}
+22 -1
View File
@@ -8,13 +8,34 @@ use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings { pub struct Settings {
/// "auto", "light" ou "dark". /// "auto", "light" ou "dark".
#[serde(default = "auto")]
pub theme: String, pub theme: String,
/// Idioma da interface: "auto", "pt" ou "en".
#[serde(default = "auto")]
pub lang: String,
/// Notificação do sistema quando uma tarefa longa termina.
#[serde(default = "yes")]
pub notify: bool,
/// Mostrar mapas das fotos com GPS (baixa blocos do OpenStreetMap pela internet).
#[serde(default)]
pub maps: bool,
}
fn auto() -> String {
"auto".into()
}
fn yes() -> bool {
true
} }
impl Default for Settings { impl Default for Settings {
fn default() -> Self { fn default() -> Self {
Self { Self {
theme: "auto".into(), theme: auto(),
lang: auto(),
notify: true,
maps: false,
} }
} }
} }
+31
View File
@@ -274,3 +274,34 @@ pub fn open_path(path: &Path) -> io::Result<Arc<dyn Source>> {
} }
Ok(Arc::new(FileSource::open(path)?)) 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())
}
+900
View File
@@ -0,0 +1,900 @@
//! Leitor de bancos SQLite feito para arquivos recuperados.
//!
//! Lê as páginas B-tree diretamente (sem a biblioteca do SQLite), então funciona
//! em bancos danificados ou truncados, para os quais a biblioteca oficial desiste.
//! Também procura **registros apagados** nas áreas livres das páginas, onde o
//! SQLite deixa o conteúdo antigo até que outro dado o sobrescreva.
use crate::source::Source;
use serde::Serialize;
use std::collections::HashSet;
const MAGIC: &[u8; 16] = b"SQLite format 3\0";
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "t", content = "v", rename_all = "lowercase")]
pub enum Cell {
Null,
Int(i64),
Real(f64),
Text(String),
/// Tamanho e os primeiros bytes (hex) para pré-visualizar; `mime` se reconhecido.
Blob {
len: usize,
head: String,
mime: Option<&'static str>,
},
}
#[derive(Debug, Clone, Serialize)]
pub struct DbColumn {
pub name: String,
pub decl_type: String,
pub pk: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct DbTable {
pub name: String,
pub rootpage: u32,
pub sql: String,
pub columns: Vec<DbColumn>,
pub without_rowid: bool,
pub virtual_table: bool,
/// Coluna `INTEGER PRIMARY KEY` (alias do rowid), se houver.
#[serde(skip)]
pub rowid_alias: Option<usize>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DbSchema {
pub page_size: u32,
pub pages: u32,
pub file_pages: u64,
pub encoding: String,
pub tables: Vec<DbTable>,
pub indexes: Vec<String>,
pub views: Vec<String>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DbRow {
pub rowid: Option<i64>,
pub cells: Vec<Cell>,
pub deleted: bool,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct RowsPage {
pub columns: Vec<String>,
pub rows: Vec<DbRow>,
pub total: u64,
pub warnings: Vec<String>,
}
pub struct Db<'a> {
src: &'a dyn Source,
ps: usize,
usable: usize,
pages: u32,
enc: u32,
}
type Res<T> = Result<T, String>;
// ------------------------------------------------------------------ utilidades
fn varint(b: &[u8]) -> Option<(i64, usize)> {
let mut v: u64 = 0;
for i in 0..9 {
let c = *b.get(i)?;
if i == 8 {
return Some((((v << 8) | c as u64) as i64, 9));
}
v = (v << 7) | (c & 0x7F) as u64;
if c & 0x80 == 0 {
return Some((v as i64, i + 1));
}
}
None
}
fn be16(b: &[u8], o: usize) -> Option<usize> {
Some(u16::from_be_bytes([*b.get(o)?, *b.get(o + 1)?]) as usize)
}
fn be32(b: &[u8], o: usize) -> Option<u32> {
Some(u32::from_be_bytes([
*b.get(o)?,
*b.get(o + 1)?,
*b.get(o + 2)?,
*b.get(o + 3)?,
]))
}
fn serial_len(t: i64) -> Option<usize> {
Some(match t {
0 | 8 | 9 => 0,
1 => 1,
2 => 2,
3 => 3,
4 => 4,
5 => 6,
6 | 7 => 8,
10 | 11 => return None,
n if n >= 12 => ((n - 12) / 2) as usize,
_ => return None,
})
}
fn blob_mime(b: &[u8]) -> Option<&'static str> {
if b.starts_with(&[0xFF, 0xD8, 0xFF]) {
Some("image/jpeg")
} else if b.starts_with(&[0x89, b'P', b'N', b'G']) {
Some("image/png")
} else if b.starts_with(b"GIF8") {
Some("image/gif")
} else if b.len() > 12 && &b[0..4] == b"RIFF" && &b[8..12] == b"WEBP" {
Some("image/webp")
} else {
None
}
}
fn decode_text(b: &[u8], enc: u32) -> String {
match enc {
2 => String::from_utf16_lossy(
&b.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect::<Vec<_>>(),
),
3 => String::from_utf16_lossy(
&b.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect::<Vec<_>>(),
),
_ => String::from_utf8_lossy(b).into_owned(),
}
}
/// Decodifica um registro (cabeçalho de tipos + corpo).
fn decode_record(p: &[u8], enc: u32) -> Option<Vec<Cell>> {
let (hlen, n) = varint(p)?;
let hlen = hlen as usize;
if hlen < n || hlen > p.len() || hlen > 4096 {
return None;
}
let mut types = vec![];
let mut o = n;
while o < hlen {
let (t, m) = varint(&p[o..hlen])?;
types.push(t);
o += m;
}
let mut cells = Vec::with_capacity(types.len());
let mut o = hlen;
for t in types {
let l = serial_len(t)?;
let b = p.get(o..o + l)?;
o += l;
cells.push(match t {
0 => Cell::Null,
1..=6 => {
let mut v: i64 = if b[0] & 0x80 != 0 { -1 } else { 0 };
for &x in b {
v = (v << 8) | x as i64;
}
Cell::Int(v)
}
7 => Cell::Real(f64::from_be_bytes(b.try_into().ok()?)),
8 => Cell::Int(0),
9 => Cell::Int(1),
n if n % 2 == 1 => Cell::Text(decode_text(b, enc)),
_ => Cell::Blob {
len: b.len(),
head: b.iter().take(24).map(|x| format!("{x:02x}")).collect(),
mime: blob_mime(b),
},
});
}
Some(cells)
}
// ------------------------------------------------------------------ banco
impl<'a> Db<'a> {
pub fn open(src: &'a dyn Source) -> Res<Self> {
let mut h = [0u8; 100];
let n = src.read_at(0, &mut h).map_err(|e| e.to_string())?;
if n < 100 || &h[0..16] != MAGIC {
return Err("Não é um banco SQLite (cabeçalho ausente). Se o arquivo é um banco, ele pode estar criptografado (SQLCipher) ou ter o início sobrescrito.".into());
}
let ps = match u16::from_be_bytes([h[16], h[17]]) {
1 => 65536usize,
v if (512..=32768).contains(&v) && v.is_power_of_two() => v as usize,
_ => return Err("Tamanho de página inválido no cabeçalho do banco.".into()),
};
let reserved = h[20] as usize;
let mut pages = u32::from_be_bytes([h[28], h[29], h[30], h[31]]);
let file_pages = (src.size() / ps as u64) as u32;
if pages == 0 || pages > file_pages.max(1) * 4 + 1 {
pages = file_pages;
}
let enc = u32::from_be_bytes([h[56], h[57], h[58], h[59]]);
Ok(Self {
src,
ps,
usable: ps - reserved,
pages: pages.max(1),
enc: if (1..=3).contains(&enc) { enc } else { 1 },
})
}
fn page(&self, n: u32) -> Res<Vec<u8>> {
if n == 0 {
return Err("página 0 inválida".into());
}
let off = (n as u64 - 1) * self.ps as u64;
if off + self.ps as u64 > self.src.size() {
return Err(format!("página {n} fora do arquivo (banco truncado)"));
}
let mut b = vec![0u8; self.ps];
let mut got = 0;
while got < b.len() {
match self.src.read_at(off + got as u64, &mut b[got..]) {
Ok(0) => return Err(format!("página {n} incompleta")),
Ok(k) => got += k,
Err(e) => return Err(e.to_string()),
}
}
Ok(b)
}
fn enc_name(&self) -> &'static str {
match self.enc {
2 => "UTF-16 LE",
3 => "UTF-16 BE",
_ => "UTF-8",
}
}
/// Lê o payload completo de uma célula (segue as páginas de overflow).
fn payload(
&self,
page: &[u8],
cell_off: usize,
p: usize,
index: bool,
hdr: usize,
) -> Option<Vec<u8>> {
let u = self.usable;
let x = if index {
((u - 12) * 64 / 255).saturating_sub(23)
} else {
u - 35
};
let m = ((u - 12) * 32 / 255).saturating_sub(23);
let local = if p <= x {
p
} else {
let k = m + (p - m) % (u - 4);
if k <= x {
k
} else {
m
}
};
let start = cell_off + hdr;
let mut out = page.get(start..start + local)?.to_vec();
if p > local {
let mut next = be32(page, start + local)?;
let mut guard = 0;
while out.len() < p && next != 0 && guard < 100_000 {
guard += 1;
let ov = self.page(next).ok()?;
next = be32(&ov, 0)?;
let take = (p - out.len()).min(u - 4);
out.extend_from_slice(ov.get(4..4 + take)?);
}
if out.len() < p {
return None;
}
}
Some(out)
}
/// Percorre as folhas de uma árvore de tabela chamando `f(rowid, payload)`.
/// `f` devolve false para parar. Devolve os avisos de corrupção encontrados.
fn walk_table(&self, root: u32, f: &mut dyn FnMut(i64, &[u8]) -> bool) -> Vec<String> {
let mut warnings = vec![];
let mut stack = vec![(root, 0u32)];
let mut seen = HashSet::new();
while let Some((pn, depth)) = stack.pop() {
if depth > 40 || !seen.insert(pn) {
continue;
}
let pg = match self.page(pn) {
Ok(p) => p,
Err(e) => {
if warnings.len() < 5 {
warnings.push(e);
}
continue;
}
};
let hb = if pn == 1 { 100 } else { 0 };
let Some(&ty) = pg.get(hb) else { continue };
let ncell = be16(&pg, hb + 3).unwrap_or(0);
match ty {
0x05 => {
let mut kids = vec![];
for i in 0..ncell {
let Some(co) = be16(&pg, hb + 12 + i * 2) else {
break;
};
if let Some(c) = be32(&pg, co) {
kids.push(c);
}
}
if let Some(r) = be32(&pg, hb + 8) {
kids.push(r);
}
for k in kids.into_iter().rev() {
stack.push((k, depth + 1));
}
}
0x0D => {
for i in 0..ncell {
let Some(co) = be16(&pg, hb + 8 + i * 2) else {
break;
};
let Some(tail) = pg.get(co..) else { continue };
let Some((plen, a)) = varint(tail) else {
continue;
};
let Some((rowid, b)) = varint(&tail[a..]) else {
continue;
};
if plen < 0 || plen as usize > 1 << 28 {
continue;
}
if let Some(pl) = self.payload(&pg, co, plen as usize, false, a + b) {
if !f(rowid, &pl) {
return warnings;
}
} else if warnings.len() < 5 {
warnings.push(format!("célula ilegível na página {pn}"));
}
}
}
_ => {
if warnings.len() < 5 {
warnings.push(format!("página {pn} de tipo inesperado (0x{ty:02x})"));
}
}
}
}
warnings
}
fn leaf_pages(&self, root: u32) -> Vec<u32> {
let mut leaves = vec![];
let mut stack = vec![(root, 0u32)];
let mut seen = HashSet::new();
while let Some((pn, depth)) = stack.pop() {
if depth > 40 || !seen.insert(pn) {
continue;
}
let Ok(pg) = self.page(pn) else { continue };
let hb = if pn == 1 { 100 } else { 0 };
match pg.get(hb) {
Some(0x05) => {
let n = be16(&pg, hb + 3).unwrap_or(0);
for i in 0..n {
if let Some(c) = be16(&pg, hb + 12 + i * 2).and_then(|co| be32(&pg, co)) {
stack.push((c, depth + 1));
}
}
if let Some(r) = be32(&pg, hb + 8) {
stack.push((r, depth + 1));
}
}
Some(0x0D) => leaves.push(pn),
_ => {}
}
}
leaves
}
// ---------------------------------------------------------- esquema
pub fn schema(&self) -> DbSchema {
let mut s = DbSchema {
page_size: self.ps as u32,
pages: self.pages,
file_pages: self.src.size() / self.ps as u64,
encoding: self.enc_name().into(),
tables: vec![],
indexes: vec![],
views: vec![],
warnings: vec![],
};
if s.file_pages < self.pages as u64 {
s.warnings.push(format!(
"Arquivo truncado: o cabeçalho indica {} páginas, mas só há {}. Parte dos dados pode estar faltando.",
self.pages, s.file_pages
));
}
let mut rows: Vec<Vec<Cell>> = vec![];
let w = self.walk_table(1, &mut |_, p| {
if let Some(c) = decode_record(p, self.enc) {
rows.push(c);
}
true
});
s.warnings.extend(w);
let text = |c: &Cell| {
if let Cell::Text(t) = c {
t.clone()
} else {
String::new()
}
};
for r in rows {
if r.len() < 5 {
continue;
}
let (ty, name, rootpage, sql) = (
text(&r[0]),
text(&r[1]),
if let Cell::Int(i) = r[3] { i as u32 } else { 0 },
text(&r[4]),
);
match ty.as_str() {
"table" => {
let (columns, rowid_alias, without_rowid) = parse_create(&sql);
s.tables.push(DbTable {
virtual_table: sql.to_uppercase().starts_with("CREATE VIRTUAL"),
name,
rootpage,
sql,
columns,
without_rowid,
rowid_alias,
});
}
"index" => s.indexes.push(name),
"view" => s.views.push(name),
_ => {}
}
}
if s.tables.is_empty() {
s.warnings.push(
"Nenhuma tabela encontrada no esquema. A primeira página pode estar danificada."
.into(),
);
}
s
}
fn table(&self, name: &str) -> Res<DbTable> {
self.schema()
.tables
.into_iter()
.find(|t| t.name == name)
.ok_or_else(|| format!("tabela {name} não encontrada"))
}
/// Conta as linhas da tabela (sem decodificar o conteúdo).
pub fn count_rows(&self, t: &DbTable) -> u64 {
if t.rootpage == 0 || t.virtual_table || t.without_rowid {
return 0;
}
let mut n = 0u64;
for leaf in self.leaf_pages(t.rootpage) {
if let Ok(pg) = self.page(leaf) {
n += be16(&pg, if leaf == 1 { 103 } else { 3 }).unwrap_or(0) as u64;
}
}
n
}
// ---------------------------------------------------------- linhas
pub fn rows(&self, table: &str, offset: usize, limit: usize) -> Res<RowsPage> {
let t = self.table(table)?;
let mut page = RowsPage {
columns: t.columns.iter().map(|c| c.name.clone()).collect(),
..Default::default()
};
if t.virtual_table || t.rootpage == 0 {
page.warnings
.push("Tabela virtual: o conteúdo não fica armazenado como linhas comuns.".into());
return Ok(page);
}
if t.without_rowid {
page.warnings.push(
"Tabela WITHOUT ROWID: as linhas ficam numa árvore de índice, ainda não exibida."
.into(),
);
return Ok(page);
}
let mut seen = 0usize;
let ncols = t.columns.len();
page.warnings = self.walk_table(t.rootpage, &mut |rowid, p| {
seen += 1;
if seen <= offset {
return true;
}
if page.rows.len() >= limit {
return false;
}
if let Some(mut cells) = decode_record(p, self.enc) {
if let Some(a) = t.rowid_alias {
if a < cells.len() && matches!(cells[a], Cell::Null) {
cells[a] = Cell::Int(rowid);
}
}
cells.resize(cells.len().max(ncols), Cell::Null);
apply_affinity(&mut cells, &t.columns);
page.rows.push(DbRow {
rowid: Some(rowid),
cells,
deleted: false,
});
}
true
});
page.total = self.count_rows(&t);
Ok(page)
}
// ---------------------------------------------------------- registros apagados
/// Procura registros apagados nas áreas livres das páginas da tabela.
/// São "prováveis": o SQLite não marca o que foi apagado, então validamos pela forma
/// do registro (mesmo número de colunas, tipos e textos coerentes).
pub fn deleted_rows(&self, table: &str) -> Res<RowsPage> {
let t = self.table(table)?;
let n = t.columns.len();
let mut out = RowsPage {
columns: t.columns.iter().map(|c| c.name.clone()).collect(),
..Default::default()
};
if n < 2 || t.rootpage == 0 || t.virtual_table || t.without_rowid {
return Ok(out);
}
for leaf in self.leaf_pages(t.rootpage) {
let Ok(pg) = self.page(leaf) else { continue };
let hb = if leaf == 1 { 100 } else { 0 };
let ncell = be16(&pg, hb + 3).unwrap_or(0);
let content = match be16(&pg, hb + 5) {
Some(0) => 65536,
Some(c) => c,
None => continue,
};
let ptr_end = hb + 8 + ncell * 2;
let mut regions: Vec<(usize, usize)> = vec![];
if content > ptr_end {
regions.push((ptr_end, content));
}
// cadeia de blocos livres
let mut fb = be16(&pg, hb + 1).unwrap_or(0);
let mut guard = 0;
while fb != 0 && guard < 2000 {
guard += 1;
let (Some(next), Some(size)) = (be16(&pg, fb), be16(&pg, fb + 2)) else {
break;
};
if fb + size <= self.usable && size >= 4 {
regions.push((fb, fb + size));
}
fb = next;
}
for (a, b) in regions {
let end = b.min(pg.len()).min(self.usable);
if a >= end {
continue;
}
let mut p = a;
while p + 3 < end {
if let Some((mut cells, used)) = self.try_deleted(&pg[p..end], &t) {
apply_affinity(&mut cells, &t.columns);
out.rows.push(DbRow {
rowid: None,
cells,
deleted: true,
});
p += used.max(1);
} else {
p += 1;
}
}
}
if out.rows.len() >= 5000 {
break;
}
}
out.total = out.rows.len() as u64;
Ok(out)
}
/// Tenta ler um registro apagado no início de `b`, em três formas:
/// (a) cabeçalho íntegro; (b) tamanho do cabeçalho perdido (o SQLite sobrescreve os 4
/// primeiros bytes do bloco livre); (c) como (b), mas sem o primeiro tipo (coluna que é
/// alias do rowid, sempre NULL).
fn try_deleted(&self, b: &[u8], t: &DbTable) -> Option<(Vec<Cell>, usize)> {
let n = t.columns.len();
// (a)
if let Some(r) = self.record_with_types(b, 1, n, None, t) {
return Some(r);
}
// (b)
if let Some(r) = self.record_with_types(b, 0, n, None, t) {
return Some(r);
}
// (c)
if t.rowid_alias == Some(0) {
return self.record_with_types(b, 0, n - 1, Some(0), t);
}
None
}
/// Lê `ntypes` tipos seriais a partir de `b[start..]` (com um tipo inicial opcional já
/// conhecido) e o corpo logo depois do cabeçalho.
fn record_with_types(
&self,
b: &[u8],
start: usize,
ntypes: usize,
first: Option<i64>,
t: &DbTable,
) -> Option<(Vec<Cell>, usize)> {
let n = t.columns.len();
let mut types: Vec<i64> = first.into_iter().collect();
let mut o = start;
let mut hdr_bytes = 1usize; // o próprio byte do tamanho do cabeçalho
for _ in 0..ntypes {
let (ty, m) = varint(b.get(o..)?)?;
types.push(ty);
o += m;
hdr_bytes += m;
}
if first.is_some() {
hdr_bytes += 1;
}
if start == 1 {
// forma (a): o primeiro byte declara o tamanho do cabeçalho e precisa bater
if *b.first()? as usize != o {
return None;
}
}
if types.len() != n {
return None;
}
for (ty, col) in types.iter().zip(&t.columns) {
if !type_ok(&col.decl_type, *ty) {
return None;
}
}
let body: usize = types
.iter()
.map(|&x| serial_len(x))
.sum::<Option<usize>>()?;
if body == 0 || o + body > b.len() {
return None;
}
// monta um registro canônico para reaproveitar o decodificador
let mut rec = vec![];
let mut hdr = vec![];
for &ty in &types {
put_varint(&mut hdr, ty);
}
put_varint(&mut rec, (hdr.len() + 1) as i64);
rec.extend(hdr);
rec.extend_from_slice(&b[o..o + body]);
let cells = decode_record(&rec, self.enc)?;
let mut texts = 0;
for c in &cells {
if let Cell::Text(s) = c {
if s.contains('\u{FFFD}')
|| s.chars()
.any(|ch| ch.is_control() && !matches!(ch, '\n' | '\r' | '\t'))
{
return None;
}
texts += 1;
}
}
let non_null = cells.iter().filter(|c| !matches!(c, Cell::Null)).count();
if non_null < 2 || (texts == 0 && n < 3) {
return None;
}
let _ = hdr_bytes;
Some((cells, o + body))
}
}
/// O tipo serial é compatível com o tipo declarado da coluna (afinidade do SQLite)?
fn type_ok(decl: &str, ty: i64) -> bool {
if ty == 10 || ty == 11 || ty < 0 {
return false;
}
if ty == 0 {
return true;
}
let d = decl.to_uppercase();
let is_text = ty >= 13 && ty % 2 == 1;
let is_blob = ty >= 12 && ty % 2 == 0;
if d.contains("INT") {
ty <= 9 || is_text
} else if d.contains("CHAR") || d.contains("CLOB") || d.contains("TEXT") {
is_text || is_blob || ty <= 9
} else if d.contains("REAL") || d.contains("FLOA") || d.contains("DOUB") {
ty <= 9
} else {
true
}
}
fn put_varint(out: &mut Vec<u8>, v: i64) {
let v = v as u64;
if v <= 0x7F {
out.push(v as u8);
return;
}
let mut tmp = vec![];
let mut x = v;
while x > 0 {
tmp.push((x & 0x7F) as u8);
x >>= 7;
}
tmp.reverse();
let last = tmp.len() - 1;
for (i, b) in tmp.iter().enumerate() {
out.push(if i == last { *b } else { *b | 0x80 });
}
}
/// O SQLite guarda REAIS inteiros como inteiros; devolve-os como REAL quando a coluna é REAL.
fn apply_affinity(cells: &mut [Cell], cols: &[DbColumn]) {
for (c, col) in cells.iter_mut().zip(cols) {
let d = col.decl_type.to_uppercase();
if d.contains("REAL") || d.contains("FLOA") || d.contains("DOUB") {
if let Cell::Int(i) = c {
*c = Cell::Real(*i as f64);
}
}
}
}
// ------------------------------------------------------------------ CREATE TABLE
fn unquote(s: &str) -> String {
let t = s.trim();
let b = t.as_bytes();
if b.len() >= 2
&& matches!(
(b[0], b[b.len() - 1]),
(b'"', b'"') | (b'`', b'`') | (b'[', b']') | (b'\'', b'\'')
)
{
t[1..t.len() - 1].to_string()
} else {
t.to_string()
}
}
/// Extrai colunas, alias de rowid e WITHOUT ROWID de um `CREATE TABLE`.
fn parse_create(sql: &str) -> (Vec<DbColumn>, Option<usize>, bool) {
let upper = sql.to_uppercase();
let without_rowid = upper.trim_end().ends_with("WITHOUT ROWID");
let Some(open) = sql.find('(') else {
return (vec![], None, without_rowid);
};
// fecha o parêntese correspondente
let mut depth = 0;
let mut close = sql.len();
let mut in_q: Option<char> = None;
for (i, ch) in sql.char_indices().skip_while(|(i, _)| *i < open) {
match (in_q, ch) {
(Some(q), c) if c == q => in_q = None,
(Some(_), _) => {}
(None, '"' | '\'' | '`') => in_q = Some(ch),
(None, '(') => depth += 1,
(None, ')') => {
depth -= 1;
if depth == 0 {
close = i;
break;
}
}
_ => {}
}
}
let body = &sql[open + 1..close.min(sql.len())];
// separa por vírgulas de nível 0
let mut parts = vec![];
let (mut d, mut start, mut q) = (0, 0, None::<char>);
for (i, ch) in body.char_indices() {
match (q, ch) {
(Some(c), x) if c == x => q = None,
(Some(_), _) => {}
(None, '"' | '\'' | '`') => q = Some(ch),
(None, '(') => d += 1,
(None, ')') => d -= 1,
(None, ',') if d == 0 => {
parts.push(&body[start..i]);
start = i + 1;
}
_ => {}
}
}
parts.push(&body[start..]);
let mut cols = vec![];
let mut alias = None;
for part in parts {
let p = part.trim();
let up = p.to_uppercase();
if ["PRIMARY", "UNIQUE", "CHECK", "FOREIGN", "CONSTRAINT"]
.iter()
.any(|k| up.starts_with(k))
{
continue;
}
let (name, rest) = if p.starts_with(['"', '`', '[']) {
let close = match p.as_bytes()[0] {
b'[' => ']',
b'`' => '`',
_ => '"',
};
let end = p[1..].find(close).map(|e| e + 2).unwrap_or(p.len());
(unquote(&p[..end]), p[end..].trim())
} else {
match p.split_once(char::is_whitespace) {
Some((a, b)) => (a.to_string(), b.trim()),
None => (p.to_string(), ""),
}
};
if name.is_empty() {
continue;
}
let ru = rest.to_uppercase();
let decl: String = rest
.split_whitespace()
.take_while(|w| {
![
"PRIMARY",
"NOT",
"NULL",
"UNIQUE",
"DEFAULT",
"CHECK",
"REFERENCES",
"COLLATE",
"CONSTRAINT",
"GENERATED",
"AS",
]
.contains(&w.to_uppercase().as_str())
})
.collect::<Vec<_>>()
.join(" ");
let pk = ru.contains("PRIMARY KEY");
if pk && decl.eq_ignore_ascii_case("INTEGER") && !ru.contains("DESC") {
alias = Some(cols.len());
}
cols.push(DbColumn {
name,
decl_type: decl,
pk,
});
}
(cols, alias, without_rowid)
}
/// O arquivo começa com o cabeçalho de um banco SQLite?
pub fn is_sqlite(head: &[u8]) -> bool {
head.len() >= 16 && &head[0..16] == MAGIC
}
+10 -1
View File
@@ -31,6 +31,13 @@ impl Sink for Collect {
} }
} }
/// Isola as configurações/sessões automáticas dos testes numa pasta temporária.
pub fn init() {
static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
let d = DIR.get_or_init(|| tempfile::tempdir().unwrap());
std::env::set_var("GUARA_CONFIG_DIR", d.path());
}
pub fn scan_path(path: &Path, opts: &ScanOptions) -> Collect { pub fn scan_path(path: &Path, opts: &ScanOptions) -> Collect {
let src = source::open_path(path).unwrap(); let src = source::open_path(path).unwrap();
let mut sink = Collect::default(); let mut sink = Collect::default();
@@ -104,7 +111,9 @@ pub fn hashes_in(dir: &Path) -> HashMap<String, PathBuf> {
let p = e.path(); let p = e.path();
if p.is_dir() { if p.is_dir() {
walk(&p, m); walk(&p, m);
} else if p.file_name().unwrap() != "guara-manifesto.json" { } else if !["guara-manifesto.json", "guara-diario.jsonl"]
.contains(&p.file_name().unwrap().to_str().unwrap())
{
m.insert(sha256_file(&p), p); m.insert(sha256_file(&p), p);
} }
} }
Binary file not shown.
+284 -5
View File
@@ -2,6 +2,7 @@
mod common; mod common;
use common::*; use common::*;
use guara_core::source::MemSource;
use guara_core::*; use guara_core::*;
use std::io::Write; 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. /// Streaming de pré-visualização: Range devolve exatamente os bytes do arquivo recuperado.
#[test] #[test]
fn file_slice_com_range_bate_com_o_arquivo_recuperado() { fn file_slice_com_range_bate_com_o_arquivo_recuperado() {
init();
use std::io::Read; use std::io::Read;
let Some(dir) = fixtures() else { return }; let Some(dir) = fixtures() else { return };
let path = dir.join("ntfs.img"); let path = dir.join("ntfs.img");
@@ -293,11 +295,12 @@ fn file_slice_com_range_bate_com_o_arquivo_recuperado() {
#[test] #[test]
fn configuracoes_de_tema_persistem() { fn configuracoes_de_tema_persistem() {
let dir = tempfile::tempdir().unwrap(); init();
std::env::set_var("GUARA_CONFIG_DIR", dir.path()); let _ = std::fs::remove_file(settings::config_dir().join("settings.json"));
assert_eq!(settings::load().theme, "auto"); assert_eq!(settings::load().theme, "auto");
settings::save(&settings::Settings { settings::save(&settings::Settings {
theme: "dark".into(), theme: "dark".into(),
..Default::default()
}) })
.unwrap(); .unwrap();
assert_eq!(settings::load().theme, "dark"); assert_eq!(settings::load().theme, "dark");
@@ -316,6 +319,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 /// 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. /// varredura real da imagem, com o alvo informado.
fn sessao_formato_v0_1_0(img: &std::path::Path, alvo: &str) -> tempfile::NamedTempFile { fn sessao_formato_v0_1_0(img: &std::path::Path, alvo: &str) -> tempfile::NamedTempFile {
init();
let eng = service::Engine::new(); let eng = service::Engine::new();
let job = eng let job = eng
.start_scan(img.to_str().unwrap(), ScanOptions::default()) .start_scan(img.to_str().unwrap(), ScanOptions::default())
@@ -329,6 +333,8 @@ fn sessao_formato_v0_1_0(img: &std::path::Path, alvo: &str) -> tempfile::NamedTe
let o = v.as_object_mut().unwrap(); let o = v.as_object_mut().unwrap();
o.remove("recoveries"); o.remove("recoveries");
o.remove("started_unix"); o.remove("started_unix");
o.remove("fingerprint");
o.remove("source_path");
o.insert("target".into(), alvo.into()); o.insert("target".into(), alvo.into());
for f in o["found"].as_array_mut().unwrap() { for f in o["found"].as_array_mut().unwrap() {
let f = f.as_object_mut().unwrap(); let f = f.as_object_mut().unwrap();
@@ -343,6 +349,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. /// Uma sessão gravada pela v0.1.0 continua abrindo nas versões novas.
#[test] #[test]
fn sessao_da_v0_1_0_abre_e_funciona() { fn sessao_da_v0_1_0_abre_e_funciona() {
init();
let Some(dir) = fixtures() else { return }; let Some(dir) = fixtures() else { return };
let img = dir.join("ntfs.img"); let img = dir.join("ntfs.img");
let tmp = sessao_formato_v0_1_0(&img, img.to_str().unwrap()); let tmp = sessao_formato_v0_1_0(&img, img.to_str().unwrap());
@@ -363,6 +370,7 @@ fn sessao_da_v0_1_0_abre_e_funciona() {
None, None,
Some(service::Query::default()), Some(service::Query::default()),
out.path().to_str().unwrap(), out.path().to_str().unwrap(),
recover::RecoverLayout::Folders,
) )
.unwrap(); .unwrap();
wait_done(&eng, &rj); wait_done(&eng, &rj);
@@ -375,9 +383,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. /// Origem indisponível não impede abrir a sessão; reapontar a origem religa tudo.
#[test] #[test]
fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() { fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
init();
let Some(dir) = fixtures() else { return }; let Some(dir) = fixtures() else { return };
let img = dir.join("ntfs.img"); let img = dir.join("ntfs.img");
let tmp = sessao_formato_v0_1_0(&img, "/dev/disco-que-nao-existe"); 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 eng = service::Engine::new();
let job = eng.load_session(tmp.path().to_str().unwrap()).unwrap(); let job = eng.load_session(tmp.path().to_str().unwrap()).unwrap();
let st = eng.status(&job).unwrap(); let st = eng.status(&job).unwrap();
@@ -407,16 +421,28 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
.contains("não está disponível")); .contains("não está disponível"));
let out = tempfile::tempdir().unwrap(); let out = tempfile::tempdir().unwrap();
let e = eng 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(); .unwrap_err();
assert!(e.to_string().contains("não está disponível"), "{e}"); assert!(e.to_string().contains("não está disponível"), "{e}");
// reconecta // reconecta
eng.relink(&job, img.to_str().unwrap()).unwrap(); eng.relink(&job, img.to_str().unwrap(), false).unwrap();
let st = eng.status(&job).unwrap(); let st = eng.status(&job).unwrap();
assert_eq!(st["source"], true); assert_eq!(st["source"], true);
assert!(st["warnings"].as_array().unwrap().is_empty()); assert!(st["warnings"].as_array().unwrap().is_empty());
let rj = eng 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(); .unwrap();
wait_done(&eng, &rj); wait_done(&eng, &rj);
assert_eq!(hashes_in(out.path()).len(), 7); assert_eq!(hashes_in(out.path()).len(), 7);
@@ -428,3 +454,256 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
"{e}" "{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);
}
+223
View File
@@ -0,0 +1,223 @@
//! Saúde do disco, notificações, configurações e retomada da recuperação.
mod common;
use common::*;
use guara_core::*;
use serde_json::json;
// ------------------------------------------------------------------ saúde (SMART)
#[test]
fn smart_reprovado_e_setores_ruins_viram_alerta_serio() {
let v = json!({
"model_name": "WDC WD5000", "serial_number": "ABC123",
"power_on_time": {"hours": 43000}, "temperature": {"current": 38},
"smart_status": {"passed": false},
"ata_smart_attributes": {"table": [
{"id": 5, "raw": {"value": 120}}, {"id": 197, "raw": {"value": 3}}, {"id": 199, "raw": {"value": 0}}
]}
});
let h = health::parse_smartctl(&v);
assert!(h.available);
assert_eq!(h.status, "failing");
assert!(h
.details
.iter()
.any(|(k, v)| k == "Setores realocados" && v == "120"));
assert!(h
.details
.iter()
.any(|(k, _)| k == "Setores pendentes de realocação"));
assert!(
h.advice.iter().any(|a| a.contains("imagem")),
"deve recomendar a imagem primeiro"
);
}
#[test]
fn smart_saudavel_nvme_desgastado_e_nvme_critico() {
let ok = json!({"smart_status": {"passed": true}, "ata_smart_attributes": {"table": [{"id": 5, "raw": {"value": 0}}, {"id": 197, "raw": {"value": 0}}]}});
let h = health::parse_smartctl(&ok);
assert_eq!(h.status, "ok");
assert!(h.advice.is_empty());
let worn = json!({"smart_status": {"passed": true}, "nvme_smart_health_information_log": {"critical_warning": 0, "percentage_used": 93, "media_errors": 0, "available_spare": 80, "available_spare_threshold": 10}});
assert_eq!(health::parse_smartctl(&worn).status, "warning");
let crit = json!({"smart_status": {"passed": true}, "nvme_smart_health_information_log": {"critical_warning": 4, "percentage_used": 10, "media_errors": 0}});
assert_eq!(health::parse_smartctl(&crit).status, "failing");
let media = json!({"nvme_smart_health_information_log": {"critical_warning": 0, "percentage_used": 10, "media_errors": 7}});
assert_eq!(health::parse_smartctl(&media).status, "failing");
}
#[test]
fn imagem_de_disco_nao_tem_smart() {
let f = tempfile::NamedTempFile::new().unwrap();
let h = health::check(f.path().to_str().unwrap());
assert!(!h.available);
assert_eq!(h.status, "unknown");
}
// ------------------------------------------------------------------ configurações
#[test]
fn configuracoes_antigas_ganham_os_valores_padrao_novos() {
init();
// arquivo gravado pela v0.3: só tinha o tema
std::fs::create_dir_all(settings::config_dir()).unwrap();
std::fs::write(
settings::config_dir().join("settings.json"),
br#"{"theme":"dark"}"#,
)
.unwrap();
let s = settings::load();
assert_eq!(
(s.theme.as_str(), s.lang.as_str(), s.notify, s.maps),
("dark", "auto", true, false)
);
settings::save(&settings::Settings {
theme: "light".into(),
lang: "en".into(),
notify: false,
maps: true,
})
.unwrap();
let s = settings::load();
assert_eq!(
(s.theme.as_str(), s.lang.as_str(), s.notify, s.maps),
("light", "en", false, true)
);
}
// ------------------------------------------------------------------ notificações
#[cfg(target_os = "linux")]
#[test]
fn notificacao_chama_o_notify_send() {
use std::os::unix::fs::PermissionsExt;
let bin = tempfile::tempdir().unwrap();
let marker = bin.path().join("recebido");
let script = bin.path().join("notify-send");
std::fs::write(
&script,
format!("#!/bin/sh\nprintf '%s|' \"$@\" > '{}'\n", marker.display()),
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let old = std::env::var("PATH").unwrap_or_default();
std::env::set_var("PATH", format!("{}:{old}", bin.path().display()));
notify::send("Guará: varredura concluída", "7 arquivos");
for _ in 0..100 {
if marker.exists() && !std::fs::read(&marker).unwrap().is_empty() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(30));
}
std::env::set_var("PATH", old);
let got = String::from_utf8(std::fs::read(&marker).unwrap()).unwrap();
assert!(
got.contains("Guará: varredura concluída") && got.contains("7 arquivos"),
"{got}"
);
}
// ------------------------------------------------------------------ retomar recuperação
#[test]
fn recuperacao_interrompida_retoma_pulando_o_que_ja_foi_conferido() {
let Some(dir) = fixtures() else { return };
let img = dir.join("fat32.img");
let res = scan_path(
&img,
&ScanOptions {
mode: ScanMode::Quick,
..Default::default()
},
);
assert_eq!(res.found.len(), 7);
let src = source::open_path(&img).unwrap();
let files: Vec<&Found> = res.found.iter().collect();
let out = tempfile::tempdir().unwrap();
let key = "disco-de-teste";
// 1ª execução: "cai a energia" depois de 3 arquivos
let n = std::sync::atomic::AtomicUsize::new(0);
let cancel = || n.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= 3;
let r1 = recover::recover_resumable(
src.as_ref(),
&files,
out.path(),
recover::RecoverLayout::Folders,
key,
true,
&cancel,
&mut |_, _, _| {},
)
.unwrap();
assert!(r1.cancelled);
assert_eq!(r1.recovered.len(), 3);
let journal = std::fs::read_to_string(out.path().join(recover::JOURNAL)).unwrap();
assert_eq!(journal.lines().count(), 3);
// 2ª execução: retoma, pula os 3 e faz os outros 4
let r2 = recover::recover_resumable(
src.as_ref(),
&files,
out.path(),
recover::RecoverLayout::Folders,
key,
true,
&|| false,
&mut |_, _, _| {},
)
.unwrap();
assert!(!r2.cancelled);
assert_eq!((r2.skipped, r2.recovered.len()), (3, 7));
let got = hashes_in(out.path());
for (h, p) in expected(&dir, "fat32") {
assert!(got.contains_key(&h), "faltou {p}");
}
assert_eq!(
std::fs::read_to_string(out.path().join(recover::JOURNAL))
.unwrap()
.lines()
.count(),
7
);
// um arquivo é danificado entre as execuções: é refeito, os outros 6 são pulados
let victim = out.path().join(&r2.recovered[0].path);
std::fs::write(&victim, b"lixo").unwrap();
let r3 = recover::recover_resumable(
src.as_ref(),
&files,
out.path(),
recover::RecoverLayout::Folders,
key,
true,
&|| false,
&mut |_, _, _| {},
)
.unwrap();
assert_eq!((r3.skipped, r3.recovered.len(), r3.failed.len()), (6, 7, 0));
assert!(
hashes_in(out.path()).contains_key(&r2.recovered[0].sha256),
"o arquivo danificado deveria ter sido refeito"
);
// outro disco (outra chave) não reaproveita o diário
let r4 = recover::recover_resumable(
src.as_ref(),
&files,
out.path(),
recover::RecoverLayout::Folders,
"outro-disco",
true,
&|| false,
&mut |_, _, _| {},
)
.unwrap();
assert_eq!(r4.skipped, 0);
let _ = json!({});
}
+160
View File
@@ -0,0 +1,160 @@
//! Leitor de SQLite: esquema, linhas (com overflow e árvores de vários níveis),
//! registros apagados e robustez contra bancos danificados.
use guara_core::source::MemSource;
use guara_core::sqlite::{is_sqlite, Cell, Db};
const DB: &[u8] = include_bytes!("data/exemplo.sqlite");
fn text(c: &Cell) -> &str {
match c {
Cell::Text(t) => t,
other => panic!("esperava texto, veio {other:?}"),
}
}
#[test]
fn esquema_com_tabelas_indices_e_views() {
let src = MemSource(DB.to_vec());
assert!(is_sqlite(DB));
let db = Db::open(&src).unwrap();
let s = db.schema();
assert_eq!(s.page_size, 1024);
assert_eq!(s.encoding, "UTF-8");
let names: Vec<_> = s.tables.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, ["usuarios", "notas", "sem_rowid"]);
let u = &s.tables[0];
assert_eq!(
u.columns
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>(),
["id", "nome", "email", "idade", "saldo", "foto"]
);
assert_eq!(u.columns[0].decl_type, "INTEGER");
assert!(u.columns[0].pk && u.rowid_alias == Some(0));
assert!(s.tables[2].without_rowid);
assert_eq!(s.indexes, ["idx_usuarios_email"]);
assert_eq!(s.views, ["maiores"]);
assert!(s.warnings.is_empty(), "{:?}", s.warnings);
assert_eq!(db.count_rows(u), 493); // 500 menos 7 apagados
}
#[test]
fn linhas_em_arvore_de_varios_niveis_e_paginacao() {
let src = MemSource(DB.to_vec());
let db = Db::open(&src).unwrap();
let p = db.rows("usuarios", 0, 5).unwrap();
assert_eq!(p.total, 493);
assert_eq!(p.rows.len(), 5);
// o alias do rowid (INTEGER PRIMARY KEY) vem preenchido
assert!(matches!(p.rows[0].cells[0], Cell::Int(1)));
assert_eq!(text(&p.rows[0].cells[1]), "Pessoa 0001");
assert_eq!(text(&p.rows[0].cells[2]), "pessoa1@exemplo.com");
assert!(matches!(p.rows[0].cells[3], Cell::Int(19)));
assert!(matches!(p.rows[1].cells[4], Cell::Real(r) if (r - 3.0).abs() < 1e-9));
assert!(matches!(&p.rows[2].cells[5], Cell::Blob { len: 3, .. }));
// pula os apagados 101..103: a linha de índice 100 é o id 104
let q = db.rows("usuarios", 100, 2).unwrap();
assert!(matches!(q.rows[0].cells[0], Cell::Int(104)));
// última linha
let last = db.rows("usuarios", 492, 10).unwrap();
assert_eq!(last.rows.len(), 1);
assert_eq!(text(&last.rows[0].cells[1]), "Pessoa 0500");
}
#[test]
fn texto_longo_com_overflow_e_acentos() {
let src = MemSource(DB.to_vec());
let db = Db::open(&src).unwrap();
let n = db.rows("notas", 0, 10).unwrap();
assert_eq!(n.rows.len(), 3);
assert_eq!(text(&n.rows[0].cells[1]), "texto pequeno");
assert_eq!(
text(&n.rows[1].cells[1]).len(),
"Lorem ipsum dolor sit amet. ".len() * 400
);
assert!(text(&n.rows[1].cells[1]).ends_with("amet. "));
assert_eq!(text(&n.rows[2].cells[1]), "coração, ação, relatório");
assert_eq!(text(&n.rows[2].cells[0]), "acentuação");
}
#[test]
fn recupera_registros_apagados_das_areas_livres() {
let src = MemSource(DB.to_vec());
let db = Db::open(&src).unwrap();
let d = db.deleted_rows("usuarios").unwrap();
let nomes: Vec<String> = d
.rows
.iter()
.filter_map(|r| r.cells.get(1))
.filter_map(|c| {
if let Cell::Text(t) = c {
Some(t.clone())
} else {
None
}
})
.collect();
let apagados = [
"Pessoa 0101",
"Pessoa 0102",
"Pessoa 0103",
"Pessoa 0250",
"Pessoa 0251",
"Pessoa 0252",
"Pessoa 0253",
];
let achados = apagados
.iter()
.filter(|n| nomes.iter().any(|x| x == *n))
.count();
assert!(
achados >= 5,
"só {achados} de 7 registros apagados recuperados: {nomes:?}"
);
assert!(d.rows.iter().all(|r| r.deleted));
// a tabela de notas não teve nada apagado
assert!(db.deleted_rows("notas").unwrap().rows.is_empty());
}
#[test]
fn banco_truncado_e_corrompido_nao_derruba_nem_trava() {
// metade do arquivo
let half = DB[..DB.len() / 2].to_vec();
let src = MemSource(half);
let db = Db::open(&src).unwrap();
let s = db.schema();
assert!(
s.warnings.iter().any(|w| w.contains("truncado")),
"{:?}",
s.warnings
);
let p = db.rows("usuarios", 0, 1000).unwrap();
assert!(!p.rows.is_empty() && p.rows.len() < 493, "{}", p.rows.len());
// sem cabeçalho
let mut bad = DB.to_vec();
bad[0] = b'X';
assert!(Db::open(&MemSource(bad)).is_err());
// corrupção aleatória (semente fixa): nunca pode dar panic
let mut x: u64 = 0x1234_5678_9abc_def0;
for _ in 0..60 {
let mut d = DB.to_vec();
for _ in 0..40 {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
let i = 100 + (x as usize % (d.len() - 100));
d[i] = (x >> 24) as u8;
}
let src = MemSource(d);
if let Ok(db) = Db::open(&src) {
let s = db.schema();
for t in &s.tables {
let _ = db.rows(&t.name, 0, 50);
let _ = db.deleted_rows(&t.name);
let _ = db.count_rows(t);
}
}
}
}