#![forbid(unsafe_code)]
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
pub mod clones;
pub mod crypto;
pub mod integrity;
pub mod recovery;
pub mod sealed;
pub mod snapshots;
pub mod timestamps;
use forensicnomicon::report::Observation;
pub use forensicnomicon::report::{Category, Finding, Severity, Source};
pub type Result<T> = std::result::Result<T, apfs_core::ApfsError>;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum AnomalyKind {
ObjectChecksumMismatch {
block: u64,
stored: u64,
computed: u64,
},
OmapInconsistent { oid: u64, xid: u64 },
OmapOrphanMapping { oid: u64 },
CheckpointRingMalformed { detail: &'static str },
CheckpointSupersededState { xid: u64 },
SnapshotXidDisorder { xid: u64 },
SnapshotMissingMetadata { name: String },
SnapshotDivergence { inode: u64 },
SealedVolumeHashMismatch { inode: u64 },
SealedVolumeBroken { broken_xid: u64 },
DeletedInodeRecoverable { oid: u64 },
DeletedExtentCarveCandidate { block: u64 },
ReaperPendingObject { oid: u64 },
CloneSharedExtent { inode_a: u64, inode_b: u64 },
CloneFlagWithoutSharing { inode: u64 },
EncryptionLocked,
EncryptionState { detail: String },
EncryptionKeybagAnomaly { raw_tag: u8, offset: u64 },
TimestampZeroed { inode: u64 },
TimestampOrder { inode: u64 },
XidReuse { oid: u64, xid: u64 },
OrphanInode { oid: u64 },
VolumeRoleMismatch { detail: String },
}
impl AnomalyKind {
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::ObjectChecksumMismatch { .. } => "APFS-OBJECT-CKSUM-MISMATCH",
Self::OmapInconsistent { .. } => "APFS-OMAP-INCONSISTENT",
Self::OmapOrphanMapping { .. } => "APFS-OMAP-ORPHAN-MAPPING",
Self::CheckpointRingMalformed { .. } => "APFS-CHECKPOINT-RING-MALFORMED",
Self::CheckpointSupersededState { .. } => "APFS-CHECKPOINT-SUPERSEDED-STATE",
Self::SnapshotXidDisorder { .. } => "APFS-SNAPSHOT-XID-DISORDER",
Self::SnapshotMissingMetadata { .. } => "APFS-SNAPSHOT-MISSING-METADATA",
Self::SnapshotDivergence { .. } => "APFS-SNAPSHOT-DIVERGENCE",
Self::SealedVolumeHashMismatch { .. } => "APFS-SEALED-VOLUME-HASH-MISMATCH",
Self::SealedVolumeBroken { .. } => "APFS-SEALED-VOLUME-BROKEN",
Self::DeletedInodeRecoverable { .. } => "APFS-DELETED-INODE-RECOVERABLE",
Self::DeletedExtentCarveCandidate { .. } => "APFS-DELETED-EXTENT-CARVE-CANDIDATE",
Self::ReaperPendingObject { .. } => "APFS-REAPER-PENDING-OBJECT",
Self::CloneSharedExtent { .. } => "APFS-CLONE-SHARED-EXTENT",
Self::CloneFlagWithoutSharing { .. } => "APFS-CLONE-FLAG-WITHOUT-SHARING",
Self::EncryptionLocked => "APFS-ENCRYPTION-LOCKED",
Self::EncryptionState { .. } => "APFS-ENCRYPTION-STATE",
Self::EncryptionKeybagAnomaly { .. } => "APFS-ENCRYPTION-KEYBAG-ANOMALY",
Self::TimestampZeroed { .. } => "APFS-TIMESTAMP-ZEROED",
Self::TimestampOrder { .. } => "APFS-TIMESTAMP-ORDER",
Self::XidReuse { .. } => "APFS-XID-REUSE",
Self::OrphanInode { .. } => "APFS-ORPHAN-INODE",
Self::VolumeRoleMismatch { .. } => "APFS-VOLUME-ROLE-MISMATCH",
}
}
}
impl Observation for AnomalyKind {
fn severity(&self) -> Option<Severity> {
Some(match self {
Self::ObjectChecksumMismatch { .. }
| Self::OmapInconsistent { .. }
| Self::CheckpointRingMalformed { .. }
| Self::SealedVolumeHashMismatch { .. }
| Self::SealedVolumeBroken { .. }
| Self::XidReuse { .. } => Severity::High,
Self::SnapshotMissingMetadata { .. }
| Self::DeletedInodeRecoverable { .. }
| Self::EncryptionKeybagAnomaly { .. } => Severity::Medium,
Self::DeletedExtentCarveCandidate { .. }
| Self::ReaperPendingObject { .. }
| Self::CloneFlagWithoutSharing { .. }
| Self::OrphanInode { .. } => Severity::Low,
Self::OmapOrphanMapping { .. }
| Self::CheckpointSupersededState { .. }
| Self::SnapshotXidDisorder { .. }
| Self::SnapshotDivergence { .. }
| Self::CloneSharedExtent { .. }
| Self::EncryptionLocked
| Self::EncryptionState { .. }
| Self::TimestampZeroed { .. }
| Self::TimestampOrder { .. }
| Self::VolumeRoleMismatch { .. } => Severity::Info,
})
}
fn code(&self) -> &'static str {
AnomalyKind::code(self)
}
fn note(&self) -> String {
match self {
Self::ObjectChecksumMismatch {
block,
stored,
computed,
} => format!(
"object at block {block} has stored Fletcher-64 {stored:#018x} but its body computes {computed:#018x}; consistent with structural corruption or tampering"
),
Self::OmapInconsistent { oid, xid } => format!(
"object-map entry for oid {oid} at xid {xid} resolves to a block whose object oid/xid/type disagrees; consistent with omap inconsistency"
),
Self::OmapOrphanMapping { oid } => format!(
"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)"
),
Self::CheckpointRingMalformed { detail } => format!(
"checkpoint ring is structurally invalid: {detail}; consistent with a malformed or truncated checkpoint area"
),
Self::CheckpointSupersededState { xid } => format!(
"a non-latest checkpoint at xid {xid} references objects absent from the latest; consistent with normal copy-on-write residue (a recovery lead)"
),
Self::SnapshotXidDisorder { xid } => format!(
"snapshot xid {xid} is not ordered consistently with its create_time; a lead for the examiner"
),
Self::SnapshotMissingMetadata { name } => format!(
"snapshot \"{name}\" appears in one of the snap-metadata / snap-name trees but not the other; consistent with a structural snapshot inconsistency"
),
Self::SnapshotDivergence { inode } => format!(
"a snapshot's view of inode {inode} differs from the live volume; a history lead, not an anomaly in itself"
),
Self::SealedVolumeHashMismatch { inode } => format!(
"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)"
),
Self::SealedVolumeBroken { broken_xid } => format!(
"integrity_meta_phys.im_broken_xid is set to {broken_xid}; consistent with the seal having been broken at that transaction"
),
Self::DeletedInodeRecoverable { oid } => format!(
"inode/dir record for oid {oid} is superseded but still present in an older checkpoint or unreaped block; consistent with recoverable residue"
),
Self::DeletedExtentCarveCandidate { block } => format!(
"a deleted file's extent block {block} is marked free in the allocation bitmap; a carve candidate only (free does not guarantee recoverable content)"
),
Self::ReaperPendingObject { oid } => format!(
"object oid {oid} is queued in the reaper (logically deleted, still physically present); a residue lead"
),
Self::CloneSharedExtent { inode_a, inode_b } => format!(
"inodes {inode_a} and {inode_b} share one or more physical extents; consistent with a clonefile/dedup provenance link"
),
Self::CloneFlagWithoutSharing { inode } => format!(
"inode {inode} has INODE_WAS_CLONED set but no shared extent was found; consistent with a clone-flag inconsistency"
),
Self::EncryptionLocked => {
"volume is encrypted and no key is available; content is not readable (a state, not a verdict)".to_string()
}
Self::EncryptionState { detail } => {
format!("observed encryption state: {detail} (raw fields; software-vs-hardware not inferred)")
}
Self::EncryptionKeybagAnomaly { raw_tag, offset } => format!(
"keybag entry at offset {offset} carries an unexpected or malformed tag {raw_tag:#04x}; consistent with a keybag anomaly"
),
Self::TimestampZeroed { inode } => format!(
"inode {inode} has one timestamp zeroed while its siblings are set; an Info lead (possible wipe)"
),
Self::TimestampOrder { inode } => format!(
"inode {inode} has timestamps out of expected order (e.g. change_time before create_time); an FP-prone Info lead"
),
Self::XidReuse { oid, xid } => format!(
"two distinct live objects claim the same (oid {oid}, xid {xid}); impossible under copy-on-write, consistent with tampering"
),
Self::OrphanInode { oid } => format!(
"inode {oid} has no DIR_REC referencing it and is not in the private directory; consistent with deleted-but-linked residue"
),
Self::VolumeRoleMismatch { detail } => format!(
"volume role flag is inconsistent with content: {detail}; a structural lead"
),
}
}
}
fn read_block<R: std::io::Read + std::io::Seek>(
reader: &mut R,
paddr: u64,
block_size: usize,
) -> Result<Vec<u8>> {
let mut buf = vec![0u8; block_size];
reader.seek(std::io::SeekFrom::Start(
paddr.saturating_mul(block_size as u64),
))?;
reader.read_exact(&mut buf)?;
Ok(buf)
}
pub fn audit_container<R: std::io::Read + std::io::Seek>(
reader: &mut R,
block_size: usize,
) -> Result<Vec<AnomalyKind>> {
let mut container = apfs_core::ApfsContainer::open(&mut *reader)?;
let mut out = integrity::audit(&container);
let mappings = container.checkpoint_mappings().to_vec();
let reaper_paddr = container.reaper_paddr();
let apsb_addrs = container.volume_superblock_addrs()?;
drop(container);
for paddr in apsb_addrs {
let block = read_block(reader, paddr, block_size)?;
if let Ok(volume) = apfs_core::volume::ApfsVolume::parse(&block) {
out.extend(audit_volume(reader, &volume, block_size)?);
}
}
if let Some(rp) = reaper_paddr {
out.extend(recovery::audit(reader, rp, &mappings, block_size)?);
}
Ok(out)
}
pub fn audit_volume<R: std::io::Read + std::io::Seek>(
reader: &mut R,
volume: &apfs_core::volume::ApfsVolume,
block_size: usize,
) -> Result<Vec<AnomalyKind>> {
let mut out = snapshots::audit(reader, volume, block_size)?;
out.extend(clones::audit(reader, volume));
Ok(out)
}
#[cfg(test)]
mod observation_tests {
use super::AnomalyKind::*;
use super::*;
#[test]
fn severity_matches_design_table() {
use Severity::*;
let cases: &[(AnomalyKind, Severity)] = &[
(
ObjectChecksumMismatch {
block: 1,
stored: 2,
computed: 3,
},
High,
),
(OmapInconsistent { oid: 1, xid: 2 }, High),
(OmapOrphanMapping { oid: 1 }, Info),
(CheckpointRingMalformed { detail: "x" }, High),
(CheckpointSupersededState { xid: 1 }, Info),
(SnapshotXidDisorder { xid: 1 }, Info),
(
SnapshotMissingMetadata {
name: "s".to_string(),
},
Medium,
),
(SnapshotDivergence { inode: 1 }, Info),
(SealedVolumeHashMismatch { inode: 1 }, High),
(SealedVolumeBroken { broken_xid: 1 }, High),
(DeletedInodeRecoverable { oid: 1 }, Medium),
(DeletedExtentCarveCandidate { block: 1 }, Low),
(ReaperPendingObject { oid: 1 }, Low),
(
CloneSharedExtent {
inode_a: 1,
inode_b: 2,
},
Info,
),
(CloneFlagWithoutSharing { inode: 1 }, Low),
(EncryptionLocked, Info),
(
EncryptionState {
detail: "d".to_string(),
},
Info,
),
(
EncryptionKeybagAnomaly {
raw_tag: 0x99,
offset: 16,
},
Medium,
),
(TimestampZeroed { inode: 1 }, Info),
(TimestampOrder { inode: 1 }, Info),
(XidReuse { oid: 1, xid: 2 }, High),
(OrphanInode { oid: 1 }, Low),
(
VolumeRoleMismatch {
detail: "r".to_string(),
},
Info,
),
];
for (k, want) in cases {
let inherent = AnomalyKind::code(k);
let observed = <AnomalyKind as Observation>::code(k);
assert_eq!(inherent, observed, "inherent/Observation code must agree");
assert!(inherent.starts_with("APFS-"), "code is scheme-prefixed");
assert!(!k.note().is_empty(), "{inherent} must carry a note");
assert_eq!(
k.severity(),
Some(*want),
"{inherent} should grade {want:?}"
);
}
}
#[test]
fn note_carries_raw_offending_values() {
let n = ObjectChecksumMismatch {
block: 4660,
stored: 0xaa,
computed: 0xbb,
}
.note();
assert!(
n.contains("4660") && n.contains("aa") && n.contains("bb"),
"{n}"
);
let n = EncryptionKeybagAnomaly {
raw_tag: 0x7f,
offset: 64,
}
.note();
assert!(n.contains("0x7f") && n.contains("64"), "{n}");
assert!(SnapshotMissingMetadata {
name: "APFSP5.snap1".to_string()
}
.note()
.contains("APFSP5.snap1"));
}
#[test]
fn notes_are_observations_not_verdicts() {
let n = SealedVolumeHashMismatch { inode: 5 }.note().to_lowercase();
assert!(
!n.contains("proves") && !n.contains("confirms") && !n.contains("modified by"),
"sealed-volume note must not assert a trust verdict: {n}"
);
}
}