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