Skip to main content

hfsplus_forensic/
lib.rs

1//! HFS+ / HFSX volume-header detection (Apple TN1150).
2//!
3//! Apple optical discs are frequently *hybrids*: an ISO 9660 filesystem and an
4//! HFS/HFS+ volume sharing the same disc, so a Mac and a PC each see their own
5//! filesystem.  The HFS+ volume header sits at a fixed 1024-byte offset from the
6//! volume start (TN1150 §"Volume Header"), with a big-endian `H+` (HFS+) or `HX`
7//! (HFSX) signature.
8//!
9//! This crate reads the volume header (geometry), walks the catalog B-tree to
10//! list directories ([`list_root`], [`list_dir`], recursive [`walk`]), and
11//! extracts file contents ([`read_file`]) — including HFS+/APFS transparently
12//! *compressed* files, which it decodes via the [`decmpfs`] module (zlib / LZVN
13//! / LZFSE, inline xattr or resource fork). Journal replay is out of scope.
14//! Validated against real `hdiutil`/`ditto`-created HFS+ volumes.
15
16pub mod decmpfs;
17pub mod findings;
18
19/// Byte offset of the HFS+ volume header from the start of the volume.
20pub(crate) const VOLUME_HEADER_OFFSET: usize = 1024;
21/// HFS+ signature `H+` (TN1150).
22pub(crate) const SIG_HFS_PLUS: u16 = 0x482B;
23/// HFSX signature `HX` (case-sensitive variant).
24pub(crate) const SIG_HFSX: u16 = 0x4858;
25
26/// Which Apple volume signature was found.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum HfsKind {
29    /// `H+` — standard HFS Plus.
30    HfsPlus,
31    /// `HX` — case-sensitive HFSX.
32    Hfsx,
33}
34
35/// Parsed HFS+ volume header fields (geometry only).
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct HfsVolume {
38    pub kind: HfsKind,
39    /// Volume format version (4 for HFS+, 5 for HFSX).
40    pub version: u16,
41    /// Number of files in the volume's catalog.
42    pub file_count: u32,
43    /// Number of folders in the volume's catalog.
44    pub folder_count: u32,
45    /// Allocation block size in bytes.
46    pub block_size: u32,
47    /// Total allocation blocks in the volume.
48    pub total_blocks: u32,
49    /// Free allocation blocks.
50    pub free_blocks: u32,
51}
52
53impl HfsVolume {
54    /// Total volume size in bytes (`block_size * total_blocks`).
55    #[must_use]
56    pub fn volume_size(&self) -> u64 {
57        u64::from(self.block_size) * u64::from(self.total_blocks)
58    }
59}
60
61/// Parse the HFS+/HFSX volume header from a buffer that begins at the volume
62/// start (the header is read at offset 1024).  Returns `None` if the buffer is
63/// too short or carries no HFS+ signature.
64#[must_use]
65pub fn parse(volume: &[u8]) -> Option<HfsVolume> {
66    let h = VOLUME_HEADER_OFFSET;
67    if volume.len() < h + 52 {
68        return None;
69    }
70    let hdr = &volume[h..];
71    let kind = match be16(&hdr[0..2]) {
72        SIG_HFS_PLUS => HfsKind::HfsPlus,
73        SIG_HFSX => HfsKind::Hfsx,
74        _ => return None,
75    };
76    Some(HfsVolume {
77        kind,
78        version: be16(&hdr[2..4]),
79        file_count: be32(&hdr[32..36]),
80        folder_count: be32(&hdr[36..40]),
81        block_size: be32(&hdr[40..44]),
82        total_blocks: be32(&hdr[44..48]),
83        free_blocks: be32(&hdr[48..52]),
84    })
85}
86
87/// Catalog node ID of the root folder (TN1150).
88const ROOT_FOLDER_CNID: u32 = 2;
89/// Catalog record types (TN1150): folder / file leaf records.
90const RECORD_FOLDER: i16 = 1;
91const RECORD_FILE: i16 = 2;
92/// Bound on catalog leaf nodes walked, guarding against a corrupt `fLink` chain.
93pub(crate) const MAX_LEAF_NODES: u32 = 65536;
94
95/// An entry in an HFS+ directory.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct HfsEntry {
98    /// File or folder name (decoded from UTF-16).
99    pub name: String,
100    /// True for a folder, false for a file.
101    pub is_dir: bool,
102    /// Catalog node ID (CNID) of this entry.
103    pub cnid: u32,
104}
105
106/// Located catalog B-tree geometry within an HFS+ volume.
107pub(crate) struct CatalogLoc {
108    pub(crate) cat_base: usize,
109    pub(crate) node_size: usize,
110    pub(crate) first_leaf: u32,
111    pub(crate) block_size: usize,
112}
113
114/// Volume-header byte offset of the extentsFile `HFSPlusForkData` (TN1150) —
115/// the extents-overflow B-tree, holding extent records for files whose fork
116/// outgrows its 8 inline extents.
117pub(crate) const EXTENTS_FORK_OFFSET: usize = 192;
118/// Volume-header byte offset of the catalogFile `HFSPlusForkData` (TN1150).
119pub(crate) const CATALOG_FORK_OFFSET: usize = 272;
120/// Volume-header byte offset of the attributesFile `HFSPlusForkData` (the
121/// catalogFile's successor, 80 bytes later) — home of extended attributes,
122/// including `com.apple.decmpfs`.
123pub(crate) const ATTRIBUTES_FORK_OFFSET: usize = 352;
124
125/// Locate the catalog B-tree from the volume header (its first extent).
126pub(crate) fn locate_catalog(volume: &[u8]) -> Option<CatalogLoc> {
127    locate_btree(volume, CATALOG_FORK_OFFSET)
128}
129
130/// Locate the attributes B-tree, or `None` when the volume has no attributes
131/// file (its fork holds zero blocks — i.e. no extended attributes anywhere).
132pub(crate) fn locate_attributes(volume: &[u8]) -> Option<CatalogLoc> {
133    locate_btree(volume, ATTRIBUTES_FORK_OFFSET)
134}
135
136/// Locate the extents-overflow B-tree, or `None` when the volume has none.
137pub(crate) fn locate_extents(volume: &[u8]) -> Option<CatalogLoc> {
138    locate_btree(volume, EXTENTS_FORK_OFFSET)
139}
140
141/// Locate a B-tree whose first-extent `HFSPlusForkData` sits at
142/// `fork_offset_in_header` bytes into the volume header. The catalog and
143/// attributes files share the identical fork-data + B-tree-header layout.
144pub(crate) fn locate_btree(volume: &[u8], fork_offset_in_header: usize) -> Option<CatalogLoc> {
145    let h = VOLUME_HEADER_OFFSET;
146    let fork = h.checked_add(fork_offset_in_header)?;
147    if volume.len() < fork + 20 {
148        return None;
149    }
150    match be16(&volume[h..h + 2]) {
151        SIG_HFS_PLUS | SIG_HFSX => {}
152        _ => return None,
153    }
154    let block_size = be32(&volume[h + 40..h + 44]) as usize;
155    if block_size == 0 {
156        return None;
157    }
158    // HFSPlusForkData: logicalSize(8) clumpSize(4) totalBlocks(4) extents(...).
159    // A zero totalBlocks means the file does not exist (no attributes B-tree).
160    if be32(&volume[fork + 12..fork + 16]) == 0 {
161        return None;
162    }
163    // First extent's startBlock is at fork+16.
164    let start_block = be32(&volume[fork + 16..fork + 20]) as usize;
165    let cat_base = start_block.checked_mul(block_size)?;
166    // B-tree header record follows the 14-byte node descriptor of node 0.
167    let hdr = cat_base.checked_add(14)?;
168    if volume.len() < hdr + 20 {
169        return None;
170    }
171    let first_leaf = be32(&volume[hdr + 10..hdr + 14]);
172    let node_size = be16(&volume[hdr + 18..hdr + 20]) as usize;
173    if node_size < 14 {
174        return None;
175    }
176    Some(CatalogLoc {
177        cat_base,
178        node_size,
179        first_leaf,
180        block_size,
181    })
182}
183
184/// Walk the catalog leaf-node chain, invoking `f` with each record slice.
185pub(crate) fn for_each_record(volume: &[u8], loc: &CatalogLoc, mut f: impl FnMut(&[u8])) {
186    let mut node = loc.first_leaf;
187    let mut walked = 0u32;
188    while node != 0 && walked < MAX_LEAF_NODES {
189        walked += 1;
190        let Some(node_off) = (node as usize)
191            .checked_mul(loc.node_size)
192            .and_then(|x| x.checked_add(loc.cat_base))
193        else {
194            break;
195        };
196        if volume.len() < node_off + loc.node_size {
197            break;
198        }
199        let nd = &volume[node_off..node_off + loc.node_size];
200        let f_link = be32(&nd[0..4]);
201        let num_records = be16(&nd[10..12]) as usize;
202        for i in 0..num_records {
203            // Record offsets are stored backwards from the node end.
204            let Some(slot) = loc.node_size.checked_sub(2 * (i + 1)) else {
205                break;
206            };
207            let rec = be16(&nd[slot..slot + 2]) as usize;
208            if rec + 8 <= loc.node_size {
209                f(&nd[rec..]);
210            }
211        }
212        node = f_link;
213    }
214}
215
216/// List the root directory of an HFS+ volume.  See [`list_dir`].
217#[must_use]
218pub fn list_root(volume: &[u8]) -> Option<Vec<HfsEntry>> {
219    list_dir(volume, ROOT_FOLDER_CNID)
220}
221
222/// List the immediate children of the folder `parent_cnid` by walking the HFS+
223/// catalog B-tree.
224///
225/// `volume` must contain the whole HFS+ volume from its first byte (header at
226/// offset 1024).  Entries include HFS+ private metadata directories (real, not
227/// hidden); thread records are skipped.  Returns `None` if this is not an HFS+
228/// volume or the catalog cannot be located.  Assumes the catalog fits in its
229/// first extent (true for typical optical/hybrid volumes).
230#[must_use]
231pub fn list_dir(volume: &[u8], parent_cnid: u32) -> Option<Vec<HfsEntry>> {
232    let loc = locate_catalog(volume)?;
233    let mut entries = Vec::new();
234    for_each_record(volume, &loc, |rec| {
235        if let Some((parent, entry)) = record_entry(rec) {
236            if parent == parent_cnid {
237                entries.push(entry);
238            }
239        }
240    });
241    Some(entries)
242}
243
244/// Read a file's data-fork contents by catalog node ID.
245///
246/// Returns the file's bytes (concatenated from its data-fork extents, truncated
247/// to the logical size), or `None` if `cnid` is not a file in this volume.
248/// Read a file's contents by catalog node ID.
249///
250/// For a normal file this returns the data fork (concatenated extents, truncated
251/// to the logical size). For an HFS+/APFS **transparently-compressed** file —
252/// one carrying a `com.apple.decmpfs` extended attribute — the data fork is
253/// empty and the real bytes are decoded from the xattr (inline) or the resource
254/// fork ([`decmpfs`]). Returns `None` if `cnid` is not a file, or if a
255/// recognised compressed file cannot be decoded (it never returns a misleading
256/// empty or raw data fork in that case).
257#[must_use]
258pub fn read_file(volume: &[u8], cnid: u32) -> Option<Vec<u8>> {
259    let loc = locate_catalog(volume)?;
260    let mut forks: Option<(Fork, Fork)> = None;
261    for_each_record(volume, &loc, |rec| {
262        if forks.is_none() {
263            forks = file_forks(rec, cnid);
264        }
265    });
266    let (data_fork, resource_fork) = forks?;
267
268    if let Some(xattr) = decmpfs_xattr(volume, cnid) {
269        let resource = if resource_fork.logical > 0 {
270            fork_bytes(volume, loc.block_size, &resource_fork)
271        } else {
272            None
273        };
274        // Fail loud: a decmpfs file we cannot decode returns None, never the
275        // empty data fork — silent data loss is the bug this whole path fixes.
276        return decmpfs::decompress(&xattr, resource.as_deref()).ok();
277    }
278
279    fork_bytes(volume, loc.block_size, &data_fork)
280}
281
282/// Parse a catalog record into `(parentID, entry)` for file/folder records.
283fn record_entry(rec: &[u8]) -> Option<(u32, HfsEntry)> {
284    if rec.len() < 8 {
285        return None;
286    }
287    let key_len = be16(&rec[0..2]) as usize;
288    let parent_id = be32(&rec[2..6]);
289    let name_len = be16(&rec[6..8]) as usize;
290    let name_end = 8 + name_len * 2;
291    if name_end > rec.len() {
292        return None;
293    }
294    let name = decode_utf16(&rec[8..name_end]);
295    let data = 2 + key_len;
296    if data + 12 > rec.len() {
297        return None;
298    }
299    let is_dir = match i16::from_be_bytes([rec[data], rec[data + 1]]) {
300        RECORD_FOLDER => true,
301        RECORD_FILE => false,
302        _ => return None, // thread records and anything else
303    };
304    // folderID / fileID at offset 8 of the folder/file record.
305    let cnid = be32(&rec[data + 8..data + 12]);
306    Some((parent_id, HfsEntry { name, is_dir, cnid }))
307}
308
309/// A file fork: logical size plus its (start_block, block_count) extents.
310struct Fork {
311    logical: u64,
312    extents: Vec<(u32, u32)>,
313}
314
315/// `com.apple.decmpfs` extended-attribute name.
316const DECMPFS_XATTR_NAME: &str = "com.apple.decmpfs";
317/// `kHFSPlusAttrInlineData` — the attribute record type whose value is stored
318/// inline (the only form the small decmpfs header ever uses).
319const ATTR_INLINE_DATA: u32 = 0x10;
320
321/// If `rec` is the file record for `cnid`, return its `(data_fork,
322/// resource_fork)`. The file record holds the data fork's `HFSPlusForkData` at
323/// +88 and the resource fork's at +168 (TN1150).
324fn file_forks(rec: &[u8], cnid: u32) -> Option<(Fork, Fork)> {
325    if rec.len() < 8 {
326        return None;
327    }
328    let key_len = be16(&rec[0..2]) as usize;
329    let data = 2 + key_len;
330    if data + 168 > rec.len() {
331        return None;
332    }
333    if i16::from_be_bytes([rec[data], rec[data + 1]]) != RECORD_FILE {
334        return None;
335    }
336    if be32(&rec[data + 8..data + 12]) != cnid {
337        return None;
338    }
339    let data_fork = parse_fork(&rec[data + 88..])?;
340    // The resource fork follows the 80-byte data fork. A record truncated before
341    // it means no resource fork (an empty one is harmless for non-compressed files).
342    let resource_fork = if data + 248 <= rec.len() {
343        parse_fork(&rec[data + 168..])?
344    } else {
345        Fork {
346            logical: 0,
347            extents: Vec::new(),
348        }
349    };
350    Some((data_fork, resource_fork))
351}
352
353/// Parse an 80-byte `HFSPlusForkData`: logical size + up to 8 extents.
354fn parse_fork(fork: &[u8]) -> Option<Fork> {
355    if fork.len() < 80 {
356        return None;
357    }
358    let logical = u64::from_be_bytes(fork[0..8].try_into().ok()?);
359    let mut extents = Vec::new();
360    for i in 0..8 {
361        let e = 16 + i * 8;
362        let start = be32(&fork[e..e + 4]);
363        let count = be32(&fork[e + 4..e + 8]);
364        if count != 0 {
365            extents.push((start, count));
366        }
367    }
368    Some(Fork { logical, extents })
369}
370
371/// Materialize a fork's bytes from `volume`, truncated to its logical size.
372fn fork_bytes(volume: &[u8], block_size: usize, fork: &Fork) -> Option<Vec<u8>> {
373    let logical = fork.logical as usize;
374    let mut data = Vec::with_capacity(logical.min(1 << 20));
375    for &(start, count) in &fork.extents {
376        if data.len() >= logical {
377            break;
378        }
379        let begin = (start as usize).checked_mul(block_size)?;
380        let len = (count as usize).checked_mul(block_size)?;
381        let end = begin.checked_add(len)?.min(volume.len());
382        if begin >= volume.len() {
383            break;
384        }
385        data.extend_from_slice(&volume[begin..end]);
386    }
387    data.truncate(logical);
388    Some(data)
389}
390
391/// Look up the `com.apple.decmpfs` extended attribute for `cnid` by walking the
392/// attributes B-tree. Returns `None` if the volume has no attributes file or the
393/// file carries no such attribute (i.e. it is not transparently compressed).
394pub(crate) fn decmpfs_xattr(volume: &[u8], cnid: u32) -> Option<Vec<u8>> {
395    let loc = locate_attributes(volume)?;
396    let mut found = None;
397    for_each_record(volume, &loc, |rec| {
398        if found.is_none() {
399            found = attr_inline_value(rec, cnid, DECMPFS_XATTR_NAME);
400        }
401    });
402    found
403}
404
405/// If `rec` is the inline-data attribute record for `(cnid, want_name)`, return
406/// its value. `HFSPlusAttrKey`: keyLength(2) pad(2) fileID@4 startBlock@8
407/// attrNameLen@12 attrName@14 (UTF-16 BE). `HFSPlusAttrData`: recordType@key_end
408/// reserved[2] attrSize@+12 attrData@+16.
409fn attr_inline_value(rec: &[u8], cnid: u32, want_name: &str) -> Option<Vec<u8>> {
410    if rec.len() < 14 {
411        return None;
412    }
413    let key_len = be16(&rec[0..2]) as usize;
414    if be32(&rec[4..8]) != cnid {
415        return None;
416    }
417    let name_len = be16(&rec[12..14]) as usize;
418    let name_end = 14usize.checked_add(name_len.checked_mul(2)?)?;
419    if name_end > rec.len() {
420        return None;
421    }
422    if decode_utf16(&rec[14..name_end]) != want_name {
423        return None;
424    }
425    let body = 2 + key_len;
426    if body + 16 > rec.len() {
427        return None;
428    }
429    if be32(&rec[body..body + 4]) != ATTR_INLINE_DATA {
430        return None;
431    }
432    let attr_size = be32(&rec[body + 12..body + 16]) as usize;
433    let end = body.checked_add(16)?.checked_add(attr_size)?;
434    if end > rec.len() {
435        return None;
436    }
437    Some(rec[body + 16..end].to_vec())
438}
439
440/// Decode a big-endian UTF-16 byte slice to a `String` (lossy).
441pub(crate) fn decode_utf16(bytes: &[u8]) -> String {
442    let units: Vec<u16> = bytes
443        .chunks_exact(2)
444        .map(|c| u16::from_be_bytes([c[0], c[1]]))
445        .collect();
446    String::from_utf16_lossy(&units)
447}
448
449pub(crate) fn be16(b: &[u8]) -> u16 {
450    u16::from_be_bytes([b[0], b[1]])
451}
452pub(crate) fn be32(b: &[u8]) -> u32 {
453    u32::from_be_bytes([b[0], b[1], b[2], b[3]])
454}
455
456/// A path-qualified entry produced by [`walk`].
457#[derive(Debug, Clone, PartialEq, Eq)]
458pub struct HfsPathEntry {
459    /// `/`-joined path from the volume root (e.g. `"SUB/NESTED.TXT"`).
460    pub path: String,
461    /// True for a folder.
462    pub is_dir: bool,
463    /// Catalog node ID (CNID).
464    pub cnid: u32,
465}
466
467/// Recursively list every file and folder in an HFS+ volume, depth-first from
468/// the root, returning `/`-joined paths.
469///
470/// Returns `None` if this is not an HFS+ volume.  A visited-CNID set guards
471/// against cycles in a corrupt catalog.
472#[must_use]
473pub fn walk(volume: &[u8]) -> Option<Vec<HfsPathEntry>> {
474    // Confirm this is an HFS+ volume up front so a non-HFS buffer yields None.
475    list_dir(volume, ROOT_FOLDER_CNID)?;
476    let mut out = Vec::new();
477    let mut visited = std::collections::HashSet::new();
478    visited.insert(ROOT_FOLDER_CNID);
479    let mut stack = vec![(ROOT_FOLDER_CNID, String::new())];
480    while let Some((parent, prefix)) = stack.pop() {
481        let Some(entries) = list_dir(volume, parent) else {
482            continue;
483        };
484        for e in entries {
485            let path = if prefix.is_empty() {
486                e.name.clone()
487            } else {
488                format!("{prefix}/{}", e.name)
489            };
490            if e.is_dir && visited.insert(e.cnid) {
491                stack.push((e.cnid, path.clone()));
492            }
493            out.push(HfsPathEntry {
494                path,
495                is_dir: e.is_dir,
496                cnid: e.cnid,
497            });
498        }
499    }
500    Some(out)
501}