Skip to content

Validation

Status: phases P1–P5 validated (Tier 2). Results are recorded here as each phase lands; later phases (spaceman, encryption, sealed) are still in progress. Claims below are scoped to the validated capabilities and tiered.

How to read the evidence tiers

  • Tier 1 — an independent third party authored both the artifact and the answer key, or the data is real-world.
  • Tier 2 — real engine/tool output whose ground truth is derivable from the documented construction or confirmed by an independent oracle, but we chose the scenario (so it can miss real-world quirks).
  • Tier 3 — we authored both the fixture and the expected answer with nothing independent vouching (maximal self-deception risk; labelled, never read as Tier 1).

Oracle independence and corpus tier are orthogonal. The tier of a claim is min(oracle independence, corpus provenance): an independent oracle run against a corpus we minted ourselves is Tier 2; only an independent oracle on real-world data reaches Tier 1.

Independent oracles

Oracle Independence Validates Install
macOS (hdiutil attach -readonly, diskutil apfs list, stat, xattr, ls -lR@) Apple's own driver directory tree, file bytes (post-decmpfs), timestamps, xattrs, snapshots present
The Sleuth Kit fsstat/fls/istat (v4.12.1) separate C codebase container/volume geometry, inode listing + metadata installed
fsapfsinfo (libfsapfs) independent NXSB/APSB fields, volumes, btree/omap, snapshots build (LGPL — oracle only)
apfsck (apfsprogs) structural fsck checksum/omap/btree/spaceman structural integrity build
apfs-fuse independent decmpfs decode + encrypted-volume unwrap build (GPL — oracle only)
apfs / exhume_apfs crates independent Rust spot field cross-check crates.io

Cross-extractor check: macOS cp of a decmpfs file vs apfs-core::extent::read_data must be byte-identical (same SHA-256), so neither extractor's assumptions are load-bearing alone.

Corpora (mintable on a macOS host)

Corpus Mint command Tier
Plain APFS hdiutil create -size 64m -fs APFS -volname APFSTEST -layout GPTSPUD apfstest.dmg 2
Snapshots attach → tmutil localsnapshot / diskutil apfs → detach 2
decmpfs ditto --hfsCompression src dst on the attached volume 2 (macOS oracle)
Clones cp -c (clonefile) on the attached volume 2
Encrypted hdiutil create -encryption -stdinpass -fs APFS … 2
Sealed system volume real macOS SSV image (env-gated, gitignored) 1
Real macOS images env-gated, gitignored 1

Verbatim mint commands are recorded in issen/docs/corpus-catalog.md and tests/data/README.md. Carving/recovery is validated against an independent oracle (real images / pre-delete capture + apfsck), not only records we deleted ourselves.

Validated capabilities

Object map + B-tree navigation + volume-superblock resolution (P2) — Tier 2

Corpus: tests/data/apfs_container_chain.bin — blocks 0–344 (1.38 MiB) of a real APFS container partition minted by Apple's own hdiutil (hdiutil create -size 128m -fs APFS -volname APFSORACLE -layout GPTSPUD). The slice holds the complete checkpoint ring and the full NXSB → container omap → omap B-tree → volume superblock (APSB) chain. Because the carve starts at NXSB block 0, a physical block address maps directly to paddr * 4096 in the fixture. Provenance + MD5 in tests/data/README.md.

What is exercised (core/tests/{omap,btree,btree_descend,volume_resolve}.rs):

