use std::collections::HashMap;
use std::io::{Read, Seek, SeekFrom};
use crate::tree::TreeNode;
const HFS_PLUS_MAGIC: u16 = 0x482B;
const HFSX_MAGIC: u16 = 0x4858;
const VOLUME_HEADER_OFFSET: u64 = 1024;
const VOLUME_HEADER_SIZE: usize = 512;
const HFS_ROOT_FOLDER_CNID: u32 = 2;
const BTREE_LEAF_NODE: u8 = 0xFF;
const BTREE_HEADER_NODE: u8 = 0x01;
const RECORD_TYPE_FOLDER: u16 = 0x0001;
const RECORD_TYPE_FILE: u16 = 0x0002;
const RECORD_TYPE_FOLDER_THREAD: u16 = 0x0003;
const RECORD_TYPE_FILE_THREAD: u16 = 0x0004;
#[derive(Debug)]
pub enum Error {
TooShort,
BadMagic,
BadVersion,
BadCatalog,
TooDeep,
Io(std::io::Error),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::TooShort => write!(f, "image too short for HFS+ volume header"),
Error::BadMagic => write!(
f,
"HFS+ magic 0x482B or HFSX magic 0x4858 not found at offset 1024"
),
Error::BadVersion => write!(f, "HFS+ version field is not 4 (HFS+) or 5 (HFSX)"),
Error::BadCatalog => write!(f, "HFS+ catalog B-tree structure is invalid"),
Error::TooDeep => write!(f, "HFS+ B-tree too deep to traverse"),
Error::Io(e) => write!(f, "HFS+ I/O error: {e}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Io(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Error::Io(e)
}
}
#[derive(Debug, Clone, Copy)]
pub struct ForkData {
pub logical_size: u64,
pub total_blocks: u32,
pub extents: [(u32, u32); 8],
}
impl ForkData {
fn from_bytes(b: &[u8]) -> Self {
let logical_size = u64::from_be_bytes(b[0..8].try_into().unwrap());
let total_blocks = u32::from_be_bytes(b[12..16].try_into().unwrap());
let mut extents = [(0u32, 0u32); 8];
for (i, ext) in extents.iter_mut().enumerate() {
let off = 16 + i * 8;
ext.0 = u32::from_be_bytes(b[off..off + 4].try_into().unwrap());
ext.1 = u32::from_be_bytes(b[off + 4..off + 8].try_into().unwrap());
}
Self {
logical_size,
total_blocks,
extents,
}
}
fn first_extent_offset(&self, block_size: u64) -> Option<u64> {
if self.extents[0].1 == 0 {
return None;
}
Some(self.extents[0].0 as u64 * block_size)
}
fn is_single_extent(&self) -> bool {
if self.total_blocks == 0 {
return false;
}
self.extents[0].1 == self.total_blocks && self.extents[1..].iter().all(|&(_, c)| c == 0)
}
}
#[derive(Debug)]
pub struct VolumeHeader {
pub signature: u16,
pub version: u16,
pub file_count: u32,
pub folder_count: u32,
pub block_size: u32,
pub cat_file: ForkData,
}
impl VolumeHeader {
fn from_bytes(b: &[u8]) -> Result<Self, Error> {
if b.len() < VOLUME_HEADER_SIZE {
return Err(Error::TooShort);
}
let signature = u16::from_be_bytes([b[0], b[1]]);
if signature != HFS_PLUS_MAGIC && signature != HFSX_MAGIC {
return Err(Error::BadMagic);
}
let version = u16::from_be_bytes([b[2], b[3]]);
if version != 4 && version != 5 {
return Err(Error::BadVersion);
}
let file_count = u32::from_be_bytes(b[32..36].try_into().unwrap());
let folder_count = u32::from_be_bytes(b[36..40].try_into().unwrap());
let block_size = u32::from_be_bytes(b[40..44].try_into().unwrap());
let cat_file = ForkData::from_bytes(&b[272..352]);
Ok(Self {
signature,
version,
file_count,
folder_count,
block_size,
cat_file,
})
}
}
struct BTreeHeader {
node_size: u16,
first_leaf_node: u32,
}
impl BTreeHeader {
fn from_bytes(b: &[u8]) -> Self {
let first_leaf_node = u32::from_be_bytes(b[10..14].try_into().unwrap());
let node_size = u16::from_be_bytes(b[18..20].try_into().unwrap());
Self {
node_size,
first_leaf_node,
}
}
}
#[derive(Debug)]
enum CatalogRecord {
Folder {
parent_cnid: u32,
name: String,
cnid: u32,
},
File {
parent_cnid: u32,
name: String,
#[allow(dead_code)]
cnid: u32,
file_length: u64,
file_location: Option<u64>,
},
Thread {
#[allow(dead_code)]
cnid_key: u32, #[allow(dead_code)]
record_type: u16,
},
}
pub fn detect<R: Read + Seek>(r: &mut R) -> Result<(), Error> {
let saved = r.stream_position()?;
let result = do_detect(r);
let _ = r.seek(SeekFrom::Start(saved));
result
}
fn do_detect<R: Read + Seek>(r: &mut R) -> Result<(), Error> {
r.seek(SeekFrom::Start(VOLUME_HEADER_OFFSET))?;
let mut sig = [0u8; 2];
r.read_exact(&mut sig).map_err(|e| {
if e.kind() == std::io::ErrorKind::UnexpectedEof {
Error::TooShort
} else {
Error::Io(e)
}
})?;
let sig_val = u16::from_be_bytes([sig[0], sig[1]]);
if sig_val != HFS_PLUS_MAGIC && sig_val != HFSX_MAGIC {
return Err(Error::BadMagic);
}
Ok(())
}
pub fn parse_volume_header<R: Read + Seek>(r: &mut R) -> Result<VolumeHeader, Error> {
r.seek(SeekFrom::Start(VOLUME_HEADER_OFFSET))?;
let mut buf = [0u8; VOLUME_HEADER_SIZE];
r.read_exact(&mut buf).map_err(|e| {
if e.kind() == std::io::ErrorKind::UnexpectedEof {
Error::TooShort
} else {
Error::Io(e)
}
})?;
VolumeHeader::from_bytes(&buf)
}
pub fn detect_and_parse<R: Read + Seek>(r: &mut R) -> Result<TreeNode, Error> {
let header = parse_volume_header(r)?;
let records = read_catalog_leaf_records(r, &header)?;
Ok(build_tree(&records, header.block_size as u64))
}
fn read_catalog_leaf_records<R: Read + Seek>(
r: &mut R,
header: &VolumeHeader,
) -> Result<Vec<CatalogRecord>, Error> {
let block_size = header.block_size as u64;
let cat = &header.cat_file;
let cat_offset = match cat.first_extent_offset(block_size) {
Some(off) => off,
None => return Ok(Vec::new()),
};
r.seek(SeekFrom::Start(cat_offset))?;
let mut header_node_buf = vec![0u8; 120]; r.read_exact(&mut header_node_buf).map_err(|e| {
if e.kind() == std::io::ErrorKind::UnexpectedEof {
Error::TooShort
} else {
Error::Io(e)
}
})?;
let node_kind = header_node_buf[8];
if node_kind != BTREE_HEADER_NODE {
return Err(Error::BadCatalog);
}
let num_records_in_header = u16::from_be_bytes([header_node_buf[10], header_node_buf[11]]);
if num_records_in_header < 1 {
return Err(Error::TooShort);
}
let btree_header = BTreeHeader::from_bytes(&header_node_buf[14..]);
let node_size = btree_header.node_size as u64;
if node_size < 512 {
return Err(Error::BadCatalog);
}
let mut first_leaf = btree_header.first_leaf_node;
if first_leaf == 0 {
return Ok(Vec::new());
}
let mut records: Vec<CatalogRecord> = Vec::new();
let max_nodes: u32 = (cat.logical_size / node_size).min(u32::MAX as u64) as u32 + 1;
let mut visited = 0u32;
loop {
if visited > max_nodes {
return Err(Error::TooDeep);
}
visited += 1;
let node_offset = cat_offset + first_leaf as u64 * node_size;
r.seek(SeekFrom::Start(node_offset))?;
let mut node_buf = vec![0u8; node_size as usize];
r.read_exact(&mut node_buf).map_err(|e| {
if e.kind() == std::io::ErrorKind::UnexpectedEof {
Error::TooShort
} else {
Error::Io(e)
}
})?;
let f_link = u32::from_be_bytes(node_buf[0..4].try_into().unwrap());
let kind = node_buf[8];
let num_records = u16::from_be_bytes([node_buf[10], node_buf[11]]);
if kind != BTREE_LEAF_NODE {
if f_link == 0 {
break;
}
first_leaf = f_link;
continue;
}
parse_leaf_node_records(&node_buf, num_records, block_size, &mut records)?;
if f_link == 0 {
break;
}
first_leaf = f_link;
}
Ok(records)
}
fn parse_leaf_node_records(
node_buf: &[u8],
num_records: u16,
block_size: u64,
out: &mut Vec<CatalogRecord>,
) -> Result<(), Error> {
let node_size = node_buf.len();
for i in 0..num_records as usize {
let off_idx = node_size.saturating_sub(2 * (i + 1));
if off_idx + 2 > node_size {
break;
}
let rec_start = u16::from_be_bytes([node_buf[off_idx], node_buf[off_idx + 1]]) as usize;
if rec_start >= node_size {
continue;
}
let rec_data = &node_buf[rec_start..];
if rec_data.len() < 6 {
continue;
}
let key_length = u16::from_be_bytes([rec_data[0], rec_data[1]]) as usize;
if key_length < 6 || rec_data.len() < 2 + key_length {
continue;
}
let parent_cnid = u32::from_be_bytes(rec_data[2..6].try_into().unwrap());
let name_len = u16::from_be_bytes([rec_data[6], rec_data[7]]) as usize;
let name_bytes_len = name_len * 2;
if rec_data.len() < 8 + name_bytes_len {
continue;
}
let name = decode_utf16_be(&rec_data[8..8 + name_bytes_len]);
let raw_data_off = 2 + key_length; let data_off = (raw_data_off + 1) & !1; if rec_data.len() < data_off + 2 {
continue;
}
let data = &rec_data[data_off..];
let record_type = u16::from_be_bytes([data[0], data[1]]);
match record_type {
RECORD_TYPE_FOLDER => {
if data.len() < 12 {
continue;
}
let cnid = u32::from_be_bytes(data[8..12].try_into().unwrap());
out.push(CatalogRecord::Folder {
parent_cnid,
name,
cnid,
});
}
RECORD_TYPE_FILE => {
if data.len() < 168 {
continue;
}
let cnid = u32::from_be_bytes(data[8..12].try_into().unwrap());
let data_fork = ForkData::from_bytes(&data[88..168]);
let file_length = data_fork.logical_size;
let file_location = if data_fork.is_single_extent() {
data_fork.first_extent_offset(block_size)
} else {
None
};
out.push(CatalogRecord::File {
parent_cnid,
name,
cnid,
file_length,
file_location,
});
}
RECORD_TYPE_FOLDER_THREAD | RECORD_TYPE_FILE_THREAD => {
out.push(CatalogRecord::Thread {
cnid_key: parent_cnid, record_type,
});
}
_ => {
}
}
}
Ok(())
}
fn find_by_path_mut<'a>(node: &'a mut TreeNode, path: &[String]) -> Option<&'a mut TreeNode> {
if path.is_empty() {
return Some(node);
}
for child in &mut node.children {
if child.is_directory && child.name == path[0] {
return find_by_path_mut(child, &path[1..]);
}
}
None
}
fn cnid_path(cnid: u32, folder_map: &std::collections::HashMap<u32, (String, u32)>) -> Vec<String> {
if cnid == HFS_ROOT_FOLDER_CNID {
return vec![];
}
let mut path = Vec::new();
let mut cur = cnid;
let mut guard = 0usize;
loop {
guard += 1;
if guard > 1000 {
break; }
match folder_map.get(&cur) {
Some((name, parent)) => {
path.push(name.clone());
if *parent == HFS_ROOT_FOLDER_CNID || *parent == 0 {
break;
}
cur = *parent;
}
None => break,
}
}
path.reverse();
path
}
fn build_tree(records: &[CatalogRecord], _block_size: u64) -> TreeNode {
let mut folder_map: HashMap<u32, (String, u32)> = HashMap::new();
folder_map.insert(HFS_ROOT_FOLDER_CNID, ("/".to_string(), 0));
for rec in records {
if let CatalogRecord::Folder {
cnid,
name,
parent_cnid,
} = rec
{
if *cnid != HFS_ROOT_FOLDER_CNID {
folder_map.insert(*cnid, (name.clone(), *parent_cnid));
}
}
}
let mut nodes: HashMap<u32, TreeNode> = HashMap::new();
nodes.insert(
HFS_ROOT_FOLDER_CNID,
TreeNode::new_directory("/".to_string()),
);
for (&cnid, (name, _)) in &folder_map {
if cnid != HFS_ROOT_FOLDER_CNID {
nodes.insert(cnid, TreeNode::new_directory(name.clone()));
}
}
let mut file_items: Vec<(u32, TreeNode)> = Vec::new();
for rec in records {
if let CatalogRecord::File {
parent_cnid,
name,
file_length,
file_location,
..
} = rec
{
let node = if let Some(loc) = file_location {
TreeNode::new_file_with_location(name.clone(), *file_length, *loc, *file_length)
} else {
TreeNode::new_file(name.clone(), *file_length)
};
file_items.push((*parent_cnid, node));
}
}
let dir_children: Vec<(u32, u32)> = folder_map
.iter()
.filter(|(&cnid, _)| cnid != HFS_ROOT_FOLDER_CNID)
.map(|(&cnid, (_, parent_cnid))| (*parent_cnid, cnid))
.collect();
let mut remaining: Vec<(u32, u32)> = dir_children; let max_iters = folder_map.len() + 1;
for _ in 0..max_iters {
if remaining.is_empty() {
break;
}
let mut still_pending: Vec<(u32, u32)> = Vec::new();
for (parent_cnid, child_cnid) in remaining.drain(..) {
if nodes.contains_key(&child_cnid) && nodes.contains_key(&parent_cnid) {
let child = nodes.remove(&child_cnid).unwrap();
nodes.entry(parent_cnid).and_modify(|p| p.add_child(child));
} else {
still_pending.push((parent_cnid, child_cnid));
}
}
remaining = still_pending;
}
let root_node = nodes.get_mut(&HFS_ROOT_FOLDER_CNID).unwrap();
for (parent_cnid, file_node) in file_items {
let path = cnid_path(parent_cnid, &folder_map);
if let Some(parent) = find_by_path_mut(root_node, &path) {
parent.add_child(file_node);
}
}
sort_children_recursive(nodes.get_mut(&HFS_ROOT_FOLDER_CNID).unwrap());
let mut root = nodes
.remove(&HFS_ROOT_FOLDER_CNID)
.unwrap_or_else(|| TreeNode::new_directory("/".to_string()));
root.calculate_directory_size();
root
}
fn sort_children_recursive(node: &mut TreeNode) {
node.children.sort_by(|a, b| a.name.cmp(&b.name));
for child in &mut node.children {
if child.is_directory {
sort_children_recursive(child);
}
}
}
fn decode_utf16_be(bytes: &[u8]) -> String {
let mut units: Vec<u16> = Vec::with_capacity(bytes.len() / 2);
for chunk in bytes.chunks_exact(2) {
let unit = u16::from_be_bytes([chunk[0], chunk[1]]);
if unit == 0 {
break;
}
units.push(unit);
}
String::from_utf16_lossy(&units)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn make_volume_header_bytes(
signature: u16,
version: u16,
file_count: u32,
folder_count: u32,
block_size: u32,
) -> Vec<u8> {
let mut buf = vec![0u8; 1024 + VOLUME_HEADER_SIZE];
let h = &mut buf[1024..1024 + VOLUME_HEADER_SIZE];
h[0..2].copy_from_slice(&signature.to_be_bytes());
h[2..4].copy_from_slice(&version.to_be_bytes());
h[32..36].copy_from_slice(&file_count.to_be_bytes());
h[36..40].copy_from_slice(&folder_count.to_be_bytes());
h[40..44].copy_from_slice(&block_size.to_be_bytes());
buf
}
#[test]
fn detect_hfsplus_signature() {
let buf = make_volume_header_bytes(HFS_PLUS_MAGIC, 4, 0, 0, 4096);
let mut c = Cursor::new(&buf);
assert!(detect(&mut c).is_ok(), "should detect HFS+ magic 0x482B");
}
#[test]
fn detect_hfsx_signature() {
let buf = make_volume_header_bytes(HFSX_MAGIC, 5, 0, 0, 4096);
let mut c = Cursor::new(&buf);
assert!(detect(&mut c).is_ok(), "should detect HFSX magic 0x4858");
}
#[test]
fn detect_rejects_bad_magic() {
let buf = make_volume_header_bytes(0xDEAD, 4, 0, 0, 4096);
let mut c = Cursor::new(&buf);
assert!(
matches!(detect(&mut c), Err(Error::BadMagic)),
"should reject non-HFS+ signature"
);
}
#[test]
fn detect_restores_cursor() {
let buf = make_volume_header_bytes(HFS_PLUS_MAGIC, 4, 0, 0, 4096);
let mut c = Cursor::new(&buf);
c.seek(SeekFrom::Start(42)).unwrap();
let _ = detect(&mut c);
assert_eq!(c.position(), 42, "detect must restore the cursor position");
}
#[test]
fn parse_volume_header_fields() {
let buf = make_volume_header_bytes(HFS_PLUS_MAGIC, 4, 100, 20, 4096);
let mut c = Cursor::new(&buf);
let vh = parse_volume_header(&mut c).expect("parse volume header");
assert_eq!(vh.signature, HFS_PLUS_MAGIC);
assert_eq!(vh.version, 4);
assert_eq!(vh.file_count, 100);
assert_eq!(vh.folder_count, 20);
assert_eq!(vh.block_size, 4096);
}
#[test]
fn parse_volume_header_rejects_bad_version() {
let buf = make_volume_header_bytes(HFS_PLUS_MAGIC, 99, 0, 0, 4096);
let mut c = Cursor::new(&buf);
assert!(
matches!(parse_volume_header(&mut c), Err(Error::BadVersion)),
"version 99 should be rejected"
);
}
#[test]
fn decode_utf16_be_ascii() {
let bytes: Vec<u8> = "Hello"
.encode_utf16()
.flat_map(|u| u.to_be_bytes())
.collect();
assert_eq!(decode_utf16_be(&bytes), "Hello");
}
#[test]
fn decode_utf16_be_stops_at_nul() {
let mut bytes: Vec<u8> = "AB".encode_utf16().flat_map(|u| u.to_be_bytes()).collect();
bytes.extend_from_slice(&[0x00, 0x00]); bytes.extend_from_slice(&[0x00, 0x43]); assert_eq!(decode_utf16_be(&bytes), "AB");
}
#[test]
fn fork_data_single_extent_detection() {
let mut b = [0u8; 80];
b[0..8].copy_from_slice(&1024u64.to_be_bytes());
b[12..16].copy_from_slice(&2u32.to_be_bytes());
b[16..20].copy_from_slice(&10u32.to_be_bytes()); b[20..24].copy_from_slice(&2u32.to_be_bytes()); let fork = ForkData::from_bytes(&b);
assert!(fork.is_single_extent(), "should be single-extent fork");
assert_eq!(
fork.first_extent_offset(512),
Some(10 * 512),
"offset = start_block * block_size"
);
}
#[test]
fn fork_data_multi_extent_not_single() {
let mut b = [0u8; 80];
b[12..16].copy_from_slice(&4u32.to_be_bytes());
b[16..20].copy_from_slice(&10u32.to_be_bytes());
b[20..24].copy_from_slice(&2u32.to_be_bytes());
b[24..28].copy_from_slice(&20u32.to_be_bytes());
b[28..32].copy_from_slice(&2u32.to_be_bytes());
let fork = ForkData::from_bytes(&b);
assert!(
!fork.is_single_extent(),
"two-extent fork should not be single-extent"
);
}
#[test]
fn detect_and_parse_empty_catalog() {
let buf = make_volume_header_bytes(HFS_PLUS_MAGIC, 4, 0, 0, 4096);
let mut c = Cursor::new(&buf);
let tree = detect_and_parse(&mut c).expect("parse empty HFS+ volume");
assert_eq!(tree.name, "/");
assert!(tree.is_directory);
assert!(tree.children.is_empty(), "empty catalog → no children");
}
#[test]
fn build_tree_single_file_in_root() {
let records = vec![CatalogRecord::File {
parent_cnid: HFS_ROOT_FOLDER_CNID, name: "hello.txt".to_string(),
cnid: 10,
file_length: 42,
file_location: Some(4096),
}];
let root = build_tree(&records, 4096);
assert_eq!(root.name, "/");
assert_eq!(root.children.len(), 1);
assert_eq!(root.children[0].name, "hello.txt");
assert_eq!(root.children[0].size, 42);
}
#[test]
fn build_tree_nested_directory() {
let records = vec![
CatalogRecord::Folder {
parent_cnid: HFS_ROOT_FOLDER_CNID,
name: "docs".to_string(),
cnid: 20,
},
CatalogRecord::File {
parent_cnid: 20, name: "readme.txt".to_string(),
cnid: 21,
file_length: 100,
file_location: None,
},
];
let root = build_tree(&records, 4096);
assert_eq!(root.children.len(), 1);
let docs = &root.children[0];
assert_eq!(docs.name, "docs");
assert!(docs.is_directory);
assert_eq!(docs.children.len(), 1);
assert_eq!(docs.children[0].name, "readme.txt");
}
#[test]
fn build_tree_thread_record_ignored() {
let records = vec![
CatalogRecord::Thread {
cnid_key: 5,
record_type: 3,
},
CatalogRecord::File {
parent_cnid: HFS_ROOT_FOLDER_CNID,
name: "f.bin".to_string(),
cnid: 5,
file_length: 0,
file_location: None,
},
];
let root = build_tree(&records, 4096);
assert_eq!(root.children.len(), 1);
assert_eq!(root.children[0].name, "f.bin");
}
#[test]
fn build_tree_file_without_location() {
let records = vec![CatalogRecord::File {
parent_cnid: HFS_ROOT_FOLDER_CNID,
name: "sparse.dat".to_string(),
cnid: 30,
file_length: 200,
file_location: None,
}];
let root = build_tree(&records, 512);
let node = &root.children[0];
assert_eq!(node.name, "sparse.dat");
assert!(node.file_location.is_none());
}
fn make_leaf_node(key_data: &[u8], record_data: &[u8]) -> Vec<u8> {
let node_size = 512usize;
let mut node = vec![0u8; node_size];
let rec_start: u16 = 14;
let rec_end = rec_start as usize + key_data.len() + record_data.len();
node[rec_start as usize..rec_start as usize + key_data.len()].copy_from_slice(key_data);
node[rec_start as usize + key_data.len()..rec_end].copy_from_slice(record_data);
let ot_idx = node_size - 2; node[ot_idx..ot_idx + 2].copy_from_slice(&rec_start.to_be_bytes());
node
}
fn make_catalog_key(parent_cnid: u32, name: &str) -> Vec<u8> {
let name_utf16: Vec<u8> = name.encode_utf16().flat_map(|u| u.to_be_bytes()).collect();
let name_len = name.encode_utf16().count() as u16;
let key_length = (6 + name_utf16.len()) as u16; let mut key = Vec::new();
key.extend_from_slice(&key_length.to_be_bytes()); key.extend_from_slice(&parent_cnid.to_be_bytes()); key.extend_from_slice(&name_len.to_be_bytes()); key.extend_from_slice(&name_utf16); if key.len() % 2 != 0 {
key.push(0);
}
key
}
#[test]
fn parse_leaf_node_folder_record() {
let key = make_catalog_key(HFS_ROOT_FOLDER_CNID, "subdir");
let cnid: u32 = 100;
let mut rec_data = vec![0u8; 248];
rec_data[0..2].copy_from_slice(&RECORD_TYPE_FOLDER.to_be_bytes());
rec_data[8..12].copy_from_slice(&cnid.to_be_bytes());
let node = make_leaf_node(&key, &rec_data);
let mut out = Vec::new();
parse_leaf_node_records(&node, 1, 4096, &mut out).unwrap();
assert_eq!(out.len(), 1);
if let CatalogRecord::Folder {
parent_cnid,
name,
cnid: c,
} = &out[0]
{
assert_eq!(*parent_cnid, HFS_ROOT_FOLDER_CNID);
assert_eq!(name, "subdir");
assert_eq!(*c, 100);
} else {
panic!("expected Folder record");
}
}
#[test]
fn parse_leaf_node_file_record() {
let key = make_catalog_key(HFS_ROOT_FOLDER_CNID, "file.txt");
let mut rec_data = vec![0u8; 248];
rec_data[0..2].copy_from_slice(&RECORD_TYPE_FILE.to_be_bytes());
rec_data[8..12].copy_from_slice(&55u32.to_be_bytes());
let fork_off = 88;
rec_data[fork_off..fork_off + 8].copy_from_slice(&1024u64.to_be_bytes()); rec_data[fork_off + 12..fork_off + 16].copy_from_slice(&1u32.to_be_bytes()); rec_data[fork_off + 16..fork_off + 20].copy_from_slice(&10u32.to_be_bytes()); rec_data[fork_off + 20..fork_off + 24].copy_from_slice(&1u32.to_be_bytes());
let node = make_leaf_node(&key, &rec_data);
let mut out = Vec::new();
parse_leaf_node_records(&node, 1, 4096, &mut out).unwrap();
assert_eq!(out.len(), 1);
if let CatalogRecord::File {
name,
file_length,
file_location,
..
} = &out[0]
{
assert_eq!(name, "file.txt");
assert_eq!(*file_length, 1024);
assert_eq!(*file_location, Some(10 * 4096));
} else {
panic!("expected File record");
}
}
#[test]
fn parse_leaf_node_thread_record() {
let key = make_catalog_key(HFS_ROOT_FOLDER_CNID, "");
let mut rec_data = vec![0u8; 10];
rec_data[0..2].copy_from_slice(&RECORD_TYPE_FOLDER_THREAD.to_be_bytes());
let node = make_leaf_node(&key, &rec_data);
let mut out = Vec::new();
parse_leaf_node_records(&node, 1, 4096, &mut out).unwrap();
assert_eq!(out.len(), 1);
assert!(matches!(out[0], CatalogRecord::Thread { .. }));
}
#[test]
fn error_display_too_short() {
let msg = format!("{}", Error::TooShort);
assert!(
msg.contains("HFS+") || msg.contains("short"),
"unexpected: {msg}"
);
}
#[test]
fn error_display_bad_magic() {
let msg = format!("{}", Error::BadMagic);
assert!(
msg.contains("magic") || msg.contains("HFS"),
"unexpected: {msg}"
);
}
#[test]
fn error_display_bad_version() {
let msg = format!("{}", Error::BadVersion);
assert!(
msg.contains("version") || msg.contains("HFS"),
"unexpected: {msg}"
);
}
#[test]
fn error_display_bad_catalog() {
let msg = format!("{}", Error::BadCatalog);
assert!(
msg.contains("catalog") || msg.contains("HFS"),
"unexpected: {msg}"
);
}
#[test]
fn error_display_too_deep() {
let msg = format!("{}", Error::TooDeep);
assert!(
msg.contains("deep") || msg.contains("HFS"),
"unexpected: {msg}"
);
}
#[test]
fn error_display_io() {
let io = std::io::Error::other("disk fail");
let msg = format!("{}", Error::Io(io));
assert!(msg.contains("disk fail"), "unexpected: {msg}");
}
#[test]
fn error_source_io() {
use std::error::Error as StdError;
let io = std::io::Error::other("src");
assert!(Error::Io(io).source().is_some());
}
#[test]
fn error_source_non_io() {
use std::error::Error as StdError;
assert!(Error::TooShort.source().is_none());
assert!(Error::BadMagic.source().is_none());
assert!(Error::BadCatalog.source().is_none());
assert!(Error::TooDeep.source().is_none());
}
#[test]
fn fork_data_first_extent_offset_zero_block_count_returns_none() {
let fd = ForkData {
logical_size: 0,
total_blocks: 0,
extents: [
(5, 0),
(0, 0),
(0, 0),
(0, 0),
(0, 0),
(0, 0),
(0, 0),
(0, 0),
],
};
assert!(fd.first_extent_offset(4096).is_none());
}
#[test]
fn fork_data_first_extent_offset_nonzero_block_count() {
let fd = ForkData {
logical_size: 100,
total_blocks: 3,
extents: [
(10, 3),
(0, 0),
(0, 0),
(0, 0),
(0, 0),
(0, 0),
(0, 0),
(0, 0),
],
};
assert_eq!(fd.first_extent_offset(4096), Some(10 * 4096));
}
#[test]
fn error_from_io_error() {
let io = std::io::Error::other("disk");
let err = Error::from(io);
assert!(matches!(err, Error::Io(_)));
}
#[test]
fn volume_header_from_bytes_too_short() {
assert!(matches!(
VolumeHeader::from_bytes(&[0u8; 10]),
Err(Error::TooShort)
));
}
#[test]
fn volume_header_from_bytes_bad_magic() {
let mut buf = vec![0u8; VOLUME_HEADER_SIZE];
buf[0..2].copy_from_slice(&0x1234u16.to_be_bytes()); buf[2..4].copy_from_slice(&4u16.to_be_bytes()); assert!(matches!(
VolumeHeader::from_bytes(&buf),
Err(Error::BadMagic)
));
}
#[test]
fn fork_data_is_single_extent_zero_total_blocks_returns_false() {
let fd = ForkData {
logical_size: 0,
total_blocks: 0,
extents: [(0, 0); 8],
};
assert!(!fd.is_single_extent());
}
#[test]
fn btree_header_from_bytes_parses_fields() {
let mut buf = vec![0u8; 106];
buf[10..14].copy_from_slice(&42u32.to_be_bytes()); buf[18..20].copy_from_slice(&512u16.to_be_bytes()); let hdr = BTreeHeader::from_bytes(&buf);
assert_eq!(hdr.first_leaf_node, 42);
assert_eq!(hdr.node_size, 512);
}
#[test]
fn sort_children_recursive_sorts_names() {
let mut root = TreeNode::new_directory("/".to_string());
root.add_child(TreeNode::new_file("z.txt".to_string(), 0));
root.add_child(TreeNode::new_file("a.txt".to_string(), 0));
root.add_child(TreeNode::new_file("m.txt".to_string(), 0));
sort_children_recursive(&mut root);
let names: Vec<&str> = root.children.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["a.txt", "m.txt", "z.txt"]);
}
#[test]
fn detect_too_short_image_returns_too_short() {
let buf = vec![0u8; 1024];
let mut c = Cursor::new(&buf);
assert!(matches!(detect(&mut c), Err(Error::TooShort)));
}
#[test]
fn parse_volume_header_too_short_image_returns_too_short() {
let buf = vec![0u8; 1024];
let mut c = Cursor::new(&buf);
assert!(matches!(parse_volume_header(&mut c), Err(Error::TooShort)));
}
#[test]
fn detect_and_parse_empty_catalog_returns_empty_root() {
let buf = make_volume_header_bytes(HFS_PLUS_MAGIC, 4, 0, 0, 4096);
let mut c = Cursor::new(&buf);
let tree = detect_and_parse(&mut c).expect("empty catalog should succeed");
assert_eq!(tree.name, "/");
assert!(tree.children.is_empty());
}
fn make_hfsplus_with_btree_header(
node_kind: u8,
num_records: u16,
btree_node_size: u16,
first_leaf: u32,
) -> Vec<u8> {
let block_size = 512u32;
let cat_block = 4u32;
let mut img = vec![0u8; 2168];
let h = &mut img[1024..1536];
h[0..2].copy_from_slice(&HFS_PLUS_MAGIC.to_be_bytes());
h[2..4].copy_from_slice(&4u16.to_be_bytes());
h[40..44].copy_from_slice(&block_size.to_be_bytes());
h[288..292].copy_from_slice(&cat_block.to_be_bytes()); h[292..296].copy_from_slice(&1u32.to_be_bytes());
let cat = &mut img[2048..2168];
cat[8] = node_kind; cat[10..12].copy_from_slice(&num_records.to_be_bytes()); cat[24..28].copy_from_slice(&first_leaf.to_be_bytes()); cat[32..34].copy_from_slice(&btree_node_size.to_be_bytes());
img
}
#[test]
fn detect_and_parse_bad_catalog_node_kind_returns_error() {
let img = make_hfsplus_with_btree_header(0x00, 1, 512, 0);
let mut c = Cursor::new(&img);
assert!(matches!(detect_and_parse(&mut c), Err(Error::BadCatalog)));
}
#[test]
fn detect_and_parse_header_node_zero_records_returns_error() {
let img = make_hfsplus_with_btree_header(BTREE_HEADER_NODE, 0, 512, 0);
let mut c = Cursor::new(&img);
assert!(matches!(detect_and_parse(&mut c), Err(Error::TooShort)));
}
#[test]
fn detect_and_parse_catalog_node_size_too_small_returns_error() {
let img = make_hfsplus_with_btree_header(BTREE_HEADER_NODE, 1, 256, 0);
let mut c = Cursor::new(&img);
assert!(matches!(detect_and_parse(&mut c), Err(Error::BadCatalog)));
}
#[test]
fn detect_and_parse_empty_leaf_chain_returns_empty_root() {
let img = make_hfsplus_with_btree_header(BTREE_HEADER_NODE, 1, 512, 0);
let mut c = Cursor::new(&img);
let tree = detect_and_parse(&mut c).expect("empty leaf chain should succeed");
assert!(tree.children.is_empty());
}
#[test]
fn parse_leaf_node_unknown_record_type_skipped() {
let key = make_catalog_key(HFS_ROOT_FOLDER_CNID, "x");
let mut rec = vec![0u8; 20];
rec[0..2].copy_from_slice(&0xFFFFu16.to_be_bytes()); let node = make_leaf_node(&key, &rec);
let mut out = Vec::new();
parse_leaf_node_records(&node, 1, 4096, &mut out).unwrap();
assert!(out.is_empty(), "unknown record type must be skipped");
}
#[test]
fn parse_leaf_node_rec_start_past_node_size_skipped() {
let mut node = vec![0u8; 512];
node[510..512].copy_from_slice(&600u16.to_be_bytes());
let mut out = Vec::new();
parse_leaf_node_records(&node, 1, 4096, &mut out).unwrap();
assert!(out.is_empty(), "out-of-bounds rec_start must be skipped");
}
#[test]
fn parse_leaf_node_rec_data_under_six_bytes_skipped() {
let mut node = vec![0u8; 512];
node[510..512].copy_from_slice(&508u16.to_be_bytes());
let mut out = Vec::new();
parse_leaf_node_records(&node, 1, 4096, &mut out).unwrap();
assert!(out.is_empty());
}
#[test]
fn parse_leaf_node_key_length_under_six_skipped() {
let mut node = vec![0u8; 512];
node[510..512].copy_from_slice(&14u16.to_be_bytes());
node[14..16].copy_from_slice(&3u16.to_be_bytes()); let mut out = Vec::new();
parse_leaf_node_records(&node, 1, 4096, &mut out).unwrap();
assert!(out.is_empty());
}
#[test]
fn parse_leaf_node_folder_record_too_short_skipped() {
let node_size = 512usize;
let mut node = vec![0u8; node_size];
let rec_start = 500usize;
node[510..512].copy_from_slice(&(rec_start as u16).to_be_bytes());
node[500..502].copy_from_slice(&6u16.to_be_bytes()); node[502..506].copy_from_slice(&HFS_ROOT_FOLDER_CNID.to_be_bytes()); node[508..510].copy_from_slice(&RECORD_TYPE_FOLDER.to_be_bytes());
let mut out = Vec::new();
parse_leaf_node_records(&node, 1, 4096, &mut out).unwrap();
assert!(out.is_empty(), "short folder record data must be skipped");
}
#[test]
fn parse_leaf_node_file_record_too_short_skipped() {
let node_size = 512usize;
let mut node = vec![0u8; node_size];
let rec_start = 490usize;
node[510..512].copy_from_slice(&(rec_start as u16).to_be_bytes());
node[490..492].copy_from_slice(&6u16.to_be_bytes()); node[492..496].copy_from_slice(&HFS_ROOT_FOLDER_CNID.to_be_bytes()); node[498..500].copy_from_slice(&RECORD_TYPE_FILE.to_be_bytes());
let mut out = Vec::new();
parse_leaf_node_records(&node, 1, 4096, &mut out).unwrap();
assert!(out.is_empty(), "short file record data must be skipped");
}
#[test]
fn parse_leaf_node_file_multi_extent_no_location() {
let key = make_catalog_key(HFS_ROOT_FOLDER_CNID, "big.dat");
let mut rec = vec![0u8; 248];
rec[0..2].copy_from_slice(&RECORD_TYPE_FILE.to_be_bytes());
rec[8..12].copy_from_slice(&99u32.to_be_bytes()); let fork_off = 88;
rec[fork_off..fork_off + 8].copy_from_slice(&4096u64.to_be_bytes()); rec[fork_off + 12..fork_off + 16].copy_from_slice(&4u32.to_be_bytes()); rec[fork_off + 16..fork_off + 20].copy_from_slice(&10u32.to_be_bytes()); rec[fork_off + 20..fork_off + 24].copy_from_slice(&2u32.to_be_bytes()); rec[fork_off + 24..fork_off + 28].copy_from_slice(&20u32.to_be_bytes()); rec[fork_off + 28..fork_off + 32].copy_from_slice(&2u32.to_be_bytes()); let node = make_leaf_node(&key, &rec);
let mut out = Vec::new();
parse_leaf_node_records(&node, 1, 4096, &mut out).unwrap();
assert_eq!(out.len(), 1);
if let CatalogRecord::File { file_location, .. } = &out[0] {
assert!(
file_location.is_none(),
"multi-extent file should have no location"
);
} else {
panic!("expected File record");
}
}
#[test]
fn build_tree_file_in_subdirectory() {
let records = vec![
CatalogRecord::Folder {
parent_cnid: HFS_ROOT_FOLDER_CNID,
name: "docs".to_string(),
cnid: 10,
},
CatalogRecord::File {
parent_cnid: 10,
name: "readme.txt".to_string(),
cnid: 11,
file_length: 42,
file_location: Some(8192),
},
];
let root = build_tree(&records, 512);
assert_eq!(root.children.len(), 1);
let dir = &root.children[0];
assert!(dir.is_directory);
assert_eq!(dir.name, "docs");
assert_eq!(dir.children.len(), 1);
assert_eq!(dir.children[0].name, "readme.txt");
}
#[test]
fn build_tree_orphan_directory_goes_to_pending() {
let records = vec![CatalogRecord::Folder {
parent_cnid: 9999, name: "orphan".to_string(),
cnid: 50,
}];
let root = build_tree(&records, 512);
assert!(root.children.is_empty());
}
fn make_hfsplus_with_leaf_node(
leaf_node_kind: u8,
leaf_f_link: u32,
leaf_num_records: u16,
) -> Vec<u8> {
let block_size = 512u32;
let cat_block = 4u32;
let node_size = 512usize;
let mut img = vec![0u8; 3072];
let h = &mut img[1024..1536];
h[0..2].copy_from_slice(&HFS_PLUS_MAGIC.to_be_bytes());
h[2..4].copy_from_slice(&4u16.to_be_bytes());
h[40..44].copy_from_slice(&block_size.to_be_bytes());
h[288..292].copy_from_slice(&cat_block.to_be_bytes());
h[292..296].copy_from_slice(&1u32.to_be_bytes());
let cat = &mut img[2048..2168];
cat[8] = BTREE_HEADER_NODE;
cat[10..12].copy_from_slice(&1u16.to_be_bytes()); cat[24..28].copy_from_slice(&1u32.to_be_bytes()); cat[32..34].copy_from_slice(&(node_size as u16).to_be_bytes());
let leaf = &mut img[2560..3072];
leaf[0..4].copy_from_slice(&leaf_f_link.to_be_bytes()); leaf[8] = leaf_node_kind;
leaf[10..12].copy_from_slice(&leaf_num_records.to_be_bytes());
img
}
#[test]
fn detect_and_parse_single_leaf_node_walks_chain() {
let img = make_hfsplus_with_leaf_node(BTREE_LEAF_NODE, 0, 0);
let mut c = Cursor::new(&img);
let tree = detect_and_parse(&mut c).expect("single leaf node walk should succeed");
assert!(tree.children.is_empty());
}
#[test]
fn detect_and_parse_non_leaf_kind_in_chain_breaks() {
let img = make_hfsplus_with_leaf_node(0x00, 0, 0);
let mut c = Cursor::new(&img);
let tree = detect_and_parse(&mut c).expect("non-leaf kind with f_link=0 should succeed");
assert!(tree.children.is_empty());
}
#[test]
fn detect_and_parse_non_leaf_then_leaf_follows_f_link() {
let block_size = 512u32;
let cat_block = 4u32;
let node_size = 512usize;
let mut img = vec![0u8; 3584];
let h = &mut img[1024..1536];
h[0..2].copy_from_slice(&HFS_PLUS_MAGIC.to_be_bytes());
h[2..4].copy_from_slice(&4u16.to_be_bytes());
h[40..44].copy_from_slice(&block_size.to_be_bytes());
h[288..292].copy_from_slice(&cat_block.to_be_bytes());
h[292..296].copy_from_slice(&1u32.to_be_bytes());
let cat = &mut img[2048..2168];
cat[8] = BTREE_HEADER_NODE;
cat[10..12].copy_from_slice(&1u16.to_be_bytes());
cat[24..28].copy_from_slice(&1u32.to_be_bytes()); cat[32..34].copy_from_slice(&(node_size as u16).to_be_bytes());
let node1 = &mut img[2560..3072];
node1[0..4].copy_from_slice(&2u32.to_be_bytes()); node1[8] = 0x00;
let node2 = &mut img[3072..3584];
node2[0..4].copy_from_slice(&0u32.to_be_bytes()); node2[8] = BTREE_LEAF_NODE;
node2[10..12].copy_from_slice(&0u16.to_be_bytes());
let mut c = Cursor::new(&img);
let tree = detect_and_parse(&mut c).expect("non-leaf then leaf should succeed");
assert!(tree.children.is_empty());
}
#[test]
fn parse_leaf_node_records_tiny_node_breaks_early() {
let mut out = Vec::new();
parse_leaf_node_records(&[0u8], 1, 4096, &mut out).unwrap();
assert!(out.is_empty());
}
#[test]
fn parse_leaf_node_name_extends_past_rec_data_skipped() {
let mut node = vec![0u8; 512];
node[510..512].copy_from_slice(&490u16.to_be_bytes()); node[490..492].copy_from_slice(&6u16.to_be_bytes()); node[492..496].copy_from_slice(&HFS_ROOT_FOLDER_CNID.to_be_bytes()); node[496..498].copy_from_slice(&10u16.to_be_bytes()); let mut out = Vec::new();
parse_leaf_node_records(&node, 1, 4096, &mut out).unwrap();
assert!(out.is_empty());
}
#[test]
fn parse_leaf_node_data_off_past_end_skipped() {
let mut node = vec![0u8; 512];
node[510..512].copy_from_slice(&459u16.to_be_bytes()); node[459..461].copy_from_slice(&50u16.to_be_bytes()); node[461..465].copy_from_slice(&HFS_ROOT_FOLDER_CNID.to_be_bytes()); let mut out = Vec::new();
parse_leaf_node_records(&node, 1, 4096, &mut out).unwrap();
assert!(out.is_empty());
}
#[test]
fn build_tree_file_with_orphaned_grandparent() {
let records = vec![
CatalogRecord::Folder {
parent_cnid: HFS_ROOT_FOLDER_CNID,
name: "B".to_string(),
cnid: 20,
},
CatalogRecord::Folder {
parent_cnid: 9999,
name: "A".to_string(),
cnid: 10,
},
CatalogRecord::File {
parent_cnid: 10,
name: "x.txt".to_string(),
cnid: 30,
file_length: 1,
file_location: None,
},
];
let root = build_tree(&records, 512);
assert_eq!(root.children.len(), 1);
assert_eq!(root.children[0].name, "B");
}
#[test]
fn detect_and_parse_leaf_node_read_too_short_returns_error() {
let block_size = 512u32;
let cat_block = 4u32;
let node_size = 512usize;
let mut img = vec![0u8; 2560];
let h = &mut img[1024..1536];
h[0..2].copy_from_slice(&HFS_PLUS_MAGIC.to_be_bytes());
h[2..4].copy_from_slice(&4u16.to_be_bytes());
h[40..44].copy_from_slice(&block_size.to_be_bytes());
h[288..292].copy_from_slice(&cat_block.to_be_bytes());
h[292..296].copy_from_slice(&1u32.to_be_bytes());
let cat = &mut img[2048..2168];
cat[8] = BTREE_HEADER_NODE;
cat[10..12].copy_from_slice(&1u16.to_be_bytes());
cat[24..28].copy_from_slice(&1u32.to_be_bytes()); cat[32..34].copy_from_slice(&(node_size as u16).to_be_bytes());
let mut c = Cursor::new(&img);
assert!(matches!(detect_and_parse(&mut c), Err(Error::TooShort)));
}
#[test]
fn detect_and_parse_leaf_chain_too_deep_returns_error() {
let block_size = 512u32;
let cat_block = 4u32;
let node_size = 512usize;
let mut img = vec![0u8; 3584];
let h = &mut img[1024..1536];
h[0..2].copy_from_slice(&HFS_PLUS_MAGIC.to_be_bytes());
h[2..4].copy_from_slice(&4u16.to_be_bytes());
h[40..44].copy_from_slice(&block_size.to_be_bytes());
h[288..292].copy_from_slice(&cat_block.to_be_bytes());
h[292..296].copy_from_slice(&1u32.to_be_bytes());
let cat = &mut img[2048..2168];
cat[8] = BTREE_HEADER_NODE;
cat[10..12].copy_from_slice(&1u16.to_be_bytes());
cat[24..28].copy_from_slice(&1u32.to_be_bytes()); cat[32..34].copy_from_slice(&(node_size as u16).to_be_bytes());
let leaf1 = &mut img[2560..3072];
leaf1[0..4].copy_from_slice(&2u32.to_be_bytes()); leaf1[8] = BTREE_LEAF_NODE;
leaf1[10..12].copy_from_slice(&0u16.to_be_bytes());
let leaf2 = &mut img[3072..3584];
leaf2[0..4].copy_from_slice(&3u32.to_be_bytes()); leaf2[8] = BTREE_LEAF_NODE;
let mut c = Cursor::new(&img);
assert!(matches!(detect_and_parse(&mut c), Err(Error::TooDeep)));
}
}