XFS: espaço livre por AG e arquivos apagados (extents sobrando no inode livre + nome da entrada liberada)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-19 00:23:47 -03:00
1 parent 0ced7b725a
commit 465bb672bd
5 files changed
+658 -1

No files matched your search

+1
View File
@@ -9,6 +9,7 @@ pub mod exfat;
pub mod ext; pub mod ext;
pub mod fat; pub mod fat;
pub mod hfsplus; pub mod hfsplus;
pub mod xfs;
pub mod ntfs; pub mod ntfs;
use crate::signatures::{category_for_ext, matches_ext}; use crate::signatures::{category_for_ext, matches_ext};
+471
View File
@@ -0,0 +1,471 @@
//! XFS (v4 e v5): mapa de espaço livre pela árvore de blocos livres de cada grupo de alocação e
//! arquivos apagados. Ao liberar um inode, o XFS zera o modo e o tamanho mas não apaga a lista de
//! extents que ficou dentro do inode; e a entrada de diretório liberada perde só os 4 primeiros
//! bytes (o número do inode), preservando o nome. Juntamos as duas sobras.
use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan};
use crate::signatures;
use crate::source::{read_vec, Source};
use crate::types::{Confidence, DataLoc, Extent, Origin};
use std::collections::{HashMap, HashSet};
fn be16(b: &[u8], o: usize) -> u16 {
b.get(o..o + 2).map(|s| u16::from_be_bytes([s[0], s[1]])).unwrap_or(0)
}
fn be32(b: &[u8], o: usize) -> u32 {
b.get(o..o + 4).map(|s| u32::from_be_bytes(s.try_into().unwrap())).unwrap_or(0)
}
fn be64(b: &[u8], o: usize) -> u64 {
b.get(o..o + 8).map(|s| u64::from_be_bytes(s.try_into().unwrap())).unwrap_or(0)
}
struct Xfs<'a> {
src: &'a dyn Source,
base: u64,
bs: u64,
agblocks: u64,
agcount: u64,
inodesize: u64,
inopblog: u32,
agblklog: u32,
sectsize: u64,
v5: bool,
ftype: bool,
root_ino: u64,
dblocks: u64,
}
impl<'a> Xfs<'a> {
fn open(src: &'a dyn Source, base: u64) -> Option<Xfs<'a>> {
let sb = read_vec(src, base, 512).ok()?;
if sb.len() < 512 || &sb[0..4] != b"XFSB" {
return None;
}
let ver = be16(&sb, 100) & 0xF;
let bs = be32(&sb, 4) as u64;
let agblocks = be32(&sb, 84) as u64;
let agcount = be32(&sb, 88) as u64;
let inodesize = be16(&sb, 104) as u64;
if !(512..=65536).contains(&bs) || !bs.is_power_of_two() || agblocks == 0 || agcount == 0 || !(256..=2048).contains(&inodesize) {
return None;
}
let incompat = be32(&sb, 216);
let features2 = be32(&sb, 200);
let v5 = ver == 5;
Some(Xfs {
src,
base,
bs,
agblocks,
agcount,
inodesize,
inopblog: sb[123] as u32,
agblklog: sb[124] as u32,
sectsize: be16(&sb, 102) as u64,
v5,
ftype: if v5 { incompat & 1 != 0 } else { features2 & 0x200 != 0 },
root_ino: be64(&sb, 56),
dblocks: be64(&sb, 8),
})
}
fn ag_off(&self, agno: u64) -> u64 {
self.base + agno * self.agblocks * self.bs
}
/// Endereço físico de um bloco "fsblock" (agno << agblklog | agbno).
fn fsb_off(&self, fsb: u64) -> Option<u64> {
let (agno, agbno) = (fsb >> self.agblklog, fsb & ((1 << self.agblklog) - 1));
(agno < self.agcount && agbno < self.agblocks).then(|| self.ag_off(agno) + agbno * self.bs)
}
fn ino_off(&self, ino: u64) -> Option<u64> {
let shift = self.agblklog + self.inopblog;
let agno = ino >> shift;
let agbno = (ino >> self.inopblog) & ((1 << self.agblklog) - 1);
let idx = ino & ((1 << self.inopblog) - 1);
(agno < self.agcount && agbno < self.agblocks).then(|| self.ag_off(agno) + agbno * self.bs + idx * self.inodesize)
}
fn read_ino(&self, ino: u64) -> Option<Vec<u8>> {
let b = read_vec(self.src, self.ino_off(ino)?, self.inodesize as usize).ok()?;
(b.len() as u64 == self.inodesize && &b[0..2] == b"IN").then_some(b)
}
fn core_size(&self, ino: &[u8]) -> usize {
if ino[4] >= 3 {
176
} else {
100
}
}
/// Extents de um inode com a lista no próprio inode (formato 2) ou em árvore (formato 3).
fn extents_of(&self, ino: &[u8], max_blocks: u64) -> Vec<(u64, u64, u64)> {
let fmt = ino[5];
let start = self.core_size(ino);
let fork_end = if ino[82] != 0 { start.max(100 + ino[82] as usize * 8) } else { self.inodesize as usize };
let lit = &ino[start..fork_end.min(ino.len())];
let mut out = vec![];
match fmt {
2 => {
let n = (be32(ino, 76) as usize).min(lit.len() / 16);
for i in 0..n {
out.push(unpack_extent(&lit[i * 16..i * 16 + 16]));
}
}
3 => {
// raiz da árvore de extents no inode: nível, nregistros, chaves e ponteiros
let level = be16(lit, 0) as usize;
let nrecs = be16(lit, 2) as usize;
let maxrecs = (lit.len() - 4) / 16;
let ptrs = 4 + maxrecs * 8;
for i in 0..nrecs.min(maxrecs) {
let fsb = be64(lit, ptrs + i * 8);
self.walk_bmbt(fsb, level - 1, &mut out, 0);
}
}
_ => {}
}
out.retain(|e| e.2 > 0 && e.2 <= max_blocks);
out
}
fn walk_bmbt(&self, fsb: u64, level: usize, out: &mut Vec<(u64, u64, u64)>, depth: u32) {
if depth > 6 || out.len() > 1_000_000 {
return;
}
let Some(off) = self.fsb_off(fsb) else { return };
let Ok(b) = read_vec(self.src, off, self.bs as usize) else { return };
if b.len() < 24 || !(&b[0..4] == b"BMAP" || &b[0..4] == b"BMA3") {
return;
}
let hdr = if self.v5 { 72 } else { 24 };
let numrecs = be16(&b, 6) as usize;
if level == 0 {
for i in 0..numrecs {
if let Some(r) = b.get(hdr + i * 16..hdr + i * 16 + 16) {
out.push(unpack_extent(r));
}
}
} else {
let maxrecs = (self.bs as usize - hdr) / 16;
let ptrs = hdr + maxrecs * 8;
for i in 0..numrecs {
self.walk_bmbt(be64(&b, ptrs + i * 8), level - 1, out, depth + 1);
}
}
}
}
/// Registro de extent empacotado em 128 bits: flag(1) | início no arquivo(54) | bloco(52) | quantidade(21).
fn unpack_extent(r: &[u8]) -> (u64, u64, u64) {
let hi = u64::from_be_bytes(r[0..8].try_into().unwrap());
let lo = u64::from_be_bytes(r[8..16].try_into().unwrap());
let startoff = (hi >> 9) & ((1 << 54) - 1);
let startblock = ((hi & 0x1FF) << 43) | (lo >> 21);
let count = lo & 0x1F_FFFF;
(startoff, startblock, count)
}
/// Percorre as folhas de uma árvore B+ curta, entregando cada registro de 16 (inobt) ou 8 (bnobt) bytes.
fn walk_short_btree(x: &Xfs, agno: u64, root: u64, levels: u32, rec: usize, key_size: usize, f: &mut dyn FnMut(&[u8])) {
let mut cur = root;
// desce pela esquerda até a folha
for lvl in (1..levels).rev() {
let Ok(b) = read_vec(x.src, x.ag_off(agno) + cur * x.bs, x.bs as usize) else { return };
if b.len() < 24 {
return;
}
let hdr = if x.v5 { 56 } else { 16 };
let maxrecs = (x.bs as usize - hdr) / (key_size + 4);
let ptrs = hdr + maxrecs * key_size;
let _ = lvl;
cur = be32(&b, ptrs) as u64;
}
let mut seen = HashSet::new();
while cur != 0 && cur != u32::MAX as u64 && seen.insert(cur) {
let Ok(b) = read_vec(x.src, x.ag_off(agno) + cur * x.bs, x.bs as usize) else { return };
if b.len() < 24 {
return;
}
let hdr = if x.v5 { 56 } else { 16 };
let n = be16(&b, 6) as usize;
for i in 0..n {
if let Some(r) = b.get(hdr + i * rec..hdr + (i + 1) * rec) {
f(r);
}
}
cur = be32(&b, 12) as u64; // rightsib
}
}
pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan {
let _ = part_len;
let mut res = VolumeScan::default();
let Some(x) = Xfs::open(ctx.src, base) else { return res };
// Espaço livre por grupo e inodes livres.
let mut free: Vec<Extent> = vec![];
let mut free_set: Vec<(u64, u64)> = vec![]; // (fsblock inicial, quantidade) para checar blocos
let mut chunks: Vec<(u64, u64, u64)> = vec![]; // (agno, startino, máscara livre)
for agno in 0..x.agcount {
if (ctx.cancelled)() {
return res;
}
let agf = read_vec(ctx.src, x.ag_off(agno) + x.sectsize, 64).unwrap_or_default();
if agf.len() >= 64 && &agf[0..4] == b"XAGF" {
let (root, levels) = (be32(&agf, 16) as u64, be32(&agf, 28));
walk_short_btree(&x, agno, root, levels, 8, 8, &mut |r| {
let (s, c) = (be32(r, 0) as u64, be32(r, 4) as u64);
free.push(Extent::new(x.ag_off(agno) + s * x.bs, c * x.bs));
free_set.push(((agno << x.agblklog) | s, c));
});
}
let agi = read_vec(ctx.src, x.ag_off(agno) + 2 * x.sectsize, 64).unwrap_or_default();
if agi.len() >= 64 && &agi[0..4] == b"XAGI" {
let (root, levels) = (be32(&agi, 20) as u64, be32(&agi, 24));
walk_short_btree(&x, agno, root, levels, 16, 4, &mut |r| {
chunks.push((agno, be32(r, 0) as u64, be64(r, 8)));
});
}
(ctx.progress)(agno, x.agcount);
}
free.sort_by_key(|e| e.offset);
let free = merge_extents(free);
res.free = Some(free.clone());
free_set.sort();
let is_free = |fsb: u64, count: u64| -> bool {
let i = free_set.partition_point(|&(s, c)| s + c <= fsb);
free_set.get(i).map_or(false, |&(s, c)| s <= fsb && s + c >= fsb + count)
};
// Nomes: entradas de diretório liberadas (guardam o nome e os 4 bytes baixos do inode).
let (live_dirs, stale_names) = collect_names(&x, ctx);
// Inodes livres com a lista de extents ainda dentro do inode.
let max_blocks = x.dblocks;
let mut seen_first: HashSet<u64> = HashSet::new();
for (agno, startino, mask) in &chunks {
if (ctx.cancelled)() {
return res;
}
for i in 0..64u64 {
if mask >> i & 1 == 0 {
continue; // em uso
}
let ino = (agno << (x.agblklog + x.inopblog)) | (startino + i);
let Some(raw) = x.read_ino(ino) else { continue };
let mode = be16(&raw, 2);
if mode != 0 && be32(&raw, 16) != 0 {
continue;
}
// Só inodes que foram arquivos: o formato de extents continua gravado e há sobras.
if raw[5] != 2 {
continue;
}
let start = x.core_size(&raw);
let lit = &raw[start..];
// lê registros plausíveis em sequência
let mut ext: Vec<(u64, u64, u64)> = vec![];
let mut next_off = 0u64;
for k in 0..lit.len() / 16 {
let e = unpack_extent(&lit[k * 16..k * 16 + 16]);
let valid = e.2 > 0
&& e.2 < 1 << 21
&& e.0 == next_off
&& x.fsb_off(e.1).is_some()
&& (e.1 & ((1 << x.agblklog) - 1)) + e.2 <= x.agblocks;
if !valid {
break;
}
next_off = e.0 + e.2;
ext.push(e);
}
if ext.is_empty() {
continue;
}
let total_blocks: u64 = ext.iter().map(|e| e.2).sum();
if total_blocks > max_blocks {
continue;
}
let all_free = ext.iter().all(|e| is_free(e.1, e.2));
let extents: Vec<Extent> = ext.iter().filter_map(|e| x.fsb_off(e.1).map(|o| Extent::new(o, e.2 * x.bs))).collect();
if extents.is_empty() || !seen_first.insert(extents[0].offset) {
continue;
}
let mut size = total_blocks * x.bs;
let mut conf = if all_free { Confidence::High } else { Confidence::Low };
let mut note = if all_free { "inode XFS liberado; lista de extents recuperada do próprio inode".to_string() } else { "blocos do arquivo já reaproveitados".to_string() };
// tamanho exato: tenta achar o fim real pelo conteúdo
let mut est = true;
if let Some(h) = read_vec(ctx.src, extents[0].offset, 128).ok().and_then(|head| signatures::detect(ctx.src, extents[0].offset, &head, size)) {
if h.len > 0 && h.len <= size {
size = h.len;
est = false;
}
}
if est {
conf = conf.min(Confidence::Medium);
note.push_str("; tamanho exato perdido: usando todos os blocos");
}
let extents = super::fat::trim_extents(extents, size);
let low = ino & 0xFFFF_FFFF;
let (dir, name) = match stale_names.get(&low) {
Some((parent, n)) => (Some(live_dirs.get(parent).cloned().unwrap_or_default()), n.clone()),
None => (None, format!("inode_{ino}")),
};
let ts = |s: u32| (s != 0).then_some(s as i64);
let nf = NewFound {
name,
dir,
size,
origin: Origin::Xfs,
data: DataLoc::Extents { extents },
modified: ts(be32(&raw, 40)),
created: if raw[4] >= 3 { ts(be32(&raw, 144)) } else { None },
accessed: ts(be32(&raw, 32)),
base: conf,
note: Some(&note),
};
let f = make_found(ctx, nf);
(ctx.emit)(f);
}
}
res
}
/// Percorre os diretórios vivos; devolve (caminho de cada diretório por inode, nome liberado por inode baixo).
fn collect_names(x: &Xfs, ctx: &Ctx) -> (HashMap<u64, String>, HashMap<u64, (u64, String)>) {
let mut paths: HashMap<u64, String> = HashMap::new();
let mut stale: HashMap<u64, (u64, String)> = HashMap::new();
let mut queue: Vec<(u64, String)> = vec![(x.root_ino, String::new())];
let mut seen: HashSet<u64> = HashSet::new();
while let Some((dino, path)) = queue.pop() {
if !seen.insert(dino) || (ctx.cancelled)() || seen.len() > 200_000 {
continue;
}
let Some(raw) = x.read_ino(dino) else { continue };
if be16(&raw, 2) & 0o170000 != 0o040000 {
continue;
}
paths.insert(dino, path.clone());
let start = x.core_size(&raw);
match raw[5] {
1 => {
// pasta curta guardada no inode
let lit = &raw[start..];
let count = lit[0] as usize;
let i8 = lit[1] != 0;
let isz = if i8 { 8 } else { 4 };
let mut p = 2 + isz;
for _ in 0..count.max(lit[1] as usize) {
if p + 3 > lit.len() {
break;
}
let nl = lit[p] as usize;
p += 3;
let name = String::from_utf8_lossy(lit.get(p..p + nl).unwrap_or(&[])).to_string();
p += nl + if x.ftype { 1 } else { 0 };
let ino = if i8 { be64(lit, p) } else { be32(lit, p) as u64 };
p += isz;
queue.push((ino, format!("{path}/{name}")));
}
}
2 | 3 => {
let size = be64(&raw, 56).min(64 << 20);
let ex = x.extents_of(&raw, 1 << 20);
let _ = size;
for (off, fsb, cnt) in ex {
// blocos de dados de diretório ficam antes do offset de 32 GiB (depois vêm folhas e índice)
if off * x.bs >= 32u64 << 30 {
continue;
}
for k in 0..cnt.min(256) {
let Some(o) = x.fsb_off(fsb + k) else { continue };
let Ok(b) = read_vec(x.src, o, x.bs as usize) else { continue };
parse_dir_block(x, &b, dino, &path, &mut queue, &mut stale);
}
}
}
_ => {}
}
}
(paths, stale)
}
fn parse_dir_block(x: &Xfs, b: &[u8], dino: u64, path: &str, queue: &mut Vec<(u64, String)>, stale: &mut HashMap<u64, (u64, String)>) {
let magic = &b[0..4];
let hdr = match magic {
b"XD2B" | b"XD2D" => 16,
b"XDB3" | b"XDD3" => 64,
_ => return,
};
let is_block = magic == b"XD2B" || magic == b"XDB3";
// em blocos únicos, o final tem a tabela de folhas
let end = if is_block {
let cnt = be32(b, b.len() - 8) as usize;
b.len().saturating_sub(8 + cnt * 8)
} else {
b.len()
};
let mut p = hdr;
while p + 8 <= end {
if be16(b, p) == 0xFFFF {
let len = be16(b, p + 2) as usize;
if len < 8 || p + len > end {
break;
}
// entrada liberada: sobraram (inode baixo, nome); outras ainda podem estar dentro da região
scan_free_region(x, &b[p..p + len], dino, stale);
p += len;
continue;
}
let ino = be64(b, p);
let nl = b[p + 8] as usize;
if nl == 0 {
break;
}
let ft = if x.ftype { 1 } else { 0 };
let size = (8 + 1 + nl + ft + 2).div_ceil(8) * 8;
if p + size > end {
break;
}
let name = String::from_utf8_lossy(&b[p + 9..p + 9 + nl]).to_string();
if name != "." && name != ".." {
queue.push((ino, format!("{path}/{name}")));
}
p += size;
}
}
fn scan_free_region(x: &Xfs, r: &[u8], dir_ino: u64, stale: &mut HashMap<u64, (u64, String)>) {
// 1) a primeira entrada perdeu só os 4 primeiros bytes do número do inode
let try_at = |p: usize, low: u64, stale: &mut HashMap<u64, (u64, String)>| -> Option<usize> {
let nl = *r.get(p + 8)? as usize;
let name = r.get(p + 9..p + 9 + nl)?;
if nl == 0 || name.iter().any(|&c| c < 0x20 || c == b'/' || c == 0x7F) {
return None;
}
stale.entry(low).or_insert((dir_ino, String::from_utf8_lossy(name).to_string()));
Some((8 + 1 + nl + if x.ftype { 1 } else { 0 } + 2).div_ceil(8) * 8)
};
if r.len() >= 16 {
let low = be32(r, 4) as u64;
if let Some(sz) = try_at(0, low, stale) {
// entradas seguintes dentro da mesma região, com o inode inteiro
let mut p = sz;
while p + 16 <= r.len() {
let ino = be64(r, p);
if ino == 0 || ino > 1 << 56 {
p += 8;
continue;
}
match try_at(p, ino & 0xFFFF_FFFF, stale) {
Some(s) => p += s,
None => p += 8,
}
}
}
}
}
+1
View File
@@ -148,6 +148,7 @@ pub fn run(
FsKind::Ext => Some(fs::ext::scan(&mut ctx, p.start, p.len)), FsKind::Ext => Some(fs::ext::scan(&mut ctx, p.start, p.len)),
FsKind::HfsPlus => Some(fs::hfsplus::scan(&mut ctx, p.start, p.len)), FsKind::HfsPlus => Some(fs::hfsplus::scan(&mut ctx, p.start, p.len)),
FsKind::Btrfs => Some(fs::btrfs::scan(&mut ctx, p.start, p.len)), FsKind::Btrfs => Some(fs::btrfs::scan(&mut ctx, p.start, p.len)),
FsKind::Xfs => Some(fs::xfs::scan(&mut ctx, p.start, p.len)),
_ => None, _ => None,
}; };
let _ = progress_phase; let _ = progress_phase;
+1 -1
View File
@@ -386,7 +386,7 @@ impl FsKind {
pub fn has_metadata_scanner(self) -> bool { pub fn has_metadata_scanner(self) -> bool {
matches!( matches!(
self, self,
FsKind::Ntfs | FsKind::Fat12 | FsKind::Fat16 | FsKind::Fat32 | FsKind::Exfat | FsKind::Ext | FsKind::HfsPlus | FsKind::Btrfs FsKind::Ntfs | FsKind::Fat12 | FsKind::Fat16 | FsKind::Fat32 | FsKind::Exfat | FsKind::Ext | FsKind::HfsPlus | FsKind::Btrfs | FsKind::Xfs
) )
} }
} }
+184
View File
@@ -0,0 +1,184 @@
//! XFS: imagem real do mkfs.xfs + exclusão simulada (inode liberado e entrada de diretório
//! liberada, como o kernel deixa) e a imagem de referência do dfvfs.
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;
// extents do arquivo (no inode, formato 3)
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_eq!(exts.iter().map(|e| e.2).sum::<u64>() * 4096 >= 150_000, true);
// 1) libera o inode como o kernel: modo 0, links 0, tamanho 0, 0 extents
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) marca livre na árvore de inodes (AGI -> raiz folha)
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) devolve os blocos à árvore de espaço livre (folha única)
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) entrada de diretório liberada: perde os 4 primeiros bytes do inode (viram marca + tamanho)
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();
// O tamanho exato se perde na exclusão: o arquivo vem com os blocos inteiros (150000 bytes + preenchimento).
assert!(got.starts_with(&doc), "conteúdo inicial difere");
assert_eq!(got.len() % 4096, 0);
// Só um arquivo apagado.
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);
}