From 6fcbe773a1cba523068eb1ac4c7cb30badf7cb11 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Mon, 19 Oct 2026 01:33:14 -0300 Subject: [PATCH] =?UTF-8?q?APFS:=20cont=C3=AAiner,=20mapa=20de=20objetos,?= =?UTF-8?q?=20=C3=A1rvore=20de=20arquivos,=20instant=C3=A2neos=20e=20n?= =?UTF-8?q?=C3=B3s=20antigos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- crates/guara-core/src/fs/apfs.rs | 531 +++++++++++++++++++++++++++++++ crates/guara-core/src/fs/mod.rs | 1 + crates/guara-core/src/scan.rs | 1 + crates/guara-core/src/types.rs | 2 +- crates/guara-core/tests/apfs.rs | 228 +++++++++++++ 5 files changed, 762 insertions(+), 1 deletion(-) create mode 100644 crates/guara-core/src/fs/apfs.rs create mode 100644 crates/guara-core/tests/apfs.rs diff --git a/crates/guara-core/src/fs/apfs.rs b/crates/guara-core/src/fs/apfs.rs new file mode 100644 index 0000000..88dc13e --- /dev/null +++ b/crates/guara-core/src/fs/apfs.rs @@ -0,0 +1,531 @@ +//! APFS (volumes sem cifragem): lê o contêiner, o mapa de objetos e a árvore de arquivos de cada +//! volume; arquivos apagados vêm de duas fontes: as árvores dos instantâneos (snapshots) e os nós +//! antigos da árvore (copy-on-write) que ainda estão no disco. Volumes cifrados (FileVault) são +//! detectados e deixados para a varredura por assinatura. + +use super::{make_found, Ctx, NewFound, VolumeScan}; +use crate::source::{read_vec, Source}; +use crate::types::{Confidence, DataLoc, Extent, Origin}; +use std::collections::{BTreeMap, HashMap, HashSet}; + +fn le16(b: &[u8], o: usize) -> u16 { + b.get(o..o + 2).map(|s| u16::from_le_bytes([s[0], s[1]])).unwrap_or(0) +} +fn le32(b: &[u8], o: usize) -> u32 { + b.get(o..o + 4).map(|s| u32::from_le_bytes(s.try_into().unwrap())).unwrap_or(0) +} +fn le64(b: &[u8], o: usize) -> u64 { + b.get(o..o + 8).map(|s| u64::from_le_bytes(s.try_into().unwrap())).unwrap_or(0) +} + +const OBJ_TYPE_MASK: u32 = 0xFFFF; +const T_BTREE_ROOT: u32 = 2; +const T_BTREE_NODE: u32 = 3; +const SUBTYPE_FSTREE: u32 = 0x0E; + +// tipos de registro da árvore de arquivos +const R_SNAP_METADATA: u8 = 1; +const R_INODE: u8 = 3; +const R_FILE_EXTENT: u8 = 8; +const R_DIR_REC: u8 = 9; + +/// Soma de verificação Fletcher-64 do APFS (cobre o bloco menos os 8 bytes iniciais). +pub fn fletcher64(data: &[u8]) -> u64 { + let (mut s1, mut s2) = (0u64, 0u64); + for w in data.chunks_exact(4) { + s1 = (s1 + u32::from_le_bytes(w.try_into().unwrap()) as u64) % 0xFFFF_FFFF; + s2 = (s2 + s1) % 0xFFFF_FFFF; + } + let c1 = 0xFFFF_FFFF - ((s1 + s2) % 0xFFFF_FFFF); + let c2 = 0xFFFF_FFFF - ((s1 + c1) % 0xFFFF_FFFF); + (c2 << 32) | c1 +} + +struct Apfs<'a> { + src: &'a dyn Source, + base: u64, + bs: u64, + blocks: u64, +} + +struct BNode { + flags: u16, + level: u16, + /// (chave, valor) por entrada + entries: Vec<(Vec, Vec)>, + oid: u64, + xid: u64, +} + +impl<'a> Apfs<'a> { + fn block(&self, paddr: u64) -> Option> { + if paddr >= self.blocks { + return None; + } + let b = read_vec(self.src, self.base + paddr * self.bs, self.bs as usize).ok()?; + (b.len() as u64 == self.bs).then_some(b) + } + + fn checked_block(&self, paddr: u64) -> Option> { + let b = self.block(paddr)?; + (fletcher64(&b[8..]) == le64(&b, 0)).then_some(b) + } + + fn parse_node(&self, b: &[u8], fixed_k: usize, fixed_v: usize) -> Option { + let flags = le16(b, 32); + let level = le16(b, 34); + let n = le32(b, 36) as usize; + let toc_off = le16(b, 40) as usize; + let toc_len = le16(b, 42) as usize; + let is_fixed = flags & 4 != 0; + let is_root = flags & 1 != 0; + let key_base = 56 + toc_off + toc_len; + let val_end = b.len() - if is_root { 40 } else { 0 }; + let mut entries = Vec::with_capacity(n); + for i in 0..n { + let (koff, klen, voff, vlen); + if is_fixed { + let o = 56 + toc_off + i * 4; + koff = le16(b, o) as usize; + voff = le16(b, o + 2) as usize; + klen = fixed_k; + vlen = if level == 0 { fixed_v } else { 8 }; + } else { + let o = 56 + toc_off + i * 8; + koff = le16(b, o) as usize; + klen = le16(b, o + 2) as usize; + voff = le16(b, o + 4) as usize; + vlen = le16(b, o + 6) as usize; + } + let ks = key_base + koff; + let k = b.get(ks..ks + klen)?.to_vec(); + let v = if voff == 0xFFFF { vec![] } else { b.get(val_end.checked_sub(voff)?..)?.get(..vlen)?.to_vec() }; + entries.push((k, v)); + } + Some(BNode { flags, level, entries, oid: le64(b, 8), xid: le64(b, 16) }) + } + + /// Todas as entradas de uma árvore física (mapa de objetos): filhos são endereços físicos. + fn walk_physical(&self, root: u64, fixed_k: usize, fixed_v: usize, f: &mut dyn FnMut(&[u8], &[u8])) { + let mut stack = vec![(root, 0u32)]; + let mut seen = HashSet::new(); + while let Some((p, depth)) = stack.pop() { + if depth > 8 || !seen.insert(p) { + continue; + } + let Some(b) = self.checked_block(p) else { continue }; + let Some(n) = self.parse_node(&b, fixed_k, fixed_v) else { continue }; + if n.level == 0 { + for (k, v) in &n.entries { + f(k, v); + } + } else { + for (_, v) in n.entries.iter().rev() { + stack.push((le64(v, 0), depth + 1)); + } + } + } + } + + /// Mapa de objetos: oid -> [(xid, paddr)] ordenado por xid. + fn omap(&self, omap_paddr: u64) -> HashMap> { + let mut m: HashMap> = HashMap::new(); + let Some(ob) = self.checked_block(omap_paddr) else { return m }; + let tree = le64(&ob, 48); + self.walk_physical(tree, 16, 16, &mut |k, v| { + m.entry(le64(k, 0)).or_default().push((le64(k, 8), le64(v, 8))); + }); + for v in m.values_mut() { + v.sort(); + } + m + } +} + +fn lookup(m: &HashMap>, oid: u64, max_xid: u64) -> Option { + m.get(&oid)?.iter().rev().find(|(x, _)| *x <= max_xid).map(|(_, p)| *p) +} + +// ---------------------------------------------------------------- registros + +#[derive(Clone, Debug, Default)] +struct Inode { + parent: u64, + private_id: u64, + mtime: i64, + ctime: i64, + atime: i64, + mode: u16, + size: u64, + name: Option, + has: bool, + compressed: bool, +} + +#[derive(Clone, Debug, Default)] +struct FileRec { + inode: Inode, + names: BTreeMap, // pai -> nome +} + +type Files = HashMap; +type Extents = HashMap>; // private_id -> logical -> (len, paddr, crypto) + +fn ns(t: u64) -> i64 { + (t / 1_000_000_000) as i64 +} + +fn add_record(files: &mut Files, extents: &mut Extents, k: &[u8], v: &[u8]) { + let hdr = le64(k, 0); + let (id, ty) = (hdr & 0x0FFF_FFFF_FFFF_FFFF, (hdr >> 60) as u8); + match ty { + R_INODE if v.len() >= 92 => { + let e = files.entry(id).or_default(); + let mut ino = Inode { + parent: le64(v, 0), + private_id: le64(v, 8), + ctime: ns(le64(v, 16)), // criação + mtime: ns(le64(v, 24)), + atime: ns(le64(v, 40)), + mode: le16(v, 80), + size: le64(v, 84).max(0), + has: true, + ..Default::default() + }; + let flags = le64(v, 48); + ino.compressed = le32(v, 68) & 0x20 != 0; + let _ = flags; + // campos estendidos + let xf = &v[92..]; + if xf.len() >= 4 { + let n = le16(xf, 0) as usize; + let mut p = 4 + n * 4; + for i in 0..n { + let (xt, xs) = (xf[4 + i * 4], le16(xf, 6 + i * 4) as usize); + let data = xf.get(p..p + xs).unwrap_or(&[]); + match xt { + 4 => ino.name = Some(String::from_utf8_lossy(data.split(|&c| c == 0).next().unwrap_or(&[])).to_string()), + 8 if data.len() >= 8 => ino.size = le64(data, 0), + _ => {} + } + p += xs.div_ceil(8) * 8; + } + } + e.inode = ino; + } + R_DIR_REC if k.len() > 12 && v.len() >= 18 => { + let nl = (le32(k, 8) & 0x3FF) as usize; + if let Some(name) = k.get(12..12 + nl) { + let name = String::from_utf8_lossy(name.split(|&c| c == 0).next().unwrap_or(&[])).to_string(); + files.entry(le64(v, 0)).or_default().names.insert(id, name); + } + } + R_FILE_EXTENT if k.len() >= 16 && v.len() >= 24 => { + let len = le64(v, 0) & 0x00FF_FFFF_FFFF_FFFF; + extents.entry(id).or_default().insert(le64(k, 8), (len, le64(v, 8), le64(v, 16))); + } + _ => {} + } +} + +impl Apfs<'_> { + /// Percorre a árvore de arquivos a partir de uma raiz virtual. + fn walk_fstree(&self, root_oid: u64, omap: &HashMap>, xid: u64, f: &mut dyn FnMut(&[u8], &[u8], u64)) { + let Some(rp) = lookup(omap, root_oid, xid) else { return }; + let mut stack = vec![(rp, 0u32)]; + let mut seen = HashSet::new(); + while let Some((p, depth)) = stack.pop() { + if depth > 10 || !seen.insert(p) { + continue; + } + let Some(b) = self.checked_block(p) else { continue }; + let Some(n) = self.parse_node(&b, 16, 16) else { continue }; + if n.level == 0 { + for (k, v) in &n.entries { + f(k, v, p); + } + } else { + for (_, v) in n.entries.iter().rev() { + if let Some(cp) = lookup(omap, le64(v, 0), xid) { + stack.push((cp, depth + 1)); + } + } + } + } + } +} + +fn path_of(files: &Files, mut id: u64) -> String { + let mut parts = vec![]; + for _ in 0..64 { + if id == 2 || id == 1 { + break; + } + match files.get(&id).and_then(|f| f.names.iter().next().map(|(p, n)| (*p, n.clone()))) { + Some((p, n)) => { + parts.push(n); + id = p; + } + None => break, + } + } + parts.reverse(); + parts.join("/") +} + +struct Volume { + name: String, + omap_paddr: u64, + root_oid: u64, + snap_tree_oid: u64, + encrypted: bool, +} + +fn parse_volume(b: &[u8]) -> Option { + if &b[32..36] != b"APSB" { + return None; + } + let name = String::from_utf8_lossy(&b[704..960]).split('\0').next().unwrap_or("").to_string(); + Some(Volume { + name, + omap_paddr: le64(b, 128), + root_oid: le64(b, 136), + snap_tree_oid: le64(b, 152), + encrypted: le64(b, 264) & 1 == 0, + }) +} + +fn open_container<'a>(src: &'a dyn Source, base: u64) -> Option<(Apfs<'a>, Vec)> { + let b0 = read_vec(src, base, 4096).ok()?; + if b0.len() < 4096 || &b0[32..36] != b"NXSB" { + return None; + } + let bs = le32(&b0, 36) as u64; + let blocks = le64(&b0, 40); + if !(4096..=65536).contains(&bs) || !bs.is_power_of_two() { + return None; + } + let a = Apfs { src, base, bs, blocks }; + // O superbloco mais recente está na área de descritores de checkpoint. + let desc_blocks = le32(&b0, 104) as u64; + let desc_base = le64(&b0, 112); + let mut best = b0.clone(); + for i in 0..desc_blocks { + if let Some(b) = a.checked_block(desc_base as u64 + i) { + if &b[32..36] == b"NXSB" && le64(&b, 16) > le64(&best, 16) { + best = b; + } + } + } + Some((a, best)) +} + +pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan { + let _ = part_len; + let mut res = VolumeScan::default(); + let Some((a, nx)) = open_container(ctx.src, base) else { return res }; + let Some(cont_omap_block) = a.checked_block(le64(&nx, 160)) else { return res }; + let _ = cont_omap_block; + let container_omap = a.omap(le64(&nx, 160)); + let max_fs = le32(&nx, 180).min(100) as usize; + let mut vols: Vec = vec![]; + for i in 0..max_fs { + let oid = le64(&nx, 184 + i * 8); + if oid == 0 { + continue; + } + if let Some(p) = lookup(&container_omap, oid, u64::MAX) { + if let Some(b) = a.checked_block(p) { + if let Some(v) = parse_volume(&b) { + vols.push(v); + } + } + } + } + let nvols = vols.len(); + for v in &vols { + if v.encrypted { + res.warnings.push(format!("APFS: o volume \"{}\" está cifrado (FileVault); os arquivos só são legíveis com a chave, então use a varredura por assinatura apenas se o volume já estiver aberto.", v.name)); + continue; + } + scan_volume(ctx, &a, v, nvols == 1, &mut res); + if (ctx.cancelled)() { + return res; + } + } + res +} + +fn scan_volume(ctx: &mut Ctx, a: &Apfs, v: &Volume, allow_stale_scan: bool, res: &mut VolumeScan) { + let omap = a.omap(v.omap_paddr); + let current_xid = u64::MAX; + // Árvore atual. + let mut live: Files = HashMap::new(); + let mut live_ext: Extents = HashMap::new(); + let mut live_nodes: HashSet = HashSet::new(); + a.walk_fstree(v.root_oid, &omap, current_xid, &mut |k, val, node| { + live_nodes.insert(node); + add_record(&mut live, &mut live_ext, k, val); + }); + // Instantâneos. + let mut stale: Files = HashMap::new(); + let mut stale_ext: Extents = HashMap::new(); + if v.snap_tree_oid != 0 { + if let Some(sp) = lookup(&omap, v.snap_tree_oid, current_xid).or(Some(v.snap_tree_oid)) { + let mut snaps: Vec<(u64, u64)> = vec![]; // (xid, sblock paddr) + let mut stack = vec![sp]; + let mut seen = HashSet::new(); + while let Some(p) = stack.pop() { + if !seen.insert(p) { + continue; + } + let Some(b) = a.checked_block(p) else { continue }; + let Some(n) = a.parse_node(&b, 0, 0) else { continue }; + if n.level == 0 { + for (k, val) in &n.entries { + let hdr = le64(k, 0); + if (hdr >> 60) as u8 == R_SNAP_METADATA && val.len() >= 40 { + snaps.push((hdr & 0x0FFF_FFFF_FFFF_FFFF, le64(val, 8))); + } + } + } else { + for (_, val) in &n.entries { + // árvore de metadados de snapshot: filhos físicos ou virtuais + let c = le64(val, 0); + stack.push(lookup(&omap, c, current_xid).unwrap_or(c)); + } + } + } + for (xid, sblock) in snaps { + let Some(sb) = a.checked_block(sblock) else { continue }; + let Some(sv) = parse_volume(&sb) else { continue }; + a.walk_fstree(sv.root_oid, &omap, xid, &mut |k, val, _| { + add_record(&mut stale, &mut stale_ext, k, val); + }); + } + } + } + // Nós antigos da árvore de arquivos. + if allow_stale_scan { + let total = a.blocks; + for p in 0..total { + if p % 4096 == 0 { + if (ctx.cancelled)() { + return; + } + (ctx.progress)(p, total); + } + let Some(b) = a.block(p) else { continue }; + let ty = le32(&b, 24) & OBJ_TYPE_MASK; + if (ty != T_BTREE_NODE && ty != T_BTREE_ROOT) || le32(&b, 28) != SUBTYPE_FSTREE || le16(&b, 34) != 0 { + continue; + } + if live_nodes.contains(&p) || fletcher64(&b[8..]) != le64(&b, 0) { + continue; + } + if let Some(n) = a.parse_node(&b, 16, 16) { + let _ = (n.oid, n.xid, n.flags); + for (k, val) in &n.entries { + add_record(&mut stale, &mut stale_ext, k, val); + } + } + } + } + // Arquivos que só existem nas versões antigas. + let mut ids: Vec<&u64> = stale.keys().collect(); + ids.sort(); + let mut seen_first: HashSet = HashSet::new(); + for id in ids { + if live.get(id).map_or(false, |f| f.inode.has) { + continue; + } + let rec = &stale[id]; + let ino = &rec.inode; + if !ino.has || ino.mode & 0o170000 != 0o100000 || ino.size == 0 { + continue; + } + let Some(ex) = stale_ext.get(&ino.private_id) else { continue }; + if ex.values().any(|e| e.2 != 0) { + continue; // extensão cifrada + } + // nome e pasta: da entrada de diretório, senão do próprio inode + let (name, parent) = match rec.names.iter().next() { + Some((p, n)) => (n.clone(), *p), + None => match &ino.name { + Some(n) => (n.clone(), ino.parent), + None => (format!("arquivo_{id}"), ino.parent), + }, + }; + let merged: Files = stale.iter().map(|(k, v)| (*k, v.clone())).chain(live.iter().map(|(k, v)| (*k, v.clone()))).collect(); + let dir = Some(format!("/{}", path_of(&merged, parent)).replace("//", "/")); + let mut extents: Vec = vec![]; + let mut pos = 0u64; + let mut ok = true; + for (&lo, &(len, paddr, _)) in ex { + if lo > pos { + extents.push(Extent { offset: 0, len: lo - pos, sparse: true }); + } + if paddr == 0 { + extents.push(Extent { offset: 0, len, sparse: true }); + } else if paddr < a.blocks { + extents.push(Extent::new(a.base + paddr * a.bs, len)); + } else { + ok = false; + } + pos = lo + len; + } + if !ok || pos < ino.size.min(1) { + continue; + } + let extents = super::fat::trim_extents(extents, ino.size); + let Some(first) = extents.iter().find(|e| !e.sparse) else { continue }; + if !seen_first.insert(first.offset) { + continue; + } + let note = if ino.compressed { "arquivo comprimido pelo macOS (decmpfs): o conteúdo pode estar incompleto" } else { "versão antiga da árvore ou instantâneo do APFS" }; + let ts = |t: i64| (t > 0).then_some(t); + let nf = NewFound { + name, + dir, + size: ino.size, + origin: Origin::Apfs, + data: DataLoc::Extents { extents }, + modified: ts(ino.mtime), + created: ts(ino.ctime), + accessed: ts(ino.atime), + base: if ino.compressed { Confidence::Low } else { Confidence::Medium }, + note: Some(note), + }; + let f = make_found(ctx, nf); + (ctx.emit)(f); + } + let _ = res; +} + +/// Arquivos e pastas atuais do primeiro volume sem cifragem (caminho, tamanho, é pasta). +pub fn live_paths(src: &dyn Source, base: u64) -> Vec<(String, u64, bool)> { + let Some((a, nx)) = open_container(src, base) else { return vec![] }; + let cont = a.omap(le64(&nx, 160)); + let mut out = vec![]; + for i in 0..le32(&nx, 180).min(100) as usize { + let oid = le64(&nx, 184 + i * 8); + let Some(p) = lookup(&cont, oid, u64::MAX) else { continue }; + let Some(vb) = a.checked_block(p) else { continue }; + let Some(v) = parse_volume(&vb) else { continue }; + if v.encrypted { + continue; + } + let omap = a.omap(v.omap_paddr); + let mut files: Files = HashMap::new(); + let mut ext: Extents = HashMap::new(); + a.walk_fstree(v.root_oid, &omap, u64::MAX, &mut |k, val, _| add_record(&mut files, &mut ext, k, val)); + for (&id, f) in &files { + if !f.inode.has || id <= 2 { + continue; + } + out.push((path_of(&files, id), f.inode.size, f.inode.mode & 0o170000 == 0o040000)); + } + } + out.sort(); + out +} diff --git a/crates/guara-core/src/fs/mod.rs b/crates/guara-core/src/fs/mod.rs index b4ebf24..114e616 100644 --- a/crates/guara-core/src/fs/mod.rs +++ b/crates/guara-core/src/fs/mod.rs @@ -4,6 +4,7 @@ //! devolve os extents ainda alocados ("live") para o carving pular e, quando //! possível, os trechos livres. +pub mod apfs; pub mod btrfs; pub mod exfat; pub mod ext; diff --git a/crates/guara-core/src/scan.rs b/crates/guara-core/src/scan.rs index 3df4e07..7ada9c5 100644 --- a/crates/guara-core/src/scan.rs +++ b/crates/guara-core/src/scan.rs @@ -149,6 +149,7 @@ pub fn run( 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::Xfs => Some(fs::xfs::scan(&mut ctx, p.start, p.len)), + FsKind::Apfs => Some(fs::apfs::scan(&mut ctx, p.start, p.len)), _ => None, }; let _ = progress_phase; diff --git a/crates/guara-core/src/types.rs b/crates/guara-core/src/types.rs index bca78ea..a0786ba 100644 --- a/crates/guara-core/src/types.rs +++ b/crates/guara-core/src/types.rs @@ -386,7 +386,7 @@ impl FsKind { pub fn has_metadata_scanner(self) -> bool { matches!( self, - FsKind::Ntfs | FsKind::Fat12 | FsKind::Fat16 | FsKind::Fat32 | FsKind::Exfat | FsKind::Ext | FsKind::HfsPlus | FsKind::Btrfs | FsKind::Xfs + FsKind::Ntfs | FsKind::Fat12 | FsKind::Fat16 | FsKind::Fat32 | FsKind::Exfat | FsKind::Ext | FsKind::HfsPlus | FsKind::Btrfs | FsKind::Xfs | FsKind::Apfs ) } } diff --git a/crates/guara-core/tests/apfs.rs b/crates/guara-core/tests/apfs.rs new file mode 100644 index 0000000..9e8a468 --- /dev/null +++ b/crates/guara-core/tests/apfs.rs @@ -0,0 +1,228 @@ +//! APFS: imagem real do dfvfs (4 checkpoints) e exclusão simulada pela troca da raiz da árvore. + +mod common; +use common::*; +use guara_core::fs::apfs::{fletcher64, live_paths}; +use guara_core::source::FileSource; +use guara_core::*; +use std::path::PathBuf; + +fn img() -> Option { + let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/testdata/dfvfs/apfs.raw"); + p.exists().then_some(p) +} + +#[test] +fn reads_the_live_tree_of_the_reference_image() { + let Some(p) = img() else { return }; + let src = FileSource::open(&p).unwrap(); + let files = live_paths(&src, 0); + eprintln!("{files:#?}"); + assert!(!files.is_empty()); + let names: Vec<&str> = files.iter().map(|f| f.0.as_str()).collect(); + assert!(names.iter().any(|n| n.ends_with("passwords.txt")), "{names:?}"); +} + +#[test] +fn checksum_matches_real_blocks() { + let Some(p) = img() else { return }; + let d = std::fs::read(p).unwrap(); + let b = &d[0..4096]; + assert_eq!(fletcher64(&b[8..]), u64::from_le_bytes(b[0..8].try_into().unwrap())); +} + +#[test] +fn quick_scan_is_clean_on_pristine_volume() { + let Some(p) = img() else { return }; + init(); + let c = scan_path(&p, &ScanOptions { mode: ScanMode::Quick, ..Default::default() }); + assert_eq!(c.layout.as_ref().unwrap().partitions[0].fs, FsKind::Apfs); + eprintln!("{:?}", c.found.iter().map(|f| (&f.name, &f.dir, f.size)).collect::>()); +} + +// --- manipulação bruta de nós (independente do módulo) --- + +fn le16(b: &[u8], o: usize) -> usize { + u16::from_le_bytes([b[o], b[o + 1]]) as usize +} +fn le32(b: &[u8], o: usize) -> u32 { + u32::from_le_bytes(b[o..o + 4].try_into().unwrap()) +} +fn le64(b: &[u8], o: usize) -> u64 { + u64::from_le_bytes(b[o..o + 8].try_into().unwrap()) +} + +type Entry = (Vec, Vec); + +fn read_node(b: &[u8]) -> (u16, Vec) { + let flags = le16(b, 32) as u16; + let n = le32(b, 36) as usize; + let (toc_off, toc_len) = (le16(b, 40), le16(b, 42)); + let root = flags & 1 != 0; + let key_base = 56 + toc_off + toc_len; + let val_end = b.len() - if root { 40 } else { 0 }; + let mut out = vec![]; + for i in 0..n { + let o = 56 + toc_off + i * 8; + let (ko, kl, vo, vl) = (le16(b, o), le16(b, o + 2), le16(b, o + 4), le16(b, o + 6)); + out.push((b[key_base + ko..key_base + ko + kl].to_vec(), b[val_end - vo..val_end - vo + vl].to_vec())); + } + (flags, out) +} + +fn write_node(old: &[u8], entries: &[Entry]) -> Vec { + let mut b = vec![0u8; old.len()]; + b[..56].copy_from_slice(&old[..56]); + let flags = le16(old, 32) as u16; + let root = flags & 1 != 0; + if root { + let l = old.len(); + b[l - 40..].copy_from_slice(&old[l - 40..]); + } + let n = entries.len(); + b[36..40].copy_from_slice(&(n as u32).to_le_bytes()); + b[40..42].copy_from_slice(&0u16.to_le_bytes()); + b[42..44].copy_from_slice(&((n * 8) as u16).to_le_bytes()); + let key_base = 56 + n * 8; + let val_end = old.len() - if root { 40 } else { 0 }; + let (mut kpos, mut vpos) = (0usize, 0usize); + for (i, (k, v)) in entries.iter().enumerate() { + vpos += v.len(); + let o = 56 + i * 8; + b[o..o + 2].copy_from_slice(&(kpos as u16).to_le_bytes()); + b[o + 2..o + 4].copy_from_slice(&(k.len() as u16).to_le_bytes()); + b[o + 4..o + 6].copy_from_slice(&(vpos as u16).to_le_bytes()); + b[o + 6..o + 8].copy_from_slice(&(v.len() as u16).to_le_bytes()); + b[key_base + kpos..key_base + kpos + k.len()].copy_from_slice(k); + b[val_end - vpos..val_end - vpos + v.len()].copy_from_slice(v); + kpos += k.len(); + } + seal(&mut b); + b +} + +fn seal(b: &mut [u8]) { + let c = fletcher64(&b[8..]); + b[0..8].copy_from_slice(&c.to_le_bytes()); +} + +#[test] +fn file_removed_from_the_current_tree_survives_in_the_old_node() { + let Some(p) = img() else { return }; + let mut d = std::fs::read(&p).unwrap(); + let bs = 4096usize; + let blk = |d: &[u8], n: u64| d[n as usize * bs..(n as usize + 1) * bs].to_vec(); + // Superbloco mais recente do contêiner. + let (desc_blocks, desc_base) = (le32(&d, 104) as u64, le64(&d, 112)); + let mut nx = d[..bs].to_vec(); + for i in 0..desc_blocks { + let b = blk(&d, desc_base + i); + if &b[32..36] == b"NXSB" && le64(&b, 16) > le64(&nx, 16) { + nx = b; + } + } + // omap do contêiner -> volume + let cont_omap = blk(&d, le64(&nx, 160)); + let cont_tree = blk(&d, le64(&cont_omap, 48)); + let (_, cont_entries) = read_node(&{ + // omap usa chaves fixas (16/16): reaproveita o leitor variável convertendo a tabela + let mut c = cont_tree.clone(); + fixed_to_var(&mut c); + c + }); + let vol_oid = le64(&nx, 184); + let vol_paddr = cont_entries.iter().filter(|(k, _)| le64(k, 0) == vol_oid).map(|(_, v)| le64(v, 8)).last().unwrap(); + let vsb = blk(&d, vol_paddr); + let (vol_omap_paddr, root_oid) = (le64(&vsb, 128), le64(&vsb, 136)); + let vol_omap = blk(&d, vol_omap_paddr); + let vol_tree_paddr = le64(&vol_omap, 48); + let mut vt = blk(&d, vol_tree_paddr); + let vt_orig = vt.clone(); + fixed_to_var(&mut vt); + let (_, omap_entries) = read_node(&vt); + let (root_idx, root_paddr) = omap_entries.iter().enumerate().filter(|(_, (k, _))| le64(k, 0) == root_oid).map(|(i, (_, v))| (i, le64(v, 8))).last().unwrap(); + let root = blk(&d, root_paddr); + assert_eq!(le16(&root, 34), 0, "raiz deveria ser folha nesta imagem"); + let (_, recs) = read_node(&root); + // inode do arquivo + let victim = b"passwords.txt"; + let ino = recs.iter().find_map(|(k, v)| { + let hdr = le64(k, 0); + ((hdr >> 60) == 9 && k.len() > 12 && k[12..].starts_with(victim)).then(|| le64(v, 0)) + }).expect("entrada de diretório"); + let private_id = recs.iter().find_map(|(k, v)| ((le64(k, 0) >> 60) == 3 && (le64(k, 0) & 0x0FFF_FFFF_FFFF_FFFF) == ino).then(|| le64(v, 8))).unwrap(); + let kept: Vec = recs + .iter() + .filter(|(k, v)| { + let hdr = le64(k, 0); + let (id, ty) = (hdr & 0x0FFF_FFFF_FFFF_FFFF, hdr >> 60); + let drec_of_victim = ty == 9 && k.len() > 12 && k[12..].starts_with(victim) && le64(v, 0) == ino; + !(id == ino && ty != 9 || id == private_id && ty == 8 || drec_of_victim) + }) + .cloned() + .collect(); + assert!(kept.len() < recs.len()); + let newnode = write_node(&root, &kept); + // bloco livre para a nova versão + let free = (10..d.len() / bs).find(|&n| d[n * bs..(n + 1) * bs].iter().all(|&x| x == 0) && n != vol_tree_paddr as usize).expect("bloco livre"); + d[free * bs..(free + 1) * bs].copy_from_slice(&newnode); + // aponta o mapa de objetos para a nova versão (mesmo xid), editando o valor da entrada + let toc_off = le16(&vt_orig, 40); + let key_base = 56 + toc_off + le16(&vt_orig, 42); + let _ = key_base; + let val_end = bs - 40; + let vo = le16(&vt_orig, 56 + toc_off + root_idx * 4 + 2); + let at = vol_tree_paddr as usize * bs + val_end - vo + 8; + d[at..at + 8].copy_from_slice(&(free as u64).to_le_bytes()); + let mut ob = d[vol_tree_paddr as usize * bs..(vol_tree_paddr as usize + 1) * bs].to_vec(); + seal(&mut ob); + d[vol_tree_paddr as usize * bs..(vol_tree_paddr as usize + 1) * bs].copy_from_slice(&ob); + + let dir = tempfile::tempdir().unwrap(); + let p2 = dir.path().join("a.img"); + std::fs::write(&p2, &d).unwrap(); + // O arquivo sumiu da árvore atual... + let src = FileSource::open(&p2).unwrap(); + assert!(!live_paths(&src, 0).iter().any(|f| f.0 == "passwords.txt")); + init(); + let c = scan_path(&p2, &ScanOptions { mode: ScanMode::Deep, ..Default::default() }); + let f = c.found.iter().find(|f| f.origin == Origin::Apfs && f.name == "passwords.txt").unwrap_or_else(|| panic!("{:?}", c.found.iter().map(|f| (&f.name, f.origin)).collect::>())); + assert_eq!(f.size, 116); + let out = dir.path().join("out"); + let rep = recover_all(&p2, 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(); + assert_eq!(got.len(), 116); + assert!(String::from_utf8_lossy(&got).chars().all(|c| !c.is_control() || c == '\n' || c == '\r' || c == '\t'), "{got:?}"); +} + +/// Converte a tabela de entradas fixas (4 bytes) de um nó de omap para o formato variável (8 bytes) em memória. +fn fixed_to_var(b: &mut Vec) { + let flags = le16(b, 32) as u16; + if flags & 4 == 0 { + return; + } + let n = le32(b, 36) as usize; + let (toc_off, toc_len) = (le16(b, 40), le16(b, 42)); + let key_base = 56 + toc_off + toc_len; + let root = flags & 1 != 0; + let val_end = b.len() - if root { 40 } else { 0 }; + let mut entries: Vec<(Vec, Vec)> = vec![]; + for i in 0..n { + let o = 56 + toc_off + i * 4; + let (ko, vo) = (le16(b, o), le16(b, o + 2)); + entries.push((b[key_base + ko..key_base + ko + 16].to_vec(), b[val_end - vo..val_end - vo + 16].to_vec())); + } + let mut nb = b.clone(); + nb[32..34].copy_from_slice(&(flags & !4).to_le_bytes()); + *b = write_node_flagsless(&nb, &entries); +} + +fn write_node_flagsless(old: &[u8], entries: &[Entry]) -> Vec { + let mut b = write_node(old, entries); + // write_node usa as flags de `old` + let fl = le16(&b, 32) as u16; + b[32..34].copy_from_slice(&fl.to_le_bytes()); + b +}