Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
282161d297 | ||
|
|
b9bef85bc7 | ||
|
|
354caaf8f0 | ||
|
|
682ba8b943 |
No files matched your search
@@ -2,6 +2,19 @@
|
||||
|
||||
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
||||
|
||||
## [0.3.1] - 2026-10-18
|
||||
|
||||
### Adicionado
|
||||
- Filtros "Ocultar arquivos pequenos (menos de 30 KB)" e "Mais de 1 megapixel" no explorador, para separar fotos de ícones e miniaturas quando há milhares de imagens.
|
||||
|
||||
### Corrigido
|
||||
- Abrir uma sessão salva falhava por inteiro quando a origem (por exemplo `/dev/sdb`) não estava acessível, e a mensagem de erro era vaga. Agora a sessão sempre abre: dá para navegar, exportar e gerar relatório, e o botão "Escolher a origem…" reconecta o disco ou uma imagem para voltar a pré-visualizar e recuperar.
|
||||
- Mensagens claras quando o arquivo de sessão não pode ser lido (por exemplo, criado como administrador em `/root`) ou não é uma sessão válida.
|
||||
- Teste automático garante que sessões gravadas pela v0.1.0 continuam abrindo.
|
||||
- Carving de PNG: texto ASCII logo após um PNG truncado era lido como um bloco de ~1,9 GB e gerava "fotos" gigantes de confiança baixa. Agora o bloco precisa ser plausível (tipo válido, tamanho razoável, dentro do disco) e o arquivo termina no último bloco válido.
|
||||
- Retomar uma varredura de sessão sem a origem disponível agora avisa na hora, em vez de falhar depois.
|
||||
- Rodando como administrador (sudo/pkexec), as pastas sugeridas passam a ser as do usuário que abriu o Guará e tudo que ele grava (sessões, CSV, relatórios, imagens e arquivos recuperados, com as pastas criadas) fica com o dono e as permissões desse usuário, e não do root.
|
||||
|
||||
## [0.3.0] - 2026-10-18
|
||||
|
||||
### Adicionado
|
||||
|
||||
Generated
+2
-2
@@ -317,7 +317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "guara-cli"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ctrlc",
|
||||
@@ -329,7 +329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "guara-core"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/guara-core", "crates/guara-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/lucasrguerra/guara"
|
||||
|
||||
@@ -34,7 +34,7 @@ Cada arquivo tem um `.sha256` ao lado. Compilar do código: `cargo build --relea
|
||||
|
||||
> Os binários ainda **não são assinados**: o Windows SmartScreen e o Gatekeeper do macOS vão avisar. Confira o SHA-256.
|
||||
|
||||
Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux/macOS).
|
||||
Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux/macOS). Mesmo como root, o Guará grava sessões, relatórios e arquivos recuperados com o dono e as permissões do usuário que o abriu.
|
||||
|
||||
## Uso (terminal)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
[package]
|
||||
name = "guara-gui"
|
||||
description = "Guará: interface gráfica de recuperação de arquivos"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/lucasrguerra/guara"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Guara",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"identifier": "dev.guara.app",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
|
||||
+9
-3
@@ -210,7 +210,7 @@ $('#loadSessionBtn').onclick = async () => {
|
||||
const s = { job, target: st.target, label: 'Sessão: ' + st.target.split(/[\\/]/).pop() };
|
||||
S.sessions.unshift(s);
|
||||
openJob(s);
|
||||
} catch (e) { toast(e.message); }
|
||||
} catch (e) { info({ title: 'Não foi possível abrir a sessão', html: `<p>${esc(e.message)}</p>` }); }
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ início
|
||||
@@ -447,7 +447,8 @@ function paintStatus() {
|
||||
const enr = st.enriching ? `<span class="sub">Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString('pt-BR')}/${st.enriching.total.toLocaleString('pt-BR')}</span>` : '';
|
||||
const buttons = running ? '<button class="btn sm" data-act="pause">Pausar</button><button class="btn sm danger" data-act="stop">Parar</button>'
|
||||
: st.status === 'paused' ? '<button class="btn sm primary" data-act="resume">Continuar</button>' : '';
|
||||
const tools = !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 relink = st.source === false ? '<button class="btn sm primary" data-act="relink">Escolher a origem…</button>' : '';
|
||||
const tools = relink + (!running && st.found ? '<button class="btn sm" data-act="csv">Exportar CSV</button><button class="btn sm" data-act="report">Relatório</button><button class="btn sm" data-act="save">Salvar sessão…</button>' : '');
|
||||
if (running || st.status === 'paused') {
|
||||
setHTML($('#progressArea'), `<div class="progress-card slim">
|
||||
<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>
|
||||
@@ -473,7 +474,12 @@ async function onProgressClick(e) {
|
||||
if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); }
|
||||
else if (act === 'stop') await rpc('scan_stop', { job: S.job });
|
||||
else if (act === 'resume') { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); }
|
||||
else if (act === 'csv') {
|
||||
else if (act === 'relink') {
|
||||
const path = await ask({ title: 'Escolher a origem', text: `Esta sessão foi feita sobre <code>${esc(S.status?.target || '')}</code>. Informe o dispositivo ou a imagem do mesmo disco para pré-visualizar e recuperar.`, value: S.status?.target || '', ok: 'Usar esta origem' });
|
||||
if (path) {
|
||||
try { await rpc('session_relink', { job: S.job, path }); toast('Origem reconectada'); const cur = { job: S.job, target: S.status.target, label: S.sessions.find((x) => x.job === S.job)?.label || 'Sessão' }; openJob(cur); } catch (err) { info({ title: 'Não foi possível usar essa origem', html: `<p>${esc(err.message)}</p>` }); }
|
||||
}
|
||||
} else if (act === 'csv') {
|
||||
const path = await ask({ title: 'Exportar para CSV', text: 'Grava os arquivos da vista atual (com os filtros aplicados). O arquivo abre direto no Excel.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-achados.csv', ok: 'Exportar' });
|
||||
if (path) { try { const r = await rpc('export_csv', { job: S.job, path, query: EX?.query() }); toast(`${r.rows.toLocaleString('pt-BR')} linhas gravadas`); } catch (err) { toast(err.message); } }
|
||||
} else if (act === 'report') {
|
||||
|
||||
+15
-4
@@ -32,7 +32,7 @@ const I = {
|
||||
export function createExplorer(deps) {
|
||||
const { host, job, rpc, esc, human, fileUrl, thumbUrl, onRecover, toast } = deps;
|
||||
const E = {
|
||||
filters: { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, query: '' },
|
||||
filters: { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, bigOnly: false, mp1: false, query: '' },
|
||||
sort: 'date',
|
||||
view: 'grid',
|
||||
tile: 150,
|
||||
@@ -95,6 +95,8 @@ export function createExplorer(deps) {
|
||||
if (f.minConf) q.min_confidence = f.minConf;
|
||||
if (f.gps) q.has_gps = true;
|
||||
if (f.dedupe) q.dedupe = true;
|
||||
if (f.bigOnly) q.min_size = 30 * 1024;
|
||||
if (f.mp1) q.min_mp = 1;
|
||||
if (f.query) q.query = f.query;
|
||||
return q;
|
||||
};
|
||||
@@ -279,7 +281,10 @@ export function createExplorer(deps) {
|
||||
if (img.tagName !== 'IMG' || !img.dataset.th) return;
|
||||
const wrap = img.closest('.xp-thumb, .xp-lth');
|
||||
img.remove();
|
||||
wrap?.insertAdjacentHTML('afterbegin', `<div class="xp-ico c-bad" title="Não foi possível gerar a miniatura (arquivo possivelmente danificado)"><span>${I.warn}</span><b>?</b></div>`);
|
||||
const offline = E.status && E.status.source === false;
|
||||
wrap?.insertAdjacentHTML('afterbegin', offline
|
||||
? '<div class="xp-ico c-image" title="Origem desconectada: sem miniatura"><span>🖼</span><b>IMG</b></div>'
|
||||
: `<div class="xp-ico c-bad" title="Não foi possível gerar a miniatura (arquivo possivelmente danificado)"><span>${I.warn}</span><b>?</b></div>`);
|
||||
}, true);
|
||||
|
||||
// ------------------------------------------------------------ seleção
|
||||
@@ -421,6 +426,8 @@ export function createExplorer(deps) {
|
||||
const cams = f.cameras.map((c) => node(esc(c.name), c.count, `data-f="camera" data-v="${esc(c.name)}"`, F.camera === c.name)).join('');
|
||||
const conf = ['high', 'medium', 'low'].map((k) => node(`Confiança ${CONF[k][0]}`, f.confidence[k], `data-f="minConf" data-v="${k}"`, F.minConf === k)).join('');
|
||||
const extra = (f.gps ? node('Com localização (GPS)', f.gps, 'data-f="gps" data-v="1"', F.gps) : '')
|
||||
+ (f.small && f.small < f.total ? node('Ocultar arquivos pequenos (menos de 30 KB)', f.total - f.small, 'data-f="bigOnly" data-v="1"', F.bigOnly) : '')
|
||||
+ (f.mp1 ? node('Mais de 1 megapixel', f.mp1, 'data-f="mp1" data-v="1"', F.mp1) : '')
|
||||
+ (f.duplicates ? node('Ocultar duplicadas', f.duplicates, 'data-f="dedupe" data-v="1"', F.dedupe) : '');
|
||||
|
||||
const html = `<div class="xp-sec">${node('Tudo', f.total, 'data-f="all"', !anyFilter())}</div>`
|
||||
@@ -447,6 +454,8 @@ export function createExplorer(deps) {
|
||||
else if (f === 'undated') { F.undated = !F.undated; F.year = F.month = null; }
|
||||
else if (f === 'gps') F.gps = !F.gps;
|
||||
else if (f === 'dedupe') F.dedupe = !F.dedupe;
|
||||
else if (f === 'bigOnly') F.bigOnly = !F.bigOnly;
|
||||
else if (f === 'mp1') F.mp1 = !F.mp1;
|
||||
else F[f] = F[f] === v ? null : v;
|
||||
if (f === 'folder' && F.folder && !F.folder.startsWith('@')) E.expanded.add('d' + F.folder);
|
||||
nav._h = null;
|
||||
@@ -455,7 +464,7 @@ export function createExplorer(deps) {
|
||||
});
|
||||
|
||||
function clearFilters(refresh = true) {
|
||||
E.filters = { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, query: '' };
|
||||
E.filters = { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, bigOnly: false, mp1: false, query: '' };
|
||||
$('#xpQ').value = '';
|
||||
if (refresh) changed(); else changed();
|
||||
}
|
||||
@@ -490,6 +499,8 @@ export function createExplorer(deps) {
|
||||
if (F.minConf) add(`Confiança ${CONF[F.minConf][0]}`, 'minConf');
|
||||
if (F.gps) add('Com GPS', 'gps');
|
||||
if (F.dedupe) add('Sem duplicadas', 'dedupe');
|
||||
if (F.bigOnly) add('Sem arquivos pequenos', 'bigOnly');
|
||||
if (F.mp1) add('Mais de 1 MP', 'mp1');
|
||||
if (F.query) add(`“${esc(F.query)}”`, 'query');
|
||||
$('#xpChips').innerHTML = chips.length ? chips.join('') + '<button class="btn sm" data-x="all">Limpar filtros</button>' : '';
|
||||
}
|
||||
@@ -497,7 +508,7 @@ export function createExplorer(deps) {
|
||||
const k = e.target.closest('[data-x]')?.dataset.x;
|
||||
if (!k) return;
|
||||
if (k === 'all') return clearFilters();
|
||||
E.filters[k] = k === 'query' ? '' : k === 'undated' || k === 'gps' || k === 'dedupe' ? false : null;
|
||||
E.filters[k] = k === 'query' ? '' : k === 'undated' || k === 'gps' || k === 'dedupe' || k === 'bigOnly' || k === 'mp1' ? false : null;
|
||||
if (k === 'year') E.filters.month = null;
|
||||
if (k === 'query') $('#xpQ').value = '';
|
||||
nav._h = null;
|
||||
|
||||
@@ -180,7 +180,14 @@ pub fn facets(found: &[Found]) -> Value {
|
||||
let mut conf = [0usize; 3];
|
||||
let mut seen_sig = std::collections::HashSet::new();
|
||||
let mut dups = 0usize;
|
||||
let (mut small, mut mp1) = (0usize, 0usize);
|
||||
for f in found {
|
||||
if f.size < 30 * 1024 {
|
||||
small += 1;
|
||||
}
|
||||
if f.px.is_some_and(|(w, h)| w as u64 * h as u64 >= 1_000_000) {
|
||||
mp1 += 1;
|
||||
}
|
||||
if let Some(g) = f.sig {
|
||||
if !seen_sig.insert((g, f.size)) {
|
||||
dups += 1;
|
||||
@@ -242,6 +249,8 @@ pub fn facets(found: &[Found]) -> Value {
|
||||
"root": root,
|
||||
"gps": gps,
|
||||
"duplicates": dups,
|
||||
"small": small,
|
||||
"mp1": mp1,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ pub fn create_image(
|
||||
dest.display()
|
||||
))
|
||||
})?;
|
||||
crate::owner::give_to_user(dest);
|
||||
let size = src.size();
|
||||
let mut rep = ImageReport::default();
|
||||
let mut hasher = MultiHasher::new();
|
||||
@@ -81,7 +82,7 @@ pub fn create_image(
|
||||
rep.md5 = h.md5;
|
||||
rep.sha1 = h.sha1;
|
||||
let map = dest.with_extension("map.json");
|
||||
std::fs::write(map, serde_json::to_vec_pretty(&rep)?)?;
|
||||
crate::owner::write_owned(&map, serde_json::to_vec_pretty(&rep)?)?;
|
||||
Ok(rep)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod fs;
|
||||
pub mod hashing;
|
||||
pub mod image;
|
||||
pub mod meta;
|
||||
pub mod owner;
|
||||
pub mod partition;
|
||||
pub mod recover;
|
||||
pub mod report;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Quando o Guará roda como administrador (sudo/pkexec), tudo que ele grava para
|
||||
//! o usuário (sessões, relatórios, arquivos recuperados) pertence ao usuário que
|
||||
//! o abriu, e não ao root, e as pastas sugeridas ficam na pasta desse usuário.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct User {
|
||||
pub uid: u32,
|
||||
pub gid: u32,
|
||||
pub home: PathBuf,
|
||||
}
|
||||
|
||||
/// O usuário real por trás de um processo root iniciado por sudo/pkexec.
|
||||
#[cfg(unix)]
|
||||
pub fn invoking_user() -> Option<User> {
|
||||
// SAFETY: geteuid não tem pré-condições.
|
||||
if unsafe { libc::geteuid() } != 0 {
|
||||
return None;
|
||||
}
|
||||
let uid = std::env::var("SUDO_UID")
|
||||
.ok()
|
||||
.or_else(|| std::env::var("PKEXEC_UID").ok())
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.filter(|&u| u != 0);
|
||||
let name = std::env::var("SUDO_USER").ok().filter(|u| u != "root");
|
||||
let passwd = std::fs::read_to_string("/etc/passwd").ok()?;
|
||||
lookup_user(&passwd, uid, name.as_deref())
|
||||
}
|
||||
|
||||
/// Procura o usuário (por uid ou, na falta dele, por nome) num conteúdo no formato do /etc/passwd.
|
||||
pub fn lookup_user(passwd: &str, uid: Option<u32>, name: Option<&str>) -> Option<User> {
|
||||
for l in passwd.lines() {
|
||||
let f: Vec<&str> = l.split(':').collect();
|
||||
if f.len() < 6 {
|
||||
continue;
|
||||
}
|
||||
let (Ok(fuid), Ok(fgid)) = (f[2].parse::<u32>(), f[3].parse::<u32>()) else {
|
||||
continue;
|
||||
};
|
||||
if uid == Some(fuid) || (uid.is_none() && name == Some(f[0])) {
|
||||
return Some(User {
|
||||
uid: fuid,
|
||||
gid: fgid,
|
||||
home: PathBuf::from(f[5]),
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn invoking_user() -> Option<User> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Pasta pessoal para sugerir ao usuário (a dele, mesmo se estivermos como root).
|
||||
pub fn user_home() -> PathBuf {
|
||||
if let Some(u) = invoking_user() {
|
||||
return u.home;
|
||||
}
|
||||
PathBuf::from(
|
||||
std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Entrega o arquivo/pasta ao usuário que abriu o Guará (não faz nada fora do modo root).
|
||||
pub fn give_to_user(path: &Path) {
|
||||
#[cfg(unix)]
|
||||
if let Some(u) = invoking_user() {
|
||||
let _ = std::os::unix::fs::lchown(path, Some(u.uid), Some(u.gid));
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = path;
|
||||
}
|
||||
|
||||
/// Como `create_dir_all`, mas as pastas criadas ficam com o dono certo.
|
||||
pub fn create_dir_all_owned(path: &Path) -> io::Result<()> {
|
||||
let mut missing = vec![];
|
||||
let mut cur = Some(path);
|
||||
while let Some(p) = cur {
|
||||
if p.as_os_str().is_empty() || p.exists() {
|
||||
break;
|
||||
}
|
||||
missing.push(p.to_path_buf());
|
||||
cur = p.parent();
|
||||
}
|
||||
std::fs::create_dir_all(path)?;
|
||||
for p in missing.iter().rev() {
|
||||
give_to_user(p);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Como `fs::write`, mas o arquivo fica com o dono certo.
|
||||
pub fn write_owned(path: &Path, bytes: impl AsRef<[u8]>) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
create_dir_all_owned(parent)?;
|
||||
}
|
||||
std::fs::write(path, bytes)?;
|
||||
give_to_user(path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PASSWD: &str = "root:x:0:0:root:/root:/bin/bash\nlucas:x:1000:1000:Lucas:/home/lucas:/bin/bash\nmaria:x:1001:1002::/home/maria:/bin/zsh\nlinha-quebrada\n";
|
||||
|
||||
#[test]
|
||||
fn acha_o_usuario_por_uid_ou_nome() {
|
||||
let u = lookup_user(PASSWD, Some(1000), None).unwrap();
|
||||
assert_eq!(
|
||||
(u.uid, u.gid, u.home.to_str().unwrap()),
|
||||
(1000, 1000, "/home/lucas")
|
||||
);
|
||||
let m = lookup_user(PASSWD, None, Some("maria")).unwrap();
|
||||
assert_eq!(
|
||||
(m.uid, m.gid, m.home.to_str().unwrap()),
|
||||
(1001, 1002, "/home/maria")
|
||||
);
|
||||
assert!(lookup_user(PASSWD, Some(4242), None).is_none());
|
||||
// o uid manda sobre o nome
|
||||
assert_eq!(
|
||||
lookup_user(PASSWD, Some(1001), Some("lucas")).unwrap().uid,
|
||||
1001
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fora_do_modo_root_nao_muda_dono_e_grava_normalmente() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let f = d.path().join("a/b/sessao.json");
|
||||
write_owned(&f, b"{}").unwrap();
|
||||
assert_eq!(std::fs::read(&f).unwrap(), b"{}");
|
||||
assert!(create_dir_all_owned(&d.path().join("x/y")).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ pub struct RecoverReport {
|
||||
|
||||
/// Recusa destinos que ficam no mesmo dispositivo da origem.
|
||||
pub fn check_destination(source_path: Option<&Path>, dest: &Path) -> Result<()> {
|
||||
std::fs::create_dir_all(dest)?;
|
||||
crate::owner::create_dir_all_owned(dest)?;
|
||||
let dest = dest.canonicalize()?;
|
||||
let Some(sp) = source_path else { return Ok(()) };
|
||||
let sp_str = sp.to_string_lossy().to_string();
|
||||
@@ -79,7 +79,7 @@ pub fn recover(
|
||||
cancelled: &(dyn Fn() -> bool + Sync),
|
||||
progress: &mut dyn FnMut(usize, usize, u64),
|
||||
) -> Result<RecoverReport> {
|
||||
std::fs::create_dir_all(dest)?;
|
||||
crate::owner::create_dir_all_owned(dest)?;
|
||||
let mut report = RecoverReport {
|
||||
destination: dest.display().to_string(),
|
||||
..Default::default()
|
||||
@@ -94,7 +94,7 @@ pub fn recover(
|
||||
let rel = target_rel(f);
|
||||
let mut path = dest.join(&rel);
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
if let Err(e) = crate::owner::create_dir_all_owned(parent) {
|
||||
report.failed.push((f.id, e.to_string()));
|
||||
continue;
|
||||
}
|
||||
@@ -144,8 +144,8 @@ pub fn recover(
|
||||
"algoritmos_hash": ["MD5", "SHA-1", "SHA-256"],
|
||||
"relatorio": &report,
|
||||
});
|
||||
std::fs::write(
|
||||
dest.join("guara-manifesto.json"),
|
||||
crate::owner::write_owned(
|
||||
&dest.join("guara-manifesto.json"),
|
||||
serde_json::to_vec_pretty(&manifest)?,
|
||||
)?;
|
||||
Ok(report)
|
||||
@@ -176,6 +176,7 @@ pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<Hashes> {
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)?;
|
||||
crate::owner::give_to_user(path);
|
||||
let mut hasher = MultiHasher::new();
|
||||
match &f.data {
|
||||
DataLoc::Resident { bytes } => {
|
||||
|
||||
@@ -63,7 +63,7 @@ pub struct Job {
|
||||
opts: ScanOptions,
|
||||
cancel: AtomicBool,
|
||||
stop_requested: AtomicBool,
|
||||
src: Option<Arc<dyn Source>>,
|
||||
src: Mutex<Option<Arc<dyn Source>>>,
|
||||
st: Mutex<State>,
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ impl Job {
|
||||
opts,
|
||||
cancel: AtomicBool::new(false),
|
||||
stop_requested: AtomicBool::new(false),
|
||||
src,
|
||||
src: Mutex::new(src),
|
||||
st: Mutex::new(State {
|
||||
status: Status::Running,
|
||||
progress: Progress::default(),
|
||||
@@ -105,6 +105,9 @@ impl Job {
|
||||
}),
|
||||
})
|
||||
}
|
||||
fn source(&self) -> Option<Arc<dyn Source>> {
|
||||
self.src.lock().unwrap().clone()
|
||||
}
|
||||
fn cancelled(&self) -> bool {
|
||||
self.cancel.load(Ordering::Relaxed)
|
||||
}
|
||||
@@ -214,6 +217,9 @@ impl Engine {
|
||||
|
||||
pub fn resume(&self, id: &str) -> Result<()> {
|
||||
let j = self.job(id)?;
|
||||
if j.source().is_none() {
|
||||
return Err(no_source());
|
||||
}
|
||||
let cp = {
|
||||
let mut s = j.st.lock().unwrap();
|
||||
if s.status != Status::Paused {
|
||||
@@ -254,6 +260,7 @@ impl Engine {
|
||||
"error": s.error,
|
||||
"report": s.report,
|
||||
"elapsed": elapsed,
|
||||
"source": j.source().is_some(),
|
||||
"hits": s.hits.len(),
|
||||
"enriching": s.enrich.map(|(d, t)| json!({ "done": d, "total": t })),
|
||||
}))
|
||||
@@ -294,7 +301,7 @@ impl Engine {
|
||||
if let Some(v) = self.thumbs.lock().unwrap().get(&key) {
|
||||
return if v.is_empty() { Err(422) } else { Ok(v) };
|
||||
}
|
||||
let (f, src) = self.open_file(job, id).map_err(|_| 404u16)?;
|
||||
let (f, src) = self.open_file(job, id).map_err(status_of)?;
|
||||
if f.category != Category::Image {
|
||||
return Err(415);
|
||||
}
|
||||
@@ -319,10 +326,7 @@ impl Engine {
|
||||
.index
|
||||
.get(&file)
|
||||
.ok_or_else(|| Error::msg("arquivo não encontrado"))?;
|
||||
Ok((
|
||||
s.found[i].clone(),
|
||||
j.src.clone().ok_or_else(|| Error::msg("sem origem"))?,
|
||||
))
|
||||
Ok((s.found[i].clone(), j.source().ok_or_else(no_source)?))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- recuperação
|
||||
@@ -335,7 +339,7 @@ impl Engine {
|
||||
dest: &str,
|
||||
) -> Result<String> {
|
||||
let sj = self.job(scan_id)?;
|
||||
let src = sj.src.clone().ok_or_else(|| Error::msg("sem origem"))?;
|
||||
let src = sj.source().ok_or_else(no_source)?;
|
||||
let files: Vec<Found> = {
|
||||
let s = sj.st.lock().unwrap();
|
||||
match (&ids, &filter) {
|
||||
@@ -449,7 +453,7 @@ impl Engine {
|
||||
Some(q) => q.select(&s.found),
|
||||
None => s.found.iter().collect(),
|
||||
};
|
||||
std::fs::write(path, crate::report::csv(&items))?;
|
||||
crate::owner::write_owned(Path::new(path), crate::report::csv(&items))?;
|
||||
Ok(items.len())
|
||||
}
|
||||
|
||||
@@ -478,7 +482,7 @@ impl Engine {
|
||||
recoveries: &s.recoveries,
|
||||
source_hashes: hashes.as_ref(),
|
||||
});
|
||||
std::fs::write(path, html)?;
|
||||
crate::owner::write_owned(Path::new(path), html)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -606,18 +610,36 @@ impl Engine {
|
||||
recoveries: s.recoveries.clone(),
|
||||
started_unix: s.started_unix,
|
||||
};
|
||||
std::fs::write(path, serde_json::to_vec(&data)?)?;
|
||||
crate::owner::write_owned(Path::new(path), serde_json::to_vec(&data)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_session(&self, path: &str) -> Result<String> {
|
||||
let data: SessionFile = serde_json::from_slice(&std::fs::read(path)?)?;
|
||||
let src = open_path(Path::new(&data.target)).map_err(|e| open_error(&data.target, e))?;
|
||||
let bytes = std::fs::read(path).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
Error::msg(format!(
|
||||
"Sem permissão para ler a sessão {path}. Se ela foi criada como administrador (por exemplo em /root), abra o Guará como administrador ou copie o arquivo para a sua pasta."
|
||||
))
|
||||
} else {
|
||||
Error::msg(format!("Não foi possível ler a sessão {path}: {e}"))
|
||||
}
|
||||
})?;
|
||||
let data: SessionFile = serde_json::from_slice(&bytes).map_err(|e| {
|
||||
Error::msg(format!(
|
||||
"O arquivo {path} não é uma sessão do Guará válida (ou foi criado por uma versão incompatível): {e}"
|
||||
))
|
||||
})?;
|
||||
// A sessão abre mesmo sem a origem: dá para navegar, exportar e gerar relatório.
|
||||
// Pré-visualização e recuperação voltam quando a origem for reconectada.
|
||||
let (src, missing) = match open_path(Path::new(&data.target)) {
|
||||
Ok(s) => (Some(s), None),
|
||||
Err(e) => (None, Some(open_error(&data.target, e).to_string())),
|
||||
};
|
||||
let job = self.register(
|
||||
JobKind::Scan,
|
||||
data.target.clone(),
|
||||
data.options.clone(),
|
||||
Some(src),
|
||||
src,
|
||||
);
|
||||
{
|
||||
let mut s = job.st.lock().unwrap();
|
||||
@@ -636,12 +658,45 @@ impl Engine {
|
||||
Status::Paused
|
||||
};
|
||||
}
|
||||
if let Some(m) = missing {
|
||||
job.st.lock().unwrap().warnings.push(format!(
|
||||
"A origem {} não está disponível agora ({m}). Você pode navegar, exportar e gerar relatório; para pré-visualizar e recuperar, reconecte o disco ou escolha a origem.",
|
||||
data.target
|
||||
));
|
||||
}
|
||||
if data.done {
|
||||
spawn_enrich(job.clone());
|
||||
}
|
||||
Ok(job.id.clone())
|
||||
}
|
||||
|
||||
/// Aponta uma sessão carregada para a origem (disco ou imagem) atual.
|
||||
pub fn relink(&self, id: &str, path: &str) -> Result<()> {
|
||||
let j = self.job(id)?;
|
||||
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
|
||||
{
|
||||
let s = j.st.lock().unwrap();
|
||||
if let Some(l) = &s.layout {
|
||||
if l.size != 0 && l.size != src.size() {
|
||||
return Err(Error::msg(format!(
|
||||
"Essa origem tem {} bytes, mas a sessão foi feita sobre um disco de {} bytes. Escolha o mesmo disco ou uma imagem dele.",
|
||||
src.size(),
|
||||
l.size
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
*j.src.lock().unwrap() = Some(src);
|
||||
j.st.lock()
|
||||
.unwrap()
|
||||
.warnings
|
||||
.retain(|w| !w.starts_with("A origem "));
|
||||
if j.st.lock().unwrap().status == Status::Done {
|
||||
spawn_enrich(j);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- RPC
|
||||
|
||||
pub fn rpc(&self, method: &str, p: Value) -> Result<Value> {
|
||||
@@ -758,11 +813,13 @@ impl Engine {
|
||||
self.save_session(&s("job")?, &s("path")?)?;
|
||||
json!({})
|
||||
}
|
||||
"session_relink" => {
|
||||
self.relink(&s("job")?, &s("path")?)?;
|
||||
json!({})
|
||||
}
|
||||
"session_load" => json!({ "job": self.load_session(&s("path")?)? }),
|
||||
"defaults" => {
|
||||
let home = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.unwrap_or_default();
|
||||
let home = crate::owner::user_home().to_string_lossy().to_string();
|
||||
json!({ "home": home, "sep": std::path::MAIN_SEPARATOR.to_string() })
|
||||
}
|
||||
"version" => {
|
||||
@@ -793,6 +850,19 @@ struct SessionFile {
|
||||
|
||||
use crate::explorer::cat_key;
|
||||
|
||||
/// 503 quando a origem da sessão está desconectada; 404 nos demais casos.
|
||||
fn status_of(e: Error) -> u16 {
|
||||
if e.to_string().contains("não está disponível") {
|
||||
503
|
||||
} else {
|
||||
404
|
||||
}
|
||||
}
|
||||
|
||||
fn no_source() -> Error {
|
||||
Error::msg("A origem desta sessão não está disponível. Reconecte o disco ou escolha a origem para pré-visualizar e recuperar.")
|
||||
}
|
||||
|
||||
fn open_error(path: &str, e: std::io::Error) -> Error {
|
||||
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
Error::msg(format!(
|
||||
@@ -805,7 +875,12 @@ fn open_error(path: &str, e: std::io::Error) -> Error {
|
||||
|
||||
fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
|
||||
std::thread::spawn(move || {
|
||||
let src = job.src.clone().expect("origem");
|
||||
let Some(src) = job.source() else {
|
||||
let mut s = job.st.lock().unwrap();
|
||||
s.status = Status::Error;
|
||||
s.error = Some(no_source().to_string());
|
||||
return;
|
||||
};
|
||||
let existing: Vec<Found> = job.st.lock().unwrap().found.clone();
|
||||
let cancelled = || job.cancelled();
|
||||
let mut sink = JobSink(job.clone());
|
||||
@@ -845,7 +920,7 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
|
||||
/// navegar por data, câmera e resolução.
|
||||
fn spawn_enrich(job: Arc<Job>) {
|
||||
std::thread::spawn(move || {
|
||||
let Some(src) = job.src.clone() else { return };
|
||||
let Some(src) = job.source() else { return };
|
||||
let todo: Vec<(u64, Found)> = {
|
||||
let s = job.st.lock().unwrap();
|
||||
s.found
|
||||
@@ -1045,7 +1120,7 @@ impl Engine {
|
||||
id: u64,
|
||||
range: Option<&str>,
|
||||
) -> std::result::Result<FileSlice, u16> {
|
||||
let (found, src) = self.open_file(job, id).map_err(|_| 404u16)?;
|
||||
let (found, src) = self.open_file(job, id).map_err(status_of)?;
|
||||
let total = found.size;
|
||||
let (mut start, mut end, mut partial) = (0u64, total.saturating_sub(1), false);
|
||||
if let Some(spec) = range.and_then(|r| r.strip_prefix("bytes=")) {
|
||||
|
||||
@@ -29,8 +29,8 @@ pub fn config_dir() -> PathBuf {
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Some(home) = invoking_user_home() {
|
||||
return default_dir(home);
|
||||
if let Some(u) = crate::owner::invoking_user() {
|
||||
return default_dir(u.home);
|
||||
}
|
||||
}
|
||||
default_dir(PathBuf::from(
|
||||
@@ -69,25 +69,6 @@ fn default_dir(home: PathBuf) -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// Se somos root por sudo/pkexec, a home de quem invocou.
|
||||
#[cfg(unix)]
|
||||
fn invoking_user_home() -> Option<PathBuf> {
|
||||
// SAFETY: geteuid não tem pré-condições.
|
||||
if unsafe { libc::geteuid() } != 0 {
|
||||
return None;
|
||||
}
|
||||
let user = std::env::var("SUDO_USER").ok().filter(|u| u != "root");
|
||||
let uid = std::env::var("PKEXEC_UID").ok();
|
||||
let passwd = std::fs::read_to_string("/etc/passwd").ok()?;
|
||||
for l in passwd.lines() {
|
||||
let f: Vec<&str> = l.split(':').collect();
|
||||
if f.len() >= 6 && (user.as_deref() == Some(f[0]) || uid.as_deref() == Some(f[2])) {
|
||||
return Some(PathBuf::from(f[5]));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn file() -> PathBuf {
|
||||
config_dir().join("settings.json")
|
||||
}
|
||||
@@ -100,19 +81,6 @@ pub fn load() -> Settings {
|
||||
}
|
||||
|
||||
pub fn save(s: &Settings) -> Result<()> {
|
||||
let dir = config_dir();
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let path = file();
|
||||
std::fs::write(&path, serde_json::to_vec_pretty(s)?)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Como root, devolve a posse ao dono da pasta para o app normal conseguir regravar.
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
if let Ok(m) = std::fs::metadata(&dir) {
|
||||
let _ = std::os::unix::fs::chown(&path, Some(m.uid()), Some(m.gid()));
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::owner::write_owned(&file(), serde_json::to_vec_pretty(s)?)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -219,8 +219,10 @@ fn jpeg(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
// ---------------------------------------------------------------- PNG
|
||||
|
||||
fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
let total = src.size().saturating_sub(off);
|
||||
let mut p = 8u64;
|
||||
let mut first = true;
|
||||
let mut saw_idat = false;
|
||||
for _ in 0..200_000 {
|
||||
let c = read_vec(src, off + p, 8).ok()?;
|
||||
if c.len() < 8 {
|
||||
@@ -228,13 +230,20 @@ fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
}
|
||||
let len = u32::from_be_bytes([c[0], c[1], c[2], c[3]]) as u64;
|
||||
let ty = &c[4..8];
|
||||
if !ty.iter().all(|b| b.is_ascii_alphabetic()) || len > 0x7FFF_FFFF {
|
||||
// Bloco válido: 4 letras, 3ª maiúscula (bit reservado), tamanho plausível e dentro do
|
||||
// disco. Texto ASCII solto parece "tamanho" de ~1,9 GB e não pode virar um PNG gigante.
|
||||
let valid = ty.iter().all(|b| b.is_ascii_alphabetic())
|
||||
&& ty[2].is_ascii_uppercase()
|
||||
&& len <= 128 * 1024 * 1024
|
||||
&& p + 12 + len <= total;
|
||||
if !valid {
|
||||
break;
|
||||
}
|
||||
if first && ty != b"IHDR" {
|
||||
return None;
|
||||
}
|
||||
first = false;
|
||||
saw_idat |= ty == b"IDAT";
|
||||
p += 12 + len;
|
||||
if p > max {
|
||||
break;
|
||||
@@ -247,11 +256,12 @@ fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
|
||||
});
|
||||
}
|
||||
}
|
||||
if first {
|
||||
// Sem IEND: só vale como imagem truncada se já tem dados e é de tamanho razoável.
|
||||
if first || !saw_idat || p > 64 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
Some(Hit {
|
||||
len: p.min(max).min(src.size().saturating_sub(off)),
|
||||
len: p,
|
||||
ext: "png",
|
||||
confidence: Confidence::Low,
|
||||
})
|
||||
|
||||
@@ -302,3 +302,129 @@ fn configuracoes_de_tema_persistem() {
|
||||
.unwrap();
|
||||
assert_eq!(settings::load().theme, "dark");
|
||||
}
|
||||
|
||||
fn wait_done(eng: &service::Engine, job: &str) {
|
||||
for _ in 0..500 {
|
||||
if eng.status(job).unwrap()["status"] != "running" {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
panic!("job não terminou");
|
||||
}
|
||||
|
||||
/// Gera uma sessão no formato da v0.1.0 (sem os campos que vieram depois) a partir de uma
|
||||
/// varredura real da imagem, com o alvo informado.
|
||||
fn sessao_formato_v0_1_0(img: &std::path::Path, alvo: &str) -> tempfile::NamedTempFile {
|
||||
let eng = service::Engine::new();
|
||||
let job = eng
|
||||
.start_scan(img.to_str().unwrap(), ScanOptions::default())
|
||||
.unwrap();
|
||||
wait_done(&eng, &job);
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
eng.save_session(&job, tmp.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
let mut v: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(tmp.path()).unwrap()).unwrap();
|
||||
let o = v.as_object_mut().unwrap();
|
||||
o.remove("recoveries");
|
||||
o.remove("started_unix");
|
||||
o.insert("target".into(), alvo.into());
|
||||
for f in o["found"].as_array_mut().unwrap() {
|
||||
let f = f.as_object_mut().unwrap();
|
||||
for k in ["created", "accessed", "taken", "camera", "px", "gps", "sig"] {
|
||||
f.remove(k);
|
||||
}
|
||||
}
|
||||
std::fs::write(tmp.path(), serde_json::to_vec(&v).unwrap()).unwrap();
|
||||
tmp
|
||||
}
|
||||
|
||||
/// Uma sessão gravada pela v0.1.0 continua abrindo nas versões novas.
|
||||
#[test]
|
||||
fn sessao_da_v0_1_0_abre_e_funciona() {
|
||||
let Some(dir) = fixtures() else { return };
|
||||
let img = dir.join("ntfs.img");
|
||||
let 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();
|
||||
wait_done(&eng, &job);
|
||||
let st = eng.status(&job).unwrap();
|
||||
assert_eq!(st["status"], "done");
|
||||
assert_eq!(st["found"], 7);
|
||||
assert_eq!(st["source"], true);
|
||||
let res = eng.results(&job, &service::Query::default()).unwrap();
|
||||
assert_eq!(res["total"], 7);
|
||||
// recuperar a partir da sessão antiga continua dando o mesmo conteúdo
|
||||
let out = tempfile::tempdir().unwrap();
|
||||
let rj = eng
|
||||
.start_recover(
|
||||
&job,
|
||||
None,
|
||||
Some(service::Query::default()),
|
||||
out.path().to_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
wait_done(&eng, &rj);
|
||||
let got = hashes_in(out.path());
|
||||
for (h, p) in expected(&dir, "ntfs") {
|
||||
assert!(got.contains_key(&h), "faltou {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Origem indisponível não impede abrir a sessão; reapontar a origem religa tudo.
|
||||
#[test]
|
||||
fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
|
||||
let Some(dir) = fixtures() else { return };
|
||||
let img = dir.join("ntfs.img");
|
||||
let tmp = sessao_formato_v0_1_0(&img, "/dev/disco-que-nao-existe");
|
||||
let eng = service::Engine::new();
|
||||
let job = eng.load_session(tmp.path().to_str().unwrap()).unwrap();
|
||||
let st = eng.status(&job).unwrap();
|
||||
assert_eq!(st["source"], false);
|
||||
assert_eq!(st["found"], 7);
|
||||
assert!(st["warnings"][0]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("não está disponível"));
|
||||
// navegar e exportar funcionam
|
||||
assert_eq!(
|
||||
eng.results(&job, &service::Query::default()).unwrap()["total"],
|
||||
7
|
||||
);
|
||||
let csv = tempfile::NamedTempFile::new().unwrap();
|
||||
assert_eq!(
|
||||
eng.export_csv(&job, csv.path().to_str().unwrap(), None)
|
||||
.unwrap(),
|
||||
7
|
||||
);
|
||||
// pré-visualização, retomada e recuperação avisam com clareza
|
||||
assert_eq!(eng.thumb(&job, 1, 128).err(), Some(503));
|
||||
assert!(eng
|
||||
.resume(&job)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("não está disponível"));
|
||||
let out = tempfile::tempdir().unwrap();
|
||||
let e = eng
|
||||
.start_recover(&job, None, None, out.path().to_str().unwrap())
|
||||
.unwrap_err();
|
||||
assert!(e.to_string().contains("não está disponível"), "{e}");
|
||||
// reconecta
|
||||
eng.relink(&job, img.to_str().unwrap()).unwrap();
|
||||
let st = eng.status(&job).unwrap();
|
||||
assert_eq!(st["source"], true);
|
||||
assert!(st["warnings"].as_array().unwrap().is_empty());
|
||||
let rj = eng
|
||||
.start_recover(&job, None, None, out.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
wait_done(&eng, &rj);
|
||||
assert_eq!(hashes_in(out.path()).len(), 7);
|
||||
// arquivo que não é sessão
|
||||
std::fs::write(tmp.path(), b"nao e json").unwrap();
|
||||
let e = eng.load_session(tmp.path().to_str().unwrap()).unwrap_err();
|
||||
assert!(
|
||||
e.to_string().contains("não é uma sessão do Guará válida"),
|
||||
"{e}"
|
||||
);
|
||||
}
|
||||
@@ -473,6 +473,7 @@ fn facetas_filtros_e_duplicadas() {
|
||||
mk(3, "c.jpg", Some("Viagens"), Some(jul23), None, Some(7)), // duplicada de a.jpg
|
||||
mk(4, "d.jpg", None, None, None, None),
|
||||
];
|
||||
let q = |json: &str| -> Query { serde_json::from_str(json).unwrap() };
|
||||
let f = explorer::facets(&found);
|
||||
assert_eq!(f["total"], 4);
|
||||
assert_eq!(f["years"][0]["year"], 2024);
|
||||
@@ -481,6 +482,8 @@ fn facetas_filtros_e_duplicadas() {
|
||||
assert_eq!(f["undated"], 1);
|
||||
assert_eq!(f["carved"], 1);
|
||||
assert_eq!(f["duplicates"], 1);
|
||||
assert_eq!(f["small"], 4); // todos têm 10 bytes
|
||||
assert_eq!(q(r#"{"min_size":30720}"#).select(&found).len(), 0);
|
||||
assert_eq!(f["cameras"][0]["name"], "Canon");
|
||||
let dcim = f["folders"]
|
||||
.as_array()
|
||||
@@ -490,7 +493,6 @@ fn facetas_filtros_e_duplicadas() {
|
||||
.unwrap();
|
||||
assert_eq!(dcim["count"], 2);
|
||||
|
||||
let q = |json: &str| -> Query { serde_json::from_str(json).unwrap() };
|
||||
assert_eq!(q(r#"{"year":2023}"#).select(&found).len(), 2);
|
||||
assert_eq!(q(r#"{"year":2023,"month":7}"#).select(&found).len(), 2);
|
||||
assert_eq!(q(r#"{"year":2023,"month":8}"#).select(&found).len(), 0);
|
||||
@@ -505,3 +507,49 @@ fn facetas_filtros_e_duplicadas() {
|
||||
assert_eq!(items[0].id, 2); // mais recente primeiro
|
||||
assert_eq!(items.last().unwrap().id, 4); // sem data por último
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ carving
|
||||
|
||||
fn png_chunk(ty: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut c = (data.len() as u32).to_be_bytes().to_vec();
|
||||
c.extend(ty);
|
||||
c.extend(data);
|
||||
c.extend([0, 0, 0, 0]);
|
||||
c
|
||||
}
|
||||
|
||||
/// Regressão: texto ASCII depois de um PNG truncado vira "tamanho" de ~1,9 GB;
|
||||
/// o carving não pode inventar um PNG gigante.
|
||||
#[test]
|
||||
fn png_truncado_seguido_de_texto_nao_vira_arquivo_gigante() {
|
||||
let mut d = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
d.extend(png_chunk(b"IHDR", &[0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]));
|
||||
d.extend(png_chunk(b"IDAT", &vec![7u8; 5000]));
|
||||
let real_end = d.len();
|
||||
d.extend(b"ualiBase64 texto qualquer que segue no disco, como parte de outro arquivo html ");
|
||||
d.resize(4 * 1024 * 1024, 0x41);
|
||||
let src = MemSource(d.clone());
|
||||
let hit =
|
||||
signatures::detect(&src, 0, &d[..64], 8 << 30).expect("PNG truncado ainda é reconhecido");
|
||||
assert_eq!(
|
||||
hit.len as usize, real_end,
|
||||
"deve parar no fim do último bloco válido"
|
||||
);
|
||||
assert_eq!(hit.confidence, Confidence::Low);
|
||||
|
||||
// sem nenhum dado de imagem (só cabeçalho) não é arquivo
|
||||
let mut d2 = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
d2.extend(png_chunk(b"IHDR", &[0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]));
|
||||
d2.resize(1 << 20, b'e');
|
||||
assert!(signatures::detect(&MemSource(d2.clone()), 0, &d2[..64], 8 << 30).is_none());
|
||||
|
||||
// PNG completo continua com confiança alta e tamanho exato
|
||||
let mut ok = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
ok.extend(png_chunk(b"IHDR", &[0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0]));
|
||||
ok.extend(png_chunk(b"IDAT", &[1; 100]));
|
||||
ok.extend(png_chunk(b"IEND", &[]));
|
||||
let n = ok.len();
|
||||
ok.resize(1 << 20, 0);
|
||||
let h = signatures::detect(&MemSource(ok.clone()), 0, &ok[..64], 8 << 30).unwrap();
|
||||
assert_eq!((h.len as usize, h.confidence), (n, Confidence::High));
|
||||
}
|
||||
Reference in new issue
Block a user