use core::fmt;
use crate::{
be16, be32, decmpfs_xattr, decode_utf16, for_each_record, locate_catalog, locate_extents,
CatalogLoc, VOLUME_HEADER_OFFSET,
};
pub use forensicnomicon::report::Severity;
const HFS_EPOCH_TO_UNIX: u32 = 2_082_844_800;
const RECORD_FOLDER: i16 = 1;
const RECORD_FILE: i16 = 2;
const RECORD_FOLDER_THREAD: i16 = 3;
const RECORD_FILE_THREAD: i16 = 4;
const NODE_LEAF: i8 = -1;
const NODE_INDEX: i8 = 0;
const NODE_HEADER: i8 = 1;
const NODE_MAP: i8 = 2;
impl forensicnomicon::report::Observation for Anomaly {
fn severity(&self) -> Option<Severity> {
Some(self.severity)
}
fn code(&self) -> &'static str {
self.code
}
fn note(&self) -> String {
self.note.clone()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AnomalyKind {
BtreeNodeInvalid {
tree: String,
node: u32,
detail: String,
},
CatalogExtentsMismatch {
cnid: u32,
name: String,
logical: u64,
allocated_blocks: u32,
needed_blocks: u32,
},
DeletedButReferenced {
cnid: u32,
parent: u32,
name: String,
},
TimeAnomaly {
cnid: u32,
name: String,
detail: String,
},
DecmpfsMissingResource {
cnid: u32,
name: String,
compression_type: u32,
detail: String,
},
}
impl AnomalyKind {
#[must_use]
pub fn severity(&self) -> Severity {
match self {
AnomalyKind::BtreeNodeInvalid { .. } => Severity::High,
AnomalyKind::CatalogExtentsMismatch { .. } => Severity::High,
AnomalyKind::DeletedButReferenced { .. } => Severity::Medium,
AnomalyKind::TimeAnomaly { .. } => Severity::Medium,
AnomalyKind::DecmpfsMissingResource { .. } => Severity::High,
}
}
#[must_use]
pub fn code(&self) -> &'static str {
match self {
AnomalyKind::BtreeNodeInvalid { .. } => "HFS-BTREE-NODE-INVALID",
AnomalyKind::CatalogExtentsMismatch { .. } => "HFS-CATALOG-EXTENTS-MISMATCH",
AnomalyKind::DeletedButReferenced { .. } => "HFS-DELETED-BUT-REFERENCED",
AnomalyKind::TimeAnomaly { .. } => "HFS-TIME-ANOMALY",
AnomalyKind::DecmpfsMissingResource { .. } => "HFS-DECMPFS-MISSING-RESOURCE",
}
}
#[must_use]
pub fn note(&self) -> String {
match self {
AnomalyKind::BtreeNodeInvalid { tree, node, detail } => format!(
"{tree} B-tree node {node}: {detail} — the reader walks the leaf chain by these \
node links/descriptors, so an inconsistent descriptor is consistent with \
structural corruption or an edit that rewired the tree to hide or strand records"
),
AnomalyKind::CatalogExtentsMismatch {
cnid,
name,
logical,
allocated_blocks,
needed_blocks,
} => format!(
"file `{name}` (CNID {cnid}) declares a {logical}-byte data fork needing \
{needed_blocks} allocation block(s) but its catalog + extents-overflow extents \
cover only {allocated_blocks} — the catalog and extents B-tree are HFS+'s two \
records of a file's allocation and must agree; a divergence is consistent with a \
truncated/edited extent list or a forged size"
),
AnomalyKind::DeletedButReferenced { cnid, parent, name } => format!(
"CNID {cnid} (`{name}`, parent {parent}) has a thread record but no file/folder \
catalog record — the thread is a dangling reference, consistent with the record \
having been deleted while its thread leaked, leaving a recoverable name/parent \
for vanished content"
),
AnomalyKind::TimeAnomaly { cnid, name, detail } => format!(
"entry `{name}` (CNID {cnid}): {detail} — consistent with a backdated/forged \
timestamp or with timestamp corruption"
),
AnomalyKind::DecmpfsMissingResource {
cnid,
name,
compression_type,
detail,
} => format!(
"file `{name}` (CNID {cnid}) carries a com.apple.decmpfs attribute \
(compression_type {compression_type}) but {detail} — `read_file` cannot \
materialize it; consistent with the compressed payload having been removed (data \
destruction) or partial corruption"
),
}
}
}
#[derive(Debug, Clone)]
pub struct Anomaly {
pub severity: Severity,
pub code: &'static str,
pub kind: AnomalyKind,
pub note: String,
}
impl Anomaly {
#[must_use]
pub fn new(kind: AnomalyKind) -> Self {
Anomaly {
severity: kind.severity(),
code: kind.code(),
note: kind.note(),
kind,
}
}
}
impl fmt::Display for Anomaly {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}] {}: {}", self.severity, self.code, self.note)
}
}
#[cfg_attr(test, derive(Debug))]
struct CatalogFile {
cnid: u32,
name: String,
logical: u64,
inline_blocks: u32,
extents_full: bool,
create: u32,
content_mod: u32,
has_resource_fork: bool,
resource_logical: u64,
resource_blocks: u32,
}
#[must_use]
pub fn audit(volume: &[u8]) -> Vec<Anomaly> {
let Some(cat) = locate_catalog(volume) else {
return Vec::new();
};
let mut out = Vec::new();
audit_btree_nodes(volume, &cat, "catalog", &mut out);
if let Some(ext) = locate_extents(volume) {
audit_btree_nodes(volume, &ext, "extents", &mut out);
}
let mut record_cnids: std::collections::HashSet<u32> = std::collections::HashSet::new();
let mut files: Vec<CatalogFile> = Vec::new();
let mut threads: Vec<(u32, u32, String)> = Vec::new();
for_each_record(volume, &cat, |rec| match classify_record(rec) {
Some(CatalogRecord::Entry { cnid, file }) => {
record_cnids.insert(cnid);
if let Some(f) = file {
files.push(f);
}
}
Some(CatalogRecord::Thread { cnid, parent, name }) => {
threads.push((cnid, parent, name));
}
None => {}
});
for (cnid, parent, name) in &threads {
if !record_cnids.contains(cnid) {
out.push(Anomaly::new(AnomalyKind::DeletedButReferenced {
cnid: *cnid,
parent: *parent,
name: name.clone(),
}));
}
}
let volume_modify = be32(&volume[VOLUME_HEADER_OFFSET + 20..VOLUME_HEADER_OFFSET + 24]);
let block_size = cat.block_size.max(1) as u64;
let ext_loc = locate_extents(volume);
for f in &files {
audit_time(f, volume_modify, &mut out);
audit_extents(volume, f, block_size, ext_loc.as_ref(), &mut out);
audit_decmpfs(volume, f, &mut out);
}
out
}
fn audit_btree_nodes(volume: &[u8], loc: &CatalogLoc, tree: &str, out: &mut Vec<Anomaly>) {
check_node(volume, loc, 0, tree, out);
let mut node = loc.first_leaf;
let mut walked = 0u32;
let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
while node != 0 && walked < crate::MAX_LEAF_NODES {
walked += 1;
if !seen.insert(node) {
out.push(Anomaly::new(AnomalyKind::BtreeNodeInvalid {
tree: tree.to_string(),
node,
detail: format!("forward-link chain loops back to already-visited node {node}"),
}));
break;
}
let Some(nd) = node_slice(volume, loc, node) else {
break; };
check_node(volume, loc, node, tree, out);
node = be32(&nd[0..4]);
}
}
fn check_node(volume: &[u8], loc: &CatalogLoc, node: u32, tree: &str, out: &mut Vec<Anomaly>) {
let Some(nd) = node_slice(volume, loc, node) else {
return; };
let f_link = be32(&nd[0..4]);
let b_link = be32(&nd[4..8]);
let kind = nd[8] as i8;
let height = nd[9];
let kind_ok = matches!(kind, NODE_LEAF | NODE_INDEX | NODE_HEADER | NODE_MAP);
if !kind_ok {
out.push(Anomaly::new(AnomalyKind::BtreeNodeInvalid {
tree: tree.to_string(),
node,
detail: format!(
"undocumented node kind {kind} (expected leaf -1, index 0, header 1, or map 2)"
),
}));
}
if kind == NODE_LEAF && height == 0 {
out.push(Anomaly::new(AnomalyKind::BtreeNodeInvalid {
tree: tree.to_string(),
node,
detail: format!("leaf node at height {height} (a leaf must sit at height >= 1)"),
}));
}
if kind == NODE_HEADER && node != 0 {
out.push(Anomaly::new(AnomalyKind::BtreeNodeInvalid {
tree: tree.to_string(),
node,
detail: format!(
"header-kind descriptor on node {node} (the header node must be node 0)"
),
}));
}
if (f_link != 0 && f_link == node) || (b_link != 0 && b_link == node) {
out.push(Anomaly::new(AnomalyKind::BtreeNodeInvalid {
tree: tree.to_string(),
node,
detail: format!(
"node links point at itself (fLink {f_link}, bLink {b_link}) — a node cannot be \
its own neighbour"
),
}));
}
}
fn node_slice<'a>(volume: &'a [u8], loc: &CatalogLoc, n: u32) -> Option<&'a [u8]> {
let off = (n as usize)
.checked_mul(loc.node_size)?
.checked_add(loc.cat_base)?;
let end = off.checked_add(loc.node_size)?;
if end > volume.len() || loc.node_size < 14 {
return None; }
Some(&volume[off..end])
}
#[cfg_attr(test, derive(Debug))]
enum CatalogRecord {
Entry {
cnid: u32,
file: Option<CatalogFile>,
},
Thread {
cnid: u32,
parent: u32,
name: String,
},
}
fn classify_record(rec: &[u8]) -> Option<CatalogRecord> {
if rec.len() < 8 {
return None;
}
let key_len = be16(&rec[0..2]) as usize;
let name_len = be16(&rec[6..8]) as usize;
let name_end = 8usize.checked_add(name_len.checked_mul(2)?)?;
if name_end > rec.len() {
return None;
}
let key_name = decode_utf16(&rec[8..name_end]);
let data = 2usize.checked_add(key_len)?;
if data.checked_add(2)? > rec.len() {
return None;
}
let rtype = i16::from_be_bytes([rec[data], rec[data + 1]]);
match rtype {
RECORD_FOLDER => {
if data.checked_add(12)? > rec.len() {
return None;
}
let cnid = be32(&rec[data + 8..data + 12]);
Some(CatalogRecord::Entry { cnid, file: None })
}
RECORD_FILE => {
if data.checked_add(248)? > rec.len() {
if data.checked_add(12)? <= rec.len() {
let cnid = be32(&rec[data + 8..data + 12]);
return Some(CatalogRecord::Entry { cnid, file: None });
}
return None;
}
let cnid = be32(&rec[data + 8..data + 12]);
let create = be32(&rec[data + 16..data + 20]);
let content_mod = be32(&rec[data + 20..data + 24]);
let logical = u64::from_be_bytes(rec[data + 88..data + 96].try_into().ok()?);
let inline_blocks = fork_inline_blocks(&rec[data + 88..]);
let extents_full = fork_all_slots_used(&rec[data + 88..]);
let resource_logical = u64::from_be_bytes(rec[data + 168..data + 176].try_into().ok()?);
let resource_blocks = fork_inline_blocks(&rec[data + 168..]);
Some(CatalogRecord::Entry {
cnid,
file: Some(CatalogFile {
cnid,
name: key_name,
logical,
inline_blocks,
extents_full,
create,
content_mod,
has_resource_fork: resource_logical > 0,
resource_logical,
resource_blocks,
}),
})
}
RECORD_FOLDER_THREAD | RECORD_FILE_THREAD => {
if data.checked_add(10)? > rec.len() {
return None;
}
let cnid = be32(&rec[2..6]);
let parent = be32(&rec[data + 4..data + 8]);
let tnl = be16(&rec[data + 8..data + 10]) as usize;
let tn_end = (data + 10).checked_add(tnl.checked_mul(2)?)?;
let name = if tn_end <= rec.len() {
decode_utf16(&rec[data + 10..tn_end])
} else {
String::new()
};
Some(CatalogRecord::Thread { cnid, parent, name })
}
_ => None,
}
}
fn fork_inline_blocks(fork: &[u8]) -> u32 {
if fork.len() < 80 {
return 0;
}
let mut total: u32 = 0;
for i in 0..8 {
let e = 16 + i * 8;
let count = be32(&fork[e + 4..e + 8]);
total = total.saturating_add(count);
}
total
}
fn fork_all_slots_used(fork: &[u8]) -> bool {
if fork.len() < 80 {
return false;
}
(0..8).all(|i| {
let e = 16 + i * 8;
be32(&fork[e + 4..e + 8]) != 0
})
}
fn overflow_blocks(volume: &[u8], loc: &CatalogLoc, cnid: u32, fork_type: u8) -> u32 {
let mut total: u32 = 0;
for_each_record(volume, loc, |rec| {
if rec.len() < 12 {
return; }
if rec[2] != fork_type {
return;
}
if be32(&rec[4..8]) != cnid {
return;
}
let key_len = be16(&rec[0..2]) as usize;
let data = 2 + key_len;
for i in 0..8 {
let e = data + i * 8;
if e + 8 > rec.len() {
break; }
total = total.saturating_add(be32(&rec[e + 4..e + 8]));
}
});
total
}
fn needed_blocks(logical: u64, block_size: u64) -> u32 {
logical.div_ceil(block_size).min(u64::from(u32::MAX)) as u32
}
fn audit_time(f: &CatalogFile, volume_modify: u32, out: &mut Vec<Anomaly>) {
if f.create > f.content_mod && f.content_mod != 0 {
out.push(Anomaly::new(AnomalyKind::TimeAnomaly {
cnid: f.cnid,
name: f.name.clone(),
detail: format!(
"creation time {} is later than content-modification time {} (a file cannot be \
modified before it is created)",
f.create, f.content_mod
),
}));
return;
}
for (label, t) in [
("creation", f.create),
("content-modification", f.content_mod),
] {
if t != 0 && t < HFS_EPOCH_TO_UNIX {
out.push(Anomaly::new(AnomalyKind::TimeAnomaly {
cnid: f.cnid,
name: f.name.clone(),
detail: format!(
"{label} time {t} predates the HFS+ epoch boundary (before 1904 — impossible)"
),
}));
return;
}
}
for (label, t) in [
("creation", f.create),
("content-modification", f.content_mod),
] {
if volume_modify != 0 && t > volume_modify {
out.push(Anomaly::new(AnomalyKind::TimeAnomaly {
cnid: f.cnid,
name: f.name.clone(),
detail: format!(
"{label} time {t} is after the volume's last-written date {volume_modify} \
(nothing inside a volume can postdate the volume header)"
),
}));
return;
}
}
}
fn audit_extents(
volume: &[u8],
f: &CatalogFile,
block_size: u64,
ext_loc: Option<&CatalogLoc>,
out: &mut Vec<Anomaly>,
) {
let needed = needed_blocks(f.logical, block_size);
let mut allocated = f.inline_blocks;
if f.extents_full {
if let Some(ext) = ext_loc {
allocated = allocated.saturating_add(overflow_blocks(volume, ext, f.cnid, 0));
}
}
if allocated < needed {
out.push(Anomaly::new(AnomalyKind::CatalogExtentsMismatch {
cnid: f.cnid,
name: f.name.clone(),
logical: f.logical,
allocated_blocks: allocated,
needed_blocks: needed,
}));
}
}
fn audit_decmpfs(volume: &[u8], f: &CatalogFile, out: &mut Vec<Anomaly>) {
let Some(xattr) = decmpfs_xattr(volume, f.cnid) else {
return;
};
if xattr.len() < forensicnomicon::decmpfs::HEADER_LEN {
out.push(Anomaly::new(AnomalyKind::DecmpfsMissingResource {
cnid: f.cnid,
name: f.name.clone(),
compression_type: 0,
detail: format!(
"its com.apple.decmpfs xattr is {} bytes, shorter than the 16-byte decmpfs header",
xattr.len()
),
}));
return;
}
let off = forensicnomicon::decmpfs::COMPRESSION_TYPE_OFFSET;
let compression_type =
u32::from_le_bytes([xattr[off], xattr[off + 1], xattr[off + 2], xattr[off + 3]]);
let Some(compression) = forensicnomicon::decmpfs::classify(compression_type) else {
out.push(Anomaly::new(AnomalyKind::DecmpfsMissingResource {
cnid: f.cnid,
name: f.name.clone(),
compression_type,
detail: "its compression_type is not a documented decmpfs type (read_file refuses it)"
.to_string(),
}));
return;
};
match compression.storage {
forensicnomicon::decmpfs::Storage::ResourceFork => {
if !f.has_resource_fork || f.resource_logical == 0 {
out.push(Anomaly::new(AnomalyKind::DecmpfsMissingResource {
cnid: f.cnid,
name: f.name.clone(),
compression_type,
detail: "it declares resource-fork storage but its resource fork is empty"
.to_string(),
}));
} else if f.resource_blocks == 0 {
out.push(Anomaly::new(AnomalyKind::DecmpfsMissingResource {
cnid: f.cnid,
name: f.name.clone(),
compression_type,
detail: format!(
"it declares a {}-byte resource fork but the fork allocates zero blocks \
(the payload is unrecoverable)",
f.resource_logical
),
}));
}
}
forensicnomicon::decmpfs::Storage::Inline => {
if xattr.len() == forensicnomicon::decmpfs::HEADER_LEN {
out.push(Anomaly::new(AnomalyKind::DecmpfsMissingResource {
cnid: f.cnid,
name: f.name.clone(),
compression_type,
detail: "it declares inline storage but the xattr holds only the header, with \
no payload following it"
.to_string(),
}));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use forensicnomicon::report::Observation;
fn all_kinds() -> Vec<AnomalyKind> {
vec![
AnomalyKind::BtreeNodeInvalid {
tree: "catalog".into(),
node: 7,
detail: "undocumented node kind 66".into(),
},
AnomalyKind::CatalogExtentsMismatch {
cnid: 18,
name: "A.TXT".into(),
logical: 9000,
allocated_blocks: 1,
needed_blocks: 3,
},
AnomalyKind::DeletedButReferenced {
cnid: 42,
parent: 2,
name: "GONE".into(),
},
AnomalyKind::TimeAnomaly {
cnid: 18,
name: "A.TXT".into(),
detail: "creation later than modification".into(),
},
AnomalyKind::DecmpfsMissingResource {
cnid: 20,
name: "comp.bin".into(),
compression_type: 8,
detail: "resource fork is empty".into(),
},
]
}
#[test]
fn every_kind_has_a_stable_code_and_grade() {
let mut codes = std::collections::HashSet::new();
for kind in all_kinds() {
let a = Anomaly::new(kind.clone());
assert_eq!(a.code, kind.code());
assert_eq!(a.severity, kind.severity());
assert_eq!(a.note, kind.note());
assert!(
a.code.starts_with("HFS-"),
"code not HFS-prefixed: {}",
a.code
);
assert!(codes.insert(a.code), "duplicate code {}", a.code);
}
}
#[test]
fn notes_are_observations_not_verdicts() {
for kind in all_kinds() {
let note = kind.note().to_lowercase();
assert!(note.contains("consistent with"), "note lacks hedge: {note}");
for verdict in ["proves", "confirms", "guilty", "malicious actor"] {
assert!(
!note.contains(verdict),
"verdict word `{verdict}` in: {note}"
);
}
}
}
#[test]
fn display_renders_severity_code_note() {
let a = Anomaly::new(AnomalyKind::DeletedButReferenced {
cnid: 42,
parent: 2,
name: "GONE".into(),
});
let s = format!("{a}");
assert!(
s.contains("HFS-DELETED-BUT-REFERENCED:") && s.starts_with('['),
"{s}"
);
}
#[test]
fn observation_to_finding_carries_code_and_grade() {
let a = Anomaly::new(AnomalyKind::BtreeNodeInvalid {
tree: "extents".into(),
node: 3,
detail: "bad".into(),
});
assert_eq!(Observation::severity(&a), Some(Severity::High));
assert_eq!(Observation::code(&a), "HFS-BTREE-NODE-INVALID");
assert_eq!(Observation::note(&a), a.note);
}
#[test]
fn audit_of_non_hfs_buffer_is_empty() {
assert!(audit(&[0u8; 64]).is_empty());
assert!(audit(&[]).is_empty());
}
#[test]
fn classify_rejects_malformed_records() {
assert!(classify_record(&[0u8; 4]).is_none());
let mut rec = vec![0u8; 8];
rec[6] = 0xFF; rec[7] = 0xFF;
assert!(classify_record(&rec).is_none());
}
#[test]
fn fork_helpers_reject_short_forks() {
assert_eq!(fork_inline_blocks(&[0u8; 10]), 0);
assert!(!fork_all_slots_used(&[0u8; 10]));
}
#[test]
fn needed_blocks_rounds_up() {
assert_eq!(needed_blocks(0, 4096), 0);
assert_eq!(needed_blocks(1, 4096), 1);
assert_eq!(needed_blocks(4096, 4096), 1);
assert_eq!(needed_blocks(4097, 4096), 2);
}
#[test]
fn classify_folder_and_thread_records() {
let mut rec = vec![0u8; 32];
rec[0..2].copy_from_slice(&6u16.to_be_bytes()); rec[6..8].copy_from_slice(&0u16.to_be_bytes()); let data = 2 + 6;
rec[data..data + 2].copy_from_slice(&1i16.to_be_bytes()); rec[data + 8..data + 12].copy_from_slice(&17u32.to_be_bytes()); match classify_record(&rec) {
Some(CatalogRecord::Entry { cnid, file }) => {
assert_eq!(cnid, 17);
assert!(file.is_none());
}
other => panic!("expected folder Entry, got {other:?}"),
}
let mut t = vec![0u8; 40];
t[0..2].copy_from_slice(&6u16.to_be_bytes());
t[2..6].copy_from_slice(&99u32.to_be_bytes()); t[6..8].copy_from_slice(&0u16.to_be_bytes());
let d = 2 + 6;
t[d..d + 2].copy_from_slice(&3i16.to_be_bytes()); t[d + 4..d + 8].copy_from_slice(&2u32.to_be_bytes()); t[d + 8..d + 10].copy_from_slice(&1u16.to_be_bytes()); t[d + 10..d + 12].copy_from_slice(&u16::from(b'A').to_be_bytes());
match classify_record(&t) {
Some(CatalogRecord::Thread { cnid, parent, name }) => {
assert_eq!(cnid, 99);
assert_eq!(parent, 2);
assert_eq!(name, "A");
}
other => panic!("expected Thread, got {other:?}"),
}
}
#[test]
fn classify_unknown_record_type_is_none() {
let mut rec = vec![0u8; 16];
rec[0..2].copy_from_slice(&6u16.to_be_bytes());
let data = 2 + 6;
rec[data..data + 2].copy_from_slice(&99i16.to_be_bytes()); assert!(classify_record(&rec).is_none());
}
#[test]
fn classify_truncated_folder_yields_none() {
let mut rec = vec![0u8; 10];
rec[0..2].copy_from_slice(&6u16.to_be_bytes());
let data = 2 + 6;
rec[data..data + 2].copy_from_slice(&1i16.to_be_bytes());
assert!(classify_record(&rec).is_none());
}
#[test]
fn classify_short_file_record_still_yields_cnid() {
let mut rec = vec![0u8; 24];
rec[0..2].copy_from_slice(&6u16.to_be_bytes());
let data = 2 + 6;
rec[data..data + 2].copy_from_slice(&2i16.to_be_bytes()); rec[data + 8..data + 12].copy_from_slice(&18u32.to_be_bytes());
match classify_record(&rec) {
Some(CatalogRecord::Entry { cnid, file }) => {
assert_eq!(cnid, 18);
assert!(file.is_none());
}
other => panic!("expected short-file Entry, got {other:?}"),
}
}
#[test]
fn classify_record_with_bad_key_len_is_none() {
let mut rec = vec![0u8; 9];
rec[0..2].copy_from_slice(&100u16.to_be_bytes());
assert!(classify_record(&rec).is_none());
}
#[test]
fn classify_record_name_runs_off_end_is_none() {
let mut rec = vec![0u8; 12];
rec[0..2].copy_from_slice(&4u16.to_be_bytes()); rec[6..8].copy_from_slice(&50u16.to_be_bytes()); assert!(classify_record(&rec).is_none());
}
#[test]
fn classify_file_record_too_short_for_cnid_is_none() {
let mut rec = vec![0u8; 12];
rec[0..2].copy_from_slice(&2u16.to_be_bytes()); let data = 2 + 2;
rec[data..data + 2].copy_from_slice(&2i16.to_be_bytes()); assert!(classify_record(&rec).is_none());
}
#[test]
fn classify_truncated_thread_is_none() {
let mut rec = vec![0u8; 11];
rec[0..2].copy_from_slice(&2u16.to_be_bytes());
let data = 2 + 2;
rec[data..data + 2].copy_from_slice(&3i16.to_be_bytes()); assert!(classify_record(&rec).is_none());
}
#[test]
fn classify_thread_with_name_overrun_uses_empty_name() {
let mut rec = vec![0u8; 20];
rec[0..2].copy_from_slice(&6u16.to_be_bytes());
rec[2..6].copy_from_slice(&77u32.to_be_bytes()); let d = 2 + 6;
rec[d..d + 2].copy_from_slice(&4i16.to_be_bytes()); rec[d + 4..d + 8].copy_from_slice(&2u32.to_be_bytes()); rec[d + 8..d + 10].copy_from_slice(&100u16.to_be_bytes()); match classify_record(&rec) {
Some(CatalogRecord::Thread { cnid, parent, name }) => {
assert_eq!(cnid, 77);
assert_eq!(parent, 2);
assert!(name.is_empty());
}
other => panic!("expected Thread, got {other:?}"),
}
}
#[test]
fn audit_extents_consults_overflow_when_slots_full() {
let f = CatalogFile {
cnid: 18,
name: "BIG.BIN".into(),
logical: 10 * 4096,
inline_blocks: 8,
extents_full: true,
create: HFS_EPOCH_TO_UNIX,
content_mod: HFS_EPOCH_TO_UNIX,
has_resource_fork: false,
resource_logical: 0,
resource_blocks: 0,
};
let node_size = 512usize;
let mut vol = vec![0u8; node_size * 2];
let no = node_size; vol[no + 8] = 0xFF;
vol[no + 9] = 1;
vol[no + 10..no + 12].copy_from_slice(&1u16.to_be_bytes());
let rec = 14usize;
let r = no + rec;
vol[r..r + 2].copy_from_slice(&10u16.to_be_bytes());
vol[r + 4..r + 8].copy_from_slice(&18u32.to_be_bytes());
let data = rec + 2 + 10;
vol[no + data + 4..no + data + 8].copy_from_slice(&2u32.to_be_bytes());
let slot = node_size - 2;
vol[no + slot..no + slot + 2].copy_from_slice(&(rec as u16).to_be_bytes());
let loc = CatalogLoc {
cat_base: 0,
node_size,
first_leaf: 1,
block_size: 4096,
};
let mut out = Vec::new();
audit_extents(&vol, &f, 4096, Some(&loc), &mut out);
assert!(
out.is_empty(),
"overflow should satisfy the size, got {out:?}"
);
let mut out2 = Vec::new();
audit_extents(&vol, &f, 4096, None, &mut out2);
assert_eq!(out2.len(), 1);
assert_eq!(out2[0].code, "HFS-CATALOG-EXTENTS-MISMATCH");
}
#[test]
fn overflow_blocks_sums_extent_record() {
let node_size = 512usize;
let cat_base = 0usize;
let mut vol = vec![0u8; node_size * 2];
let leaf = 1usize;
let no = leaf * node_size + cat_base;
vol[no + 8] = 0xFF; vol[no + 9] = 1;
vol[no + 10..no + 12].copy_from_slice(&1u16.to_be_bytes());
let rec = 14usize;
let r = no + rec;
vol[r..r + 2].copy_from_slice(&10u16.to_be_bytes()); vol[r + 2] = 0; vol[r + 4..r + 8].copy_from_slice(&18u32.to_be_bytes()); let data = rec + 2 + 10;
vol[no + data + 4..no + data + 8].copy_from_slice(&5u32.to_be_bytes());
let slot = node_size - 2;
vol[no + slot..no + slot + 2].copy_from_slice(&(rec as u16).to_be_bytes());
let loc = CatalogLoc {
cat_base,
node_size,
first_leaf: leaf as u32,
block_size: 4096,
};
assert_eq!(overflow_blocks(&vol, &loc, 18, 0), 5);
assert_eq!(overflow_blocks(&vol, &loc, 18, 0xFF), 0);
assert_eq!(overflow_blocks(&vol, &loc, 999, 0), 0);
}
}