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:
1 parent
e5ae3ea570
commit
4d93d9d512
5 files changed
+80
-11
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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 | |
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
Reference in new issue
Block a user