Skip to main content

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    open_depth(path, 0)
84}
85
86/// Maximum nested compression layers to peel before giving up (bomb guard).
87const MAX_PEEL_DEPTH: usize = 4;
88
89fn open_depth(path: &Path, depth: usize) -> Result<OpenedImage, OpenError> {
90    // Transparently peel an OUTER compression wrapper (evidence.dd.gz -> dd)
91    // via archive-core — but only when BOTH the content magic and the file
92    // extension agree it is a wrapper, so a raw disk with coincidental magic
93    // still opens as raw. A raw inner is served from memory; a container inner
94    // is spilled to a temp file and re-opened.
95    if depth < MAX_PEEL_DEPTH {
96        if let Some(inner) = try_peel(path)? {
97            if sniff_bytes(&inner) == ContainerFormat::Raw {
98                let size = inner.len() as u64;
99                return Ok(OpenedImage {
100                    format: ContainerFormat::Raw,
101                    size,
102                    reader: Box::new(std::io::Cursor::new(inner)),
103                    findings: Vec::new(),
104                });
105            }
106            let tmp = spill_to_tmp(&inner)?;
107            return open_depth(&tmp, depth + 1);
108        }
109    }
110    let mut file = File::open(path)?;
111    let format = sniff(&mut file)?;
112    match format {
113        ContainerFormat::Raw => {
114            let size = file.metadata()?.len();
115            Ok(OpenedImage {
116                format,
117                size,
118                reader: Box::new(file),
119                findings: Vec::new(),
120            })
121        }
122        ContainerFormat::Ewf => {
123            // `ewf` (imported) is forensicnomicon's magic module; the decoder is
124            // the external `ewf` crate, reached via the absolute path.
125            let reader = ::ewf::EwfReader::open(path)
126                .map_err(|e| OpenError::Decode(format, e.to_string()))?;
127            let size = reader.total_size();
128            Ok(OpenedImage {
129                format,
130                size,
131                reader: Box::new(reader),
132                findings: Vec::new(),
133            })
134        }
135        ContainerFormat::Vmdk => {
136            // `vmdk` (imported) is forensicnomicon's magic module; the decoder is
137            // the external `vmdk` crate, reached via the absolute path. The chain
138            // reader resolves any snapshot/delta extents to the base image.
139            let reader = ::vmdk::VmdkChainReader::open(path)
140                .map_err(|e| OpenError::Decode(format, e.to_string()))?;
141            let size = reader.virtual_disk_size();
142            // Run the VMDK forensic analyzer over the same path so its findings
143            // (RGD mismatch, dangling pointers, unclean shutdown, FTP-mangling)
144            // aggregate into the report. A failed analysis must not fail the open —
145            // the disk view is still usable — so it degrades to no findings.
146            let findings = File::open(path)
147                .ok()
148                .map(vmdk_forensic::VmdkIntegrity::new)
149                .and_then(|mut i| i.analyse().ok())
150                .unwrap_or_default();
151            Ok(OpenedImage {
152                format,
153                size,
154                reader: Box::new(reader),
155                findings,
156            })
157        }
158        ContainerFormat::Qcow2 => {
159            // Our qcow2-core reader owns the file and is Read + Seek directly;
160            // it rejects QCOW1 / encrypted / backing-file images at open().
161            let reader = ::qcow2::Qcow2Reader::open(path)
162                .map_err(|e| OpenError::Decode(format, e.to_string()))?;
163            let size = reader.virtual_disk_size();
164            Ok(OpenedImage {
165                format,
166                size,
167                reader: Box::new(reader),
168                findings: Vec::new(),
169            })
170        }
171        ContainerFormat::Vhd => {
172            // Hand-rolled decoder (no crate): handles fixed + dynamic subformats.
173            let reader = crate::vhd::VhdReader::open(File::open(path)?)
174                .map_err(|e| OpenError::Decode(format, e.to_string()))?;
175            let size = reader.virtual_size();
176            Ok(OpenedImage {
177                format,
178                size,
179                reader: Box::new(reader),
180                findings: Vec::new(),
181            })
182        }
183        ContainerFormat::Vhdx => {
184            // Our own `vhdx-core` reader (imported as `vhdx`) returns an owned
185            // `VhdxReader` that is itself `Read + Seek` with a real `Result` — box
186            // it directly; no adapter and no panic guard needed.
187            let reader = ::vhdx::VhdxReader::open(path)
188                .map_err(|e| OpenError::Decode(format, e.to_string()))?;
189            let size = reader.virtual_disk_size();
190            Ok(OpenedImage {
191                format,
192                size,
193                reader: Box::new(reader),
194                findings: Vec::new(),
195            })
196        }
197        ContainerFormat::Dmg => {
198            // Our own `dmg-core` reader is `Read + Seek` directly (no buffering)
199            // and decodes every UDIF block codec — ADC/zlib/bzip2/LZFSE/LZMA —
200            // in pure Rust. `::dmg` is the crate; `dmg` (imported) is
201            // forensicnomicon's magic module used for sniffing.
202            let reader = ::dmg::DmgReader::open(File::open(path)?)
203                .map_err(|e| OpenError::Decode(format, e.to_string()))?;
204            let size = reader.virtual_disk_size();
205            Ok(OpenedImage {
206                format,
207                size,
208                reader: Box::new(reader),
209                findings: Vec::new(),
210            })
211        }
212        ContainerFormat::Iso => {
213            // An ISO 9660 image needs no container decoding — it is a flat
214            // filesystem image. Pass the file through; disk4n6 routes the `Iso`
215            // format to the filesystem analyzer instead of the partition parsers.
216            let size = file.metadata()?.len();
217            Ok(OpenedImage {
218                format,
219                size,
220                reader: Box::new(file),
221                findings: Vec::new(),
222            })
223        }
224        ContainerFormat::Aff4 => {
225            // `aff4` (imported) is forensicnomicon's magic module; the reader is
226            // the external `aff4` crate, reached via the absolute path. AFF4 has
227            // two shapes: a physical disk image (aff4:ImageStream / aff4:Map) is
228            // a Read + Seek disk view; a logical collection (aff4:FileImage) is a
229            // file tree with no disk underneath. Classify cheaply first so a
230            // logical container is routed out instead of yielding a bogus disk.
231            match ::aff4::container_kind(path)
232                .map_err(|e| OpenError::Decode(format, e.to_string()))?
233            {
234                ::aff4::ContainerKind::Disk => {
235                    let reader = ::aff4::Aff4Reader::open(path)
236                        .map_err(|e| OpenError::Decode(format, e.to_string()))?;
237                    let size = reader.virtual_disk_size();
238                    Ok(OpenedImage {
239                        format,
240                        size,
241                        reader: Box::new(reader),
242                        findings: Vec::new(),
243                    })
244                }
245                ::aff4::ContainerKind::Logical => Err(OpenError::LogicalContainer(format)),
246                ::aff4::ContainerKind::Encrypted => Err(OpenError::Decode(
247                    format,
248                    "encrypted AFF4 container (aff4:EncryptedStream) — needs a password".into(),
249                )),
250            }
251        }
252        ContainerFormat::Ad1 => {
253            // AD1 is FTK's logical "Custom Content Image" — a file tree, no raw
254            // disk. It cannot yield a Read + Seek disk view; route it to
255            // `logical::open`.
256            Err(OpenError::LogicalContainer(format))
257        }
258        ContainerFormat::Dar => {
259            // DAR (Disk ARchiver) is a logical backup archive — a file tree, no
260            // raw disk. Route it to `logical::open` like AD1.
261            Err(OpenError::LogicalContainer(format))
262        }
263    }
264}
265
266/// Bytes read from the start for header-magic detection — large enough to reach
267/// the ISO 9660 PVD "CD001" at offset 32769.
268const HEADER_SNIFF_BYTES: usize = 34816;
269/// Bytes read from the end for footer/trailer-magic detection (VHD, DMG).
270const FOOTER_SNIFF_BYTES: u64 = 512;
271/// AD1 offset-0 signature — the "ADSEGMENTEDFILE" segmented-file marker (the
272/// trailing NUL of `ADSEGMENTEDFILE\0` is not required to disambiguate). Mirrors
273/// `ad1-core`'s `AD1_SEGMENTED_MARKER`.
274const AD1_SEGMENTED_MARKER: &[u8] = b"ADSEGMENTEDFILE";
275/// DAR offset-0 magic — `SAUV_MAGIC_NUMBER` (123) as a big-endian u32. Mirrors
276/// `dar-core`'s `DAR_MAGIC`.
277const DAR_MAGIC: [u8; 4] = [0x00, 0x00, 0x00, 0x7b];
278
279/// A detected disk-image container format.
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281#[cfg_attr(feature = "serde", derive(serde::Serialize))]
282pub enum ContainerFormat {
283    /// No container wrapper — a flat raw/`dd` image (analyse in place).
284    Raw,
285    /// Expert Witness Format (EnCase E01 / Ex01 / logical L01).
286    Ewf,
287    /// Microsoft VHD (fixed / dynamic / differencing).
288    Vhd,
289    /// Microsoft VHDX.
290    Vhdx,
291    /// VMware VMDK (sparse extent).
292    Vmdk,
293    /// QEMU / KVM QCOW2.
294    Qcow2,
295    /// Advanced Forensic Format 4 (ZIP-based). Physical (`aff4:ImageStream` /
296    /// `aff4:Map`) images decode to a disk view via [`open`]; logical
297    /// (`aff4:FileImage`) collections are read via [`crate::logical::open`].
298    Aff4,
299    /// AccessData AD1 (FTK "Custom Content Image") — a *logical* file container,
300    /// not a raw disk. Read via [`crate::logical::open`]; [`open`] refuses it
301    /// with [`OpenError::LogicalContainer`].
302    Ad1,
303    /// DAR (Denis Corbin Disk ARchiver) backup archive — a *logical* file
304    /// container, not a raw disk. Read via [`crate::logical::open`].
305    Dar,
306    /// Apple Disk Image (UDIF).
307    Dmg,
308    /// ISO 9660 optical-disc image (a filesystem, not a partitioned disk —
309    /// analysed by `iso9660-forensic` rather than the partition parsers).
310    Iso,
311}
312
313/// Sniff the container format from a disk image's `header` (its first bytes,
314/// ideally ≥512) and `footer` (its last 512 bytes — VHD's `conectix` cookie and
315/// DMG's `koly` trailer live at the *end* of the file).
316///
317/// Returns [`ContainerFormat::Raw`] when no wrapper magic is present (a bare
318/// MBR/GPT/APM disk).
319#[must_use]
320pub fn detect(header: &[u8], footer: &[u8]) -> ContainerFormat {
321    // ── Offset-0 magics ──────────────────────────────────────────────────────
322    if header.starts_with(&ewf::EVF1_SIGNATURE)
323        || header.starts_with(&ewf::EVF2_SIGNATURE)
324        || header.starts_with(&ewf::LEF2_SIGNATURE)
325    {
326        return ContainerFormat::Ewf;
327    }
328    if header.starts_with(vhdx::FILE_IDENTIFIER) {
329        return ContainerFormat::Vhdx;
330    }
331    // A dynamic VHD mirrors its footer cookie at offset 0.
332    if header.starts_with(vhd::FOOTER_COOKIE) {
333        return ContainerFormat::Vhd;
334    }
335    if header.starts_with(&vmdk::VMDK4_MAGIC.to_le_bytes()) {
336        return ContainerFormat::Vmdk;
337    }
338    if header.starts_with(&qcow2::MAGIC.to_be_bytes()) {
339        return ContainerFormat::Qcow2;
340    }
341    if header.starts_with(&aff4::ZIP_LOCAL_FILE_HEADER_MAGIC) {
342        return ContainerFormat::Aff4;
343    }
344    // AccessData AD1 (FTK "Custom Content Image"): the segmented-file marker
345    // "ADSEGMENTEDFILE\0" sits at offset 0 (al3ks1s/AD1-tools; `ad1-core`'s
346    // AD1_SEGMENTED_MARKER). forensicnomicon carries no AD1 magic module yet, so
347    // the signature is spelled out here.
348    if header.starts_with(AD1_SEGMENTED_MARKER) {
349        return ContainerFormat::Ad1;
350    }
351    // DAR (Disk ARchiver): the SAUV magic (123, big-endian u32) at offset 0.
352    if header.starts_with(&DAR_MAGIC) {
353        return ContainerFormat::Dar;
354    }
355    // ── Optical (ISO 9660): "CD001" at the PVD, offset 32769 (ECMA-119) ───────
356    const ISO_PVD_OFFSET: usize = 32769;
357    if header.len() >= ISO_PVD_OFFSET + 5 && &header[ISO_PVD_OFFSET..ISO_PVD_OFFSET + 5] == b"CD001"
358    {
359        return ContainerFormat::Iso;
360    }
361    // ── Footer / trailer magics ──────────────────────────────────────────────
362    if footer.starts_with(vhd::FOOTER_COOKIE) {
363        return ContainerFormat::Vhd;
364    }
365    if footer.starts_with(&dmg::KOLY_MAGIC.to_be_bytes()) {
366        return ContainerFormat::Dmg;
367    }
368    ContainerFormat::Raw
369}
370
371/// Sniff the container format of a seekable image: read its header and trailing
372/// footer, classify via [`detect`], and **rewind the reader to 0** for the
373/// caller. A sub-512-byte image is read without a footer.
374///
375/// # Errors
376/// Propagates any I/O error from seeking/reading the image.
377/// Attempt to peel one outer compression wrapper. Returns the inner bytes when
378/// `path` is a compression-wrapped image (magic AND extension agree), `None`
379/// when it is not a wrapper, and an error only when a genuinely-named wrapper
380/// fails to decode.
381fn try_peel(path: &Path) -> Result<Option<Vec<u8>>, OpenError> {
382    let name = path.file_name().and_then(|n| n.to_str());
383    // Sniff the head only — never slurp a large non-wrapper image.
384    let mut head = [0u8; 16];
385    let read = {
386        let mut file = File::open(path)?;
387        file.read(&mut head)?
388    };
389    // Only compression wrappers are peeled here; archive *containers* (AFF4/AD1,
390    // both ZIP-based) have dedicated decoders downstream and must not be
391    // intercepted. The sniff/decode/guard policy lives once in
392    // archive_core::peel_archive.
393    if !archive_core::sniff(name, &head[..read]).is_compression_wrapper() {
394        return Ok(None);
395    }
396    let data = std::fs::read(path)?;
397    match archive_core::peel_archive(&data, name, &archive_core::Limits::default()) {
398        Ok(archive_core::Peel::Inner(inner)) => Ok(Some(inner)),
399        Ok(archive_core::Peel::NotPacked) => Ok(None),
400        Err(e) => Err(OpenError::Decode(
401            ContainerFormat::Raw,
402            format!("archive peel failed: {e}"),
403        )),
404    }
405}
406
407/// Sniff an in-memory (peeled) image's container format.
408fn sniff_bytes(bytes: &[u8]) -> ContainerFormat {
409    sniff(&mut std::io::Cursor::new(bytes)).unwrap_or(ContainerFormat::Raw)
410}
411
412/// Spill peeled bytes to a temp file so a path-based container decoder can open
413/// them (the rare compression-wrapped *container*, e.g. `evidence.E01.gz`).
414fn spill_to_tmp(bytes: &[u8]) -> Result<std::path::PathBuf, OpenError> {
415    use std::sync::atomic::{AtomicU64, Ordering};
416    static COUNTER: AtomicU64 = AtomicU64::new(0);
417    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
418    let path = std::env::temp_dir().join(format!("disk4n6-peel-{}-{n}.img", std::process::id()));
419    std::fs::write(&path, bytes)?;
420    Ok(path)
421}
422
423pub fn sniff<R: Read + Seek>(reader: &mut R) -> std::io::Result<ContainerFormat> {
424    let len = reader.seek(SeekFrom::End(0))?;
425
426    reader.seek(SeekFrom::Start(0))?;
427    let header_len = (len as usize).min(HEADER_SNIFF_BYTES);
428    let mut header = vec![0u8; header_len];
429    reader.read_exact(&mut header)?;
430
431    let footer = if len >= FOOTER_SNIFF_BYTES {
432        reader.seek(SeekFrom::End(-(FOOTER_SNIFF_BYTES as i64)))?;
433        let mut f = vec![0u8; FOOTER_SNIFF_BYTES as usize];
434        reader.read_exact(&mut f)?;
435        f
436    } else {
437        Vec::new()
438    };
439
440    reader.seek(SeekFrom::Start(0))?;
441    Ok(detect(&header, &footer))
442}