disk_forensic/container.rs
1//! Container-format detection (magic-sniff) — which decoder a disk image needs.
2//!
3//! disk4n6 analyses a `Read + Seek` view of a *disk*. Most evidence arrives
4//! wrapped in a container (E01, VHD/VHDX, VMDK, QCOW2, AFF4, DMG); this sniffs
5//! the magic so an opener can pick the right decoder. The magics come from the
6//! `forensicnomicon` knowledge modules (single source of truth). A flat raw/`dd`
7//! image has no wrapper and is analysed in place.
8
9use std::fs::File;
10use std::io::{Read, Seek, SeekFrom};
11use std::path::Path;
12
13use forensicnomicon::report::Finding;
14use forensicnomicon::{aff4, dmg, ewf, qcow2, vhd, vhdx, vmdk};
15
16/// Anything that can be both read and seeked, and moved across threads — the
17/// disk view `analyse_disk` consumes. A blanket impl covers every
18/// `Read + Seek + Send`, so a decoder's reader or a plain `File` both box into
19/// `Box<dyn ReadSeek + Send>`. `Send` lets a consumer hand the decoded disk (or
20/// a partition slice of it) to a background mount thread.
21pub trait ReadSeek: Read + Seek + Send {}
22impl<T: Read + Seek + Send> ReadSeek for T {}
23
24/// A decoded, analysable disk image.
25pub struct OpenedImage {
26 /// The container format it was decoded from (`Raw` for a flat image).
27 pub format: ContainerFormat,
28 /// Logical disk size in bytes (the decoded media size).
29 pub size: u64,
30 /// A `Read + Seek` view of the decoded disk, ready for `analyse_disk`.
31 pub reader: Box<dyn ReadSeek>,
32 /// Container-level forensic findings (e.g. VMDK redundant-GD / dangling-pointer
33 /// / provenance anomalies), surfaced so they aggregate into the normalized
34 /// report alongside the partition/filesystem findings. Empty for containers
35 /// without a forensic analyzer.
36 pub findings: Vec<Finding>,
37}
38
39impl core::fmt::Debug for OpenedImage {
40 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
41 f.debug_struct("OpenedImage")
42 .field("format", &self.format)
43 .field("size", &self.size)
44 .finish_non_exhaustive()
45 }
46}
47
48/// Failure opening/decoding an image.
49#[derive(Debug, thiserror::Error)]
50pub enum OpenError {
51 /// I/O failure opening or reading the file.
52 #[error("I/O error: {0}")]
53 Io(#[from] std::io::Error),
54 /// A recognized container's decoder failed (corrupt/unsupported variant).
55 #[error("{0:?} decode error: {1}")]
56 Decode(ContainerFormat, String),
57 /// The container format is recognized but its decoder is not yet wired —
58 /// decode it to a raw image first. A defensive arm for any future
59 /// recognized-but-unwired format; every format `sniff` recognizes today is
60 /// either decoded or routed.
61 #[error("{0:?} container decoding is not yet supported — decode it to a raw image first")]
62 Unsupported(ContainerFormat),
63 /// The format is a *logical* file container (AD1, or an AFF4-Logical
64 /// `aff4:FileImage` collection), not a raw disk image — it has no block
65 /// device / partition table underneath, so it does not fit `open`'s
66 /// `Read + Seek` disk contract. Open it with [`crate::logical::open`].
67 #[error(
68 "{0:?} is a logical file container, not a raw disk image — open it with \
69 `disk_forensic::logical::open`"
70 )]
71 LogicalContainer(ContainerFormat),
72}
73
74/// Open `path`, sniff its container format, and return a decoded `Read + Seek`
75/// disk view: raw images pass through; E01/EWF is decoded; other recognized
76/// containers return [`OpenError::Unsupported`].
77///
78/// # Errors
79/// [`OpenError::Io`] on a read failure, [`OpenError::Decode`] on a corrupt
80/// image, or [`OpenError::Unsupported`] for a container whose decoder is not yet
81/// wired.
82pub fn open(path: &Path) -> Result<OpenedImage, OpenError> {
83 let mut file = File::open(path)?;
84 let format = sniff(&mut file)?;
85 match format {
86 ContainerFormat::Raw => {
87 let size = file.metadata()?.len();
88 Ok(OpenedImage {
89 format,
90 size,
91 reader: Box::new(file),
92 findings: Vec::new(),
93 })
94 }
95 ContainerFormat::Ewf => {
96 // `ewf` (imported) is forensicnomicon's magic module; the decoder is
97 // the external `ewf` crate, reached via the absolute path.
98 let reader = ::ewf::EwfReader::open(path)
99 .map_err(|e| OpenError::Decode(format, e.to_string()))?;
100 let size = reader.total_size();
101 Ok(OpenedImage {
102 format,
103 size,
104 reader: Box::new(reader),
105 findings: Vec::new(),
106 })
107 }
108 ContainerFormat::Vmdk => {
109 // `vmdk` (imported) is forensicnomicon's magic module; the decoder is
110 // the external `vmdk` crate, reached via the absolute path. The chain
111 // reader resolves any snapshot/delta extents to the base image.
112 let reader = ::vmdk::VmdkChainReader::open(path)
113 .map_err(|e| OpenError::Decode(format, e.to_string()))?;
114 let size = reader.virtual_disk_size();
115 // Run the VMDK forensic analyzer over the same path so its findings
116 // (RGD mismatch, dangling pointers, unclean shutdown, FTP-mangling)
117 // aggregate into the report. A failed analysis must not fail the open —
118 // the disk view is still usable — so it degrades to no findings.
119 let findings = File::open(path)
120 .ok()
121 .map(vmdk_forensic::VmdkIntegrity::new)
122 .and_then(|mut i| i.analyse().ok())
123 .unwrap_or_default();
124 Ok(OpenedImage {
125 format,
126 size,
127 reader: Box::new(reader),
128 findings,
129 })
130 }
131 ContainerFormat::Qcow2 => {
132 // Our qcow2-core reader owns the file and is Read + Seek directly;
133 // it rejects QCOW1 / encrypted / backing-file images at open().
134 let reader = ::qcow2::Qcow2Reader::open(path)
135 .map_err(|e| OpenError::Decode(format, e.to_string()))?;
136 let size = reader.virtual_disk_size();
137 Ok(OpenedImage {
138 format,
139 size,
140 reader: Box::new(reader),
141 findings: Vec::new(),
142 })
143 }
144 ContainerFormat::Vhd => {
145 // Hand-rolled decoder (no crate): handles fixed + dynamic subformats.
146 let reader = crate::vhd::VhdReader::open(File::open(path)?)
147 .map_err(|e| OpenError::Decode(format, e.to_string()))?;
148 let size = reader.virtual_size();
149 Ok(OpenedImage {
150 format,
151 size,
152 reader: Box::new(reader),
153 findings: Vec::new(),
154 })
155 }
156 ContainerFormat::Vhdx => {
157 // Our own `vhdx-core` reader (imported as `vhdx`) returns an owned
158 // `VhdxReader` that is itself `Read + Seek` with a real `Result` — box
159 // it directly; no adapter and no panic guard needed.
160 let reader = ::vhdx::VhdxReader::open(path)
161 .map_err(|e| OpenError::Decode(format, e.to_string()))?;
162 let size = reader.virtual_disk_size();
163 Ok(OpenedImage {
164 format,
165 size,
166 reader: Box::new(reader),
167 findings: Vec::new(),
168 })
169 }
170 ContainerFormat::Dmg => {
171 // Our own `dmg-core` reader is `Read + Seek` directly (no buffering)
172 // and decodes every UDIF block codec — ADC/zlib/bzip2/LZFSE/LZMA —
173 // in pure Rust. `::dmg` is the crate; `dmg` (imported) is
174 // forensicnomicon's magic module used for sniffing.
175 let reader = ::dmg::DmgReader::open(File::open(path)?)
176 .map_err(|e| OpenError::Decode(format, e.to_string()))?;
177 let size = reader.virtual_disk_size();
178 Ok(OpenedImage {
179 format,
180 size,
181 reader: Box::new(reader),
182 findings: Vec::new(),
183 })
184 }
185 ContainerFormat::Iso => {
186 // An ISO 9660 image needs no container decoding — it is a flat
187 // filesystem image. Pass the file through; disk4n6 routes the `Iso`
188 // format to the filesystem analyzer instead of the partition parsers.
189 let size = file.metadata()?.len();
190 Ok(OpenedImage {
191 format,
192 size,
193 reader: Box::new(file),
194 findings: Vec::new(),
195 })
196 }
197 ContainerFormat::Aff4 => {
198 // `aff4` (imported) is forensicnomicon's magic module; the reader is
199 // the external `aff4` crate, reached via the absolute path. AFF4 has
200 // two shapes: a physical disk image (aff4:ImageStream / aff4:Map) is
201 // a Read + Seek disk view; a logical collection (aff4:FileImage) is a
202 // file tree with no disk underneath. Classify cheaply first so a
203 // logical container is routed out instead of yielding a bogus disk.
204 match ::aff4::container_kind(path)
205 .map_err(|e| OpenError::Decode(format, e.to_string()))?
206 {
207 ::aff4::ContainerKind::Disk => {
208 let reader = ::aff4::Aff4Reader::open(path)
209 .map_err(|e| OpenError::Decode(format, e.to_string()))?;
210 let size = reader.virtual_disk_size();
211 Ok(OpenedImage {
212 format,
213 size,
214 reader: Box::new(reader),
215 findings: Vec::new(),
216 })
217 }
218 ::aff4::ContainerKind::Logical => Err(OpenError::LogicalContainer(format)),
219 ::aff4::ContainerKind::Encrypted => Err(OpenError::Decode(
220 format,
221 "encrypted AFF4 container (aff4:EncryptedStream) — needs a password".into(),
222 )),
223 }
224 }
225 ContainerFormat::Ad1 => {
226 // AD1 is FTK's logical "Custom Content Image" — a file tree, no raw
227 // disk. It cannot yield a Read + Seek disk view; route it to
228 // `logical::open`.
229 Err(OpenError::LogicalContainer(format))
230 }
231 ContainerFormat::Dar => {
232 // DAR (Disk ARchiver) is a logical backup archive — a file tree, no
233 // raw disk. Route it to `logical::open` like AD1.
234 Err(OpenError::LogicalContainer(format))
235 }
236 }
237}
238
239/// Bytes read from the start for header-magic detection — large enough to reach
240/// the ISO 9660 PVD "CD001" at offset 32769.
241const HEADER_SNIFF_BYTES: usize = 34816;
242/// Bytes read from the end for footer/trailer-magic detection (VHD, DMG).
243const FOOTER_SNIFF_BYTES: u64 = 512;
244/// AD1 offset-0 signature — the "ADSEGMENTEDFILE" segmented-file marker (the
245/// trailing NUL of `ADSEGMENTEDFILE\0` is not required to disambiguate). Mirrors
246/// `ad1-core`'s `AD1_SEGMENTED_MARKER`.
247const AD1_SEGMENTED_MARKER: &[u8] = b"ADSEGMENTEDFILE";
248/// DAR offset-0 magic — `SAUV_MAGIC_NUMBER` (123) as a big-endian u32. Mirrors
249/// `dar-core`'s `DAR_MAGIC`.
250const DAR_MAGIC: [u8; 4] = [0x00, 0x00, 0x00, 0x7b];
251
252/// A detected disk-image container format.
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254#[cfg_attr(feature = "serde", derive(serde::Serialize))]
255pub enum ContainerFormat {
256 /// No container wrapper — a flat raw/`dd` image (analyse in place).
257 Raw,
258 /// Expert Witness Format (EnCase E01 / Ex01 / logical L01).
259 Ewf,
260 /// Microsoft VHD (fixed / dynamic / differencing).
261 Vhd,
262 /// Microsoft VHDX.
263 Vhdx,
264 /// VMware VMDK (sparse extent).
265 Vmdk,
266 /// QEMU / KVM QCOW2.
267 Qcow2,
268 /// Advanced Forensic Format 4 (ZIP-based). Physical (`aff4:ImageStream` /
269 /// `aff4:Map`) images decode to a disk view via [`open`]; logical
270 /// (`aff4:FileImage`) collections are read via [`crate::logical::open`].
271 Aff4,
272 /// AccessData AD1 (FTK "Custom Content Image") — a *logical* file container,
273 /// not a raw disk. Read via [`crate::logical::open`]; [`open`] refuses it
274 /// with [`OpenError::LogicalContainer`].
275 Ad1,
276 /// DAR (Denis Corbin Disk ARchiver) backup archive — a *logical* file
277 /// container, not a raw disk. Read via [`crate::logical::open`].
278 Dar,
279 /// Apple Disk Image (UDIF).
280 Dmg,
281 /// ISO 9660 optical-disc image (a filesystem, not a partitioned disk —
282 /// analysed by `iso9660-forensic` rather than the partition parsers).
283 Iso,
284}
285
286/// Sniff the container format from a disk image's `header` (its first bytes,
287/// ideally ≥512) and `footer` (its last 512 bytes — VHD's `conectix` cookie and
288/// DMG's `koly` trailer live at the *end* of the file).
289///
290/// Returns [`ContainerFormat::Raw`] when no wrapper magic is present (a bare
291/// MBR/GPT/APM disk).
292#[must_use]
293pub fn detect(header: &[u8], footer: &[u8]) -> ContainerFormat {
294 // ── Offset-0 magics ──────────────────────────────────────────────────────
295 if header.starts_with(&ewf::EVF1_SIGNATURE)
296 || header.starts_with(&ewf::EVF2_SIGNATURE)
297 || header.starts_with(&ewf::LEF2_SIGNATURE)
298 {
299 return ContainerFormat::Ewf;
300 }
301 if header.starts_with(vhdx::FILE_IDENTIFIER) {
302 return ContainerFormat::Vhdx;
303 }
304 // A dynamic VHD mirrors its footer cookie at offset 0.
305 if header.starts_with(vhd::FOOTER_COOKIE) {
306 return ContainerFormat::Vhd;
307 }
308 if header.starts_with(&vmdk::VMDK4_MAGIC.to_le_bytes()) {
309 return ContainerFormat::Vmdk;
310 }
311 if header.starts_with(&qcow2::MAGIC.to_be_bytes()) {
312 return ContainerFormat::Qcow2;
313 }
314 if header.starts_with(&aff4::ZIP_LOCAL_FILE_HEADER_MAGIC) {
315 return ContainerFormat::Aff4;
316 }
317 // AccessData AD1 (FTK "Custom Content Image"): the segmented-file marker
318 // "ADSEGMENTEDFILE\0" sits at offset 0 (al3ks1s/AD1-tools; `ad1-core`'s
319 // AD1_SEGMENTED_MARKER). forensicnomicon carries no AD1 magic module yet, so
320 // the signature is spelled out here.
321 if header.starts_with(AD1_SEGMENTED_MARKER) {
322 return ContainerFormat::Ad1;
323 }
324 // DAR (Disk ARchiver): the SAUV magic (123, big-endian u32) at offset 0.
325 if header.starts_with(&DAR_MAGIC) {
326 return ContainerFormat::Dar;
327 }
328 // ── Optical (ISO 9660): "CD001" at the PVD, offset 32769 (ECMA-119) ───────
329 const ISO_PVD_OFFSET: usize = 32769;
330 if header.len() >= ISO_PVD_OFFSET + 5 && &header[ISO_PVD_OFFSET..ISO_PVD_OFFSET + 5] == b"CD001"
331 {
332 return ContainerFormat::Iso;
333 }
334 // ── Footer / trailer magics ──────────────────────────────────────────────
335 if footer.starts_with(vhd::FOOTER_COOKIE) {
336 return ContainerFormat::Vhd;
337 }
338 if footer.starts_with(&dmg::KOLY_MAGIC.to_be_bytes()) {
339 return ContainerFormat::Dmg;
340 }
341 ContainerFormat::Raw
342}
343
344/// Sniff the container format of a seekable image: read its header and trailing
345/// footer, classify via [`detect`], and **rewind the reader to 0** for the
346/// caller. A sub-512-byte image is read without a footer.
347///
348/// # Errors
349/// Propagates any I/O error from seeking/reading the image.
350pub fn sniff<R: Read + Seek>(reader: &mut R) -> std::io::Result<ContainerFormat> {
351 let len = reader.seek(SeekFrom::End(0))?;
352
353 reader.seek(SeekFrom::Start(0))?;
354 let header_len = (len as usize).min(HEADER_SNIFF_BYTES);
355 let mut header = vec![0u8; header_len];
356 reader.read_exact(&mut header)?;
357
358 let footer = if len >= FOOTER_SNIFF_BYTES {
359 reader.seek(SeekFrom::End(-(FOOTER_SNIFF_BYTES as i64)))?;
360 let mut f = vec![0u8; FOOTER_SNIFF_BYTES as usize];
361 reader.read_exact(&mut f)?;
362 f
363 } else {
364 Vec::new()
365 };
366
367 reader.seek(SeekFrom::Start(0))?;
368 Ok(detect(&header, &footer))
369}