Skip to main content

disk_forensic/
lib.rs

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