CI / Format and clippy (push) Successful in 1m57s
CI / Dependency audit (push) Successful in 13s
CI / Check desktop app (Linux) (push) Successful in 3m54s
CI / Tests (Linux) (push) Failing after 7m25s
Release / Create release (push) Successful in 6s
Release / CLI (linux-aarch64) (push) Successful in 1m48s
Release / CLI (linux-x86_64) (push) Successful in 2m39s
Release / Desktop app (linux-aarch64) (push) Successful in 10m9s
Release / Desktop app (linux-x86_64) (push) Successful in 12m20s
Release / Desktop app (windows) (push) Successful in 7m32s
Release / Build latest.json (push) Successful in 5s
Completes the English-only standardization for code, comments, scripts and CI templates (UI/CLI-facing strings stay bilingual pt-BR/en, untouched). Covers the remaining crates/, apps/gui/ui/ and apps/gui/src-tauri/ comments, .gitea/ workflow and template comments, .githooks/ hooks, and the scripts/ source comments for the files already renamed in a prior commit (check.sh, generate-logo.py, install-hooks.sh). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
434 lines
14 KiB
Rust
434 lines
14 KiB
Rust
//! XFS: real `mkfs.xfs` image + simulated unlink (freed inode with leftover extents and freed
|
|
//! directory entry, matching XFS v4/unwiped behavior) plus the `dfvfs` reference image.
|
|
|
|
mod common;
|
|
use common::*;
|
|
use guara_core::*;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
|
|
fn have(t: &str) -> bool {
|
|
Command::new("sh")
|
|
.args(["-c", &format!("command -v {t}")])
|
|
.output()
|
|
.map(|o| o.status.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn data(seed: u64, n: usize) -> Vec<u8> {
|
|
let mut x = seed | 1;
|
|
(0..n)
|
|
.map(|_| {
|
|
x ^= x << 13;
|
|
x ^= x >> 7;
|
|
x ^= x << 17;
|
|
(x >> 24) as u8
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn be16(b: &[u8], o: usize) -> u16 {
|
|
u16::from_be_bytes([b[o], b[o + 1]])
|
|
}
|
|
fn be32(b: &[u8], o: usize) -> u32 {
|
|
u32::from_be_bytes(b[o..o + 4].try_into().unwrap())
|
|
}
|
|
fn be64(b: &[u8], o: usize) -> u64 {
|
|
u64::from_be_bytes(b[o..o + 8].try_into().unwrap())
|
|
}
|
|
|
|
fn unpack(r: &[u8]) -> (u64, u64, u64) {
|
|
let hi = be64(r, 0);
|
|
let lo = be64(r, 8);
|
|
(
|
|
(hi >> 9) & ((1 << 54) - 1),
|
|
((hi & 0x1FF) << 43) | (lo >> 21),
|
|
lo & 0x1F_FFFF,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn deleted_file_is_recovered_from_free_inode_and_stale_directory_entry() {
|
|
if !(have("mkfs.xfs") && have("xfs_db")) {
|
|
eprintln!("SKIP: xfsprogs ausente");
|
|
return;
|
|
}
|
|
init();
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let src = dir.path().join("src/docs");
|
|
std::fs::create_dir_all(&src).unwrap();
|
|
let doc = data(5, 150_000);
|
|
std::fs::write(src.join("relatorio.dat"), &doc).unwrap();
|
|
for i in 1..=40 {
|
|
std::fs::write(src.join(format!("a_longa_{i}.txt")), format!("arquivo {i}")).unwrap();
|
|
}
|
|
let mut proto = String::from("/dev/null\n0 0\nd--755 0 0\ndocs d--755 0 0\n");
|
|
let mut names: Vec<_> = std::fs::read_dir(&src)
|
|
.unwrap()
|
|
.flatten()
|
|
.map(|e| e.file_name().to_string_lossy().to_string())
|
|
.collect();
|
|
names.sort();
|
|
for n in &names {
|
|
proto.push_str(&format!(" {n} ---644 0 0 {}\n", src.join(n).display()));
|
|
}
|
|
proto.push_str(" $\n$\n");
|
|
let pf = dir.path().join("proto");
|
|
std::fs::write(&pf, proto).unwrap();
|
|
let img = dir.path().join("x.img");
|
|
std::fs::File::create(&img)
|
|
.unwrap()
|
|
.set_len(320 << 20)
|
|
.unwrap();
|
|
let o = Command::new("mkfs.xfs")
|
|
.args(["-q", "-f", "-p"])
|
|
.arg(&pf)
|
|
.arg(&img)
|
|
.output()
|
|
.unwrap();
|
|
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
|
|
|
let db = |cmds: &[&str]| -> String {
|
|
let mut c = Command::new("xfs_db");
|
|
c.arg("-r");
|
|
for x in cmds {
|
|
c.args(["-c", x]);
|
|
}
|
|
c.arg(&img);
|
|
String::from_utf8_lossy(&c.output().unwrap().stdout).to_string()
|
|
};
|
|
let nc = db(&["blockget -n", "ncheck"]);
|
|
let ino: u64 = nc
|
|
.lines()
|
|
.find(|l| l.ends_with("docs/relatorio.dat"))
|
|
.unwrap()
|
|
.split_whitespace()
|
|
.next()
|
|
.unwrap()
|
|
.parse()
|
|
.unwrap();
|
|
let conv = db(&[&format!("convert ino {ino} byte")]);
|
|
let ino_off = usize::from_str_radix(
|
|
conv.split("0x")
|
|
.nth(1)
|
|
.unwrap()
|
|
.split_whitespace()
|
|
.next()
|
|
.unwrap()
|
|
.trim_end_matches(')'),
|
|
16,
|
|
)
|
|
.unwrap();
|
|
let sbv = db(&["sb 0", "p agblocks agblklog inopblog"]);
|
|
let num = |k: &str| -> u64 {
|
|
sbv.lines()
|
|
.find(|l| l.starts_with(k))
|
|
.unwrap()
|
|
.split('=')
|
|
.nth(1)
|
|
.unwrap()
|
|
.trim()
|
|
.parse()
|
|
.unwrap()
|
|
};
|
|
let (agblocks, agblklog, inopblog) = (num("agblocks"), num("agblklog"), num("inopblog"));
|
|
|
|
let mut b = std::fs::read(&img).unwrap();
|
|
let bs = 4096usize;
|
|
// read the file's inline extent list before simulating deletion
|
|
assert_eq!(b[ino_off + 5], 2);
|
|
let nex = be32(&b[ino_off..], 76) as usize;
|
|
let exts: Vec<_> = (0..nex)
|
|
.map(|i| unpack(&b[ino_off + 176 + i * 16..]))
|
|
.collect();
|
|
assert!(exts.iter().map(|e| e.2).sum::<u64>() * 4096 >= 150_000);
|
|
// 1) free the inode (mode = 0, nlink = 0, size = 0, nextents = 0) while leaving fork bytes
|
|
b[ino_off + 2..ino_off + 4].fill(0);
|
|
b[ino_off + 16..ino_off + 20].fill(0);
|
|
b[ino_off + 56..ino_off + 64].fill(0);
|
|
b[ino_off + 76..ino_off + 80].fill(0);
|
|
// 2) mark the inode bit as free in the AGI `inobt` root leaf
|
|
let agno = (ino >> (agblklog + inopblog)) as usize;
|
|
let ag_off = agno * agblocks as usize * bs;
|
|
let agi = &b[ag_off + 2 * 512..ag_off + 2 * 512 + 64];
|
|
let (iroot, ilevel) = (be32(agi, 20) as usize, be32(agi, 24));
|
|
assert_eq!(ilevel, 1);
|
|
let blk = ag_off + iroot * bs;
|
|
let n = be16(&b[blk..], 6) as usize;
|
|
let agino = ino & ((1 << (agblklog + inopblog)) - 1);
|
|
let mut done = false;
|
|
for i in 0..n {
|
|
let r = blk + 56 + i * 16;
|
|
let start = be32(&b[r..], 0) as u64;
|
|
if agino >= start && agino < start + 64 {
|
|
let mask = be64(&b[r..], 8) | (1u64 << (agino - start));
|
|
b[r + 8..r + 16].copy_from_slice(&mask.to_be_bytes());
|
|
let fc = be32(&b[r..], 4) + 1;
|
|
b[r + 4..r + 8].copy_from_slice(&fc.to_be_bytes());
|
|
done = true;
|
|
}
|
|
}
|
|
assert!(done);
|
|
// 3) return the file's data blocks to the AGF `bnobt` free-space B+tree leaf
|
|
let agf = &b[ag_off + 512..ag_off + 512 + 64].to_vec();
|
|
let (broot, blevel) = (be32(agf, 16) as usize, be32(agf, 28));
|
|
assert_eq!(blevel, 1);
|
|
let bb = ag_off + broot * bs;
|
|
let nrec = be16(&b[bb..], 6) as usize;
|
|
let mut recs: Vec<(u32, u32)> = (0..nrec)
|
|
.map(|i| {
|
|
(
|
|
be32(&b[bb + 56 + i * 8..], 0),
|
|
be32(&b[bb + 56 + i * 8..], 4),
|
|
)
|
|
})
|
|
.collect();
|
|
for e in &exts {
|
|
recs.push(((e.1 & ((1 << agblklog) - 1)) as u32, e.2 as u32));
|
|
}
|
|
recs.sort();
|
|
let mut merged: Vec<(u32, u32)> = vec![];
|
|
for r in recs {
|
|
match merged.last_mut() {
|
|
Some(l) if l.0 + l.1 == r.0 => l.1 += r.1,
|
|
_ => merged.push(r),
|
|
}
|
|
}
|
|
for (i, r) in merged.iter().enumerate() {
|
|
b[bb + 56 + i * 8..bb + 56 + i * 8 + 4].copy_from_slice(&r.0.to_be_bytes());
|
|
b[bb + 56 + i * 8 + 4..bb + 56 + i * 8 + 8].copy_from_slice(&r.1.to_be_bytes());
|
|
}
|
|
b[bb + 6..bb + 8].copy_from_slice(&(merged.len() as u16).to_be_bytes());
|
|
// 4) mark the directory entry freed: overwrite the upper 4 bytes of its 8-byte inode with `(0xFFFF, length)`
|
|
let name = b"relatorio.dat";
|
|
let pos = (0..b.len() - 20)
|
|
.find(|&p| {
|
|
&b[p + 9..p + 9 + name.len()] == name
|
|
&& b[p + 8] as usize == name.len()
|
|
&& be64(&b[p..], 0) == ino
|
|
})
|
|
.expect("entrada do diretório");
|
|
let size = (8 + 1 + name.len() + 1 + 2usize).div_ceil(8) * 8;
|
|
b[pos..pos + 2].copy_from_slice(&[0xFF, 0xFF]);
|
|
b[pos + 2..pos + 4].copy_from_slice(&(size as u16).to_be_bytes());
|
|
std::fs::write(&img, &b).unwrap();
|
|
|
|
let c = scan_path(
|
|
&img,
|
|
&ScanOptions {
|
|
mode: ScanMode::Deep,
|
|
..Default::default()
|
|
},
|
|
);
|
|
let f = c
|
|
.found
|
|
.iter()
|
|
.find(|f| f.origin == Origin::Xfs)
|
|
.unwrap_or_else(|| {
|
|
panic!(
|
|
"nada do XFS: {:?}",
|
|
c.found
|
|
.iter()
|
|
.map(|f| (&f.name, f.origin))
|
|
.collect::<Vec<_>>()
|
|
)
|
|
});
|
|
assert_eq!(f.name, "relatorio.dat");
|
|
assert_eq!(f.dir.as_deref(), Some("/docs"));
|
|
assert!(f.confidence >= Confidence::Medium, "{:?}", f.note);
|
|
let out = dir.path().join("out");
|
|
let rep = recover_all(&img, std::slice::from_ref(f), &out);
|
|
assert!(rep.failed.is_empty());
|
|
let rp = PathBuf::from(&rep.recovered[0].path);
|
|
let got = std::fs::read(if rp.is_absolute() { rp } else { out.join(rp) }).unwrap();
|
|
// Exact byte size was zeroed on deletion: recovered size is rounded up to full filesystem blocks.
|
|
assert!(got.starts_with(&doc), "conteúdo inicial difere");
|
|
assert_eq!(got.len() % 4096, 0);
|
|
// Only the deleted file is reported (live files are excluded).
|
|
assert_eq!(
|
|
c.found.iter().filter(|f| f.origin == Origin::Xfs).count(),
|
|
1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reference_image_is_recognized() {
|
|
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/testdata/dfvfs/xfs.raw");
|
|
if !p.exists() {
|
|
return;
|
|
}
|
|
init();
|
|
let c = scan_path(
|
|
&p,
|
|
&ScanOptions {
|
|
mode: ScanMode::Quick,
|
|
..Default::default()
|
|
},
|
|
);
|
|
let l = c.layout.unwrap();
|
|
assert_eq!(l.partitions[0].fs, FsKind::Xfs);
|
|
}
|
|
|
|
/// Target goal for kernel-level XFS v5 unlink (`scripts/make-xfs-real-deletion.sh`, requires root):
|
|
/// each deleted file should be recovered with its original name, folder, and content without
|
|
/// reporting live files as deleted.
|
|
///
|
|
/// Currently `#[ignore]` because on XFS v5 the Linux kernel zeroes the inode fork on unlink,
|
|
/// so recovering filenames and extents from a real XFS v5 unlink requires parsing the XFS
|
|
/// journal log. Run with `cargo test -p guara-core --test xfs -- --ignored`.
|
|
///
|
|
#[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();
|
|
// exact byte size is lost on unlink: recovered size is rounded up to full blocks
|
|
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"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// What works today on a real kernel-unlinked XFS v5 image: the freed data blocks remain intact
|
|
/// on disk and deep scan recovers structured files (such as SQLite) via signature carving with
|
|
/// bit-identical content (though original filename and folder path are lost).
|
|
#[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<_>>()
|
|
);
|
|
}
|