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