Skip to main content

apm_forensic/
lib.rs

1//! Apple Partition Map (APM) detection.
2//!
3//! Apple hybrid optical discs carry an Apple Partition Map so a Mac sees the
4//! disc's partitions (typically an `Apple_HFS` slice alongside the ISO 9660
5//! filesystem).  The layout (Inside Macintosh: Devices) is big-endian, in
6//! fixed-size device blocks: block 0 is the Driver Descriptor Map (signature
7//! `ER`, carrying the block size), and blocks 1.. are partition entries
8//! (signature `PM`), the first of which reports how many entries the map holds.
9//!
10//! This module reads the map for *detection and partition geometry* (name,
11//! type, start block, block count).  Validated against a real `hdiutil` APM.
12//!
13//! For forensic anomaly detection (overlaps, out-of-bounds, map-count
14//! inconsistency, residual/hidden entries) see [`analyse`] and the
15//! [`findings`] module.
16
17pub mod findings;
18
19mod analyse;
20pub use analyse::{analyse, analyse_reader};
21pub use findings::{Anomaly, AnomalyKind, ApmAnalysis, Severity};
22
23/// Crate-level error type. (Manual impl — no `thiserror` dependency.)
24#[derive(Debug)]
25pub enum Error {
26    /// The buffer did not begin with the Driver Descriptor Map `ER` signature,
27    /// or the first partition entry lacked the `PM` signature.
28    NotApm,
29    /// The buffer was shorter than the structure it was asked to hold.
30    TooShort { need: usize, got: usize },
31    /// I/O failure while reading the disk image (from [`analyse_reader`]).
32    Io(std::io::Error),
33}
34
35impl core::fmt::Display for Error {
36    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
37        match self {
38            Error::NotApm => f.write_str("not an Apple Partition Map (missing ER/PM signature)"),
39            Error::TooShort { need, got } => {
40                write!(f, "buffer too short: need {need} bytes, got {got}")
41            }
42            Error::Io(e) => write!(f, "I/O error: {e}"),
43        }
44    }
45}
46
47impl std::error::Error for Error {
48    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
49        match self {
50            Error::Io(e) => Some(e),
51            _ => None,
52        }
53    }
54}
55
56impl From<std::io::Error> for Error {
57    fn from(e: std::io::Error) -> Self {
58        Error::Io(e)
59    }
60}
61
62/// Driver Descriptor Map signature (`ER`).
63const SIG_DDM: &[u8; 2] = b"ER";
64/// Partition map entry signature (`PM`).
65const SIG_PM: &[u8; 2] = b"PM";
66/// Cap on partition entries, guarding against a corrupt map.
67const MAX_PARTITIONS: u32 = 256;
68
69/// One Apple Partition Map entry.
70#[derive(Debug, Clone, PartialEq, Eq)]
71#[cfg_attr(feature = "serde", derive(serde::Serialize))]
72pub struct ApmPartition {
73    /// Partition name (`pmPartName`), e.g. `"disk image"`.
74    pub name: String,
75    /// Partition type (`pmPartType`), e.g. `"Apple_HFS"`.
76    pub type_name: String,
77    /// Physical start block of the partition (`pmPyPartStart`).
78    pub start_block: u32,
79    /// Partition length in blocks (`pmPartBlkCnt`).
80    pub block_count: u32,
81    /// Number of blocks in the partition map, as recorded by *this* entry
82    /// (`pmMapBlkCnt`). Every entry should report the same value.
83    pub map_count: u32,
84    /// Partition status bits (`pmPartStatus`).
85    pub status: u32,
86}
87
88impl ApmPartition {
89    /// Inclusive last block of this partition, saturating on overflow.
90    #[must_use]
91    pub fn end_block(&self) -> u32 {
92        self.start_block
93            .saturating_add(self.block_count)
94            .saturating_sub(1)
95    }
96}
97
98/// A parsed Apple Partition Map.
99#[derive(Debug, Clone, PartialEq, Eq)]
100#[cfg_attr(feature = "serde", derive(serde::Serialize))]
101pub struct ApplePartitionMap {
102    /// Device block size in bytes (from the Driver Descriptor Map).
103    pub block_size: u32,
104    /// Number of blocks on the device (`sbBlkCount` in the Driver Descriptor Map).
105    pub device_block_count: u32,
106    /// Partition entries in map order.
107    pub partitions: Vec<ApmPartition>,
108}
109
110impl ApplePartitionMap {
111    /// The first `Apple_HFS` (or HFS+) partition, if any.
112    #[must_use]
113    pub fn hfs_partition(&self) -> Option<&ApmPartition> {
114        self.partitions
115            .iter()
116            .find(|p| p.type_name.starts_with("Apple_HFS"))
117    }
118}
119
120/// Parse an Apple Partition Map from a buffer beginning at the device start
121/// (block 0 = Driver Descriptor Map).  Returns `None` without the `ER`/`PM`
122/// signatures or if the buffer is too short.
123#[must_use]
124pub fn parse(data: &[u8]) -> Option<ApplePartitionMap> {
125    if data.len() < 512 || &data[0..2] != SIG_DDM {
126        return None;
127    }
128    let block_size = u32::from(be16(&data[2..4]));
129    let device_block_count = be32(&data[4..8]);
130    let bs = block_size as usize;
131    if bs == 0 {
132        return None;
133    }
134    // First partition entry sits at block 1 and reports the map's entry count.
135    let first = bs;
136    if data.len() < first + 8 || &data[first..first + 2] != SIG_PM {
137        return None;
138    }
139    let map_count = be32(&data[first + 4..first + 8]).min(MAX_PARTITIONS);
140
141    let mut partitions = Vec::new();
142    for i in 0..map_count {
143        let off = bs * (1 + i as usize);
144        if data.len() < off + 92 || &data[off..off + 2] != SIG_PM {
145            break;
146        }
147        partitions.push(ApmPartition {
148            map_count: be32(&data[off + 4..off + 8]),
149            start_block: be32(&data[off + 8..off + 12]),
150            block_count: be32(&data[off + 12..off + 16]),
151            name: cstr(&data[off + 16..off + 48]),
152            type_name: cstr(&data[off + 48..off + 80]),
153            status: be32(&data[off + 88..off + 92]),
154        });
155    }
156    Some(ApplePartitionMap {
157        block_size,
158        device_block_count,
159        partitions,
160    })
161}
162
163/// Decode a fixed-width NUL-terminated ASCII field.
164fn cstr(bytes: &[u8]) -> String {
165    let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
166    bytes[..end].iter().map(|&b| b as char).collect()
167}
168
169fn be16(b: &[u8]) -> u16 {
170    u16::from_be_bytes([b[0], b[1]])
171}
172fn be32(b: &[u8]) -> u32 {
173    u32::from_be_bytes([b[0], b[1], b[2], b[3]])
174}