apm-forensic 0.3.0

Forensic-grade Apple Partition Map (APM) reader — Driver Descriptor Map + partition entries (name, type, start, count) from a byte buffer
Documentation
//! Forensic finding types for Apple Partition Map analysis.
//!
//! Mirrors the model in `mbr-forensic` / `gpt-forensic`: every anomaly's
//! severity, stable code, and human note are derived from its [`AnomalyKind`],
//! so they cannot drift out of sync.

use core::fmt;

use crate::ApmPartition;

/// Severity of an APM anomaly.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum Severity {
    Info,
    Low,
    Medium,
    High,
    Critical,
}

impl fmt::Display for Severity {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(match self {
            Severity::Info => "INFO",
            Severity::Low => "LOW",
            Severity::Medium => "MEDIUM",
            Severity::High => "HIGH",
            Severity::Critical => "CRITICAL",
        })
    }
}

/// Classification of an APM anomaly.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum AnomalyKind {
    /// A partition entry's `pmMapBlkCnt` disagrees with the first entry's — every
    /// entry must report the same map block count, so this is corruption/tampering.
    MapCountMismatch {
        index: usize,
        found: u32,
        expected: u32,
    },
    /// Two partitions claim overlapping block ranges.
    OverlappingPartitions { a: usize, b: usize },
    /// A partition extends beyond the device's block count.
    PartitionOutOfBounds {
        index: usize,
        last_block: u32,
        device_last_block: u32,
    },
    /// A `PM`-signature entry exists beyond the declared map count — a hidden or
    /// residual partition entry.
    ResidualEntry { block: u32 },
    /// A partition has a zero block count.
    ZeroLengthPartition { index: usize },
    /// No entry of type `Apple_partition_map` — the map must describe itself, so
    /// its absence is a structural anomaly / tampering signal.
    NoPartitionMapEntry,
    /// A partition's `pmPartType` is not a recognised APM type — possibly a
    /// custom or hidden partition.
    UnknownPartitionType { index: usize, type_name: String },
    /// A block range between two partitions is described by no entry. An APM
    /// covers the whole device (free space is an `Apple_Free` entry), so an
    /// interior gap is unaccounted/hidden space.
    UnmappedRegion { start_block: u32, end_block: u32 },
}

impl AnomalyKind {
    /// Severity assigned to this kind — the single source of truth.
    #[must_use]
    pub fn severity(&self) -> Severity {
        use AnomalyKind as K;
        match self {
            K::OverlappingPartitions { .. } => Severity::Critical,
            K::PartitionOutOfBounds { .. } | K::ResidualEntry { .. } | K::NoPartitionMapEntry => {
                Severity::High
            }
            K::MapCountMismatch { .. } | K::UnmappedRegion { .. } => Severity::Medium,
            K::ZeroLengthPartition { .. } => Severity::Low,
            K::UnknownPartitionType { .. } => Severity::Info,
        }
    }

    /// Stable machine-readable code.
    #[must_use]
    pub fn code(&self) -> &'static str {
        use AnomalyKind as K;
        match self {
            K::MapCountMismatch { .. } => "APM-MAP-COUNT",
            K::OverlappingPartitions { .. } => "APM-PART-OVERLAP",
            K::PartitionOutOfBounds { .. } => "APM-PART-OOB",
            K::ResidualEntry { .. } => "APM-PART-RESIDUAL",
            K::ZeroLengthPartition { .. } => "APM-PART-ZEROLEN",
            K::NoPartitionMapEntry => "APM-NO-MAP-ENTRY",
            K::UnknownPartitionType { .. } => "APM-PART-UNKNOWN",
            K::UnmappedRegion { .. } => "APM-UNMAPPED",
        }
    }

    /// Human-readable description.
    #[must_use]
    pub fn note(&self) -> String {
        use AnomalyKind as K;
        match self {
            K::MapCountMismatch {
                index,
                found,
                expected,
            } => format!(
                "Entry {index}: pmMapBlkCnt {found} disagrees with the map's {expected} \
                 — corruption or tampering"
            ),
            K::OverlappingPartitions { a, b } => {
                format!("Partitions {a} and {b} claim overlapping block ranges")
            }
            K::PartitionOutOfBounds {
                index,
                last_block,
                device_last_block,
            } => format!(
                "Partition {index} ends at block {last_block}, beyond the device's last block \
                 {device_last_block}"
            ),
            K::ResidualEntry { block } => format!(
                "A PM partition entry exists at block {block}, beyond the declared map count \
                 — hidden or residual entry"
            ),
            K::ZeroLengthPartition { index } => format!("Partition {index} has a zero block count"),
            K::NoPartitionMapEntry => {
                "No Apple_partition_map entry — the map does not describe itself".to_string()
            }
            K::UnknownPartitionType { index, type_name } => {
                format!("Partition {index}: unrecognised type \"{type_name}\" — possibly custom or hidden")
            }
            K::UnmappedRegion {
                start_block,
                end_block,
            } => format!(
                "Blocks {start_block}{end_block} are described by no partition entry — \
                 unaccounted/hidden space"
            ),
        }
    }
}

/// A single APM anomaly with derived severity/code/note.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Anomaly {
    pub severity: Severity,
    pub code: &'static str,
    pub kind: AnomalyKind,
    pub note: String,
}

impl Anomaly {
    #[must_use]
    pub fn new(kind: AnomalyKind) -> Self {
        Anomaly {
            severity: kind.severity(),
            code: kind.code(),
            note: kind.note(),
            kind,
        }
    }
}

impl fmt::Display for Anomaly {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "[{}] {}: {}", self.severity, self.code, self.note)
    }
}

/// Result of a full APM forensic analysis.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct ApmAnalysis {
    /// Device block size in bytes.
    pub block_size: u32,
    /// Device block count (from the Driver Descriptor Map).
    pub device_block_count: u32,
    /// Partition entries in map order.
    pub partitions: Vec<ApmPartition>,
    /// All detected anomalies, in discovery order.
    pub anomalies: Vec<Anomaly>,
}

impl ApmAnalysis {
    /// The highest severity among all anomalies, or `None` when clean.
    #[must_use]
    pub fn max_severity(&self) -> Option<Severity> {
        self.anomalies.iter().map(|a| a.severity).max()
    }
}