Skip to main content

apfs_core/
lib.rs

1//! `apfs-core` — a pure-Rust, from-scratch, panic-free reader for the Apple File
2//! System (APFS).
3//!
4//! APFS is a copy-on-write, transactional, object-oriented filesystem. Every
5//! on-disk object carries a 32-byte [`object::ObjPhys`] header with a Fletcher-64
6//! checksum, an object identifier (`oid`), and a transaction identifier (`xid`).
7//! Navigation is two-staged compared to NTFS: the **object map** ([`omap`])
8//! resolves a *virtual* oid at a given xid to a physical block address, and the
9//! **checkpoint ring** ([`checkpoint`]) locates the live container superblock.
10//!
11//! ```text
12//! container (NXSB) → checkpoint ring → live nx_superblock (highest valid xid)
13//!   → container omap → volume superblock (APSB) per volume
14//!      → volume omap → root fs-tree (FSTREE)
15//!         → j_key lookup: name → DIR_REC → INODE → DSTREAM_ID
16//!            → FILE_EXTENT records → blocks → bytes → (decmpfs?) → content
17//! ```
18//!
19//! This is the APFS analogue of NTFS `name → inode → runs → bytes`. The reader
20//! exposes this over any [`std::io::Read`] + [`std::io::Seek`] source and never
21//! panics on malformed input (Paranoid Gatekeeper: bounds-checked reads,
22//! range-checked length/offset/count fields, capped allocations, cycle-guarded
23//! tree walks).
24//!
25//! Forensic format constants (magics, object-type codes, the decmpfs type map)
26//! live in the KNOWLEDGE leaf [`forensicnomicon`]; this crate holds the parsing
27//! *algorithms*, not the constant tables.
28//!
29//! # Scaffold notice
30//!
31//! Implements phases P1–P8 of `docs/plans/2026-06-21-apfs-forensic-design.md`:
32//! container open + checkpoint, object map / B-tree, volume superblock, inode /
33//! directory navigation, file extents + decmpfs + xattrs, snapshots + point-in-
34//! time view, the space manager (allocation bitmap) + reaper, and keybag /
35//! sealed-volume metadata parsing. Full Fusion address translation is the one
36//! remaining gap (rejected loudly at open until a Fusion fixture exists).
37#![forbid(unsafe_code)]
38#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
39
40mod bytes;
41
42pub mod btree;
43pub mod checkpoint;
44pub mod compression;
45pub mod container;
46pub mod dir;
47pub mod encryption;
48pub mod extent;
49pub mod fsrecord;
50pub mod fusion;
51pub mod inode;
52pub mod object;
53pub mod omap;
54pub mod reaper;
55pub mod sealed;
56pub mod snapshot;
57pub mod spaceman;
58pub mod volume;
59pub mod xattr;
60
61use std::io::{Read, Seek};
62
63/// Errors surfaced by the reader. Bootstrap failures (no valid superblock, omap
64/// unresolvable) are **loud, named** variants — never silently absorbed into an
65/// empty result (fleet fail-loud-on-bootstrap rule).
66#[derive(Debug, thiserror::Error)]
67#[non_exhaustive]
68pub enum ApfsError {
69    /// Underlying I/O error.
70    #[error("i/o error: {0}")]
71    Io(#[from] std::io::Error),
72    /// No valid container superblock (NXSB) found in the checkpoint ring — a
73    /// bootstrap failure, carries what was seen so the examiner can diagnose.
74    #[error("no valid NXSB superblock found (checked {checked} checkpoint slots; last magic seen: {last_magic:#010x})")]
75    NoValidSuperblock { checked: usize, last_magic: u32 },
76    /// Object Fletcher-64 checksum did not validate.
77    #[error("object checksum mismatch at block {block}: stored {stored:#018x}, computed {computed:#018x}")]
78    ChecksumMismatch {
79        block: u64,
80        stored: u64,
81        computed: u64,
82    },
83    /// An object map could not resolve a virtual oid at the requested xid.
84    #[error("omap could not resolve virtual oid {oid:#x} at xid {xid}")]
85    OmapUnresolved { oid: u64, xid: u64 },
86    /// A block did not carry the expected object type — a short block, a
87    /// wrong-typed object, or corruption. Carries the offending raw `o_type`
88    /// (fleet "show the unrecognized value" rule).
89    #[error(
90        "unexpected object type in {structure}: expected {expected:#06x}, found {found:#010x}"
91    )]
92    UnexpectedObjectType {
93        structure: &'static str,
94        expected: u32,
95        found: u32,
96    },
97    /// A Fusion container was encountered but Fusion address translation is not
98    /// yet supported — fail loud rather than mis-read physical addresses.
99    #[error("unsupported Fusion container (tier-2 device present); Fusion addressing not yet implemented")]
100    UnsupportedFusion,
101    /// The space manager uses chunk-info **address** blocks (`sm_cab_count > 0`),
102    /// the multi-TB CAB indirection tier, which is not yet implemented — fail
103    /// loud rather than mis-resolve an allocation chunk. Carries the count.
104    #[error("unsupported space-manager CAB tier (sm_cab_count = {count}); only inline CIB layout is implemented")]
105    UnsupportedSpacemanCab { count: u64 },
106    /// A length/offset/count field from the image exceeded a sanity cap
107    /// (allocation-bomb / corruption defense). Carries the offending value.
108    #[error("structural field out of range in {structure}: {field} = {value} (cap {cap})")]
109    FieldOutOfRange {
110        structure: &'static str,
111        field: &'static str,
112        value: u64,
113        cap: u64,
114    },
115    /// A tree walk exceeded the cycle-guard depth (malicious/cyclic oid graph).
116    #[error("tree walk exceeded depth cap {cap} (possible cyclic object graph)")]
117    CycleGuard { cap: usize },
118    /// The checkpoint descriptor or data area is stored as a B-tree (high bit of
119    /// `nx_xp_{desc,data}_blocks` set), which needs B-tree resolution not yet
120    /// implemented — fail loud rather than mis-read a tree oid as a base address.
121    #[error("checkpoint {area} area is tree-backed; B-tree resolution not yet implemented")]
122    CheckpointTreeUnsupported { area: &'static str },
123    /// A transparently-compressed file's `com.apple.decmpfs` payload could not be
124    /// decoded — a named codec/format failure (unknown/unsupported compression
125    /// type, malformed header, codec rejection, or a length mismatch). The reader
126    /// **refuses** rather than returning plausible-but-wrong bytes (fail-loud:
127    /// fabricating file content in a forensic tool is the worst failure).
128    #[error("decmpfs decode failure: {0}")]
129    Decmpfs(&'static str),
130}
131
132/// Result alias for the crate.
133pub type Result<T> = std::result::Result<T, ApfsError>;
134
135/// An open APFS container, the entry point for navigation.
136///
137/// Opening walks the checkpoint ring to the **live** [`container::NxSuperblock`]
138/// (highest valid xid, checksum + magic validated before trust), resolves the
139/// container object map, and enumerates volumes.
140pub struct ApfsContainer<R: Read + Seek> {
141    reader: R,
142    /// The live container superblock (highest valid xid in the checkpoint ring),
143    /// magic + Fletcher-64 validated before it was trusted.
144    superblock: container::NxSuperblock,
145    /// Block address of the live superblock within the checkpoint descriptor area.
146    live_superblock_paddr: u64,
147    /// Ephemeral oid → paddr mappings from the live checkpoint map (resolve the
148    /// spaceman, reaper, and reap-list ephemeral objects).
149    checkpoint_mappings: Vec<checkpoint::CheckpointMapping>,
150}
151
152impl<R: Read + Seek> ApfsContainer<R> {
153    /// Open a container from a `Read + Seek` source, validating the bootstrap.
154    ///
155    /// Reads block zero (a copy of the container superblock; Apple "Mounting an
156    /// APFS Partition" step 1), validates its magic + Fletcher-64, walks the
157    /// checkpoint descriptor ring to the live superblock (highest valid xid),
158    /// and re-reads that superblock as the live container state.
159    ///
160    /// # Errors
161    /// [`ApfsError::NoValidSuperblock`] if block zero is malformed or the
162    /// checkpoint ring holds no cksum-valid, correctly-magicked NXSB;
163    /// [`ApfsError::CheckpointTreeUnsupported`] for a tree-backed descriptor
164    /// area (phase P2); [`ApfsError::Io`] on a read/seek failure.
165    pub fn open(mut reader: R) -> Result<Self> {
166        // Read block zero. Block size is not yet known, so read the minimum APFS
167        // block — block zero's geometry fields all sit within the first 4 KiB.
168        let mut block0 = vec![0u8; container::NX_MINIMUM_BLOCK_SIZE as usize];
169        reader.seek(std::io::SeekFrom::Start(0))?;
170        match reader.read_exact(&mut block0) {
171            Ok(()) => {}
172            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
173                return Err(ApfsError::NoValidSuperblock {
174                    checked: 0,
175                    last_magic: 0,
176                });
177            }
178            Err(e) => return Err(e.into()),
179        }
180
181        // Parse + validate the block-zero bootstrap superblock (magic + cksum).
182        let bootstrap = container::NxSuperblock::parse(&block0)?;
183
184        // Fail loud on a Fusion container BEFORE trusting any addressing: the
185        // `nx_incompatible_features` Fusion bit means physical-address resolution
186        // is tier-aware, which this reader does not yet implement. Detect it on
187        // the bootstrap (the flag is set at container creation and invariant
188        // across checkpoints) and reject rather than silently mis-read.
189        if fusion::is_fusion(&bootstrap) {
190            return Err(ApfsError::UnsupportedFusion);
191        }
192
193        // Walk the checkpoint ring to the live superblock.
194        let live = checkpoint::resolve_live_checkpoint(&mut reader, &bootstrap)?;
195
196        // Re-read the chosen superblock as the authoritative live state.
197        let block_size = bootstrap.block_size as usize;
198        let mut buf = vec![0u8; block_size];
199        let offset = live.superblock_paddr.saturating_mul(block_size as u64);
200        reader.seek(std::io::SeekFrom::Start(offset))?;
201        reader.read_exact(&mut buf)?;
202        let superblock = container::NxSuperblock::parse(&buf)?;
203
204        Ok(Self {
205            reader,
206            superblock,
207            live_superblock_paddr: live.superblock_paddr,
208            checkpoint_mappings: live.mappings,
209        })
210    }
211
212    /// The live checkpoint-map mappings (ephemeral oid → paddr). Pass these to
213    /// [`reaper::pending_objects`] to walk the reaper's ephemeral reap lists.
214    #[must_use]
215    pub fn checkpoint_mappings(&self) -> &[checkpoint::CheckpointMapping] {
216        &self.checkpoint_mappings
217    }
218
219    /// Physical block of the live space manager (`nx_spaceman_oid` resolved
220    /// through the checkpoint map), or `None` if it is not mapped. Feed it to
221    /// [`spaceman::is_block_free`].
222    #[must_use]
223    pub fn spaceman_paddr(&self) -> Option<u64> {
224        self.resolve_ephemeral(self.superblock.spaceman_oid)
225    }
226
227    /// Physical block of the live reaper (`nx_reaper_oid` resolved through the
228    /// checkpoint map), or `None` if it is not mapped. Feed it to
229    /// [`reaper::pending_objects`].
230    #[must_use]
231    pub fn reaper_paddr(&self) -> Option<u64> {
232        self.resolve_ephemeral(self.superblock.reaper_oid)
233    }
234
235    /// Resolve an ephemeral oid to its physical block via the checkpoint map.
236    fn resolve_ephemeral(&self, oid: u64) -> Option<u64> {
237        self.checkpoint_mappings
238            .iter()
239            .find(|m| m.oid == oid)
240            .map(|m| m.paddr)
241    }
242
243    /// The live container superblock resolved from the checkpoint ring.
244    #[must_use]
245    pub fn superblock(&self) -> &container::NxSuperblock {
246        &self.superblock
247    }
248
249    /// Block address of the live superblock within the checkpoint descriptor area.
250    #[must_use]
251    pub fn live_superblock_paddr(&self) -> u64 {
252        self.live_superblock_paddr
253    }
254
255    /// Resolve the physical block address of each volume superblock (APSB).
256    ///
257    /// The live NXSB names its volumes by *virtual* oid (`nx_fs_oid[]`). Each is
258    /// resolved through the **container object map** (`nx_omap_oid`, a physical
259    /// omap object whose B-tree is stored physically) at the container's
260    /// transaction id, yielding the physical block address of that volume's
261    /// `apfs_superblock_t`. These paddrs feed volume parsing (phase P3).
262    ///
263    /// Resolution is deterministic and leaves the reader position arbitrary (it
264    /// seeks as it walks), so callers should not assume a cursor position after.
265    ///
266    /// # Errors
267    /// [`ApfsError::FieldOutOfRange`] if `nx_block_size` is outside the spec
268    /// range; [`ApfsError::UnexpectedObjectType`] if `nx_omap_oid` does not point
269    /// at an omap object; [`ApfsError::OmapUnresolved`] if a `nx_fs_oid` has no
270    /// mapping; [`ApfsError::ChecksumMismatch`] / [`ApfsError::CycleGuard`] /
271    /// [`ApfsError::Io`] on a structurally invalid omap or a read failure.
272    pub fn volume_superblock_addrs(&mut self) -> Result<Vec<u64>> {
273        let block_size = self.superblock.block_size;
274        if !(container::NX_MINIMUM_BLOCK_SIZE..=container::NX_MAXIMUM_BLOCK_SIZE)
275            .contains(&block_size)
276        {
277            return Err(ApfsError::FieldOutOfRange {
278                structure: "nx_superblock",
279                field: "nx_block_size",
280                value: u64::from(block_size),
281                cap: u64::from(container::NX_MAXIMUM_BLOCK_SIZE),
282            });
283        }
284        let block_size = block_size as usize;
285
286        // Read the container omap_phys block (nx_omap_oid is a physical oid, so
287        // it is also the omap object's block address).
288        let mut buf = vec![0u8; block_size];
289        let omap_off = self.superblock.omap_oid.saturating_mul(block_size as u64);
290        self.reader.seek(std::io::SeekFrom::Start(omap_off))?;
291        self.reader.read_exact(&mut buf)?;
292        let omap = omap::ObjectMap::parse(&buf)?;
293
294        // Resolve each virtual fs_oid through the omap at the container xid.
295        let xid = self.superblock.xid;
296        let mut addrs = Vec::with_capacity(self.superblock.fs_oids.len());
297        for &fs_oid in &self.superblock.fs_oids {
298            let entry = omap.resolve(&mut self.reader, fs_oid, xid, block_size)?;
299            addrs.push(entry.paddr);
300        }
301        Ok(addrs)
302    }
303
304    /// Consume the container, returning the underlying reader.
305    #[must_use]
306    pub fn into_reader(self) -> R {
307        self.reader
308    }
309}