Claim Evidence Oracle
omap_phys_t header decode (om_flags/om_tree_type/om_tree_oid/om_snapshot_tree_oid) nx_omap_oid = 343om_tree_oid = 344, om_flags = OMAP_MANUALLY_MANAGED offsets verified verbatim vs the Apple reference + libfsapfs; raw-image decode
btree_node_phys_t header + TOC iterate (fixed + variable KV) omap root @344: btn_flags = 0x7 (ROOT|LEAF|FIXED), 1 fixed entry omap_key(1026,2)omap_val(paddr 342) Apple reference + libfsapfs spec (4-byte kvoff_t, 8-byte kvloc_t, btree_info footer)
Root→leaf descent with cksum + cycle guards real omap walk yields exactly (1026, 2, 342); a corrupted node fails Fletcher-64 (ChecksumMismatch); a self-referential index node trips CycleGuard construction; checksum-before-trust
Virtual nx_fs_oid → physical APSB paddr volume_superblock_addrs() == [342]; block 342 carries APSB magic 0x42535041 + valid Fletcher-64 Apple diskutil apfs list and libfsapfs fsapfsinfo (run on the committed fixture) each report exactly one volume (APFSORACLE) — matching the single resolved APSB

Independence: fsapfsinfo (libfsapfs, a separate C codebase) reads the exact committed apfs_container_chain.bin and reports Number of volumes: 1, volume identifier fa8b74aa-…, name APFSORACLE — matching Apple diskutil. The B-tree TOC and value offsets were verified verbatim against the Apple APFS Reference and the libfsapfs format spec before implementation, not from memory.

Tier rationale: independent oracle (libfsapfs/diskutil) on a self-minted corpus ⇒ Tier 2 (min(independent, self-minted)). A Tier-1 lift needs the same resolution run on a real-world / third-party image (env-gated, future work).

Fusion container detection + fail-loud (P1/P2 robustness) — Tier 2

