1#![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
39pub type Result<T> = std::result::Result<T, apfs_core::ApfsError>;
42
43#[derive(Debug, Clone)]
47#[non_exhaustive]
48pub enum AnomalyKind {
49 ObjectChecksumMismatch {
52 block: u64,
53 stored: u64,
54 computed: u64,
55 },
56 OmapInconsistent { oid: u64, xid: u64 },
59 OmapOrphanMapping { oid: u64 },
62 CheckpointRingMalformed { detail: &'static str },
65 CheckpointSupersededState { xid: u64 },
68 SnapshotXidDisorder { xid: u64 },
71 SnapshotMissingMetadata { name: String },
74 SnapshotDivergence { inode: u64 },
77 SealedVolumeHashMismatch { inode: u64 },
80 SealedVolumeBroken { broken_xid: u64 },
82 DeletedInodeRecoverable { oid: u64 },
85 DeletedExtentCarveCandidate { block: u64 },
88 ReaperPendingObject { oid: u64 },
90 CloneSharedExtent { inode_a: u64, inode_b: u64 },
93 CloneFlagWithoutSharing { inode: u64 },
96 EncryptionLocked,
98 EncryptionState { detail: String },
100 EncryptionKeybagAnomaly { raw_tag: u8, offset: u64 },
103 TimestampZeroed { inode: u64 },
105 TimestampOrder { inode: u64 },
107 XidReuse { oid: u64, xid: u64 },
109 OrphanInode { oid: u64 },
111 VolumeRoleMismatch { detail: String },
114}
115
116impl AnomalyKind {
117 #[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 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 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
268fn 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
282pub 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); 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
313pub 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 #[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 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 #[test]
418 fn note_carries_raw_offending_values() {
419 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 let n = EncryptionKeybagAnomaly {
433 raw_tag: 0x7f,
434 offset: 64,
435 }
436 .note();
437 assert!(n.contains("0x7f") && n.contains("64"), "{n}");
438
439 assert!(SnapshotMissingMetadata {
441 name: "APFSP5.snap1".to_string()
442 }
443 .note()
444 .contains("APFSP5.snap1"));
445 }
446
447 #[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}