//! XFS: real `mkfs.xfs` image + simulated unlink (freed inode with leftover extents and freed //! directory entry, matching XFS v4/unwiped behavior) plus the `dfvfs` reference image. mod common; use common::*; use guara_core::*; use std::path::PathBuf; use std::process::Command; fn have(t: &str) -> bool { Command::new("sh") .args(["-c", &format!("command -v {t}")]) .output() .map(|o| o.status.success()) .unwrap_or(false) } fn data(seed: u64, n: usize) -> Vec { let mut x = seed | 1; (0..n) .map(|_| { x ^= x << 13; x ^= x >> 7; x ^= x << 17; (x >> 24) as u8 }) .collect() } fn be16(b: &[u8], o: usize) -> u16 { u16::from_be_bytes([b[o], b[o + 1]]) } fn be32(b: &[u8], o: usize) -> u32 { u32::from_be_bytes(b[o..o + 4].try_into().unwrap()) } fn be64(b: &[u8], o: usize) -> u64 { u64::from_be_bytes(b[o..o + 8].try_into().unwrap()) } fn unpack(r: &[u8]) -> (u64, u64, u64) { let hi = be64(r, 0); let lo = be64(r, 8); ( (hi >> 9) & ((1 << 54) - 1), ((hi & 0x1FF) << 43) | (lo >> 21), lo & 0x1F_FFFF, ) } #[test] fn deleted_file_is_recovered_from_free_inode_and_stale_directory_entry() { if !(have("mkfs.xfs") && have("xfs_db")) { eprintln!("SKIP: xfsprogs ausente"); return; } init(); let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("src/docs"); std::fs::create_dir_all(&src).unwrap(); let doc = data(5, 150_000); std::fs::write(src.join("relatorio.dat"), &doc).unwrap(); for i in 1..=40 { std::fs::write(src.join(format!("a_longa_{i}.txt")), format!("arquivo {i}")).unwrap(); } let mut proto = String::from("/dev/null\n0 0\nd--755 0 0\ndocs d--755 0 0\n"); let mut names: Vec<_> = std::fs::read_dir(&src) .unwrap() .flatten() .map(|e| e.file_name().to_string_lossy().to_string()) .collect(); names.sort(); for n in &names { proto.push_str(&format!(" {n} ---644 0 0 {}\n", src.join(n).display())); } proto.push_str(" $\n$\n"); let pf = dir.path().join("proto"); std::fs::write(&pf, proto).unwrap(); let img = dir.path().join("x.img"); std::fs::File::create(&img) .unwrap() .set_len(320 << 20) .unwrap(); let o = Command::new("mkfs.xfs") .args(["-q", "-f", "-p"]) .arg(&pf) .arg(&img) .output() .unwrap(); assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); let db = |cmds: &[&str]| -> String { let mut c = Command::new("xfs_db"); c.arg("-r"); for x in cmds { c.args(["-c", x]); } c.arg(&img); String::from_utf8_lossy(&c.output().unwrap().stdout).to_string() }; let nc = db(&["blockget -n", "ncheck"]); let ino: u64 = nc .lines() .find(|l| l.ends_with("docs/relatorio.dat")) .unwrap() .split_whitespace() .next() .unwrap() .parse() .unwrap(); let conv = db(&[&format!("convert ino {ino} byte")]); let ino_off = usize::from_str_radix( conv.split("0x") .nth(1) .unwrap() .split_whitespace() .next() .unwrap() .trim_end_matches(')'), 16, ) .unwrap(); let sbv = db(&["sb 0", "p agblocks agblklog inopblog"]); let num = |k: &str| -> u64 { sbv.lines() .find(|l| l.starts_with(k)) .unwrap() .split('=') .nth(1) .unwrap() .trim() .parse() .unwrap() }; let (agblocks, agblklog, inopblog) = (num("agblocks"), num("agblklog"), num("inopblog")); let mut b = std::fs::read(&img).unwrap(); let bs = 4096usize; // read the file's inline extent list before simulating deletion assert_eq!(b[ino_off + 5], 2); let nex = be32(&b[ino_off..], 76) as usize; let exts: Vec<_> = (0..nex) .map(|i| unpack(&b[ino_off + 176 + i * 16..])) .collect(); assert!(exts.iter().map(|e| e.2).sum::() * 4096 >= 150_000); // 1) free the inode (mode = 0, nlink = 0, size = 0, nextents = 0) while leaving fork bytes b[ino_off + 2..ino_off + 4].fill(0); b[ino_off + 16..ino_off + 20].fill(0); b[ino_off + 56..ino_off + 64].fill(0); b[ino_off + 76..ino_off + 80].fill(0); // 2) mark the inode bit as free in the AGI `inobt` root leaf let agno = (ino >> (agblklog + inopblog)) as usize; let ag_off = agno * agblocks as usize * bs; let agi = &b[ag_off + 2 * 512..ag_off + 2 * 512 + 64]; let (iroot, ilevel) = (be32(agi, 20) as usize, be32(agi, 24)); assert_eq!(ilevel, 1); let blk = ag_off + iroot * bs; let n = be16(&b[blk..], 6) as usize; let agino = ino & ((1 << (agblklog + inopblog)) - 1); let mut done = false; for i in 0..n { let r = blk + 56 + i * 16; let start = be32(&b[r..], 0) as u64; if agino >= start && agino < start + 64 { let mask = be64(&b[r..], 8) | (1u64 << (agino - start)); b[r + 8..r + 16].copy_from_slice(&mask.to_be_bytes()); let fc = be32(&b[r..], 4) + 1; b[r + 4..r + 8].copy_from_slice(&fc.to_be_bytes()); done = true; } } assert!(done); // 3) return the file's data blocks to the AGF `bnobt` free-space B+tree leaf let agf = &b[ag_off + 512..ag_off + 512 + 64].to_vec(); let (broot, blevel) = (be32(agf, 16) as usize, be32(agf, 28)); assert_eq!(blevel, 1); let bb = ag_off + broot * bs; let nrec = be16(&b[bb..], 6) as usize; let mut recs: Vec<(u32, u32)> = (0..nrec) .map(|i| { ( be32(&b[bb + 56 + i * 8..], 0), be32(&b[bb + 56 + i * 8..], 4), ) }) .collect(); for e in &exts { recs.push(((e.1 & ((1 << agblklog) - 1)) as u32, e.2 as u32)); } recs.sort(); let mut merged: Vec<(u32, u32)> = vec![]; for r in recs { match merged.last_mut() { Some(l) if l.0 + l.1 == r.0 => l.1 += r.1, _ => merged.push(r), } } for (i, r) in merged.iter().enumerate() { b[bb + 56 + i * 8..bb + 56 + i * 8 + 4].copy_from_slice(&r.0.to_be_bytes()); b[bb + 56 + i * 8 + 4..bb + 56 + i * 8 + 8].copy_from_slice(&r.1.to_be_bytes()); } b[bb + 6..bb + 8].copy_from_slice(&(merged.len() as u16).to_be_bytes()); // 4) mark the directory entry freed: overwrite the upper 4 bytes of its 8-byte inode with `(0xFFFF, length)` let name = b"relatorio.dat"; let pos = (0..b.len() - 20) .find(|&p| { &b[p + 9..p + 9 + name.len()] == name && b[p + 8] as usize == name.len() && be64(&b[p..], 0) == ino }) .expect("entrada do diretório"); let size = (8 + 1 + name.len() + 1 + 2usize).div_ceil(8) * 8; b[pos..pos + 2].copy_from_slice(&[0xFF, 0xFF]); b[pos + 2..pos + 4].copy_from_slice(&(size as u16).to_be_bytes()); std::fs::write(&img, &b).unwrap(); let c = scan_path( &img, &ScanOptions { mode: ScanMode::Deep, ..Default::default() }, ); let f = c .found .iter() .find(|f| f.origin == Origin::Xfs) .unwrap_or_else(|| { panic!( "nada do XFS: {:?}", c.found .iter() .map(|f| (&f.name, f.origin)) .collect::>() ) }); assert_eq!(f.name, "relatorio.dat"); assert_eq!(f.dir.as_deref(), Some("/docs")); assert!(f.confidence >= Confidence::Medium, "{:?}", f.note); let out = dir.path().join("out"); let rep = recover_all(&img, std::slice::from_ref(f), &out); assert!(rep.failed.is_empty()); let rp = PathBuf::from(&rep.recovered[0].path); let got = std::fs::read(if rp.is_absolute() { rp } else { out.join(rp) }).unwrap(); // Exact byte size was zeroed on deletion: recovered size is rounded up to full filesystem blocks. assert!(got.starts_with(&doc), "conteúdo inicial difere"); assert_eq!(got.len() % 4096, 0); // Only the deleted file is reported (live files are excluded). assert_eq!( c.found.iter().filter(|f| f.origin == Origin::Xfs).count(), 1 ); } #[test] fn reference_image_is_recognized() { let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/testdata/dfvfs/xfs.raw"); if !p.exists() { return; } init(); let c = scan_path( &p, &ScanOptions { mode: ScanMode::Quick, ..Default::default() }, ); let l = c.layout.unwrap(); assert_eq!(l.partitions[0].fs, FsKind::Xfs); } /// Target goal for kernel-level XFS v5 unlink (`scripts/make-xfs-real-deletion.sh`, requires root): /// each deleted file should be recovered with its original name, folder, and content without /// reporting live files as deleted. /// /// Currently `#[ignore]` because on XFS v5 the Linux kernel zeroes the inode fork on unlink, /// so recovering filenames and extents from a real XFS v5 unlink requires parsing the XFS /// journal log. Run with `cargo test -p guara-core --test xfs -- --ignored`. /// #[test] #[ignore = "XFS real: o kernel apaga os metadados; falta ler o diário (ver ROADMAP)"] fn real_kernel_deletion_names_are_recovered() { let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/testdata/xfs-real-delete"); let (img, tsv) = (dir.join("disk.img"), dir.join("expected.tsv")); if !(img.exists() && tsv.exists()) { assert!( std::env::var("GUARA_REQUIRE_FIXTURES").is_err(), "faltou a imagem: sudo scripts/make-xfs-real-deletion.sh" ); eprintln!("SKIP: rode sudo scripts/make-xfs-real-deletion.sh"); return; } init(); let rows: Vec<(String, u64, String, bool)> = std::fs::read_to_string(&tsv) .unwrap() .lines() .map(|l| { let c: Vec<&str> = l.split('\t').collect(); ( c[0].to_string(), c[1].parse().unwrap(), c[2].to_string(), c[3] == "deleted", ) }) .collect(); let c = scan_path( &img, &ScanOptions { mode: ScanMode::Deep, ..Default::default() }, ); let xfs: Vec<&Found> = c.found.iter().filter(|f| f.origin == Origin::Xfs).collect(); let listing = || { xfs.iter() .map(|f| { format!( "{}/{} ({} bytes, {:?})", f.dir.as_deref().unwrap_or(""), f.name, f.size, f.confidence ) }) .collect::>() }; let out = tempfile::tempdir().unwrap(); for (rel, size, sha, deleted) in &rows { let (dirname, name) = match rel.rsplit_once('/') { Some((d, n)) => (format!("/{d}"), n), None => (String::new(), rel.as_str()), }; let hit = xfs .iter() .find(|f| f.name == name && f.dir.as_deref().unwrap_or("") == dirname); if !*deleted { assert!( hit.is_none(), "{rel} não foi apagado, mas apareceu como recuperável: {:?}", listing() ); continue; } let f = hit.unwrap_or_else(|| { panic!( "{rel} apagado pelo kernel não foi encontrado; achados: {:?}", listing() ) }); let rep = recover_all(&img, std::slice::from_ref(*f), &out.path().join(name)); assert!(rep.failed.is_empty(), "{rel}: {:?}", rep.failed); let rp = PathBuf::from(&rep.recovered[0].path); let got = std::fs::read(if rp.is_absolute() { rp } else { out.path().join(name).join(rp) }) .unwrap(); // exact byte size is lost on unlink: recovered size is rounded up to full blocks assert!( got.len() as u64 >= *size, "{rel}: veio menor ({} < {size})", got.len() ); assert_eq!( sha256(&got[..*size as usize]), *sha, "{rel}: o conteúdo inicial difere do original" ); } } /// What works today on a real kernel-unlinked XFS v5 image: the freed data blocks remain intact /// on disk and deep scan recovers structured files (such as SQLite) via signature carving with /// bit-identical content (though original filename and folder path are lost). #[test] fn real_kernel_deletion_content_is_recovered_by_carving() { let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/testdata/xfs-real-delete"); let (img, tsv) = (dir.join("disk.img"), dir.join("expected.tsv")); if !(img.exists() && tsv.exists()) { assert!( std::env::var("GUARA_REQUIRE_FIXTURES").is_err(), "faltou a imagem: sudo scripts/make-xfs-real-deletion.sh" ); eprintln!("SKIP: rode sudo scripts/make-xfs-real-deletion.sh"); return; } init(); let want = std::fs::read_to_string(&tsv) .unwrap() .lines() .map(|l| l.split('\t').collect::>()) .find(|c| c[0].ends_with(".sqlite")) .map(|c| (c[1].parse::().unwrap(), c[2].to_string())) .expect("o SQLite apagado consta do expected.tsv"); let c = scan_path( &img, &ScanOptions { mode: ScanMode::Deep, ..Default::default() }, ); let out = tempfile::tempdir().unwrap(); let mut seen = vec![]; for (i, f) in c.found.iter().enumerate() { if f.size as usize != want.0 { continue; } let rep = recover_all( &img, std::slice::from_ref(f), &out.path().join(i.to_string()), ); let rp = PathBuf::from(&rep.recovered[0].path); let p = if rp.is_absolute() { rp } else { out.path().join(i.to_string()).join(rp) }; seen.push(sha256_file(&p)); } assert!( seen.contains(&want.1), "o SQLite apagado não voltou idêntico; achados do mesmo tamanho: {seen:?}; todos: {:?}", c.found .iter() .map(|f| (&f.name, f.size, f.origin)) .collect::>() ); }