diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e65dc2..06e8216 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,10 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y mtools dosfstools exfatprogs ntfs-3g e2fsprogs imagemagick zip + sudo apt-get install -y mtools dosfstools exfatprogs ntfs-3g e2fsprogs xfsprogs imagemagick zip + - name: Imagem XFS com exclusão feita pelo kernel (Linux) + if: runner.os == 'Linux' + run: sudo scripts/make-xfs-real-deletion.sh - name: Testes (com imagens reais FAT32/NTFS/ext4 no Linux) shell: bash run: | diff --git a/crates/guara-core/tests/xfs.rs b/crates/guara-core/tests/xfs.rs index 9062597..49bed9b 100644 --- a/crates/guara-core/tests/xfs.rs +++ b/crates/guara-core/tests/xfs.rs @@ -268,3 +268,100 @@ fn reference_image_is_recognized() { let l = c.layout.unwrap(); assert_eq!(l.partitions[0].fs, FsKind::Xfs); } + +/// Exclusão feita pelo kernel (scripts/make-xfs-real-deletion.sh, precisa de root): cada arquivo +/// apagado precisa voltar pelo nome, na pasta certa, com o conteúdo original no começo; os que +/// ficaram não podem aparecer como apagados. Pulado quando a imagem não foi gerada. +#[test] +fn real_kernel_deletion_is_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(); + // o tamanho exato se perde na exclusão: o arquivo vem com os blocos inteiros + 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" + ); + } +} diff --git a/scripts/make-xfs-real-deletion.sh b/scripts/make-xfs-real-deletion.sh new file mode 100755 index 0000000..1927461 --- /dev/null +++ b/scripts/make-xfs-real-deletion.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Cria uma imagem XFS com exclusão REAL (feita pelo kernel), para validar a recuperação de XFS +# sem simulação. Precisa de root, porque monta a imagem por loop: sudo scripts/make-xfs-real-deletion.sh +# +# Saída em target/testdata/xfs-real-delete/: disk.img e expected.tsv (nome, tamanho, sha256 e se +# o arquivo foi apagado). O teste em crates/guara-core/tests/xfs.rs usa esses dois arquivos e é +# pulado quando eles não existem. +set -euo pipefail +[ "$(id -u)" = 0 ] || { echo "rode com sudo: sudo $0" >&2; exit 1; } +root="$(cd "$(dirname "$0")/.." && pwd)" +out="${1:-$root/target/testdata/xfs-real-delete}" +mkdir -p "$out" +img="$out/disk.img" +rm -f "$img" +truncate -s 64M "$img" +mkfs.xfs -q -f "$img" +mnt="$(mktemp -d)" +cleanup() { mountpoint -q "$mnt" && umount "$mnt"; rmdir "$mnt" 2>/dev/null || true; } +trap cleanup EXIT +mount -o loop "$img" "$mnt" +python3 - "$mnt" "$out/expected.tsv" <<'PY' +import hashlib, os, sys +mnt, tsv = sys.argv[1], sys.argv[2] +def data(seed, n): + x, out = seed | 1, bytearray() + for _ in range(n): + x ^= (x << 13) & 0xFFFFFFFFFFFFFFFF; x ^= x >> 7; x ^= (x << 17) & 0xFFFFFFFFFFFFFFFF + out.append((x >> 24) & 0xFF) + return bytes(out) +files = [ # (caminho, tamanho, semente, apagar?) + ("docs/relatorio.dat", 150000, 5, True), + ("docs/mantido.dat", 90000, 6, False), + ("fotos/foto1.bin", 300000, 7, True), + ("fotos/foto2.bin", 40000, 8, False), + ("raiz-apagada.txt", 20000, 9, True), +] +rows = [] +for rel, n, seed, gone in files: + p = os.path.join(mnt, rel) + os.makedirs(os.path.dirname(p), exist_ok=True) + d = data(seed, n) + with open(p, "wb") as f: + f.write(d) + rows.append((rel, n, hashlib.sha256(d).hexdigest(), "deleted" if gone else "live")) +os.sync() +for rel, n, seed, gone in files: + if gone: + os.remove(os.path.join(mnt, rel)) +os.sync() +with open(tsv, "w") as f: + for r in rows: + f.write("\t".join(map(str, r)) + "\n") +PY +sync +umount "$mnt" +[ -n "${SUDO_UID:-}" ] && chown -R "$SUDO_UID:${SUDO_GID:-$SUDO_UID}" "$out" +echo "ok: $img"