diff --git a/README.md b/README.md index 0359f6f..48f3c77 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ In a single scan, Guará does what tools like Recuva, PhotoRec, TestDisk and Disk Drill do separately: -- **File system metadata:** finds deleted files **with name, folder and date** on NTFS, FAT12/16/32, exFAT, ext2/3/4, HFS+, Btrfs, XFS and APFS. +- **File system metadata:** finds deleted files **with name, folder and date** on NTFS, FAT12/16/32, exFAT, ext2/3/4, HFS+, Btrfs and APFS (on XFS only the content comes back, by carving, see below). - **Structure-aware carving** in free space, damaged partitions and unpartitioned space. The detectors read the real structure of dozens of formats to learn the **exact size** and a **confidence** level. - **Encrypted volumes:** opens BitLocker, LUKS and FileVault 2 with your password or key and scans inside. - **RAID and LVM:** reassembles arrays from loose disks (RAID 0, 1, 4, 5, 6, 10, LVM2), working out level, stripe size and order by itself when there is no metadata. @@ -98,7 +98,7 @@ A desktop app (Tauri) in the style of a file manager, designed for when Guará f - **Overwritten data does not come back.** On an SSD with TRIM the disk may zero deleted blocks; Guará warns you, but it cannot work miracles. - **Fragments:** only JPEG (up to three pieces) and MP4 without an index are reassembled. Other formats, if fragmented, come out truncated with low confidence. - **ReFS and Storage Spaces** are not supported (ReFS is only detected). -- **APFS:** no free-space map. **XFS and APFS:** deletion recovery tested only in simulation. +- **XFS:** on a real XFS (v5, kernel deletion) the inode and its extent list are wiped, so Guará recovers the **content** of files with a recognizable structure by carving, but not their name or folder; reading names needs the file system journal, which is not implemented. The old inode-based path only works on simulated deletions. **APFS:** deletion recovery tested only in simulation. - **NTFS:** compressed files, EFS and heavily fragmented records are skipped or marked as incomplete. - **exFAT:** validated with a synthetic image; real media are still missing. - **Phones:** Guará works with storage media and does **not** unlock devices or bypass passwords. diff --git a/crates/guara-core/tests/xfs.rs b/crates/guara-core/tests/xfs.rs index 49bed9b..7ff0e5d 100644 --- a/crates/guara-core/tests/xfs.rs +++ b/crates/guara-core/tests/xfs.rs @@ -269,11 +269,17 @@ fn reference_image_is_recognized() { 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. +/// Meta: exclusão feita pelo kernel (scripts/make-xfs-real-deletion.sh, precisa de root): cada +/// arquivo apagado volta pelo nome, na pasta certa, com o conteúdo original no começo, e os que +/// ficaram não aparecem como apagados. +/// +/// HOJE FALHA, e é por isso que está `ignore`: num XFS v5 real o kernel zera o inode e a lista de +/// extensões ao apagar, então só a simulação (teste acima) deixa a metadados para ler. Recuperar +/// nome e conteúdo de um XFS real exige ler o diário (log) do sistema de arquivos. Rodar com +/// `cargo test -p guara-core --test xfs -- --ignored` para ver o estado. #[test] -fn real_kernel_deletion_is_recovered() { +#[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")); @@ -365,3 +371,63 @@ fn real_kernel_deletion_is_recovered() { ); } } + +/// O que funciona hoje num XFS apagado de verdade: o conteúdo dos blocos livres continua lá e a +/// varredura profunda recupera, por carving, o arquivo com estrutura reconhecível (SQLite), com +/// o conteúdo idêntico ao original. O nome e a pasta se perdem. +#[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::>() + ); +} diff --git a/docs/pt-BR/suporte.md b/docs/pt-BR/suporte.md index b2f9a2d..39586f8 100644 --- a/docs/pt-BR/suporte.md +++ b/docs/pt-BR/suporte.md @@ -20,7 +20,7 @@ MBR e GPT. Quando a tabela falha, o Guará procura volumes por assinatura (NTFS, | ext2/3/4 | ✅ | Inodes apagados, cópias antigas no diário (ext4), mapa de blocos livres | | | HFS+ | ✅ | Sobras de nós da árvore de catálogo, diário | | | Btrfs | ✅ | Versões antigas das árvores; extensões zlib, zstd e lzo | | -| XFS | ✅ | Inodes liberados, nomes de entradas liberadas, espaço livre por grupo | Exclusão testada só em simulação | +| XFS | 🟡 | Inodes liberados, nomes de entradas liberadas e espaço livre por grupo (funciona na exclusão simulada); conteúdo por carving | Testado no CI com exclusão real do kernel: o kernel zera o inode e as extensões, então nome e pasta se perdem e só o carving recupera o conteúdo. Falta ler o diário | | APFS | 🟡 | Contêiner, mapa de objetos, árvore de arquivos, instantâneos, nós antigos | Sem mapa de espaço livre; exclusão testada só em simulação | | ReFS | ❌ | Apenas detectado | Recuperação por carving | | Storage Spaces | ❌ | Não suportado | | diff --git a/docs/support.md b/docs/support.md index 5b61db7..21ae5cd 100644 --- a/docs/support.md +++ b/docs/support.md @@ -20,7 +20,7 @@ MBR and GPT. When the table fails, Guará searches for volumes by signature (NTF | ext2/3/4 | ✅ | Deleted inodes, old copies in the journal (ext4), free block map | | | HFS+ | ✅ | Leftovers of catalog tree nodes, journal | | | Btrfs | ✅ | Old versions of the trees; zlib, zstd and lzo extents | | -| XFS | ✅ | Freed inodes, names of freed entries, free space per group | Deletion tested only in simulation | +| XFS | 🟡 | Freed inodes, names of freed entries and free space per group (works on simulated deletion); content by carving | Tested with real kernel deletion in CI: the kernel wipes the inode and extents, so names and folders are lost and only carving recovers content. Journal reading is missing | | APFS | 🟡 | Container, object map, file tree, snapshots, old nodes | No free-space map; deletion tested only in simulation | | ReFS | ❌ | Only detected | Recovery through carving | | Storage Spaces | ❌ | Not supported | | diff --git a/scripts/make-xfs-real-deletion.sh b/scripts/make-xfs-real-deletion.sh index 7408c38..cd0d39f 100755 --- a/scripts/make-xfs-real-deletion.sh +++ b/scripts/make-xfs-real-deletion.sh @@ -18,9 +18,9 @@ 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' +python3 - "$mnt" "$out/expected.tsv" "$root/crates/guara-core/tests/data/exemplo.sqlite" <<'PY' import hashlib, os, sys -mnt, tsv = sys.argv[1], sys.argv[2] +mnt, tsv, sqlite = sys.argv[1], sys.argv[2], sys.argv[3] def data(seed, n): x, out = seed | 1, bytearray() for _ in range(n): @@ -34,11 +34,14 @@ files = [ # (caminho, tamanho, semente, apagar?) ("fotos/foto2.bin", 40000, 8, False), ("raiz-apagada.txt", 20000, 9, True), ] +# um arquivo com estrutura reconhecível (SQLite), para a recuperação por carving +real = open(sqlite, "rb").read() rows = [] +files.append(("dados/exemplo.sqlite", len(real), 0, True)) 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) + d = real if rel.endswith('.sqlite') else 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"))