Skip to main content

disk_forensic/
lib.rs

1//! # disk-forensic
2//!
3//! Point it at any disk image and it identifies the partitioning scheme — MBR,
4//! GPT, or Apple Partition Map — and dispatches to the matching forensic parser,
5//! so you get the right structural analysis without choosing a crate up front.
6//!
7//! It is pure orchestration: scheme detection comes from the
8//! [`forensicnomicon`](https://docs.rs/forensicnomicon) knowledge base, and every
9//! real parse is delegated to a sibling crate
10//! ([`mbr_forensic`], [`gpt_forensic`], [`apm_forensic`]). Like them, it works
11//! over any `Read + Seek`, so it composes with the container crates (`ewf`,
12//! `vhd`, …) for E01/VHD/VMDK evidence.
13//!
14//! ```no_run
15//! use std::fs::File;
16//! let mut img = File::open("disk.img")?;
17//! let size = img.metadata()?.len();
18//! match disk_forensic::analyse_disk(&mut img, size)? {
19//!     disk_forensic::DiskReport::Gpt(a) => println!("GPT, {} partitions", a.partitions.len()),
20//!     disk_forensic::DiskReport::Mbr(a) => println!("MBR, {} partitions", a.partitions.len()),
21//!     disk_forensic::DiskReport::Apm(a) => println!("APM, {} partitions", a.partitions.len()),
22//! }
23//! # Ok::<(), disk_forensic::Error>(())
24//! ```
25
26use std::io::{Read, Seek, SeekFrom};
27
28pub mod container;
29pub mod normalize;
30pub mod report;
31
32pub use forensicnomicon::partition_schemes::Scheme;
33
34/// Bytes read from the start (LBA 0 + LBA 1) for scheme detection.
35const BOOT_AREA_BYTES: usize = 1024;
36/// Upper bound on bytes the APM parser reads — the map lives in the first blocks.
37const APM_MAX_BYTES: usize = 1 << 20;
38
39/// Crate-level error.
40#[derive(Debug, thiserror::Error)]
41pub enum Error {
42    /// No MBR, GPT, or APM signature was found in the boot area (e.g. a disk
43    /// with a filesystem written directly to it, or unrecognised media).
44    #[error("unrecognised partitioning scheme (no MBR, GPT, or APM signature found)")]
45    UnknownScheme,
46    /// The Apple Partition Map parser failed.
47    #[error("APM analysis failed: {0}")]
48    Apm(#[from] apm_forensic::Error),
49    /// The MBR/GPT parser failed.
50    #[error("MBR/GPT analysis failed: {0}")]
51    Mbr(#[from] mbr_forensic::Error),
52    /// I/O failure while reading the disk image.
53    #[error("I/O error: {0}")]
54    Io(#[from] std::io::Error),
55}
56
57/// A full forensic analysis, tagged by the partitioning scheme that was found.
58///
59/// The `Gpt` variant carries the protective-MBR analysis with its parsed GPT
60/// (`.gpt` is `Some`); `Mbr` is a classic MBR with no GPT.
61#[derive(Debug)]
62#[cfg_attr(feature = "serde", derive(serde::Serialize))]
63pub enum DiskReport {
64    /// Apple Partition Map.
65    Apm(apm_forensic::ApmAnalysis),
66    /// Classic Master Boot Record (no GPT).
67    Mbr(Box<mbr_forensic::MbrAnalysis>),
68    /// GUID Partition Table (protective MBR + parsed GPT).
69    Gpt(Box<mbr_forensic::MbrAnalysis>),
70}
71
72impl DiskReport {
73    /// The detected partitioning scheme.
74    #[must_use]
75    pub fn scheme(&self) -> Scheme {
76        match self {
77            DiskReport::Apm(_) => Scheme::Apm,
78            DiskReport::Mbr(_) => Scheme::Mbr,
79            DiskReport::Gpt(_) => Scheme::Gpt,
80        }
81    }
82
83    /// `true` when the analysis recorded at least one anomaly — the CLI's
84    /// non-zero exit signal for triage pipelines.
85    #[must_use]
86    pub fn has_anomalies(&self) -> bool {
87        match self {
88            DiskReport::Apm(a) => !a.anomalies.is_empty(),
89            DiskReport::Mbr(m) | DiskReport::Gpt(m) => !m.anomalies.is_empty(),
90        }
91    }
92}
93
94/// Detect the partitioning scheme of the disk behind `reader` and run the
95/// matching forensic parser.
96///
97/// `disk_size_bytes` bounds MBR/GPT gap and out-of-bounds analysis (pass the
98/// image length; `0` skips it). The reader is rewound before each parser runs.
99///
100/// # Errors
101/// [`Error::UnknownScheme`] when no scheme signature is present, [`Error::Apm`] /
102/// [`Error::Mbr`] when the chosen parser fails, or [`Error::Io`] on a read error.
103pub fn analyse_disk<R: Read + Seek>(
104    reader: &mut R,
105    disk_size_bytes: u64,
106) -> Result<DiskReport, Error> {
107    let boot = read_boot_area(reader)?;
108    match forensicnomicon::partition_schemes::detect_scheme(&boot) {
109        Some(Scheme::Apm) => Ok(DiskReport::Apm(apm_forensic::analyse_reader(
110            reader,
111            APM_MAX_BYTES,
112        )?)),
113        Some(Scheme::Gpt | Scheme::Mbr) => {
114            let mbr = mbr_forensic::analyse(reader, disk_size_bytes)?;
115            // The parser's own GPT detection is authoritative for the label: a
116            // protective MBR with a parseable GPT → Gpt, otherwise classic Mbr.
117            if mbr.gpt.is_some() {
118                Ok(DiskReport::Gpt(Box::new(mbr)))
119            } else {
120                Ok(DiskReport::Mbr(Box::new(mbr)))
121            }
122        }
123        None => Err(Error::UnknownScheme),
124    }
125}
126
127/// Read up to [`BOOT_AREA_BYTES`] from the start, tolerating short reads and EOF.
128fn read_boot_area<R: Read + Seek>(reader: &mut R) -> Result<Vec<u8>, std::io::Error> {
129    reader.seek(SeekFrom::Start(0))?;
130    let mut buf = vec![0u8; BOOT_AREA_BYTES];
131    let mut filled = 0;
132    while filled < BOOT_AREA_BYTES {
133        match reader.read(&mut buf[filled..]) {
134            Ok(0) => break,
135            Ok(n) => filled += n,
136            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
137            Err(e) => return Err(e),
138        }
139    }
140    buf.truncate(filled);
141    Ok(buf)
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use std::io::{Error as IoError, ErrorKind};
148
149    #[test]
150    fn error_display_covers_every_variant() {
151        assert!(Error::UnknownScheme.to_string().contains("unrecognised"));
152        let apm: Error = apm_forensic::Error::NotApm.into();
153        assert!(apm.to_string().contains("APM"));
154        let mbr: Error = mbr_forensic::Error::TooShort(1).into();
155        assert!(mbr.to_string().contains("MBR"));
156        let io: Error = IoError::other("boom").into();
157        assert!(io.to_string().contains("I/O"));
158    }
159
160    /// Yields `Interrupted` once (must be retried), then a hard error.
161    struct FlakyReader {
162        interrupted_once: bool,
163    }
164    impl Read for FlakyReader {
165        fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
166            if self.interrupted_once {
167                Err(IoError::other("hard read failure"))
168            } else {
169                self.interrupted_once = true;
170                Err(IoError::from(ErrorKind::Interrupted))
171            }
172        }
173    }
174    impl Seek for FlakyReader {
175        fn seek(&mut self, _: SeekFrom) -> std::io::Result<u64> {
176            Ok(0)
177        }
178    }
179
180    #[test]
181    fn read_boot_area_retries_interrupted_then_propagates_error() {
182        let mut r = FlakyReader {
183            interrupted_once: false,
184        };
185        let err = read_boot_area(&mut r).unwrap_err();
186        assert_eq!(err.to_string(), "hard read failure");
187    }
188
189    #[test]
190    fn read_boot_area_stops_at_eof_on_short_image() {
191        // A sub-1024-byte reader hits the `Ok(0) => break` path.
192        let mut r = std::io::Cursor::new(vec![0u8; 16]);
193        let boot = read_boot_area(&mut r).unwrap();
194        assert_eq!(boot.len(), 16);
195    }
196}