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
577 lines
18 KiB
Rust
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(¬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<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;
|
|
}
|
|
}
|