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