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}