A Fusion (SSD+HDD) container resolves physical addresses tier-aware (a tier bit in the high address, via the fusion middle tree). This reader does not yet implement that translation (full support is P8), so reading a Fusion image without it would silently mis-address blocks — the worst failure class. Per the design (risk #6), detection + loud rejection lands early.

ApfsContainer::open checks the NXSB nx_incompatible_features NX_INCOMPAT_FUSION bit (offset 64, value 0x100, Apple APFS Reference) on the bootstrap superblock — before walking the checkpoint ring or resolving any address — and returns ApfsError::UnsupportedFusion rather than proceed.

What is exercised (core/tests/container_open.rs):

Claim Evidence Oracle
Fusion container is rejected at open the real apfs_nxsb_head.bin with NX_INCOMPAT_FUSION set (+ re-stamped Fletcher-64) opens to UnsupportedFusion, never a mis-read construction (documented spec bit) on a real container
non-Fusion container is unaffected the unmodified real container still opens and resolves its geometry real Apple-minted fixture (Tier 2)

Volume superblock + fs-record dispatch + inode metadata + name→inode (P3) — Tier 2

Corpus: tests/data/apfs_fstree.bin — blocks 0–373 (1.46 MiB) of a real APFS container partition minted by Apple's own hdiutil (hdiutil create -size 128m -fs APFS -volname APFSP3 -layout GPTSPUD), populated with a known directory tree before carving. The slice reaches the full chain NXSB → checkpoint → container omap → APSB (block 371) → volume omap (366/367) → the virtual file-system tree leaf node (block 365). Provenance + the known-tree table + MD5 in tests/data/README.md.

What is exercised (core/tests/{volume,fsrecord,inode,dir}.rs):

Claim Evidence Oracle
apfs_superblock_t (APSB) decode block 371: magic APSB, apfs_fs_index = 0, apfs_omap_oid = 366, apfs_root_tree_oid = 1028 (virtual), volname APFSP3 offsets verbatim vs libfsapfs fsapfs_volume_superblock; TSK pstat reports APSB block 371, oid 1026, xid 6, volume APFSP3
j_key dispatch + xf_blob TLV walk top-4-bit type / low-60-bit oid split; xfields decode NAME + DSTREAM with 8-byte value alignment Apple reference + libfsapfs format spec
j_inode_val_t metadata Beth.txt (inode 20): parent 18, size 38, mode 0644, uid/gid 99, create 1782060082608648902, mod …686902, access …733745215; root (2): mode 0755, uid 501, 3 children TSK istat -o 40 -B 371 — every field (size, mode, uid/gid, child/link count, all four ns-timestamps) matches per inode
name→inode path navigation open_path resolves /top.txt→22 (15 B), /Dir1/Beth.txt→20 (38 B), /Dir1/Sub/secret.bin→21 (26 B); /→root(2); // and trailing / normalize; missing components error loudly TSK fls -r -o 40 -B 371 lists the identical tree + inode numbers; macOS stat confirmed sizes pre-detach

Inode-offset correction (vs the design doc). The design doc placed the inode timestamps/mode following the libfsapfs asciidoc table (access@48, flags@56, nchildren@64, mode@86, xfields@98). The real on-disk j_inode_val_t — verified empirically and reconciled against TSK istat (timestamps + mode + uid for inode 20/2) — has these fields 8 bytes earlier: access@40, internal_flags@48, nchildren/nlink@56, bsd_flags@68, owner@72, gid@76, mode@80, xfields@92. The asciidoc table inserts a phantom 8-byte gap; the empirical layout matches Apple's struct and the istat oracle exactly. inode.rs uses the corrected offsets.

fs-tree is virtual. The fs-tree's node oids resolve through the volume object map (ObjectMap::resolve) at the volume xid; dir::for_each_fs_record walks it with checksum-before-trust, a visited-set cycle guard, and a depth cap. The fixture's fs-tree is a single leaf, so the index-node descent and cycle guard are exercised on a synthetic two-level virtual tree (valid Fletcher-64), mirroring the P2 btree_descend.rs pattern — every functional assertion uses the real fixture + TSK oracle.

Tier rationale: independent oracle (TSK fls/istat, a separate C codebase) on a self-minted corpus ⇒ Tier 2. A Tier-1 lift needs the same navigation run on a real-world macOS image (env-gated, future work).

Corpus: tests/data/apfs_content.bin — the first 442 blocks (1.73 MiB, 4096-byte blocks) of a real APFS container partition minted by Apple's own hdiutil (hdiutil create -size 128m -fs APFS -volname APFSP4 -layout GPTSPUD), populated with known content covering every read path: a plain file, a sparse file (a 64 KiB hole + a tail extent), a transparently-compressed file (ditto --hfsCompression → decmpfs type 8, LZVN, resource fork), a file with custom xattrs, and a symlink. The carve reaches the full chain NXSB → checkpoint → container omap → live APSB (block 438, xid 14) → volume omap (433/434) → fs-tree leaf (block 432) and every file's data extents + resource fork (blocks 345–426). Provenance + MD5 in tests/data/README.md.

What is exercised (core/tests/{extent,compression,xattr}.rs):

Claim Evidence Oracle
plain-file extent assembly /plain.txt (35 B, single extent phys 347) reads back byte-identical macOS shasum -a 256 = 289af0a0… (matches read_data)
sparse-hole handling /sparse.bin (69632 B): FILE_EXTENT records are a 64 KiB hole (phys_block_num 0) + a 4 KiB tail @65536; the hole reads back as zeroes macOS cp SHA-256 = fe0fc4fa…; list_extents surfaces the raw hole+tail
nested-file read /Dir1/Beth.txt (33 B, phys 400) macOS SHA-256 = ee7c2682…
decmpfs type-8 LZVN (resource fork) /compressed.txt (180000 B): com.apple.decmpfs header (type 8) + a com.apple.ResourceFork stream xattr (dstream 24, 1526 B, three LZVN chunks); decoded transparently by read_data macOS cp SHA-256 = 3f58a418… — identical to our decoded output (the cross-extractor check)
decmpfs other types inline 1/9, zlib 3 (+0xFF-stored), LZVN 7 (real chunk + 0x06-stored), LZFSE 11, resource-fork zlib 4 / LZFSE 12 / uncompressed 10 forensicnomicon::decmpfs::classify + flate2/lzvn/lzfse_rust; round-trip / real-chunk
decmpfs refusals (fail-loud) bad magic, truncated, unknown type, dedup type 5, LZBitmap 13/14, missing fork, length mismatch all return a named ApfsError::Decmpfs — never fabricated bytes construction
extended attributes /plain.txt carries com.example.tag="forensic-marker-P4", user.note="second custom attr" (both embedded) macOS xattr -l — names + values match list_xattrs
symlink target /symlink_to_bethcom.apple.fs.symlink (embedded) = "Dir1/Beth.txt" macOS readlink = Dir1/Beth.txt

SHA-256 oracle reconciliation (the byte-identical cross-extractor check). Each file's content, as assembled by apfs_core::extent::read_data, hashes identically to the macOS-read bytes captured before detach:

File Bytes apfs_core::read_data SHA-256 macOS shasum -a 256 Match
/plain.txt 35 289af0a0…abf86b 289af0a0…abf86b
/sparse.bin 69632 fe0fc4fa…cc822a fe0fc4fa…cc822a
/Dir1/Beth.txt 33 ee7c2682…cfeb96 ee7c2682…cfeb96
/compressed.txt (decompressed) 180000 3f58a418…3abc78 3f58a418…3abc78

decmpfs codec reuse (not reinvented). core/src/compression.rs is thin glue over the fleet's validated codec stack — forensicnomicon::decmpfs::classify for the type→algorithm/storage map, flate2 (zlib 3/4), our length-tolerant lzvn (lzvn-core, types 7/8 — the decoder hfsplus-forensic validated 25/25 on real macOS Tahoe data, tolerant of the trailing bytes that lzfse_rust's strict path rejects), and lzfse_rust (types 11/12). The only APFS-specific logic is locating the payload (inline xattr vs com.apple.ResourceFork stream).

