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}