Files
guara/crates/guara-core/src/fs/hfsplus.rs
T
Lucas RayanandClaude Sonnet 5 6328220c60
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
chore(i18n): finish translating code comments and scripts to English
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
2026-10-30 20:43:02 -03:00

577 lines
18 KiB
Rust

//! 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<u64> {
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<Vol<'a>> {
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<Extent> {
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::<Vec<_>>(),
)
.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<usize> {
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<Vec<u8>> {
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<Vec<u8>> {
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<u16> = 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<i64> {
(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<CatFile>,
threads: &mut HashMap<u32, (u32, String)>,
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<CatFile>,
threads: &mut HashMap<u32, (u32, String)>,
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<u64> = 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<CatFile> = vec![];
let mut threads: HashMap<u32, (u32, String)> = HashMap::new();
let mut folders: HashMap<(u32, String), u32> = HashMap::new();
let mut live_ids: HashSet<u32> = 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<CatFile> = 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(&note),
};
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<Vec<u8>> {
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<CatFile>,
threads: &mut HashMap<u32, (u32, String)>,
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;
}
}