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:
8 files changed
+239
-11
No files matched your search
@@ -46,7 +46,7 @@ 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: Cache das imagens de referência
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
@@ -55,6 +55,9 @@ jobs:
|
||||
- name: Imagens de referência (BitLocker, FileVault 2, dfvfs, AFF4)
|
||||
shell: bash
|
||||
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)
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -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.
|
||||
|
||||
+4
-3
@@ -40,12 +40,13 @@ Details of each version in the [CHANGELOG](CHANGELOG.md).
|
||||
| ext2/3/4 | ✅ | Deleted inodes and old copies in the journal |
|
||||
| HFS+ | ✅ | Tree node leftovers and journal |
|
||||
| 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 |
|
||||
| ReFS | 🔜 | Only detected today; recovery depends on carving. Reference image missing |
|
||||
| Storage Spaces | 🔜 | Not done. Reference image missing |
|
||||
| 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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**
|
||||
NSRL and hash lists, search in file content, unified timeline.
|
||||
|
||||
+4
-3
@@ -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 |
|
||||
| HFS+ | ✅ | Sobras de nós da árvore e diário |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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**
|
||||
NSRL e listas de hashes, busca em conteúdo de arquivos, linha do tempo unificada.
|
||||
|
||||
@@ -268,3 +268,166 @@ fn reference_image_is_recognized() {
|
||||
let l = c.layout.unwrap();
|
||||
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<_>>()
|
||||
);
|
||||
}
|
||||
@@ -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 | |
|
||||
|
||||
+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 | |
|
||||
| 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 | |
|
||||
|
||||
Executable
+60
@@ -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"
|
||||
Reference in new issue
Block a user