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;
58#[cfg(feature = "vfs")]
59pub mod vfs;
60pub mod volume;
61pub mod xattr;
62
63use std::io::{Read, Seek};
64
65/// Errors surfaced by the reader. Bootstrap failures (no valid superblock, omap
66/// unresolvable) are **loud, named** variants — never silently absorbed into an
67/// empty result (fleet fail-loud-on-bootstrap rule).
68#[derive(Debug, thiserror::Error)]
69#[non_exhaustive]
70pub enum ApfsError {
71    /// Underlying I/O error.
72    #[error("i/o error: {0}")]
73    Io(#[from] std::io::Error),
74    /// No valid container superblock (NXSB) found in the checkpoint ring — a
75    /// bootstrap failure, carries what was seen so the examiner can diagnose.
76    #[error("no valid NXSB superblock found (checked {checked} checkpoint slots; last magic seen: {last_magic:#010x})")]
77    NoValidSuperblock { checked: usize, last_magic: u32 },
78    /// Object Fletcher-64 checksum did not validate.
79    #[error("object checksum mismatch at block {block}: stored {stored:#018x}, computed {computed:#018x}")]
80    ChecksumMismatch {
81        block: u64,
82        stored: u64,
83        computed: u64,
84    },
85    /// An object map could not resolve a virtual oid at the requested xid.
86    #[error("omap could not resolve virtual oid {oid:#x} at xid {xid}")]
87    OmapUnresolved { oid: u64, xid: u64 },
88    /// A block did not carry the expected object type — a short block, a
89    /// wrong-typed object, or corruption. Carries the offending raw `o_type`
90    /// (fleet "show the unrecognized value" rule).
91    #[error(
92        "unexpected object type in {structure}: expected {expected:#06x}, found {found:#010x}"
93    )]
94    UnexpectedObjectType {
95        structure: &'static str,
96        expected: u32,
97        found: u32,
98    },
99    /// A Fusion container was encountered but Fusion address translation is not
100    /// yet supported — fail loud rather than mis-read physical addresses.
101    #[error("unsupported Fusion container (tier-2 device present); Fusion addressing not yet implemented")]
102    UnsupportedFusion,
103    /// The space manager uses chunk-info **address** blocks (`sm_cab_count > 0`),
104    /// the multi-TB CAB indirection tier, which is not yet implemented — fail
105    /// loud rather than mis-resolve an allocation chunk. Carries the count.
106    #[error("unsupported space-manager CAB tier (sm_cab_count = {count}); only inline CIB layout is implemented")]
107    UnsupportedSpacemanCab { count: u64 },
108    /// A length/offset/count field from the image exceeded a sanity cap
109    /// (allocation-bomb / corruption defense). Carries the offending value.
110    #[error("structural field out of range in {structure}: {field} = {value} (cap {cap})")]
111    FieldOutOfRange {
112        structure: &'static str,
113        field: &'static str,
114        value: u64,
115        cap: u64,
116    },
117    /// A tree walk exceeded the cycle-guard depth (malicious/cyclic oid graph).
118    #[error("tree walk exceeded depth cap {cap} (possible cyclic object graph)")]
119    CycleGuard { cap: usize },
120    /// The checkpoint descriptor or data area is stored as a B-tree (high bit of
121    /// `nx_xp_{desc,data}_blocks` set), which needs B-tree resolution not yet
122    /// implemented — fail loud rather than mis-read a tree oid as a base address.
123    #[error("checkpoint {area} area is tree-backed; B-tree resolution not yet implemented")]
124    CheckpointTreeUnsupported { area: &'static str },
125    /// A snapshot mount was requested for a transaction id that names neither the
126    /// live volume nor any retained snapshot — fail loud with the offending xid
127    /// rather than silently mounting the wrong (or live) state.
128    #[error("no snapshot with transaction id {xid} (and it is not the live volume's xid)")]
129    SnapshotNotFound { xid: u64 },
130    /// A transparently-compressed file's `com.apple.decmpfs` payload could not be
131    /// decoded — a named codec/format failure (unknown/unsupported compression
132    /// type, malformed header, codec rejection, or a length mismatch). The reader
133    /// **refuses** rather than returning plausible-but-wrong bytes (fail-loud:
134    /// fabricating file content in a forensic tool is the worst failure).
135    #[error("decmpfs decode failure: {0}")]
136    Decmpfs(&'static str),
137}
138
139/// Result alias for the crate.
140pub type Result<T> = std::result::Result<T, ApfsError>;
141
142/// An open APFS container, the entry point for navigation.
143///
144/// Opening walks the checkpoint ring to the **live** [`container::NxSuperblock`]
145/// (highest valid xid, checksum + magic validated before trust), resolves the
146/// container object map, and enumerates volumes.
147pub struct ApfsContainer<R: Read + Seek> {
148    reader: R,
149    /// The live container superblock (highest valid xid in the checkpoint ring),
150    /// magic + Fletcher-64 validated before it was trusted.
151    superblock: container::NxSuperblock,
152    /// Block address of the live superblock within the checkpoint descriptor area.
153    live_superblock_paddr: u64,
154    /// Ephemeral oid → paddr mappings from the live checkpoint map (resolve the
155    /// spaceman, reaper, and reap-list ephemeral objects).
156    checkpoint_mappings: Vec<checkpoint::CheckpointMapping>,
157}
158
159impl<R: Read + Seek> ApfsContainer<R> {
160    /// Open a container from a `Read + Seek` source, validating the bootstrap.
161    ///
162    /// Reads block zero (a copy of the container superblock; Apple "Mounting an
163    /// APFS Partition" step 1), validates its magic + Fletcher-64, walks the
164    /// checkpoint descriptor ring to the live superblock (highest valid xid),
165    /// and re-reads that superblock as the live container state.
166    ///
167    /// # Errors
168    /// [`ApfsError::NoValidSuperblock`] if block zero is malformed or the
169    /// checkpoint ring holds no cksum-valid, correctly-magicked NXSB;
170    /// [`ApfsError::CheckpointTreeUnsupported`] for a tree-backed descriptor
171    /// area (phase P2); [`ApfsError::Io`] on a read/seek failure.
172    pub fn open(mut reader: R) -> Result<Self> {
173        // Read block zero. Block size is not yet known, so read the minimum APFS
174        // block — block zero's geometry fields all sit within the first 4 KiB.
175        let mut block0 = vec![0u8; container::NX_MINIMUM_BLOCK_SIZE as usize];
176        reader.seek(std::io::SeekFrom::Start(0))?;
177        match reader.read_exact(&mut block0) {
178            Ok(()) => {}
179            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
180                return Err(ApfsError::NoValidSuperblock {
181                    checked: 0,
182                    last_magic: 0,
183                });
184            }
185            Err(e) => return Err(e.into()), // cov:unreachable: in-memory readers only surface EOF
186        }
187
188        // Parse + validate the block-zero bootstrap superblock (magic + cksum).
189        let bootstrap = container::NxSuperblock::parse(&block0)?;
190
191        // Fail loud on a Fusion container BEFORE trusting any addressing: the
192        // `nx_incompatible_features` Fusion bit means physical-address resolution
193        // is tier-aware, which this reader does not yet implement. Detect it on
194        // the bootstrap (the flag is set at container creation and invariant
195        // across checkpoints) and reject rather than silently mis-read.
196        if fusion::is_fusion(&bootstrap) {
197            return Err(ApfsError::UnsupportedFusion);
198        }
199
200        // Walk the checkpoint ring to the live superblock.
201        let live = checkpoint::resolve_live_checkpoint(&mut reader, &bootstrap)?;
202
203        // Re-read the chosen superblock as the authoritative live state.
204        let block_size = bootstrap.block_size as usize;
205        let mut buf = vec![0u8; block_size];
206        let offset = live.superblock_paddr.saturating_mul(block_size as u64);
207        reader.seek(std::io::SeekFrom::Start(offset))?;
208        reader.read_exact(&mut buf)?;
209        let superblock = container::NxSuperblock::parse(&buf)?;
210
211        Ok(Self {
212            reader,
213            superblock,
214            live_superblock_paddr: live.superblock_paddr,
215            checkpoint_mappings: live.mappings,
216        })
217    }
218
219    /// The live checkpoint-map mappings (ephemeral oid → paddr). Pass these to
220    /// [`reaper::pending_objects`] to walk the reaper's ephemeral reap lists.
221    #[must_use]
222    pub fn checkpoint_mappings(&self) -> &[checkpoint::CheckpointMapping] {
223        &self.checkpoint_mappings
224    }
225
226    /// Physical block of the live space manager (`nx_spaceman_oid` resolved
227    /// through the checkpoint map), or `None` if it is not mapped. Feed it to
228    /// [`spaceman::is_block_free`].
229    #[must_use]
230    pub fn spaceman_paddr(&self) -> Option<u64> {
231        self.resolve_ephemeral(self.superblock.spaceman_oid)
232    }
233
234    /// Physical block of the live reaper (`nx_reaper_oid` resolved through the
235    /// checkpoint map), or `None` if it is not mapped. Feed it to
236    /// [`reaper::pending_objects`].
237    #[must_use]
238    pub fn reaper_paddr(&self) -> Option<u64> {
239        self.resolve_ephemeral(self.superblock.reaper_oid)
240    }
241
242    /// Resolve an ephemeral oid to its physical block via the checkpoint map.
243    fn resolve_ephemeral(&self, oid: u64) -> Option<u64> {
244        self.checkpoint_mappings
245            .iter()
246            .find(|m| m.oid == oid)
247            .map(|m| m.paddr)
248    }
249
250    /// The live container superblock resolved from the checkpoint ring.
251    #[must_use]
252    pub fn superblock(&self) -> &container::NxSuperblock {
253        &self.superblock
254    }
255
256    /// Block address of the live superblock within the checkpoint descriptor area.
257    #[must_use]
258    pub fn live_superblock_paddr(&self) -> u64 {
259        self.live_superblock_paddr
260    }
261
262    /// Resolve the physical block address of each volume superblock (APSB).
263    ///
264    /// The live NXSB names its volumes by *virtual* oid (`nx_fs_oid[]`). Each is
265    /// resolved through the **container object map** (`nx_omap_oid`, a physical
266    /// omap object whose B-tree is stored physically) at the container's
267    /// transaction id, yielding the physical block address of that volume's
268    /// `apfs_superblock_t`. These paddrs feed volume parsing (phase P3).
269    ///
270    /// Resolution is deterministic and leaves the reader position arbitrary (it
271    /// seeks as it walks), so callers should not assume a cursor position after.
272    ///
273    /// # Errors
274    /// [`ApfsError::FieldOutOfRange`] if `nx_block_size` is outside the spec
275    /// range; [`ApfsError::UnexpectedObjectType`] if `nx_omap_oid` does not point
276    /// at an omap object; [`ApfsError::OmapUnresolved`] if a `nx_fs_oid` has no
277    /// mapping; [`ApfsError::ChecksumMismatch`] / [`ApfsError::CycleGuard`] /
278    /// [`ApfsError::Io`] on a structurally invalid omap or a read failure.
279    pub fn volume_superblock_addrs(&mut self) -> Result<Vec<u64>> {
280        let block_size = self.superblock.block_size;
281        if !(container::NX_MINIMUM_BLOCK_SIZE..=container::NX_MAXIMUM_BLOCK_SIZE)
282            .contains(&block_size)
283        {
284            // block_size was validated at open (resolve_live_checkpoint errors on an
285            // out-of-range value), so the live superblock is always in range here.
286            return Err(ApfsError::FieldOutOfRange {
287                structure: "nx_superblock",
288                field: "nx_block_size",
289                value: u64::from(block_size),
290                cap: u64::from(container::NX_MAXIMUM_BLOCK_SIZE),
291            }); // cov:unreachable
292        }
293        let block_size = block_size as usize;
294
295        // Read the container omap_phys block (nx_omap_oid is a physical oid, so
296        // it is also the omap object's block address).
297        let mut buf = vec![0u8; block_size];
298        let omap_off = self.superblock.omap_oid.saturating_mul(block_size as u64);
299        self.reader.seek(std::io::SeekFrom::Start(omap_off))?;
300        self.reader.read_exact(&mut buf)?;
301        let omap = omap::ObjectMap::parse(&buf)?;
302
303        // Resolve each virtual fs_oid through the omap at the container xid.
304        let xid = self.superblock.xid;
305        let mut addrs = Vec::with_capacity(self.superblock.fs_oids.len());
306        for &fs_oid in &self.superblock.fs_oids {
307            let entry = omap.resolve(&mut self.reader, fs_oid, xid, block_size)?;
308            addrs.push(entry.paddr);
309        }
310        Ok(addrs)
311    }
312
313    /// Consume the container, returning the underlying reader.
314    #[must_use]
315    pub fn into_reader(self) -> R {
316        self.reader
317    }
318}