fix: PNG truncado não vira arquivo gigante; filtros de tamanho e megapixels

Descoberto ao abrir uma sessão real: texto ASCII após um PNG truncado era lido
como tamanho de bloco (~1,9 GB). Também adiciona filtros para separar fotos de
ícones e falha cedo ao retomar sem a origem.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-18 16:44:03 -03:00
1 parent 354caaf8f0
commit b9bef85bc7
6 files changed
+90 -7

No files matched your search

+5
View File
@@ -4,10 +4,15 @@ Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/);
## [0.3.1] - 2026-10-18 ## [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 ### 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. - 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. - 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. - 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. - 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 ## [0.3.0] - 2026-10-18
+11 -3
View File
@@ -32,7 +32,7 @@ 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 E = { 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', sort: 'date',
view: 'grid', view: 'grid',
tile: 150, tile: 150,
@@ -95,6 +95,8 @@ export function createExplorer(deps) {
if (f.minConf) q.min_confidence = f.minConf; if (f.minConf) q.min_confidence = f.minConf;
if (f.gps) q.has_gps = true; if (f.gps) q.has_gps = true;
if (f.dedupe) q.dedupe = 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; if (f.query) q.query = f.query;
return q; 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 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 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) : '') 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) : ''); + (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>` const html = `<div class="xp-sec">${node('Tudo', f.total, 'data-f="all"', !anyFilter())}</div>`
@@ -450,6 +454,8 @@ export function createExplorer(deps) {
else if (f === 'undated') { F.undated = !F.undated; F.year = F.month = null; } else if (f === 'undated') { F.undated = !F.undated; F.year = F.month = null; }
else if (f === 'gps') F.gps = !F.gps; else if (f === 'gps') F.gps = !F.gps;
else if (f === 'dedupe') F.dedupe = !F.dedupe; 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; else F[f] = F[f] === v ? null : v;
if (f === 'folder' && F.folder && !F.folder.startsWith('@')) E.expanded.add('d' + F.folder); if (f === 'folder' && F.folder && !F.folder.startsWith('@')) E.expanded.add('d' + F.folder);
nav._h = null; nav._h = null;
@@ -458,7 +464,7 @@ export function createExplorer(deps) {
}); });
function clearFilters(refresh = true) { 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 = ''; $('#xpQ').value = '';
if (refresh) changed(); else changed(); 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.minConf) add(`Confiança ${CONF[F.minConf][0]}`, 'minConf');
if (F.gps) add('Com GPS', 'gps'); if (F.gps) add('Com GPS', 'gps');
if (F.dedupe) add('Sem duplicadas', 'dedupe'); 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'); if (F.query) add(`“${esc(F.query)}”`, 'query');
$('#xpChips').innerHTML = chips.length ? chips.join('') + '<button class="btn sm" data-x="all">Limpar filtros</button>' : ''; $('#xpChips').innerHTML = chips.length ? chips.join('') + '<button class="btn sm" data-x="all">Limpar filtros</button>' : '';
} }
@@ -500,7 +508,7 @@ export function createExplorer(deps) {
const k = e.target.closest('[data-x]')?.dataset.x; const k = e.target.closest('[data-x]')?.dataset.x;
if (!k) return; if (!k) return;
if (k === 'all') return clearFilters(); 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 === 'year') E.filters.month = null;
if (k === 'query') $('#xpQ').value = ''; if (k === 'query') $('#xpQ').value = '';
nav._h = null; nav._h = null;
+9
View File
@@ -180,7 +180,14 @@ pub fn facets(found: &[Found]) -> Value {
let mut conf = [0usize; 3]; let mut conf = [0usize; 3];
let mut seen_sig = std::collections::HashSet::new(); let mut seen_sig = std::collections::HashSet::new();
let mut dups = 0usize; let mut dups = 0usize;
let (mut small, mut mp1) = (0usize, 0usize);
for f in found { 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 let Some(g) = f.sig {
if !seen_sig.insert((g, f.size)) { if !seen_sig.insert((g, f.size)) {
dups += 1; dups += 1;
@@ -242,6 +249,8 @@ pub fn facets(found: &[Found]) -> Value {
"root": root, "root": root,
"gps": gps, "gps": gps,
"duplicates": dups, "duplicates": dups,
"small": small,
"mp1": mp1,
}) })
} }
+3
View File
@@ -217,6 +217,9 @@ 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() {
return Err(no_source());
}
let cp = { let cp = {
let mut s = j.st.lock().unwrap(); let mut s = j.st.lock().unwrap();
if s.status != Status::Paused { if s.status != Status::Paused {
+13 -3
View File
@@ -219,8 +219,10 @@ fn jpeg(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
// ---------------------------------------------------------------- PNG // ---------------------------------------------------------------- PNG
fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> { fn png(src: &dyn Source, off: u64, max: u64) -> Option<Hit> {
let total = src.size().saturating_sub(off);
let mut p = 8u64; let mut p = 8u64;
let mut first = true; let mut first = true;
let mut saw_idat = false;
for _ in 0..200_000 { for _ in 0..200_000 {
let c = read_vec(src, off + p, 8).ok()?; let c = read_vec(src, off + p, 8).ok()?;
if c.len() < 8 { 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 len = u32::from_be_bytes([c[0], c[1], c[2], c[3]]) as u64;
let ty = &c[4..8]; 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; break;
} }
if first && ty != b"IHDR" { if first && ty != b"IHDR" {
return None; return None;
} }
first = false; first = false;
saw_idat |= ty == b"IDAT";
p += 12 + len; p += 12 + len;
if p > max { if p > max {
break; 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; return None;
} }
Some(Hit { Some(Hit {
len: p.min(max).min(src.size().saturating_sub(off)), len: p,
ext: "png", ext: "png",
confidence: Confidence::Low, confidence: Confidence::Low,
}) })
+49 -1
View File
@@ -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(3, "c.jpg", Some("Viagens"), Some(jul23), None, Some(7)), // duplicada de a.jpg
mk(4, "d.jpg", None, None, None, None), mk(4, "d.jpg", None, None, None, None),
]; ];
let q = |json: &str| -> Query { serde_json::from_str(json).unwrap() };
let f = explorer::facets(&found); let f = explorer::facets(&found);
assert_eq!(f["total"], 4); assert_eq!(f["total"], 4);
assert_eq!(f["years"][0]["year"], 2024); assert_eq!(f["years"][0]["year"], 2024);
@@ -481,6 +482,8 @@ fn facetas_filtros_e_duplicadas() {
assert_eq!(f["undated"], 1); assert_eq!(f["undated"], 1);
assert_eq!(f["carved"], 1); assert_eq!(f["carved"], 1);
assert_eq!(f["duplicates"], 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"); assert_eq!(f["cameras"][0]["name"], "Canon");
let dcim = f["folders"] let dcim = f["folders"]
.as_array() .as_array()
@@ -490,7 +493,6 @@ fn facetas_filtros_e_duplicadas() {
.unwrap(); .unwrap();
assert_eq!(dcim["count"], 2); 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}"#).select(&found).len(), 2);
assert_eq!(q(r#"{"year":2023,"month":7}"#).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); 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[0].id, 2); // mais recente primeiro
assert_eq!(items.last().unwrap().id, 4); // sem data por último 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));
}