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::{aff4, dmg, ewf, qcow2, vhd, vhdx, vmdk};
14
15/// Anything that can be both read and seeked — the disk view `analyse_disk`
16/// consumes. A blanket impl covers every `Read + Seek`, so a decoder's reader or
17/// a plain `File` both box into `Box<dyn ReadSeek>`.
18pub trait ReadSeek: Read + Seek {}
19impl<T: Read + Seek> ReadSeek for T {}
20
21/// A decoded, analysable disk image.
22pub struct OpenedImage {
23    /// The container format it was decoded from (`Raw` for a flat image).
24    pub format: ContainerFormat,
25    /// Logical disk size in bytes (the decoded media size).
26    pub size: u64,
27    /// A `Read + Seek` view of the decoded disk, ready for `analyse_disk`.
28    pub reader: Box<dyn ReadSeek>,
29}
30
31impl core::fmt::Debug for OpenedImage {
32    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
33        f.debug_struct("OpenedImage")
34            .field("format", &self.format)
35            .field("size", &self.size)
36            .finish_non_exhaustive()
37    }
38}
39
40/// Failure opening/decoding an image.
41#[derive(Debug, thiserror::Error)]
42pub enum OpenError {
43    /// I/O failure opening or reading the file.
44    #[error("I/O error: {0}")]
45    Io(#[from] std::io::Error),
46    /// EWF (E01) decoding failed.
47    #[error("EWF decode error: {0}")]
48    Ewf(String),
49    /// An optical (ISO 9660) image — a filesystem, not a partitioned disk;
50    /// analyse it with `iso9660-forensic` (FS dispatch is not yet wired here).
51    #[error(
52        "ISO 9660 optical image — a filesystem, not a partitioned disk; analyse it with \
53         iso9660-forensic (disk4n6 filesystem dispatch is not yet wired)"
54    )]
55    Optical,
56    /// The container format is recognized but its decoder is not yet wired —
57    /// decode it to a raw image first.
58    #[error("{0:?} container decoding is not yet supported — decode it to a raw image first")]
59    Unsupported(ContainerFormat),
60}
61
62/// Open `path`, sniff its container format, and return a decoded `Read + Seek`
63/// disk view: raw images pass through; E01/EWF is decoded; other recognized
64/// containers return [`OpenError::Unsupported`].
65///
66/// # Errors
67/// [`OpenError::Io`] on a read failure, [`OpenError::Ewf`] on a bad E01, or
68/// [`OpenError::Unsupported`] for a container whose decoder is not yet wired.
69pub fn open(path: &Path) -> Result<OpenedImage, OpenError> {
70    let mut file = File::open(path)?;
71    let format = sniff(&mut file)?;
72    match format {
73        ContainerFormat::Raw => {
74            let size = file.metadata()?.len();
75            Ok(OpenedImage {
76                format,
77                size,
78                reader: Box::new(file),
79            })
80        }
81        ContainerFormat::Ewf => {
82            // `ewf` (imported) is forensicnomicon's magic module; the decoder is
83            // the external `ewf` crate, reached via the absolute path.
84            let reader = ::ewf::EwfReader::open(path).map_err(|e| OpenError::Ewf(e.to_string()))?;
85            let size = reader.total_size();
86            Ok(OpenedImage {
87                format,
88                size,
89                reader: Box::new(reader),
90            })
91        }
92        ContainerFormat::Iso => Err(OpenError::Optical),
93        other => Err(OpenError::Unsupported(other)),
94    }
95}
96
97/// Bytes read from the start for header-magic detection — large enough to reach
98/// the ISO 9660 PVD "CD001" at offset 32769.
99const HEADER_SNIFF_BYTES: usize = 34816;
100/// Bytes read from the end for footer/trailer-magic detection (VHD, DMG).
101const FOOTER_SNIFF_BYTES: u64 = 512;
102
103/// A detected disk-image container format.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105#[cfg_attr(feature = "serde", derive(serde::Serialize))]
106pub enum ContainerFormat {
107    /// No container wrapper — a flat raw/`dd` image (analyse in place).
108    Raw,
109    /// Expert Witness Format (EnCase E01 / Ex01 / logical L01).
110    Ewf,
111    /// Microsoft VHD (fixed / dynamic / differencing).
112    Vhd,
113    /// Microsoft VHDX.
114    Vhdx,
115    /// VMware VMDK (sparse extent).
116    Vmdk,
117    /// QEMU / KVM QCOW2.
118    Qcow2,
119    /// Advanced Forensic Format 4 (ZIP-based).
120    Aff4,
121    /// Apple Disk Image (UDIF).
122    Dmg,
123    /// ISO 9660 optical-disc image (a filesystem, not a partitioned disk —
124    /// analysed by `iso9660-forensic` rather than the partition parsers).
125    Iso,
126}
127
128/// Sniff the container format from a disk image's `header` (its first bytes,
129/// ideally ≥512) and `footer` (its last 512 bytes — VHD's `conectix` cookie and
130/// DMG's `koly` trailer live at the *end* of the file).
131///
132/// Returns [`ContainerFormat::Raw`] when no wrapper magic is present (a bare
133/// MBR/GPT/APM disk).
134#[must_use]
135pub fn detect(header: &[u8], footer: &[u8]) -> ContainerFormat {
136    // ── Offset-0 magics ──────────────────────────────────────────────────────
137    if header.starts_with(&ewf::EVF1_SIGNATURE)
138        || header.starts_with(&ewf::EVF2_SIGNATURE)
139        || header.starts_with(&ewf::LEF2_SIGNATURE)
140    {
141        return ContainerFormat::Ewf;
142    }
143    if header.starts_with(vhdx::FILE_IDENTIFIER) {
144        return ContainerFormat::Vhdx;
145    }
146    // A dynamic VHD mirrors its footer cookie at offset 0.
147    if header.starts_with(vhd::FOOTER_COOKIE) {
148        return ContainerFormat::Vhd;
149    }
150    if header.starts_with(&vmdk::VMDK4_MAGIC.to_le_bytes()) {
151        return ContainerFormat::Vmdk;
152    }
153    if header.starts_with(&qcow2::MAGIC.to_be_bytes()) {
154        return ContainerFormat::Qcow2;
155    }
156    if header.starts_with(&aff4::ZIP_LOCAL_FILE_HEADER_MAGIC) {
157        return ContainerFormat::Aff4;
158    }
159    // ── Optical (ISO 9660): "CD001" at the PVD, offset 32769 (ECMA-119) ───────
160    const ISO_PVD_OFFSET: usize = 32769;
161    if header.len() >= ISO_PVD_OFFSET + 5 && &header[ISO_PVD_OFFSET..ISO_PVD_OFFSET + 5] == b"CD001"
162    {
163        return ContainerFormat::Iso;
164    }
165    // ── Footer / trailer magics ──────────────────────────────────────────────
166    if footer.starts_with(vhd::FOOTER_COOKIE) {
167        return ContainerFormat::Vhd;
168    }
169    if footer.starts_with(&dmg::KOLY_MAGIC.to_be_bytes()) {
170        return ContainerFormat::Dmg;
171    }
172    ContainerFormat::Raw
173}
174
175/// Sniff the container format of a seekable image: read its header and trailing
176/// footer, classify via [`detect`], and **rewind the reader to 0** for the
177/// caller. A sub-512-byte image is read without a footer.
178///
179/// # Errors
180/// Propagates any I/O error from seeking/reading the image.
181pub fn sniff<R: Read + Seek>(reader: &mut R) -> std::io::Result<ContainerFormat> {
182    let len = reader.seek(SeekFrom::End(0))?;
183
184    reader.seek(SeekFrom::Start(0))?;
185    let header_len = (len as usize).min(HEADER_SNIFF_BYTES);
186    let mut header = vec![0u8; header_len];
187    reader.read_exact(&mut header)?;
188
189    let footer = if len >= FOOTER_SNIFF_BYTES {
190        reader.seek(SeekFrom::End(-(FOOTER_SNIFF_BYTES as i64)))?;
191        let mut f = vec![0u8; FOOTER_SNIFF_BYTES as usize];
192        reader.read_exact(&mut f)?;
193        f
194    } else {
195        Vec::new()
196    };
197
198    reader.seek(SeekFrom::Start(0))?;
199    Ok(detect(&header, &footer))
200}