XFS: o teste com exclusão real mostra o que funciona de verdade

No CI, com um XFS apagado pelo kernel, a recuperação por metadados não acha nada (o kernel zera
o inode e as extensões); o carving recupera o conteúdo de arquivos com estrutura reconhecível.
O teste de nome e pasta fica ignorado até o Guará ler o diário, e o roadmap e a documentação
passam a dizer isso em vez de marcar o XFS como validado.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-23 08:35:55 -03:00
1 parent e5ae3ea570
commit 4d93d9d512
5 files changed
+80 -11

No files matched your search

+2 -2
View File
@@ -12,7 +12,7 @@
In a single scan, Guará does what tools like Recuva, PhotoRec, TestDisk and Disk Drill do separately: 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. - **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. - **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. - **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. - **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. - **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). - **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. - **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. - **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. - **Phones:** Guará works with storage media and does **not** unlock devices or bypass passwords.
+70 -4
View File
@@ -269,11 +269,17 @@ fn reference_image_is_recognized() {
assert_eq!(l.partitions[0].fs, FsKind::Xfs); assert_eq!(l.partitions[0].fs, FsKind::Xfs);
} }
/// Exclusão feita pelo kernel (scripts/make-xfs-real-deletion.sh, precisa de root): cada arquivo /// Meta: exclusão feita pelo kernel (scripts/make-xfs-real-deletion.sh, precisa de root): cada
/// apagado precisa voltar pelo nome, na pasta certa, com o conteúdo original no começo; os que /// arquivo apagado volta pelo nome, na pasta certa, com o conteúdo original no começo, e os que
/// ficaram não podem aparecer como apagados. Pulado quando a imagem não foi gerada. /// 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] #[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 = let dir =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/testdata/xfs-real-delete"); PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/testdata/xfs-real-delete");
let (img, tsv) = (dir.join("disk.img"), dir.join("expected.tsv")); 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::<Vec<_>>())
.find(|c| c[0].ends_with(".sqlite"))
.map(|c| (c[1].parse::<usize>().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::<Vec<_>>()
);
}
+1 -1
View File
@@ -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 | | | 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 | | | HFS+ | ✅ | Sobras de nós da árvore de catálogo, diário | |
| Btrfs | ✅ | Versões antigas das árvores; extensões zlib, zstd e lzo | | | 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 | | 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 | | ReFS | ❌ | Apenas detectado | Recuperação por carving |
| Storage Spaces | ❌ | Não suportado | | | Storage Spaces | ❌ | Não suportado | |
+1 -1
View File
@@ -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 | | | ext2/3/4 | ✅ | Deleted inodes, old copies in the journal (ext4), free block map | |
| HFS+ | ✅ | Leftovers of catalog tree nodes, journal | | | HFS+ | ✅ | Leftovers of catalog tree nodes, journal | |
| Btrfs | ✅ | Old versions of the trees; zlib, zstd and lzo extents | | | 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 | | APFS | 🟡 | Container, object map, file tree, snapshots, old nodes | No free-space map; deletion tested only in simulation |
| ReFS | ❌ | Only detected | Recovery through carving | | ReFS | ❌ | Only detected | Recovery through carving |
| Storage Spaces | ❌ | Not supported | | | Storage Spaces | ❌ | Not supported | |
+6 -3
View File
@@ -18,9 +18,9 @@ mnt="$(mktemp -d)"
cleanup() { mountpoint -q "$mnt" && umount "$mnt"; rmdir "$mnt" 2>/dev/null || true; } cleanup() { mountpoint -q "$mnt" && umount "$mnt"; rmdir "$mnt" 2>/dev/null || true; }
trap cleanup EXIT trap cleanup EXIT
mount -o loop "$img" "$mnt" 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 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): def data(seed, n):
x, out = seed | 1, bytearray() x, out = seed | 1, bytearray()
for _ in range(n): for _ in range(n):
@@ -34,11 +34,14 @@ files = [ # (caminho, tamanho, semente, apagar?)
("fotos/foto2.bin", 40000, 8, False), ("fotos/foto2.bin", 40000, 8, False),
("raiz-apagada.txt", 20000, 9, True), ("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 = [] rows = []
files.append(("dados/exemplo.sqlite", len(real), 0, True))
for rel, n, seed, gone in files: for rel, n, seed, gone in files:
p = os.path.join(mnt, rel) p = os.path.join(mnt, rel)
os.makedirs(os.path.dirname(p), exist_ok=True) 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: with open(p, "wb") as f:
f.write(d) f.write(d)
rows.append((rel, n, hashlib.sha256(d).hexdigest(), "deleted" if gone else "live")) rows.append((rel, n, hashlib.sha256(d).hexdigest(), "deleted" if gone else "live"))