Skip to main content

ewf_forensic/
integrity_path.rs

1use crate::integrity::{AnalysisProgress, ComputedHashes, EwfIntegrity, EwfIntegrityAnomaly};
2use memmap2::Mmap;
3use std::fs::File;
4use std::io;
5use std::path::{Path, PathBuf};
6
7/// Path-based, mmap-backed EWF integrity analyser.
8///
9/// Unlike [`EwfIntegrity`] (which takes `&[u8]` slices already in memory),
10/// `EwfIntegrityPath` opens segment files and memory-maps them read-only.
11/// The OS pages data on demand, so 500 GB evidence files are handled without
12/// loading them into RAM.
13///
14/// # Segment auto-discovery
15///
16/// [`from_path`][EwfIntegrityPath::from_path] accepts the first segment
17/// (`evidence.E01` / `evidence.e01`) and automatically discovers consecutive
18/// siblings (`E02`, `E03`, … up to `EZZ`) in the same directory. Pass
19/// [`from_paths`][EwfIntegrityPath::from_paths] to supply the segment list
20/// explicitly.
21pub struct EwfIntegrityPath {
22    segment_paths: Vec<PathBuf>,
23    expected_md5: Option<[u8; 16]>,
24    expected_sha1: Option<[u8; 20]>,
25    expected_sha256: Option<[u8; 32]>,
26}
27
28impl EwfIntegrityPath {
29    /// Analyse a single segment or auto-discover a multi-segment image.
30    ///
31    /// If `path` has an extension matching the EWF numbered-segment pattern
32    /// (`E01`/`e01`, `E02`/`e02`, …) this will look for consecutive siblings
33    /// in the same directory and include them automatically.
34    pub fn from_path(path: impl AsRef<Path>) -> Self {
35        let base = path.as_ref();
36        Self {
37            segment_paths: discover_segments(base),
38            expected_md5: None,
39            expected_sha1: None,
40            expected_sha256: None,
41        }
42    }
43
44    /// Analyse an explicit ordered list of segment paths.
45    pub fn from_paths(paths: &[impl AsRef<Path>]) -> Self {
46        Self {
47            segment_paths: paths.iter().map(|p| p.as_ref().to_path_buf()).collect(),
48            expected_md5: None,
49            expected_sha1: None,
50            expected_sha256: None,
51        }
52    }
53
54    /// Supply an external chain-of-custody MD5 to compare against.
55    pub fn with_expected_md5(mut self, hash: [u8; 16]) -> Self {
56        self.expected_md5 = Some(hash);
57        self
58    }
59
60    /// Supply an external chain-of-custody SHA-1 to compare against.
61    pub fn with_expected_sha1(mut self, hash: [u8; 20]) -> Self {
62        self.expected_sha1 = Some(hash);
63        self
64    }
65
66    /// Supply an external chain-of-custody SHA-256 to compare against.
67    /// Mismatch → `ExternalSha256Mismatch` (Critical).
68    pub fn with_expected_sha256(mut self, hash: [u8; 32]) -> Self {
69        self.expected_sha256 = Some(hash);
70        self
71    }
72
73    /// Memory-map every segment and compute sector data hashes.
74    ///
75    /// Returns `Err` if any segment cannot be opened or mapped.
76    /// Returns `Ok(None)` if the image is unparseable or is EWF v2.
77    pub fn compute_hashes(&self) -> io::Result<Option<ComputedHashes>> {
78        let mmaps = self
79            .segment_paths
80            .iter()
81            .map(|p| {
82                let file = File::open(p)?;
83                unsafe { Mmap::map(&file) }
84            })
85            .collect::<io::Result<Vec<Mmap>>>()?;
86        let seg_refs: Vec<&[u8]> = mmaps.iter().map(|m| m.as_ref()).collect();
87        Ok(EwfIntegrity::from_segments(&seg_refs).compute_hashes())
88    }
89
90    /// Memory-map every segment and run the full integrity analyser.
91    ///
92    /// Returns `Err` if any segment file cannot be opened or mapped.
93    pub fn analyse(&self) -> io::Result<Vec<EwfIntegrityAnomaly>> {
94        let mmaps = self
95            .segment_paths
96            .iter()
97            .map(|p| {
98                let file = File::open(p)?;
99                // SAFETY: we open the file read-only and do not modify it.
100                // Concurrent truncation is not a concern for immutable evidence files.
101                unsafe { Mmap::map(&file) }
102            })
103            .collect::<io::Result<Vec<Mmap>>>()?;
104
105        let seg_refs: Vec<&[u8]> = mmaps.iter().map(|m| m.as_ref()).collect();
106
107        let mut checker = EwfIntegrity::from_segments(&seg_refs);
108        if let Some(h) = self.expected_md5 {
109            checker = checker.with_expected_md5(h);
110        }
111        if let Some(h) = self.expected_sha1 {
112            checker = checker.with_expected_sha1(h);
113        }
114        if let Some(h) = self.expected_sha256 {
115            checker = checker.with_expected_sha256(h);
116        }
117
118        Ok(checker.analyse())
119    }
120
121    /// Memory-map every segment once and run analysis + hash computation in a
122    /// single pass, avoiding duplicate I/O compared to calling [`analyse`] and
123    /// [`compute_hashes`] separately.
124    ///
125    /// Returns `Err` if any segment file cannot be opened or mapped.
126    /// If the image is too corrupted to compute hashes, a zeroed
127    /// [`ComputedHashes`] is returned alongside the anomalies.
128    pub fn analyse_and_compute_hashes(
129        &self,
130    ) -> io::Result<(Vec<EwfIntegrityAnomaly>, ComputedHashes)> {
131        let mmaps = self
132            .segment_paths
133            .iter()
134            .map(|p| {
135                let file = File::open(p)?;
136                // SAFETY: read-only mmap of an immutable evidence file.
137                unsafe { Mmap::map(&file) }
138            })
139            .collect::<io::Result<Vec<Mmap>>>()?;
140
141        let seg_refs: Vec<&[u8]> = mmaps.iter().map(|m| m.as_ref()).collect();
142
143        let mut checker = EwfIntegrity::from_segments(&seg_refs);
144        if let Some(h) = self.expected_md5 {
145            checker = checker.with_expected_md5(h);
146        }
147        if let Some(h) = self.expected_sha1 {
148            checker = checker.with_expected_sha1(h);
149        }
150        if let Some(h) = self.expected_sha256 {
151            checker = checker.with_expected_sha256(h);
152        }
153
154        let anomalies = checker.analyse();
155        let hashes = EwfIntegrity::from_segments(&seg_refs)
156            .compute_hashes()
157            .unwrap_or(ComputedHashes { md5: [0u8; 16], sha1: [0u8; 20], sha256: [0u8; 32] });
158
159        Ok((anomalies, hashes))
160    }
161
162    /// Run integrity analysis while reporting progress to a callback.
163    ///
164    /// Identical to [`analyse`] but invokes `progress` after each chunk is
165    /// processed so callers can display a progress bar for large images.
166    ///
167    /// Returns `(anomalies, ())` on success, or `Err` if a segment cannot be
168    /// opened or memory-mapped.
169    pub fn analyse_with_progress(
170        &self,
171        mut progress: impl FnMut(AnalysisProgress),
172    ) -> io::Result<(Vec<EwfIntegrityAnomaly>, ())> {
173        let mmaps = self
174            .segment_paths
175            .iter()
176            .map(|p| {
177                let file = File::open(p)?;
178                // SAFETY: read-only mmap of an immutable evidence file.
179                unsafe { Mmap::map(&file) }
180            })
181            .collect::<io::Result<Vec<Mmap>>>()?;
182
183        let seg_refs: Vec<&[u8]> = mmaps.iter().map(|m| m.as_ref()).collect();
184
185        let mut checker = EwfIntegrity::from_segments(&seg_refs);
186        if let Some(h) = self.expected_md5 {
187            checker = checker.with_expected_md5(h);
188        }
189        if let Some(h) = self.expected_sha1 {
190            checker = checker.with_expected_sha1(h);
191        }
192        if let Some(h) = self.expected_sha256 {
193            checker = checker.with_expected_sha256(h);
194        }
195
196        let anomalies = checker.analyse_with_progress(&mut progress);
197        Ok((anomalies, ()))
198    }
199}
200
201// ── Segment auto-discovery ────────────────────────────────────────────────────
202
203/// Given the path to an E01 segment, return an ordered list of all discovered
204/// sibling segments (E01, E02, … E09, E10, … EZZ).
205///
206/// If the path does not have a recognised numbered-extension, returns a
207/// single-element vec containing the given path.
208fn discover_segments(base: &Path) -> Vec<PathBuf> {
209    let ext = match base.extension().and_then(|e| e.to_str()) {
210        Some(e) => e,
211        None => return vec![base.to_path_buf()],
212    };
213
214    // Match E01 / e01 / Ex01 style (first segment is always *01)
215    let (prefix_char, has_x, digits) = match parse_ewf_extension(ext) {
216        Some(v) => v,
217        None => return vec![base.to_path_buf()],
218    };
219
220    let stem = match base.file_stem().and_then(|s| s.to_str()) {
221        Some(s) => s,
222        None => return vec![base.to_path_buf()],
223    };
224    let dir = base.parent().unwrap_or(Path::new("."));
225
226    let mut segments = Vec::new();
227    for n in 1u32.. {
228        let ext_str = make_ewf_extension(prefix_char, has_x, digits, n);
229        let candidate = dir.join(format!("{stem}.{ext_str}"));
230        if candidate.exists() {
231            segments.push(candidate);
232        } else {
233            break;
234        }
235        if n >= 999 {
236            break;
237        }
238    }
239
240    if segments.is_empty() {
241        vec![base.to_path_buf()]
242    } else {
243        segments
244    }
245}
246
247/// Parse an EWF extension like `E01`, `e01`, `Ex01`, `L01` into
248/// `(prefix_char, has_x, digit_count)`.
249fn parse_ewf_extension(ext: &str) -> Option<(char, bool, usize)> {
250    let mut chars = ext.chars();
251    let prefix = chars.next()?;
252    if !prefix.is_ascii_alphabetic() {
253        return None;
254    }
255    let rest: String = chars.collect();
256    let has_x = rest.starts_with('x') || rest.starts_with('X');
257    let rest = rest.trim_start_matches(|c| c == 'x' || c == 'X');
258    if rest.chars().all(|c| c.is_ascii_digit()) && !rest.is_empty() {
259        Some((prefix, has_x, rest.len()))
260    } else {
261        None
262    }
263}
264
265/// Reconstruct an EWF extension for segment number `n` (1-based).
266/// `prefix_char` = 'E' or 'e', `has_x` = true for Ex01/Lx01 style.
267fn make_ewf_extension(prefix: char, has_x: bool, digit_count: usize, n: u32) -> String {
268    let width = digit_count.max(2);
269    let x = if has_x { "x" } else { "" };
270    format!("{}{}{:0width$}", prefix, x, n, width = width)
271}