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