Symlink-target source (verified, not assumed). Empirically the symlink target is the embedded com.apple.fs.symlink xattr value (flags 0x6 = EMBEDDED | 0x4, value "Dir1/Beth.txt\0"), confirmed against the raw fixture and macOS readlink. (APFS does also store it as a data stream; the xattr form is what ditto/ln -s produced here and what the reader resolves.)

Tier rationale: independent oracle (macOS cp/xattr/readlink, Apple's own driver, plus the documented construction) on a self-minted corpus ⇒ Tier 2 (min(independent, self-minted)). The decmpfs decoder additionally inherits hfsplus-forensic's real-macOS validation of the same codec stack. A Tier-1 lift needs the same reads run on a real-world macOS image (env-gated, future work).

Snapshots + point-in-time volume view (P5) — Tier 2 (+ env-gated populated path)

What P5 reads. A volume's snapshots live in a single B-tree located by the APSB's physical apfs_snap_meta_tree_oid block number (offset 152; libfsapfs names it snapshot_metadata_tree_block_number, o_subtype 0x10 SNAPMETATREE). The tree is variable-KV and holds two record kinds dispatched by the j_key top-4-bit type: metadata (SNAP_METADATA 1, keyed by the snapshot xid, value j_snap_metadata_val_t) and name (SNAP_NAME 11, keyed by name, value j_snap_name_val_t { snap_xid }). mount_snapshot reads a snapshot's sblock_oid (a physical APSB block) and grafts the live volume's object map onto the frozen superblock: a snapshot's own apfs_omap_oid is 0, so its fs-tree is read through the live omap, resolving oids at the snapshot's xid (ok_xid ≤ snapshot_xid). The existing P3/P4 navigation then reads the volume exactly as it stood at snapshot time.

Constant verification (before coding). The j_snap_metadata_val_t field offsets were taken verbatim from libfsapfs fsapfs_snapshot_metadata_btree_value and independently cross-confirmed against dissect.apfs j_snap_metadata_val; both agree exactly: extentref_tree_oid@0, sblock_oid@8, create_time@16, change_time@24, inum@32, extentref_tree_type@40, flags@44, name_len@48, name@50. A RED unit test caught an early off-by-2 (name placed at @48 instead of @50, mis-reading flags@44 as ending at @46) — the offsets above are what snapshot.rs implements and what the unit test now asserts.

What is exercised (core/tests/snapshot.rs + core/src/snapshot.rs units):

Claim Evidence Oracle / tier
snap-meta tree located + walked on real Apple bytes the P4 fixture's APSB (block 438) carries apfs_snap_meta_tree_oid = block 340, a real o_subtype 0x10 btree node whose btn_nkeys is 0; the walk reads it, verifies its Fletcher-64 checksum, and returns zero snapshots the documented empty tree + Apple-authored checksum (Tier 2, real structure)
j_snap_metadata_val_t decode every field decoded at the verified offsets libfsapfs + dissect.apfs agreement (constant verification)
j_snap_name_key_t decode + name→xid resolve resolve_snapshot_xid returns None on the empty real tree; returns the right xid on a populated vector construction + the libfsapfs snap_xid value layout
point-in-time seam mount_snapshot reads sblock_oid as an ApfsVolume and grafts the live omap (a snapshot's own apfs_omap_oid is 0); the frozen root_tree_oid/xid are preserved. A unit test pins the graft (omap_oid overridden to the live volume's); mounting a non-APSB block (NXSB @0) fails loudly with UnexpectedObjectType real P4 APSB + omap_oid==0 contract (Tier 2)
walk control flow (leaf dispatch, index-node virtual child descent through the volume omap, checksum-mismatch fail-loud, visited-set cycle guard) spec-faithful hand-built APFS micro-images: real obj_phys headers, real Fletcher-64, real variable-KV TOC/key/value + fixed-KV omap layout Tier 3 (walk control flow only; every offset/decode it relies on is Tier-2-validated on real data above)

Populated changing-file path (env-gated) — VALIDATED on real snapshots. The v1-vs-v2 point-in-time read (read changing.txt at the earliest snapshot → v1 SHA-256; at the live volume → v2 SHA-256; assert each matches what macOS wrote, and that they differ) ran from core/tests/snapshot.rs::populated_fixture_point_in_time_read against a real Apple-minted fixture and passed — byte-identical to the macOS oracle (v1 da27342b…, v2 cfd0476c…; snapshot XIDs 6094/6096). This is the strongest P5 evidence: an independent oracle (macOS itself) on real snapshots, and it caught a real bug the synthetic seam test missed — the committed mount_snapshot did not graft the live omap, so point-in-time open_path read block 0 (the NXSB) and failed. The fixture is a whole GPT disk image (a Tart VM disk.img); the test opens it through a partition-offset view and derives the live APSB from the container omap. Gated on APFS_P5_FIXTURE (+ APFS_P5_PART_OFFSET, APFS_P5_FILE, APFS_P5_V1_SHA256, APFS_P5_V2_SHA256); skips cleanly when absent. See tests/data/README.md for the mint recipe and recorded values.

Performance (keyed descent, two layers). Navigation was quadratic on real volumes; two keyed-descent changes fixed it, each measured on the real P5 fixture:

  1. omap (btree::find_leaf): omap.resolve reads one root→leaf path instead of scanning the whole omap B-tree on every node resolution. Cut the point-in-time validation run from ~4962 s (~83 min) to ~5 s.
  2. fs-tree (dir::for_each_fs_record_for_oid): the fs-tree walk now prunes to the target object id — at each index node it descends only children whose key range can cover that oid (child_may_contain_oid), instead of visiting every node and filtering. Every navigation entry point (lookup_child, load_inode, list_dir, list_extents, list_xattrs) keys by one object id, so all benefit.

Measured (env-gated keyed_nav.rs::keyed_navigation_prunes_real_fs_tree, real macOS multi-level fs-tree): resolving + reading /Users/admin/changing.txt touched 25 955 distinct blocks before → 26 after the fs-tree pruning, same byte-identical bytes (the macOS APFS_P5_V2_SHA256 oracle). With both layers the point-in-time test runs in ~0.01 s (from ~83 min). The committed fixtures are single-leaf, so the index-pruning branch is validated on the real multi-level tree (above) plus a CI-visible cross-check that the keyed walk returns the same record set as the full unpruned walk on apfs_fstree.bin (dir::tests::keyed_walk_matches_full_walk_on_real_fs_tree) and a boundary-case unit test of the prune predicate (child_pruning_selects_only_covering_subtrees).

Minting blocker (host capability, documented). Creating an APFS snapshot on an arbitrary (DMG) volume requires the com.apple.developer.vfs.snapshot entitlement: under SIP, fs_snapshot_create(2) returns EPERM even as root, diskutil apfs has no addSnapshot verb, and tmutil localsnapshot only snapshots the Time-Machine-eligible system data volume (never the DMG). The only entitled creator on a stock host is tmutil, which cannot target a DMG. So the populated changing.txt fixture is minted on a host where snapshot creation is permitted (SIP-relaxed dev box, or a TM-registered volume); the exact mint commands are recorded in tests/data/README.md and issen/docs/corpus-catalog.md. The location, empty-case, decode, point-in-time seam, and walk control flow are all validated now on real Apple bytes + spec-faithful vectors per the table above.

Tier rationale: independent structure (Apple's own snap-meta tree + Fletcher-64) on a self-minted corpus ⇒ Tier 2 for the location/empty/seam claims; the populated v1-vs-v2 path lifts to Tier 2 with an independent oracle (diskutil apfs listSnapshots + macOS-written SHA-256) once the env-gated fixture is supplied.

Space manager (allocation bitmap) + reaper (P6) — Tier 2

What P6 reads. The space manager (spaceman_phys_t, ephemeral nx_spaceman_oid, resolved via the checkpoint map) answers "is this physical block free?": block → chunk → chunk-info (CIB) → SPACEMAN_BITMAP. The reaper (nx_reaper_phys_t, ephemeral nx_reaper_oid) surfaces objects logically deleted but still physically present (pending_objects).

Bit polarity (the inverted-bit trap). A set bit = allocated, clear = free (apfsck count_chunk_free: free = total − popcount(bitmap)); ci_bitmap_addr == 0 ⇒ whole chunk free. Offsets/polarity were taken before coding from linux-apfs apfs_raw.h + apfsprogs spaceman.c, cross-checked against libfsapfs (which leaves the chunk fields Unknown — so polarity rests on apfsprogs' runtime-self-validated code, flagged below).

What is exercised (core/tests/{spaceman,reaper,container_open}.rs + reaper.rs units):

Claim Evidence Oracle / tier
is_block_free correct on real data on apfs_container_chain.bin every block 0..=344 (live objects: NXSB@0, spaceman@11, reaper@12, CIB@331, bitmap@332, omap@343, APSB@342) reports allocated; every block ≥ 345 reports free the carve boundary (Tier 2)
bit polarity verified by an independent count counting allocations via is_block_free over all 32 758 blocks yields 345 == block_count − free_count, the spaceman's own accounting (independent of how each bit is read — a flipped polarity changes this total) spaceman free_count (Tier 2)
CAB tier fails loud sm_cab_count > 0 (multi-TB indirection, not yet implemented) returns UnsupportedSpacemanCab, never a mis-resolved chunk construction
reaper empty on a clean container the fixture's live reaper (block 12) has nr_head == 0, nr_oid == 0pending_objects returns no entries real fixture (Tier 2)
reaper walk + in-progress object synthetic nx_reaper_phys_tnx_reap_list_phys_t (two entries, chained via a checkpoint mapping) yields both (oid, type); an in-progress nr_oid is reported with no lists spec-faithful hand-built blocks (Tier 3; the offsets they use are Tier-2-validated above)
ephemeral resolution ApfsContainer::spaceman_paddr()/reaper_paddr() resolve nx_spaceman_oid/nx_reaper_oid to blocks 11/12 via the live checkpoint map real fixture (Tier 2)

Flag (honest tiering). The 1=allocated polarity is confirmed by one decoder family (apfsprogs/linux-apfs) whose code self-checks against ci_free_count on every real image, not by two independently-reasoned decoders — libfsapfs does not decode these fields. The independent free-count cross-check above (345 from the bitmap == 345 from the spaceman accounting) is what raises this to Tier 2 on our real fixture. The populated reap-list walk is Tier 3 (no committed fixture has a queued reaper; a real deleted-volume image would lift it).