Skip to main content

apfs_forensic/
lib.rs

1//! `apfs-forensic` — a graded anomaly auditor over [`apfs_core`].
2//!
3//! Mirrors `ntfs-forensic`: a typed [`AnomalyKind`] domain enum that keeps APFS
4//! knowledge, plus `audit_*` entry points that convert each anomaly into a
5//! [`forensicnomicon::report::Finding`] via [`forensicnomicon::report::Observation`]
6//! (static codes) so an APFS volume's anomalies aggregate uniformly with the
7//! partition and container layers. Every finding is an **observation**
8//! ("consistent with …"), never a verdict — the examiner/tribunal concludes.
9//!
10//! Anomaly findings that report something *unrecognized* (an unexpected keybag
11//! tag, a bad magic, an oid/xid) MUST carry the raw offending value + location
12//! in their evidence (fleet "show the unrecognized value" rule).
13//!
14//! # Coverage
15//!
16//! Implements the P9 audits of `docs/plans/2026-06-21-apfs-forensic-design.md`:
17//! integrity (XID-REUSE), snapshots (name↔metadata + xid ordering), recovery
18//! (reaper-pending), encryption-state surfacing, broken-seal detection, and
19//! clone-finding logic, driven by [`audit_container`] / [`audit_volume`]. The
20//! fixture-dependent leads documented in the design — sealed-volume hash
21//! recomputation (needs a real SSV), extent-reference shared-block detection
22//! (needs the extentref reader + a clone corpus), and the broader
23//! superseded-checkpoint / orphan-inode recovery leads — are scoped to land with
24//! their validating corpora rather than guess.
25#![forbid(unsafe_code)]
26#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
27
28pub mod clones;
29pub mod crypto;
30pub mod integrity;
31pub mod recovery;
32pub mod sealed;
33pub mod snapshots;
34pub mod timestamps;
35
36use forensicnomicon::report::Observation;
37pub use forensicnomicon::report::{Category, Finding, Severity, Source};
38
39/// Audit result — errors are `apfs_core` read/parse failures surfaced loudly
40/// (never swallowed into an empty finding set).
41pub type Result<T> = std::result::Result<T, apfs_core::ApfsError>;
42
43/// The APFS-specific anomalies this analyzer can surface. Each variant maps to a
44/// published, scheme-prefixed SCREAMING-KEBAB `code` (never changed once
45/// shipped; new variants get new codes).
46#[derive(Debug, Clone)]
47#[non_exhaustive]
48pub enum AnomalyKind {
49    /// `APFS-OBJECT-CKSUM-MISMATCH` — Fletcher-64 over an object body ≠ stored
50    /// `o_cksum`. Carries the block, stored, and computed values.
51    ObjectChecksumMismatch {
52        block: u64,
53        stored: u64,
54        computed: u64,
55    },
56    /// `APFS-OMAP-INCONSISTENT` — omap maps a virtual oid to a paddr whose
57    /// object oid/xid/type disagrees.
58    OmapInconsistent { oid: u64, xid: u64 },
59    /// `APFS-OMAP-ORPHAN-MAPPING` (Info) — omap entry for a block not referable
60    /// from any live tree (FP-prone without a full reachability model).
61    OmapOrphanMapping { oid: u64 },
62    /// `APFS-CHECKPOINT-RING-MALFORMED` — structurally invalid checkpoint ring
63    /// (no cksum-valid NXSB, bad magic, wrap/index inconsistency).
64    CheckpointRingMalformed { detail: &'static str },
65    /// `APFS-CHECKPOINT-SUPERSEDED-STATE` (Info) — recoverable prior state in a
66    /// non-latest checkpoint (normal copy-on-write residue).
67    CheckpointSupersededState { xid: u64 },
68    /// `APFS-SNAPSHOT-XID-DISORDER` (Info) — snapshot xids inconsistent with
69    /// `create_time` ordering.
70    SnapshotXidDisorder { xid: u64 },
71    /// `APFS-SNAPSHOT-MISSING-METADATA` — snap-name without snap-metadata (or
72    /// vice-versa).
73    SnapshotMissingMetadata { name: String },
74    /// `APFS-SNAPSHOT-DIVERGENCE` (Info) — a snapshot's inode view differs from
75    /// the live volume.
76    SnapshotDivergence { inode: u64 },
77    /// `APFS-SEALED-VOLUME-HASH-MISMATCH` — sealed-volume file-info hash ≠
78    /// recomputed content hash (a hash-metadata mismatch, not a trust verdict).
79    SealedVolumeHashMismatch { inode: u64 },
80    /// `APFS-SEALED-VOLUME-BROKEN` — `integrity_meta_phys.im_broken_xid` set.
81    SealedVolumeBroken { broken_xid: u64 },
82    /// `APFS-DELETED-INODE-RECOVERABLE` — superseded inode/dir record still in an
83    /// older checkpoint / unreaped block.
84    DeletedInodeRecoverable { oid: u64 },
85    /// `APFS-DELETED-EXTENT-CARVE-CANDIDATE` (Low) — extent blocks marked free
86    /// (carve candidate, NOT a recoverability guarantee).
87    DeletedExtentCarveCandidate { block: u64 },
88    /// `APFS-REAPER-PENDING-OBJECT` (Low) — object queued in the reaper.
89    ReaperPendingObject { oid: u64 },
90    /// `APFS-CLONE-SHARED-EXTENT` (Info) — inode shares physical extents
91    /// (clonefile/dedup provenance link).
92    CloneSharedExtent { inode_a: u64, inode_b: u64 },
93    /// `APFS-CLONE-FLAG-WITHOUT-SHARING` (Low) — `INODE_WAS_CLONED` set but no
94    /// shared extent found.
95    CloneFlagWithoutSharing { inode: u64 },
96    /// `APFS-ENCRYPTION-LOCKED` (Info) — volume encrypted, no key available.
97    EncryptionLocked,
98    /// `APFS-ENCRYPTION-STATE` (Info) — observed keybag/crypto-state fields (raw).
99    EncryptionState { detail: String },
100    /// `APFS-ENCRYPTION-KEYBAG-ANOMALY` — malformed/unexpected keybag entry;
101    /// carries the raw tag value + offset.
102    EncryptionKeybagAnomaly { raw_tag: u8, offset: u64 },
103    /// `APFS-TIMESTAMP-ZEROED` (Info) — one timestamp 0 while siblings are set.
104    TimestampZeroed { inode: u64 },
105    /// `APFS-TIMESTAMP-ORDER` (Info) — `change_time` < `create_time`, etc. (FP-prone).
106    TimestampOrder { inode: u64 },
107    /// `APFS-XID-REUSE` — two live objects claim the same (oid, xid).
108    XidReuse { oid: u64, xid: u64 },
109    /// `APFS-ORPHAN-INODE` (Low) — inode with no referencing `DIR_REC`.
110    OrphanInode { oid: u64 },
111    /// `APFS-VOLUME-ROLE-MISMATCH` (Info) — volume role flag inconsistent with
112    /// content.
113    VolumeRoleMismatch { detail: String },
114}
115
116impl AnomalyKind {
117    /// The published anomaly code.
118    #[must_use]
119    pub fn code(&self) -> &'static str {
120        match self {
121            Self::ObjectChecksumMismatch { .. } => "APFS-OBJECT-CKSUM-MISMATCH",
122            Self::OmapInconsistent { .. } => "APFS-OMAP-INCONSISTENT",
123            Self::OmapOrphanMapping { .. } => "APFS-OMAP-ORPHAN-MAPPING",
124            Self::CheckpointRingMalformed { .. } => "APFS-CHECKPOINT-RING-MALFORMED",
125            Self::CheckpointSupersededState { .. } => "APFS-CHECKPOINT-SUPERSEDED-STATE",
126            Self::SnapshotXidDisorder { .. } => "APFS-SNAPSHOT-XID-DISORDER",
127            Self::SnapshotMissingMetadata { .. } => "APFS-SNAPSHOT-MISSING-METADATA",
128            Self::SnapshotDivergence { .. } => "APFS-SNAPSHOT-DIVERGENCE",
129            Self::SealedVolumeHashMismatch { .. } => "APFS-SEALED-VOLUME-HASH-MISMATCH",
130            Self::SealedVolumeBroken { .. } => "APFS-SEALED-VOLUME-BROKEN",
131            Self::DeletedInodeRecoverable { .. } => "APFS-DELETED-INODE-RECOVERABLE",
132            Self::DeletedExtentCarveCandidate { .. } => "APFS-DELETED-EXTENT-CARVE-CANDIDATE",
133            Self::ReaperPendingObject { .. } => "APFS-REAPER-PENDING-OBJECT",
134            Self::CloneSharedExtent { .. } => "APFS-CLONE-SHARED-EXTENT",
135            Self::CloneFlagWithoutSharing { .. } => "APFS-CLONE-FLAG-WITHOUT-SHARING",
136            Self::EncryptionLocked => "APFS-ENCRYPTION-LOCKED",
137            Self::EncryptionState { .. } => "APFS-ENCRYPTION-STATE",
138            Self::EncryptionKeybagAnomaly { .. } => "APFS-ENCRYPTION-KEYBAG-ANOMALY",
139            Self::TimestampZeroed { .. } => "APFS-TIMESTAMP-ZEROED",
140            Self::TimestampOrder { .. } => "APFS-TIMESTAMP-ORDER",
141            Self::XidReuse { .. } => "APFS-XID-REUSE",
142            Self::OrphanInode { .. } => "APFS-ORPHAN-INODE",
143            Self::VolumeRoleMismatch { .. } => "APFS-VOLUME-ROLE-MISMATCH",
144        }
145    }
146}
147
148impl Observation for AnomalyKind {
149    fn severity(&self) -> Option<Severity> {
150        // Grades from the design-doc anomaly table. Codex's tempering applies:
151        // copy-on-write residue and FP-prone leads are Info; only structural
152        // contradictions and integrity breaks are High.
153        Some(match self {
154            Self::ObjectChecksumMismatch { .. }
155            | Self::OmapInconsistent { .. }
156            | Self::CheckpointRingMalformed { .. }
157            | Self::SealedVolumeHashMismatch { .. }
158            | Self::SealedVolumeBroken { .. }
159            | Self::XidReuse { .. } => Severity::High,
160
161            Self::SnapshotMissingMetadata { .. }
162            | Self::DeletedInodeRecoverable { .. }
163            | Self::EncryptionKeybagAnomaly { .. } => Severity::Medium,
164
165            Self::DeletedExtentCarveCandidate { .. }
166            | Self::ReaperPendingObject { .. }
167            | Self::CloneFlagWithoutSharing { .. }
168            | Self::OrphanInode { .. } => Severity::Low,
169
170            Self::OmapOrphanMapping { .. }
171            | Self::CheckpointSupersededState { .. }
172            | Self::SnapshotXidDisorder { .. }
173            | Self::SnapshotDivergence { .. }
174            | Self::CloneSharedExtent { .. }
175            | Self::EncryptionLocked
176            | Self::EncryptionState { .. }
177            | Self::TimestampZeroed { .. }
178            | Self::TimestampOrder { .. }
179            | Self::VolumeRoleMismatch { .. } => Severity::Info,
180        })
181    }
182
183    fn code(&self) -> &'static str {
184        AnomalyKind::code(self)
185    }
186
187    fn note(&self) -> String {
188        // "Consistent with …", never a verdict — the examiner/tribunal concludes.
189        // Every raw offending value (block, oid/xid, tag, name) is surfaced.
190        match self {
191            Self::ObjectChecksumMismatch {
192                block,
193                stored,
194                computed,
195            } => format!(
196                "object at block {block} has stored Fletcher-64 {stored:#018x} but its body computes {computed:#018x}; consistent with structural corruption or tampering"
197            ),
198            Self::OmapInconsistent { oid, xid } => format!(
199                "object-map entry for oid {oid} at xid {xid} resolves to a block whose object oid/xid/type disagrees; consistent with omap inconsistency"
200            ),
201            Self::OmapOrphanMapping { oid } => format!(
202                "object-map entry for oid {oid} targets a block not reachable from any live tree examined; consistent with an orphaned mapping (reachability not exhaustively modelled)"
203            ),
204            Self::CheckpointRingMalformed { detail } => format!(
205                "checkpoint ring is structurally invalid: {detail}; consistent with a malformed or truncated checkpoint area"
206            ),
207            Self::CheckpointSupersededState { xid } => format!(
208                "a non-latest checkpoint at xid {xid} references objects absent from the latest; consistent with normal copy-on-write residue (a recovery lead)"
209            ),
210            Self::SnapshotXidDisorder { xid } => format!(
211                "snapshot xid {xid} is not ordered consistently with its create_time; a lead for the examiner"
212            ),
213            Self::SnapshotMissingMetadata { name } => format!(
214                "snapshot \"{name}\" appears in one of the snap-metadata / snap-name trees but not the other; consistent with a structural snapshot inconsistency"
215            ),
216            Self::SnapshotDivergence { inode } => format!(
217                "a snapshot's view of inode {inode} differs from the live volume; a history lead, not an anomaly in itself"
218            ),
219            Self::SealedVolumeHashMismatch { inode } => format!(
220                "sealed-volume file-info hash for inode {inode} does not match the recomputed content hash; consistent with a hash-metadata mismatch (not a trust-chain verdict)"
221            ),
222            Self::SealedVolumeBroken { broken_xid } => format!(
223                "integrity_meta_phys.im_broken_xid is set to {broken_xid}; consistent with the seal having been broken at that transaction"
224            ),
225            Self::DeletedInodeRecoverable { oid } => format!(
226                "inode/dir record for oid {oid} is superseded but still present in an older checkpoint or unreaped block; consistent with recoverable residue"
227            ),
228            Self::DeletedExtentCarveCandidate { block } => format!(
229                "a deleted file's extent block {block} is marked free in the allocation bitmap; a carve candidate only (free does not guarantee recoverable content)"
230            ),
231            Self::ReaperPendingObject { oid } => format!(
232                "object oid {oid} is queued in the reaper (logically deleted, still physically present); a residue lead"
233            ),
234            Self::CloneSharedExtent { inode_a, inode_b } => format!(
235                "inodes {inode_a} and {inode_b} share one or more physical extents; consistent with a clonefile/dedup provenance link"
236            ),
237            Self::CloneFlagWithoutSharing { inode } => format!(
238                "inode {inode} has INODE_WAS_CLONED set but no shared extent was found; consistent with a clone-flag inconsistency"
239            ),
240            Self::EncryptionLocked => {
241                "volume is encrypted and no key is available; content is not readable (a state, not a verdict)".to_string()
242            }
243            Self::EncryptionState { detail } => {
244                format!("observed encryption state: {detail} (raw fields; software-vs-hardware not inferred)")
245            }
246            Self::EncryptionKeybagAnomaly { raw_tag, offset } => format!(
247                "keybag entry at offset {offset} carries an unexpected or malformed tag {raw_tag:#04x}; consistent with a keybag anomaly"
248            ),
249            Self::TimestampZeroed { inode } => format!(
250                "inode {inode} has one timestamp zeroed while its siblings are set; an Info lead (possible wipe)"
251            ),
252            Self::TimestampOrder { inode } => format!(
253                "inode {inode} has timestamps out of expected order (e.g. change_time before create_time); an FP-prone Info lead"
254            ),
255            Self::XidReuse { oid, xid } => format!(
256                "two distinct live objects claim the same (oid {oid}, xid {xid}); impossible under copy-on-write, consistent with tampering"
257            ),
258            Self::OrphanInode { oid } => format!(
259                "inode {oid} has no DIR_REC referencing it and is not in the private directory; consistent with deleted-but-linked residue"
260            ),
261            Self::VolumeRoleMismatch { detail } => format!(
262                "volume role flag is inconsistent with content: {detail}; a structural lead"
263            ),
264        }
265    }
266}
267
268/// Read a checksum-agnostic raw block at `paddr`.
269fn read_block<R: std::io::Read + std::io::Seek>(
270    reader: &mut R,
271    paddr: u64,
272    block_size: usize,
273) -> Result<Vec<u8>> {
274    let mut buf = vec![0u8; block_size];
275    reader.seek(std::io::SeekFrom::Start(
276        paddr.saturating_mul(block_size as u64),
277    ))?;
278    reader.read_exact(&mut buf)?;
279    Ok(buf)
280}
281
282/// Audit a whole container: open it, run the container-level integrity audit,
283/// then every volume ([`audit_volume`]) and the reaper recovery audit. Reads
284/// through `reader` (the same source the container was opened over), so the
285/// caller need not pre-open — pass the image reader and block size.
286///
287/// # Errors
288/// Surfaces an [`apfs_core::ApfsError`] from opening the container or reading any
289/// audited structure (never swallowed into an empty result).
290pub fn audit_container<R: std::io::Read + std::io::Seek>(
291    reader: &mut R,
292    block_size: usize,
293) -> Result<Vec<AnomalyKind>> {
294    let mut container = apfs_core::ApfsContainer::open(&mut *reader)?;
295    let mut out = integrity::audit(&container);
296    let mappings = container.checkpoint_mappings().to_vec();
297    let reaper_paddr = container.reaper_paddr();
298    let apsb_addrs = container.volume_superblock_addrs()?;
299    drop(container); // release the borrow on `reader` for the read-based audits
300
301    for paddr in apsb_addrs {
302        let block = read_block(reader, paddr, block_size)?;
303        if let Ok(volume) = apfs_core::volume::ApfsVolume::parse(&block) {
304            out.extend(audit_volume(reader, &volume, block_size)?);
305        }
306    }
307    if let Some(rp) = reaper_paddr {
308        out.extend(recovery::audit(reader, rp, &mappings, block_size)?);
309    }
310    Ok(out)
311}
312
313/// Audit a single volume: snapshot consistency ([`snapshots::audit`]) and clone
314/// relationships ([`clones::audit`]). Per-inode timestamp leads
315/// ([`timestamps::audit`]) and encryption/sealed audits are driven by callers
316/// that hold the relevant inode / state / integrity-metadata.
317///
318/// # Errors
319/// Surfaces an [`apfs_core::ApfsError`] from reading the volume's trees.
320pub fn audit_volume<R: std::io::Read + std::io::Seek>(
321    reader: &mut R,
322    volume: &apfs_core::volume::ApfsVolume,
323    block_size: usize,
324) -> Result<Vec<AnomalyKind>> {
325    let mut out = snapshots::audit(reader, volume, block_size)?;
326    out.extend(clones::audit(reader, volume));
327    Ok(out)
328}
329
330#[cfg(test)]
331mod observation_tests {
332    use super::AnomalyKind::*;
333    use super::*;
334
335    /// Every variant's severity must match the published design-doc grading.
336    #[test]
337    fn severity_matches_design_table() {
338        use Severity::*;
339        let cases: &[(AnomalyKind, Severity)] = &[
340            (
341                ObjectChecksumMismatch {
342                    block: 1,
343                    stored: 2,
344                    computed: 3,
345                },
346                High,
347            ),
348            (OmapInconsistent { oid: 1, xid: 2 }, High),
349            (OmapOrphanMapping { oid: 1 }, Info),
350            (CheckpointRingMalformed { detail: "x" }, High),
351            (CheckpointSupersededState { xid: 1 }, Info),
352            (SnapshotXidDisorder { xid: 1 }, Info),
353            (
354                SnapshotMissingMetadata {
355                    name: "s".to_string(),
356                },
357                Medium,
358            ),
359            (SnapshotDivergence { inode: 1 }, Info),
360            (SealedVolumeHashMismatch { inode: 1 }, High),
361            (SealedVolumeBroken { broken_xid: 1 }, High),
362            (DeletedInodeRecoverable { oid: 1 }, Medium),
363            (DeletedExtentCarveCandidate { block: 1 }, Low),
364            (ReaperPendingObject { oid: 1 }, Low),
365            (
366                CloneSharedExtent {
367                    inode_a: 1,
368                    inode_b: 2,
369                },
370                Info,
371            ),
372            (CloneFlagWithoutSharing { inode: 1 }, Low),
373            (EncryptionLocked, Info),
374            (
375                EncryptionState {
376                    detail: "d".to_string(),
377                },
378                Info,
379            ),
380            (
381                EncryptionKeybagAnomaly {
382                    raw_tag: 0x99,
383                    offset: 16,
384                },
385                Medium,
386            ),
387            (TimestampZeroed { inode: 1 }, Info),
388            (TimestampOrder { inode: 1 }, Info),
389            (XidReuse { oid: 1, xid: 2 }, High),
390            (OrphanInode { oid: 1 }, Low),
391            (
392                VolumeRoleMismatch {
393                    detail: "r".to_string(),
394                },
395                Info,
396            ),
397        ];
398        for (k, want) in cases {
399            // Exercise every arm of the inherent code(), the Observation::code()
400            // delegate, and note() (all unconditionally, not only in an assert
401            // message that runs on failure) for each variant.
402            let inherent = AnomalyKind::code(k);
403            let observed = <AnomalyKind as Observation>::code(k);
404            assert_eq!(inherent, observed, "inherent/Observation code must agree");
405            assert!(inherent.starts_with("APFS-"), "code is scheme-prefixed");
406            assert!(!k.note().is_empty(), "{inherent} must carry a note");
407            assert_eq!(
408                k.severity(),
409                Some(*want),
410                "{inherent} should grade {want:?}"
411            );
412        }
413    }
414
415    /// Findings that carry raw offending values must surface them in the note
416    /// (fleet "show the unrecognized value" rule) — never a value-less message.
417    #[test]
418    fn note_carries_raw_offending_values() {
419        // checksum: block (decimal) + stored/computed (hex) must all appear.
420        let n = ObjectChecksumMismatch {
421            block: 4660,
422            stored: 0xaa,
423            computed: 0xbb,
424        }
425        .note();
426        assert!(
427            n.contains("4660") && n.contains("aa") && n.contains("bb"),
428            "{n}"
429        );
430
431        // keybag anomaly: raw tag (hex) + offset (decimal).
432        let n = EncryptionKeybagAnomaly {
433            raw_tag: 0x7f,
434            offset: 64,
435        }
436        .note();
437        assert!(n.contains("0x7f") && n.contains("64"), "{n}");
438
439        // names/details pass through.
440        assert!(SnapshotMissingMetadata {
441            name: "APFSP5.snap1".to_string()
442        }
443        .note()
444        .contains("APFSP5.snap1"));
445    }
446
447    /// `note()` is an observation, never a verdict (no "proves"/"confirms").
448    #[test]
449    fn notes_are_observations_not_verdicts() {
450        let n = SealedVolumeHashMismatch { inode: 5 }.note().to_lowercase();
451        assert!(
452            !n.contains("proves") && !n.contains("confirms") && !n.contains("modified by"),
453            "sealed-volume note must not assert a trust verdict: {n}"
454        );
455    }
456}