//! Forensic features: multi-hash, metadata extraction (EXIF/PDF/Office/MP4), thumbnails, //! full-disk text search, CSV/HTML exports, and explorer facets/deduplication. use guara_core::explorer::{self, Query}; use guara_core::hashing::hash_bytes; use guara_core::source::MemSource; use guara_core::*; use std::io::Write; fn resident(name: &str, bytes: Vec) -> Found { let ext = name.rsplit('.').next().unwrap_or("").to_string(); Found { id: 1, name: name.into(), dir: None, category: signatures::category_for_ext(&ext), ext, size: bytes.len() as u64, origin: Origin::Carved, confidence: Confidence::High, data: DataLoc::Resident { bytes }, modified: None, created: None, accessed: None, taken: None, camera: None, px: None, gps: false, sig: None, note: None, } } // ------------------------------------------------------------------ hashes #[test] fn hashes_conferem_com_vetores_conhecidos() { let h = hash_bytes(b"abc"); assert_eq!(h.md5, "900150983cd24fb0d6963f7d28e17f72"); assert_eq!(h.sha1, "a9993e364706816aba3e25717850c26c9cd0d89d"); assert_eq!( h.sha256, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" ); } // ------------------------------------------------------------------ EXIF fn ifd(entries: &[(u16, u16, u32, Vec)], start: usize) -> Vec { let n = entries.len(); let mut table = (n as u16).to_le_bytes().to_vec(); let mut data: Vec = vec![]; let data_off = start + 2 + 12 * n + 4; for (tag, typ, cnt, val) in entries { table.extend(tag.to_le_bytes()); table.extend(typ.to_le_bytes()); table.extend(cnt.to_le_bytes()); if val.len() <= 4 { let mut v = val.clone(); v.resize(4, 0); table.extend(v); } else { if data.len() % 2 == 1 { data.push(0); } table.extend(((data_off + data.len()) as u32).to_le_bytes()); data.extend(val); } } table.extend([0, 0, 0, 0]); table.extend(data); table } fn rat(p: &[(u32, u32)]) -> Vec { p.iter() .flat_map(|(a, b)| a.to_le_bytes().into_iter().chain(b.to_le_bytes())) .collect() } fn jpeg_with_exif(orientation: u16, w: usize, h: usize) -> Vec { // gradient pixel pattern let mut px = Vec::with_capacity(w * h * 3); for y in 0..h { for x in 0..w { px.extend([(x * 255 / w) as u8, (y * 255 / h) as u8, 128]); } } let mut jpg = vec![]; jpeg_encoder::Encoder::new(&mut jpg, 85) .encode(&px, w as u16, h as u16, jpeg_encoder::ColorType::Rgb) .unwrap(); let asc = |s: &str| { let mut v = s.as_bytes().to_vec(); v.push(0); v }; let make = asc("Canon"); let model = asc("Canon EOS 80D"); let dt = asc("2023:07:14 18:22:05"); let e0 = |exif: u32, gps: u32| { ifd( &[ (0x010F, 2, make.len() as u32, make.clone()), (0x0110, 2, model.len() as u32, model.clone()), (0x0112, 3, 1, orientation.to_le_bytes().to_vec()), (0x8769, 4, 1, exif.to_le_bytes().to_vec()), (0x8825, 4, 1, gps.to_le_bytes().to_vec()), ], 8, ) }; let first = e0(0, 0); let exif_off = 8 + first.len(); let exif_ifd = ifd(&[(0x9003, 2, dt.len() as u32, dt.clone())], exif_off); let gps_off = exif_off + exif_ifd.len(); let gps_ifd = ifd( &[ (1, 2, 2, b"S\0".to_vec()), (2, 5, 3, rat(&[(23, 1), (33, 1), (1800, 100)])), (3, 2, 2, b"W\0".to_vec()), (4, 5, 3, rat(&[(46, 1), (38, 1), (0, 1)])), ], gps_off, ); let mut tiff = b"II*\0".to_vec(); tiff.extend(8u32.to_le_bytes()); tiff.extend(e0(exif_off as u32, gps_off as u32)); tiff.extend(exif_ifd); tiff.extend(gps_ifd); let mut app1 = b"Exif\0\0".to_vec(); app1.extend(tiff); let mut out = vec![0xFF, 0xD8, 0xFF, 0xE1]; out.extend(((app1.len() + 2) as u16).to_be_bytes()); out.extend(app1); out.extend(&jpg[2..]); out } #[test] fn exif_camera_data_e_gps() { let f = resident("foto.jpg", jpeg_with_exif(1, 64, 48)); let m = meta::extract(&MemSource(vec![]), &f); assert_eq!(m.camera.as_deref(), Some("Canon EOS 80D")); assert_eq!(m.px, Some((64, 48))); // `2023:07:14 18:22:05` interpreted as UTC assert_eq!(m.taken, Some(1_689_358_925)); let (lat, lon) = m.gps.unwrap(); assert!((lat + 23.5550).abs() < 0.001, "{lat}"); assert!((lon + 46.6333).abs() < 0.001, "{lon}"); assert!(m .fields .iter() .any(|(k, v)| k == "Foto tirada em" && v == "2023-07-14T18:22:05")); } #[test] fn miniatura_respeita_orientacao_e_tamanho() { let src = MemSource(vec![]); // 64x48 with EXIF orientation 6 (90° CW) -> portrait 48x64, scaled down to fit within 32px let f = resident("foto.jpg", jpeg_with_exif(6, 64, 48)); let t = thumb::thumbnail(&src, &f, 32).expect("miniatura"); let mut dec = jpeg_decoder::Decoder::new(&t[..]); dec.decode().unwrap(); let i = dec.info().unwrap(); assert_eq!((i.width, i.height), (24, 32)); // PNG thumbnail generation let mut img = ::image::RgbImage::new(100, 50); for p in img.pixels_mut() { *p = ::image::Rgb([200, 30, 30]); } let mut png = std::io::Cursor::new(vec![]); img.write_to(&mut png, ::image::ImageFormat::Png).unwrap(); let f = resident("x.png", png.into_inner()); let t = thumb::thumbnail(&src, &f, 40).expect("miniatura png"); let mut dec = jpeg_decoder::Decoder::new(&t[..]); dec.decode().unwrap(); assert_eq!( (dec.info().unwrap().width, dec.info().unwrap().height), (40, 20) ); // random bytes return `None` assert!(thumb::thumbnail(&src, &resident("x.jpg", vec![1, 2, 3, 4, 5]), 32).is_none()); } // ------------------------------------------------------------------ PDF, Office, MP4 #[test] fn metadados_de_pdf() { let pdf = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Count 12 /Kids [] >>\nendobj\n3 0 obj\n<< /Title (Relat\\363rio Final) /Author (Maria \\(Perita\\) Silva) /Creator (LibreOffice Writer) /Producer /CreationDate (D:20230714182205-03'00') >>\nendobj\ntrailer\n%%EOF\n".to_vec(); let m = meta::extract(&MemSource(vec![]), &resident("a.pdf", pdf)); let get = |k: &str| { m.fields .iter() .find(|(a, _)| a == k) .map(|(_, v)| v.clone()) }; assert_eq!(get("Título").as_deref(), Some("Relatório Final")); assert_eq!(get("Autor").as_deref(), Some("Maria (Perita) Silva")); assert_eq!(get("Criado com").as_deref(), Some("LibreOffice Writer")); assert_eq!(get("Gerado por").as_deref(), Some("PDF12")); assert_eq!(get("Criado em").as_deref(), Some("2023-07-14T18:22:05")); assert_eq!(get("Páginas (aprox.)").as_deref(), Some("12")); } fn zip_store(entries: &[(&str, &str)], deflate: bool) -> Vec { let mut out = vec![]; let mut cd = vec![]; for (name, body) in entries { let (method, data): (u16, Vec) = if deflate { let mut e = flate2::write::DeflateEncoder::new(vec![], flate2::Compression::default()); e.write_all(body.as_bytes()).unwrap(); (8, e.finish().unwrap()) } else { (0, body.as_bytes().to_vec()) }; let off = out.len() as u32; let crc = signatures::crc32(body.as_bytes()); let mut lh = b"PK\x03\x04".to_vec(); lh.extend([20, 0, 0, 0]); lh.extend(method.to_le_bytes()); lh.extend([0, 0, 0, 0]); lh.extend(crc.to_le_bytes()); lh.extend((data.len() as u32).to_le_bytes()); lh.extend((body.len() as u32).to_le_bytes()); lh.extend((name.len() as u16).to_le_bytes()); lh.extend([0, 0]); lh.extend(name.as_bytes()); out.extend(&lh); out.extend(&data); let mut c = b"PK\x01\x02".to_vec(); c.extend([20, 0, 20, 0, 0, 0]); c.extend(method.to_le_bytes()); c.extend([0, 0, 0, 0]); c.extend(crc.to_le_bytes()); c.extend((data.len() as u32).to_le_bytes()); c.extend((body.len() as u32).to_le_bytes()); c.extend((name.len() as u16).to_le_bytes()); c.extend([0; 12]); c.extend(off.to_le_bytes()); c.extend(name.as_bytes()); cd.extend(c); } let cd_off = out.len() as u32; out.extend(&cd); out.extend(b"PK\x05\x06\0\0\0\0"); out.extend((entries.len() as u16).to_le_bytes()); out.extend((entries.len() as u16).to_le_bytes()); out.extend((cd.len() as u32).to_le_bytes()); out.extend(cd_off.to_le_bytes()); out.extend([0, 0]); out } #[test] fn metadados_de_documentos_office() { let core = r#"Tese & DefesaAna SouzaBruno Lima2023-07-14T18:22:05Z"#; let app = "Microsoft Office WordACME"; for deflate in [false, true] { let z = zip_store( &[ ("[Content_Types].xml", ""), ("docProps/core.xml", core), ("docProps/app.xml", app), ], deflate, ); let m = meta::extract(&MemSource(vec![]), &resident("t.docx", z)); let get = |k: &str| { m.fields .iter() .find(|(a, _)| a == k) .map(|(_, v)| v.clone()) }; assert_eq!( get("Título").as_deref(), Some("Tese & Defesa"), "deflate={deflate}" ); assert_eq!(get("Autor").as_deref(), Some("Ana Souza")); assert_eq!(get("Última modificação por").as_deref(), Some("Bruno Lima")); assert_eq!(get("Criado em").as_deref(), Some("2023-07-14T18:22:05Z")); assert_eq!(get("Aplicativo").as_deref(), Some("Microsoft Office Word")); assert_eq!(get("Empresa").as_deref(), Some("ACME")); } } #[test] fn metadados_de_mp4() { fn bx(ty: &[u8; 4], body: &[u8]) -> Vec { let mut v = ((body.len() + 8) as u32).to_be_bytes().to_vec(); v.extend(ty); v.extend(body); v } let created_1904: u32 = 1_689_358_925 + 2_082_844_800; let mut mvhd = vec![0, 0, 0, 0]; mvhd.extend(created_1904.to_be_bytes()); mvhd.extend(created_1904.to_be_bytes()); mvhd.extend(1000u32.to_be_bytes()); // timescale mvhd.extend(125_000u32.to_be_bytes()); // 125 s mvhd.extend(vec![0u8; 80]); let mut tkhd = vec![0u8; 84]; tkhd[76..80].copy_from_slice(&(1920u32 << 16).to_be_bytes()); tkhd[80..84].copy_from_slice(&(1080u32 << 16).to_be_bytes()); let moov = bx( b"moov", &[bx(b"mvhd", &mvhd), bx(b"trak", &bx(b"tkhd", &tkhd))].concat(), ); let mut file = bx(b"ftyp", b"isom\0\0\0\0isom"); file.extend(moov); file.extend(bx(b"mdat", &[0u8; 32])); let m = meta::extract(&MemSource(vec![]), &resident("v.mp4", file)); let get = |k: &str| { m.fields .iter() .find(|(a, _)| a == k) .map(|(_, v)| v.clone()) }; assert_eq!(get("Duração").as_deref(), Some("0:02:05")); assert_eq!( get("Gravado em (metadado)").as_deref(), Some("2023-07-14T18:22:05Z") ); assert_eq!(get("Dimensões").as_deref(), Some("1920 × 1080 px")); } // ------------------------------------------------------------------ text search #[test] fn busca_acha_utf8_utf16_e_na_fronteira_dos_blocos() { let mb = 1024 * 1024; let mut disk = vec![0u8; 20 * mb]; let put = |d: &mut Vec, at: usize, b: &[u8]| d[at..at + b.len()].copy_from_slice(b); put(&mut disk, 1000, b"... Segredo-123 ..."); let u16s: Vec = "segredo-123" .encode_utf16() .flat_map(|c| c.to_le_bytes()) .collect(); put(&mut disk, 3 * mb + 6, &u16s); // string placed right across the 8 MiB chunk boundary put(&mut disk, 8 * mb - 4, b"SEGREDO-123"); let opts = search::SearchOpts { terms: vec!["segredo-123".into()], ignore_case: true, utf16: true, max_hits: 100, }; let mut hits = vec![]; let n = search::search( &MemSource(disk.clone()), &opts, &|| false, &mut |_, _| {}, &mut |h| { hits.push(h); true }, ); assert_eq!(n, 3, "{hits:?}"); assert_eq!(hits[0].offset, 1004); assert_eq!(hits[0].encoding, "UTF-8"); assert!(hits[0].context.contains("Segredo-123")); assert_eq!(hits[1].offset, 3 * mb as u64 + 6); assert_eq!(hits[1].encoding, "UTF-16"); assert_eq!(hits[2].offset, 8 * mb as u64 - 4); // case-sensitive mode matches only exact casing let opts = search::SearchOpts { terms: vec!["Segredo-123".into()], ignore_case: false, utf16: false, max_hits: 100, }; let n = search::search( &MemSource(disk), &opts, &|| false, &mut |_, _| {}, &mut |_| true, ); assert_eq!(n, 1); } // ------------------------------------------------------------------ exports #[test] fn csv_escapa_e_neutraliza_formulas() { let mut f = resident("=SOMA(A1).xlsx", vec![1]); f.note = Some("diz \"oi\"; ok".into()); f.camera = Some("Cam;X".into()); let csv = report::csv(&[&f]); assert!(csv.starts_with('\u{feff}')); assert!(csv.contains("\"'=SOMA(A1).xlsx\""), "{csv}"); assert!(csv.contains("\"diz \"\"oi\"\"; ok\"")); assert_eq!(csv.lines().count(), 2); } #[test] fn relatorio_html_escapa_conteudo_e_traz_o_caso() { let f = resident(".txt", vec![1, 2, 3]); let case = report::CaseInfo { case_id: "2026-001".into(), examiner: "Perita ".into(), organization: "Lab".into(), notes: "obs".into(), }; let opts = ScanOptions::default(); let hashes = hash_bytes(b"disco"); let html = report::html(&report::ReportInput { case: &case, target: "/dev/sdb", options: &opts, layout: None, found: &[f], warnings: &["aviso ".to_string()], started_unix: Some(1_689_358_925), elapsed: 1.5, recoveries: &[], source_hashes: Some(&hashes), audit: Some(report::AuditNote { entries: 3, ok: true, head: "ab".repeat(32), }), }); assert!(!html.contains("