//! HFS+ / HFSX (including volumes embedded inside a classic HFS wrapper). //! //! Reads the catalog B-tree for names and paths, the allocation bitmap for free-space //! mapping, and searches for deleted files in three places: slack space inside catalog leaf //! nodes, older leaf node copies in the journal, and orphaned file records. use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan}; use crate::source::{read_vec, Source}; use crate::types::{Confidence, DataLoc, Extent, Origin}; use std::collections::{HashMap, HashSet}; const HFS_EPOCH: i64 = 2_082_844_800; 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) } #[derive(Clone, Debug, Default)] struct Fork { size: u64, blocks: u32, extents: Vec<(u32, u32)>, } fn parse_fork(b: &[u8]) -> Fork { let mut f = Fork { size: be64(b, 0), blocks: be32(b, 12), extents: vec![], }; for i in 0..8 { let (s, c) = (be32(b, 16 + i * 8), be32(b, 20 + i * 8)); if c != 0 { f.extents.push((s, c)); } } f } /// Byte offset of the HFS+ volume inside the partition (handles HFS wrapper), or `None`. pub fn locate(src: &dyn Source, base: u64) -> Option { let h = read_vec(src, base + 1024, 512).ok()?; if h.len() < 512 { return None; } match &h[0..2] { b"H+" | b"HX" => Some(base), b"BD" if &h[124..126] == b"H+" => { let alblk = be32(&h, 20) as u64; let alblst = be16(&h, 28) as u64; let start = be16(&h, 126) as u64; Some(base + alblst * 512 + start * alblk) } _ => None, } } struct Vol<'a> { src: &'a dyn Source, base: u64, bs: u64, total: u64, catalog: Fork, extents_file: Fork, alloc: Fork, next_cnid: u32, journal_info: u32, } impl<'a> Vol<'a> { fn open(src: &'a dyn Source, base: u64) -> Option> { let h = read_vec(src, base + 1024, 512).ok()?; if h.len() < 512 || !(&h[0..2] == b"H+" || &h[0..2] == b"HX") { return None; } let bs = be32(&h, 40) as u64; if bs < 512 || !bs.is_power_of_two() { return None; } Some(Vol { src, base, bs, total: be32(&h, 44) as u64, alloc: parse_fork(&h[112..192]), extents_file: parse_fork(&h[192..272]), catalog: parse_fork(&h[272..352]), next_cnid: be32(&h, 64), journal_info: be32(&h, 12), }) } fn block_off(&self, b: u64) -> u64 { self.base + b * self.bs } /// Returns all extents of a fork (including overflow extents from the extents B-tree). fn fork_extents(&self, f: &Fork, file_id: u32, fork_type: u8) -> Vec { let mut ex: Vec<(u32, u32)> = f.extents.clone(); let mut have: u64 = ex.iter().map(|e| e.1 as u64).sum(); if have < f.blocks as u64 && file_id >= 2 { ex.extend(self.overflow_extents(file_id, fork_type, have as u32)); have = ex.iter().map(|e| e.1 as u64).sum(); let _ = have; } merge_extents( ex.into_iter() .map(|(s, c)| Extent::new(self.block_off(s as u64), c as u64 * self.bs)) .collect::>(), ) .into_iter() .collect() } /// Looks up extents beyond the first eight in the extents overflow B-tree. fn overflow_extents(&self, file_id: u32, fork_type: u8, mut start: u32) -> Vec<(u32, u32)> { let mut out = vec![]; let node_size = match self.tree_node_size(&self.extents_file) { Some(n) => n, None => return out, }; let total_nodes = self.extents_file.size / node_size as u64; for n in 1..total_nodes.min(100_000) { let Some(node) = self.read_tree_node(&self.extents_file, n as u32, node_size) else { continue; }; if node[8] != 0xFF { continue; } for r in records(&node) { // key: keyLength u16, forkType u8, pad u8, fileID u32, startBlock u32 if r.len() < 12 + 64 || be16(r, 0) != 10 { continue; } if r[2] == fork_type && be32(r, 4) == file_id && be32(r, 8) == start { let mut got = 0; for i in 0..8 { let (s, c) = (be32(r, 12 + i * 8), be32(r, 16 + i * 8)); if c != 0 { out.push((s, c)); got += c; } } start += got; } } } out } fn tree_node_size(&self, f: &Fork) -> Option { let first = self.read_fork_bytes(f, 0, 64)?; let ns = be16(&first, 32) as usize; (512..=65536).contains(&ns).then_some(ns) } fn read_tree_node(&self, f: &Fork, n: u32, ns: usize) -> Option> { self.read_fork_bytes(f, n as u64 * ns as u64, ns) } /// Reads bytes from a fork using its resolved extent list (used for system B-trees). fn read_fork_bytes(&self, f: &Fork, off: u64, len: usize) -> Option> { let mut out = Vec::with_capacity(len); let mut pos = 0u64; for &(s, c) in &f.extents { let elen = c as u64 * self.bs; if off + out.len() as u64 >= pos && off + (out.len() as u64) < pos + elen { let within = off + out.len() as u64 - pos; let n = ((elen - within) as usize).min(len - out.len()); out.extend(read_vec(self.src, self.block_off(s as u64) + within, n).ok()?); if out.len() == len { return Some(out); } } pos += elen; } (out.len() == len).then_some(out) } } /// Returns the record slices of a B-tree node (each starting at its key). fn records(node: &[u8]) -> Vec<&[u8]> { let n = be16(node, 10) as usize; let mut offs = vec![]; for i in 0..=n { let p = node.len().saturating_sub(2 * (i + 1)); offs.push(be16(node, p) as usize); } let mut out = vec![]; for i in 0..n { let (a, b) = (offs[i], offs[i + 1]); if a >= 14 && b > a && b <= node.len() { out.push(&node[a..b]); } } out } fn utf16be(b: &[u8]) -> String { let u: Vec = b .chunks_exact(2) .map(|c| u16::from_be_bytes([c[0], c[1]])) .collect(); String::from_utf16_lossy(&u) .replace('/', "_") .replace(':', "/") } struct CatFile { parent: u32, name: String, id: u32, data: Fork, created: u32, modified: u32, accessed: u32, stale: bool, } fn date(d: u32) -> Option { (d != 0).then(|| d as i64 - HFS_EPOCH) } /// Decodes a catalog record (key + data). `max_id` filters out garbage CNIDs. fn decode_record( r: &[u8], max_id: u32, files: &mut Vec, threads: &mut HashMap, folders: &mut HashMap<(u32, String), u32>, stale: bool, ) { let klen = be16(r, 0) as usize; if klen < 6 || klen + 2 > r.len() { return; } let parent = be32(r, 2); let nlen = be16(r, 6) as usize; if nlen > 255 || klen != 6 + nlen * 2 && klen != 6 { return; } let name = utf16be(r.get(8..8 + nlen * 2).unwrap_or(&[])); let mut d = 2 + klen; if d % 2 == 1 { d += 1; } let Some(rec) = r.get(d..) else { return }; match be16(rec, 0) { 1 if rec.len() >= 88 => { let id = be32(rec, 8); if id >= 2 && id < max_id.max(16) { folders.entry((parent, name)).or_insert(id); } } 3 if rec.len() >= 10 => { // folder thread record: key = (folder_id, ""); data = (parent_id, folder_name) let pn = be16(rec, 8) as usize; if pn <= 255 { threads.entry(parent).or_insert(( be32(rec, 4), utf16be(rec.get(10..10 + pn * 2).unwrap_or(&[])), )); } } 2 if rec.len() >= 248 => { let id = be32(rec, 8); if id < 16 || id >= max_id.max(16) { return; } let data = parse_fork(&rec[88..168]); files.push(CatFile { parent, name, id, data, created: be32(rec, 12), modified: be32(rec, 16), accessed: be32(rec, 24), stale, }); } 4 if rec.len() >= 10 => { let pn = be16(rec, 8) as usize; if pn <= 255 { threads.entry(parent).or_insert(( be32(rec, 4), utf16be(rec.get(10..10 + pn * 2).unwrap_or(&[])), )); } } _ => {} } } /// Carves deleted catalog file records from B-tree node slack space (between active records and offset table). fn scan_slack( node: &[u8], max_id: u32, files: &mut Vec, threads: &mut HashMap, folders: &mut HashMap<(u32, String), u32>, ) { let n = be16(node, 10) as usize; let table = node.len().saturating_sub(2 * (n + 1)); let used_end = (be16(node, table) as usize).max(14); let mut p = used_end + (used_end % 2); while p + 40 < table { let klen = be16(node, p) as usize; let nlen = be16(node, p + 6) as usize; if klen == 6 + nlen * 2 && (1..=255).contains(&nlen) && p + 2 + klen + 2 < table { let parent = be32(node, p + 2); if parent >= 1 && parent < max_id.max(16) { let d = (p + 2 + klen + 1) & !1; let rt = be16(node, d); if matches!(rt, 1..=4) { let end = (d + 248).min(table); decode_record(&node[p..end], max_id, files, threads, folders, true); p = d + if rt == 2 { 248 } else { 16 }; continue; } } } p += 2; } } pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan { let mut res = VolumeScan::default(); let _ = part_len; let Some(vbase) = locate(ctx.src, base) else { return res; }; let Some(vol) = Vol::open(ctx.src, vbase) else { return res; }; // Free-space map from the allocation file. if let Some(bm) = vol.read_fork_all(&vol.alloc, (vol.total / 8 + 1) as usize) { let mut free = vec![]; let mut run: Option = None; for b in 0..vol.total { let used = bm .get((b / 8) as usize) .map(|x| x >> (7 - (b % 8)) & 1 == 1) .unwrap_or(true); match (used, run) { (false, None) => run = Some(b), (true, Some(s)) => { free.push(Extent::new(vol.block_off(s), (b - s) * vol.bs)); run = None; } _ => {} } } if let Some(s) = run { free.push(Extent::new(vol.block_off(s), (vol.total - s) * vol.bs)); } res.free = Some(merge_extents(free)); } let Some(ns) = vol.tree_node_size(&vol.catalog) else { res.warnings.push("HFS+: catálogo ilegível.".into()); return res; }; let Some(hdr) = vol.read_tree_node(&vol.catalog, 0, ns) else { return res; }; let first_leaf = be32(&hdr, 24); let max_id = vol.next_cnid.max(16); let mut files: Vec = vec![]; let mut threads: HashMap = HashMap::new(); let mut folders: HashMap<(u32, String), u32> = HashMap::new(); let mut live_ids: HashSet = HashSet::new(); let mut n = first_leaf; let mut guard = 0; while n != 0 && guard < 5_000_000 { guard += 1; if (ctx.cancelled)() { return res; } let Some(node) = vol.read_tree_node(&vol.catalog, n, ns) else { break; }; if node[8] != 0xFF { break; } let mut live_here: Vec = vec![]; for r in records(&node) { decode_record(r, max_id, &mut live_here, &mut threads, &mut folders, false); } for f in &live_here { live_ids.insert(f.id); } files.extend(live_here); scan_slack(&node, max_id, &mut files, &mut threads, &mut folders); n = be32(&node, 0); } // Journal: older copies of catalog leaf nodes. if vol.journal_info != 0 { scan_journal( &vol, ns, max_id, &mut files, &mut threads, &mut folders, ctx, ); } // Reconstruct folder paths from folder thread records. let path_of = |parent: u32| -> String { let mut parts = vec![]; let mut cur = parent; for _ in 0..64 { if cur <= 2 { break; } match threads.get(&cur) { Some((p, name)) => { parts.push(name.clone()); cur = *p; } None => break, } } parts.reverse(); format!("/{}", parts.join("/")) }; let free_set = res.free.clone().unwrap_or_default(); let mut seen: HashSet<(u32, u64)> = HashSet::new(); for f in files .iter() .filter(|f| f.stale && !live_ids.contains(&f.id)) { if f.data.size == 0 || f.data.extents.is_empty() { continue; } let ex = vol.fork_extents(&f.data, f.id, 0); let total_len: u64 = ex.iter().map(|e| e.len).sum(); if ex.is_empty() || total_len < f.data.size || ex.iter().any(|e| e.end() > vol.base + vol.total * vol.bs) { continue; } if !seen.insert((f.id, ex[0].offset)) { continue; } // Trim extents to the logical file size. let extents = super::fat::trim_extents(ex, f.data.size); // Check whether any blocks have already been reallocated. let reused = extents.iter().filter(|e| !covered_by(&free_set, e)).count(); let (conf, note) = if reused == 0 { ( Confidence::High, "registro apagado do catálogo HFS+; blocos ainda livres".to_string(), ) } else { ( Confidence::Low, "alguns blocos do arquivo já foram reaproveitados".to_string(), ) }; let nf = NewFound { name: f.name.clone(), dir: Some(path_of(f.parent)), size: f.data.size, origin: Origin::Hfs, data: DataLoc::Extents { extents }, modified: date(f.modified), created: date(f.created), accessed: date(f.accessed), base: conf, note: Some(¬e), }; let found = make_found(ctx, nf); (ctx.emit)(found); } res } fn covered_by(free: &[Extent], e: &Extent) -> bool { let i = free.partition_point(|f| f.end() <= e.offset); free.get(i) .map(|f| f.offset <= e.offset && f.end() >= e.end()) .unwrap_or(false) } impl Vol<'_> { fn read_fork_all(&self, f: &Fork, max: usize) -> Option> { let mut out = vec![]; for &(s, c) in &f.extents { let n = (c as u64 * self.bs) as usize; out.extend( read_vec( self.src, self.block_off(s as u64), n.min(max.saturating_sub(out.len())), ) .ok()?, ); if out.len() >= max { break; } } Some(out) } } /// Scans the HFS+ journal buffer for historical catalog leaf nodes. fn scan_journal( vol: &Vol, ns: usize, max_id: u32, files: &mut Vec, threads: &mut HashMap, folders: &mut HashMap<(u32, String), u32>, ctx: &Ctx, ) { let Some(info) = read_vec(vol.src, vol.block_off(vol.journal_info as u64), 52) .ok() .filter(|b| b.len() == 52) else { return; }; let (joff, jsize) = (be64(&info, 36), be64(&info, 44)); if jsize == 0 || jsize > 1 << 30 || vol.base + joff + jsize > vol.src.size() { return; } let mut pos = 0u64; let chunk = 4 << 20; while pos < jsize { if (ctx.cancelled)() { return; } let n = chunk.min(jsize - pos) as usize; let Ok(buf) = read_vec(vol.src, vol.base + joff + pos, n + ns) else { break; }; let mut i = 0; while i + ns <= buf.len().min(n + ns) && i < n { let nd = &buf[i..i + ns]; // catalog leaf node: kind = -1, height = 1, plausible record offsets if nd[8] == 0xFF && nd[9] == 1 && be16(nd, 10) > 0 && (be16(nd, 10) as usize) < ns / 8 { let table = ns - 2 * (be16(nd, 10) as usize + 1); if (be16(nd, ns - 2) as usize) == 14 && be16(nd, table) as usize > 14 && (be16(nd, table) as usize) <= table { for r in records(nd) { decode_record(r, max_id, files, threads, folders, true); } scan_slack(nd, max_id, files, threads, folders); } } i += 512; } pos += n as u64; } }