diff --git a/CHANGELOG.md b/CHANGELOG.md index 5183b67..8316f52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,15 @@ Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); ## [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 diff --git a/apps/gui/ui/explorer.js b/apps/gui/ui/explorer.js index 3f2adce..78ebc6c 100644 --- a/apps/gui/ui/explorer.js +++ b/apps/gui/ui/explorer.js @@ -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; }; @@ -424,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 = `
${node('Tudo', f.total, 'data-f="all"', !anyFilter())}
` @@ -450,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; @@ -458,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(); } @@ -493,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('') + '' : ''; } @@ -500,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; diff --git a/crates/guara-core/src/explorer.rs b/crates/guara-core/src/explorer.rs index 98112f5..ec0cc8d 100644 --- a/crates/guara-core/src/explorer.rs +++ b/crates/guara-core/src/explorer.rs @@ -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, }) } diff --git a/crates/guara-core/src/service.rs b/crates/guara-core/src/service.rs index c41eef6..ef665d3 100644 --- a/crates/guara-core/src/service.rs +++ b/crates/guara-core/src/service.rs @@ -217,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 { diff --git a/crates/guara-core/src/signatures.rs b/crates/guara-core/src/signatures.rs index 2179fd6..6bd4020 100644 --- a/crates/guara-core/src/signatures.rs +++ b/crates/guara-core/src/signatures.rs @@ -219,8 +219,10 @@ fn jpeg(src: &dyn Source, off: u64, max: u64) -> Option { // ---------------------------------------------------------------- PNG fn png(src: &dyn Source, off: u64, max: u64) -> Option { + 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 { } 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 { }); } } - 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, }) diff --git a/crates/guara-core/tests/forensics.rs b/crates/guara-core/tests/forensics.rs index 9d0de30..6eed391 100644 --- a/crates/guara-core/tests/forensics.rs +++ b/crates/guara-core/tests/forensics.rs @@ -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 { + 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)); +}