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

+9
View File
@@ -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,
})
}
+3
View File
@@ -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 {
+13 -3
View File
@@ -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,
})
+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(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));
}