Merge pull request #42 from lucasrguerra/test/xfs-real-deletion

XFS: teste com exclusão real do kernel mostra o que funciona (carving sim, nomes não)
This commit is contained in:
Lucas Rayan Guerra authored and GitHub committed 2026-10-23 09:25:09 -03:00
commit 26099eaacf
8 files changed
+239 -11

No files matched your search

+4 -1
View File
@@ -46,7 +46,7 @@ jobs:
if: runner.os == 'Linux' if: runner.os == 'Linux'
run: | run: |
sudo apt-get update 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: Cache das imagens de referência - name: Cache das imagens de referência
uses: actions/cache@v6 uses: actions/cache@v6
with: with:
@@ -55,6 +55,9 @@ jobs:
- name: Imagens de referência (BitLocker, FileVault 2, dfvfs, AFF4) - name: Imagens de referência (BitLocker, FileVault 2, dfvfs, AFF4)
shell: bash shell: bash
run: scripts/fetch-testdata.sh run: scripts/fetch-testdata.sh
- name: Imagem XFS com exclusão feita pelo kernel (Linux)
if: runner.os == 'Linux'
run: sudo scripts/make-xfs-real-deletion.sh
- name: Testes (imagens de referência nos três sistemas; FAT32, NTFS e ext4 gerados no Linux) - name: Testes (imagens de referência nos três sistemas; FAT32, NTFS e ext4 gerados no Linux)
shell: bash shell: bash
run: | run: |
+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.
+4 -3
View File
@@ -40,12 +40,13 @@ Details of each version in the [CHANGELOG](CHANGELOG.md).
| ext2/3/4 | ✅ | Deleted inodes and old copies in the journal | | ext2/3/4 | ✅ | Deleted inodes and old copies in the journal |
| HFS+ | ✅ | Tree node leftovers and journal | | HFS+ | ✅ | Tree node leftovers and 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 and names of freed entries. Tested only with simulated deletion | | XFS | 🟡 | Freed inodes and names of freed entries work on simulated deletion only. With real kernel deletion (tested in CI) the metadata is wiped: content comes back by carving, names do not. **Journal reading is missing** |
| APFS | 🟡 | Container, object map, free-space map (spaceman and bitmaps), snapshots and old nodes. Deletion tested only in simulation; containers with chunk-info address blocks (large ones) fall back to scanning the whole volume | | APFS | 🟡 | Container, object map, free-space map (spaceman and bitmaps), snapshots and old nodes. Deletion tested only in simulation; containers with chunk-info address blocks (large ones) fall back to scanning the whole volume |
| ReFS | 🔜 | Only detected today; recovery depends on carving. Reference image missing | | ReFS | 🔜 | Only detected today; recovery depends on carving. Reference image missing |
| Storage Spaces | 🔜 | Not done. Reference image missing | | Storage Spaces | 🔜 | Not done. Reference image missing |
| NTFS: compressed attributes, EFS, attribute list | 🔜 | Marked as incomplete today | | NTFS: compressed attributes, EFS, attribute list | 🔜 | Marked as incomplete today |
| XFS and APFS validation with real deletion | 🔜 | Replace the simulation with images from real devices | | XFS: names and folders after real deletion (read the journal) | 🔜 | The CI test exists and is ignored until this lands |
| APFS validation with real deletion | 🔜 | Needs an image from a real Mac |
### 2. Encrypted volumes ### 2. Encrypted volumes
@@ -167,7 +168,7 @@ External validation is what makes a report accepted. Without it, Guará is good
No dates, in the order that makes the most sense. No dates, in the order that makes the most sense.
**1.3: trust and custody** **1.3: trust and custody**
Trusted timestamps, XFS and APFS validation with real deletions, tests with real images on Windows and macOS. Trusted timestamps, XFS journal reading, APFS validation with real deletions.
**1.4: analysis** **1.4: analysis**
NSRL and hash lists, search in file content, unified timeline. NSRL and hash lists, search in file content, unified timeline.
+4 -3
View File
@@ -40,12 +40,13 @@ Detalhes de cada versão no [CHANGELOG](CHANGELOG.pt-BR.md).
| ext2/3/4 | ✅ | Inodes apagados e cópias antigas no diário | | ext2/3/4 | ✅ | Inodes apagados e cópias antigas no diário |
| HFS+ | ✅ | Sobras de nós da árvore e diário | | HFS+ | ✅ | Sobras de nós da árvore e 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 e nomes de entradas liberadas. Testado só com exclusão simulada | | XFS | 🟡 | Inodes liberados e nomes de entradas liberadas só funcionam na exclusão simulada. Com exclusão real do kernel (testada no CI) os metadados são zerados: o conteúdo volta por carving, o nome não. **Falta ler o diário** |
| APFS | 🟡 | Contêiner, mapa de objetos, mapa de espaço livre (spaceman e bitmaps), instantâneos e nós antigos. Exclusão testada só em simulação; contêineres com blocos de endereço de chunk-info (grandes) voltam a varrer o volume inteiro | | APFS | 🟡 | Contêiner, mapa de objetos, mapa de espaço livre (spaceman e bitmaps), instantâneos e nós antigos. Exclusão testada só em simulação; contêineres com blocos de endereço de chunk-info (grandes) voltam a varrer o volume inteiro |
| ReFS | 🔜 | Hoje só é detectado; a recuperação depende do carving. Falta imagem de referência | | ReFS | 🔜 | Hoje só é detectado; a recuperação depende do carving. Falta imagem de referência |
| Storage Spaces | 🔜 | Não feito. Falta imagem de referência | | Storage Spaces | 🔜 | Não feito. Falta imagem de referência |
| NTFS: atributos comprimidos, EFS, lista de atributos | 🔜 | Hoje marcados como incompletos | | NTFS: atributos comprimidos, EFS, lista de atributos | 🔜 | Hoje marcados como incompletos |
| Validação de XFS e APFS com exclusão real | 🔜 | Trocar a simulação por imagens de dispositivos reais | | XFS: nomes e pastas após exclusão real (ler o diário) | 🔜 | O teste no CI existe e fica ignorado até isso entrar |
| Validação de APFS com exclusão real | 🔜 | Precisa de imagem de um Mac de verdade |
### 2. Volumes cifrados ### 2. Volumes cifrados
@@ -167,7 +168,7 @@ A validação externa é o que faz um laudo ser aceito. Sem ela, o Guará serve
Sem datas, na ordem em que fazem mais sentido. Sem datas, na ordem em que fazem mais sentido.
**1.3: confiança e custódia** **1.3: confiança e custódia**
Carimbo de tempo, validação de XFS e APFS com exclusões reais, testes com imagens reais no Windows e macOS. Carimbo de tempo de terceiros, leitura do diário do XFS, validação de APFS com exclusões reais.
**1.4: análise** **1.4: análise**
NSRL e listas de hashes, busca em conteúdo de arquivos, linha do tempo unificada. NSRL e listas de hashes, busca em conteúdo de arquivos, linha do tempo unificada.
+163
View File
@@ -268,3 +268,166 @@ fn reference_image_is_recognized() {
let l = c.layout.unwrap(); let l = c.layout.unwrap();
assert_eq!(l.partitions[0].fs, FsKind::Xfs); assert_eq!(l.partitions[0].fs, FsKind::Xfs);
} }
/// 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]
#[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::<Vec<_>>()
};
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"
);
}
}
/// 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 | |
+60
View File
@@ -0,0 +1,60 @@
#!/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 320M "$img" # o mkfs.xfs recente recusa menos de 300 MiB
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" "$root/crates/guara-core/tests/data/exemplo.sqlite" <<'PY'
import hashlib, os, sys
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):
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),
]
# 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 = 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"))
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"