pub mod decmpfs;
pub mod findings;
pub(crate) const VOLUME_HEADER_OFFSET: usize = 1024;
pub(crate) const SIG_HFS_PLUS: u16 = 0x482B;
pub(crate) const SIG_HFSX: u16 = 0x4858;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HfsKind {
HfsPlus,
Hfsx,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HfsVolume {
pub kind: HfsKind,
pub version: u16,
pub file_count: u32,
pub folder_count: u32,
pub block_size: u32,
pub total_blocks: u32,
pub free_blocks: u32,
}
impl HfsVolume {
#[must_use]
pub fn volume_size(&self) -> u64 {
u64::from(self.block_size) * u64::from(self.total_blocks)
}
}
#[must_use]
pub fn parse(volume: &[u8]) -> Option<HfsVolume> {
let h = VOLUME_HEADER_OFFSET;
if volume.len() < h + 52 {
return None;
}
let hdr = &volume[h..];
let kind = match be16(&hdr[0..2]) {
SIG_HFS_PLUS => HfsKind::HfsPlus,
SIG_HFSX => HfsKind::Hfsx,
_ => return None,
};
Some(HfsVolume {
kind,
version: be16(&hdr[2..4]),
file_count: be32(&hdr[32..36]),
folder_count: be32(&hdr[36..40]),
block_size: be32(&hdr[40..44]),
total_blocks: be32(&hdr[44..48]),
free_blocks: be32(&hdr[48..52]),
})
}
const ROOT_FOLDER_CNID: u32 = 2;
const RECORD_FOLDER: i16 = 1;
const RECORD_FILE: i16 = 2;
pub(crate) const MAX_LEAF_NODES: u32 = 65536;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HfsEntry {
pub name: String,
pub is_dir: bool,
pub cnid: u32,
}
pub(crate) struct CatalogLoc {
pub(crate) cat_base: usize,
pub(crate) node_size: usize,
pub(crate) first_leaf: u32,
pub(crate) block_size: usize,
}
pub(crate) const EXTENTS_FORK_OFFSET: usize = 192;
pub(crate) const CATALOG_FORK_OFFSET: usize = 272;
pub(crate) const ATTRIBUTES_FORK_OFFSET: usize = 352;
pub(crate) fn locate_catalog(volume: &[u8]) -> Option<CatalogLoc> {
locate_btree(volume, CATALOG_FORK_OFFSET)
}
pub(crate) fn locate_attributes(volume: &[u8]) -> Option<CatalogLoc> {
locate_btree(volume, ATTRIBUTES_FORK_OFFSET)
}
pub(crate) fn locate_extents(volume: &[u8]) -> Option<CatalogLoc> {
locate_btree(volume, EXTENTS_FORK_OFFSET)
}
pub(crate) fn locate_btree(volume: &[u8], fork_offset_in_header: usize) -> Option<CatalogLoc> {
let h = VOLUME_HEADER_OFFSET;
let fork = h.checked_add(fork_offset_in_header)?;
if volume.len() < fork + 20 {
return None;
}
match be16(&volume[h..h + 2]) {
SIG_HFS_PLUS | SIG_HFSX => {}
_ => return None,
}
let block_size = be32(&volume[h + 40..h + 44]) as usize;
if block_size == 0 {
return None;
}
if be32(&volume[fork + 12..fork + 16]) == 0 {
return None;
}
let start_block = be32(&volume[fork + 16..fork + 20]) as usize;
let cat_base = start_block.checked_mul(block_size)?;
let hdr = cat_base.checked_add(14)?;
if volume.len() < hdr + 20 {
return None;
}
let first_leaf = be32(&volume[hdr + 10..hdr + 14]);
let node_size = be16(&volume[hdr + 18..hdr + 20]) as usize;
if node_size < 14 {
return None;
}
Some(CatalogLoc {
cat_base,
node_size,
first_leaf,
block_size,
})
}
pub(crate) fn for_each_record(volume: &[u8], loc: &CatalogLoc, mut f: impl FnMut(&[u8])) {
let mut node = loc.first_leaf;
let mut walked = 0u32;
while node != 0 && walked < MAX_LEAF_NODES {
walked += 1;
let Some(node_off) = (node as usize)
.checked_mul(loc.node_size)
.and_then(|x| x.checked_add(loc.cat_base))
else {
break;
};
if volume.len() < node_off + loc.node_size {
break;
}
let nd = &volume[node_off..node_off + loc.node_size];
let f_link = be32(&nd[0..4]);
let num_records = be16(&nd[10..12]) as usize;
for i in 0..num_records {
let Some(slot) = loc.node_size.checked_sub(2 * (i + 1)) else {
break;
};
let rec = be16(&nd[slot..slot + 2]) as usize;
if rec + 8 <= loc.node_size {
f(&nd[rec..]);
}
}
node = f_link;
}
}
#[must_use]
pub fn list_root(volume: &[u8]) -> Option<Vec<HfsEntry>> {
list_dir(volume, ROOT_FOLDER_CNID)
}
#[must_use]
pub fn list_dir(volume: &[u8], parent_cnid: u32) -> Option<Vec<HfsEntry>> {
let loc = locate_catalog(volume)?;
let mut entries = Vec::new();
for_each_record(volume, &loc, |rec| {
if let Some((parent, entry)) = record_entry(rec) {
if parent == parent_cnid {
entries.push(entry);
}
}
});
Some(entries)
}
#[must_use]
pub fn read_file(volume: &[u8], cnid: u32) -> Option<Vec<u8>> {
let loc = locate_catalog(volume)?;
let mut forks: Option<(Fork, Fork)> = None;
for_each_record(volume, &loc, |rec| {
if forks.is_none() {
forks = file_forks(rec, cnid);
}
});
let (data_fork, resource_fork) = forks?;
if let Some(xattr) = decmpfs_xattr(volume, cnid) {
let resource = if resource_fork.logical > 0 {
fork_bytes(volume, loc.block_size, &resource_fork)
} else {
None
};
return decmpfs::decompress(&xattr, resource.as_deref()).ok();
}
fork_bytes(volume, loc.block_size, &data_fork)
}
fn record_entry(rec: &[u8]) -> Option<(u32, HfsEntry)> {
if rec.len() < 8 {
return None;
}
let key_len = be16(&rec[0..2]) as usize;
let parent_id = be32(&rec[2..6]);
let name_len = be16(&rec[6..8]) as usize;
let name_end = 8 + name_len * 2;
if name_end > rec.len() {
return None;
}
let name = decode_utf16(&rec[8..name_end]);
let data = 2 + key_len;
if data + 12 > rec.len() {
return None;
}
let is_dir = match i16::from_be_bytes([rec[data], rec[data + 1]]) {
RECORD_FOLDER => true,
RECORD_FILE => false,
_ => return None, };
let cnid = be32(&rec[data + 8..data + 12]);
Some((parent_id, HfsEntry { name, is_dir, cnid }))
}
struct Fork {
logical: u64,
extents: Vec<(u32, u32)>,
}
const DECMPFS_XATTR_NAME: &str = "com.apple.decmpfs";
const ATTR_INLINE_DATA: u32 = 0x10;
fn file_forks(rec: &[u8], cnid: u32) -> Option<(Fork, Fork)> {
if rec.len() < 8 {
return None;
}
let key_len = be16(&rec[0..2]) as usize;
let data = 2 + key_len;
if data + 168 > rec.len() {
return None;
}
if i16::from_be_bytes([rec[data], rec[data + 1]]) != RECORD_FILE {
return None;
}
if be32(&rec[data + 8..data + 12]) != cnid {
return None;
}
let data_fork = parse_fork(&rec[data + 88..])?;
let resource_fork = if data + 248 <= rec.len() {
parse_fork(&rec[data + 168..])?
} else {
Fork {
logical: 0,
extents: Vec::new(),
}
};
Some((data_fork, resource_fork))
}
fn parse_fork(fork: &[u8]) -> Option<Fork> {
if fork.len() < 80 {
return None;
}
let logical = u64::from_be_bytes(fork[0..8].try_into().ok()?);
let mut extents = Vec::new();
for i in 0..8 {
let e = 16 + i * 8;
let start = be32(&fork[e..e + 4]);
let count = be32(&fork[e + 4..e + 8]);
if count != 0 {
extents.push((start, count));
}
}
Some(Fork { logical, extents })
}
fn fork_bytes(volume: &[u8], block_size: usize, fork: &Fork) -> Option<Vec<u8>> {
let logical = fork.logical as usize;
let mut data = Vec::with_capacity(logical.min(1 << 20));
for &(start, count) in &fork.extents {
if data.len() >= logical {
break;
}
let begin = (start as usize).checked_mul(block_size)?;
let len = (count as usize).checked_mul(block_size)?;
let end = begin.checked_add(len)?.min(volume.len());
if begin >= volume.len() {
break;
}
data.extend_from_slice(&volume[begin..end]);
}
data.truncate(logical);
Some(data)
}
pub(crate) fn decmpfs_xattr(volume: &[u8], cnid: u32) -> Option<Vec<u8>> {
let loc = locate_attributes(volume)?;
let mut found = None;
for_each_record(volume, &loc, |rec| {
if found.is_none() {
found = attr_inline_value(rec, cnid, DECMPFS_XATTR_NAME);
}
});
found
}
fn attr_inline_value(rec: &[u8], cnid: u32, want_name: &str) -> Option<Vec<u8>> {
if rec.len() < 14 {
return None;
}
let key_len = be16(&rec[0..2]) as usize;
if be32(&rec[4..8]) != cnid {
return None;
}
let name_len = be16(&rec[12..14]) as usize;
let name_end = 14usize.checked_add(name_len.checked_mul(2)?)?;
if name_end > rec.len() {
return None;
}
if decode_utf16(&rec[14..name_end]) != want_name {
return None;
}
let body = 2 + key_len;
if body + 16 > rec.len() {
return None;
}
if be32(&rec[body..body + 4]) != ATTR_INLINE_DATA {
return None;
}
let attr_size = be32(&rec[body + 12..body + 16]) as usize;
let end = body.checked_add(16)?.checked_add(attr_size)?;
if end > rec.len() {
return None;
}
Some(rec[body + 16..end].to_vec())
}
pub(crate) fn decode_utf16(bytes: &[u8]) -> String {
let units: Vec<u16> = bytes
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
String::from_utf16_lossy(&units)
}
pub(crate) fn be16(b: &[u8]) -> u16 {
u16::from_be_bytes([b[0], b[1]])
}
pub(crate) fn be32(b: &[u8]) -> u32 {
u32::from_be_bytes([b[0], b[1], b[2], b[3]])
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HfsPathEntry {
pub path: String,
pub is_dir: bool,
pub cnid: u32,
}
#[must_use]
pub fn walk(volume: &[u8]) -> Option<Vec<HfsPathEntry>> {
list_dir(volume, ROOT_FOLDER_CNID)?;
let mut out = Vec::new();
let mut visited = std::collections::HashSet::new();
visited.insert(ROOT_FOLDER_CNID);
let mut stack = vec![(ROOT_FOLDER_CNID, String::new())];
while let Some((parent, prefix)) = stack.pop() {
let Some(entries) = list_dir(volume, parent) else {
continue;
};
for e in entries {
let path = if prefix.is_empty() {
e.name.clone()
} else {
format!("{prefix}/{}", e.name)
};
if e.is_dir && visited.insert(e.cnid) {
stack.push((e.cnid, path.clone()));
}
out.push(HfsPathEntry {
path,
is_dir: e.is_dir,
cnid: e.cnid,
});
}
}
Some(out)
}