Skip to main content

ewf_image/
verify.rs

1use std::ops::ControlFlow;
2
3use md5::{Digest, Md5};
4use sha1::Sha1;
5use sha2::Sha256;
6
7use crate::index::logical_chunk_count;
8use crate::{EwfError, Image, Result, VerifyResult};
9
10/// Options shared by verification and media integrity analysis.
11#[derive(Debug, Clone, PartialEq, Eq)]
12#[must_use]
13pub struct VerifyOptions {
14    expected_md5: Option<[u8; 16]>,
15    expected_sha1: Option<[u8; 20]>,
16    expected_sha256: Option<[u8; 32]>,
17    parallelism: usize,
18    chunk_buffer_size_bytes: usize,
19    maximum_findings: usize,
20}
21
22impl Default for VerifyOptions {
23    fn default() -> Self {
24        Self {
25            expected_md5: None,
26            expected_sha1: None,
27            expected_sha256: None,
28            parallelism: 1,
29            chunk_buffer_size_bytes: 8 * 1024 * 1024,
30            maximum_findings: 1024,
31        }
32    }
33}
34
35impl VerifyOptions {
36    /// Compares media MD5 with an independently supplied reference.
37    pub fn with_expected_md5(mut self, expected: [u8; 16]) -> Self {
38        self.expected_md5 = Some(expected);
39        self
40    }
41
42    /// Compares media SHA1 with an independently supplied reference.
43    pub fn with_expected_sha1(mut self, expected: [u8; 20]) -> Self {
44        self.expected_sha1 = Some(expected);
45        self
46    }
47
48    /// Compares media SHA256 with an independently supplied reference.
49    pub fn with_expected_sha256(mut self, expected: [u8; 32]) -> Self {
50        self.expected_sha256 = Some(expected);
51        self
52    }
53
54    /// Sets the worker limit (1 through 64); values above 1 require `parallel`.
55    /// Invalid settings return an error before a scan starts.
56    pub fn with_parallelism(mut self, workers: usize) -> Self {
57        self.parallelism = workers;
58        self
59    }
60
61    /// Sets the decoded batch budget. A chunk larger than this budget is
62    /// processed alone. Encoded buffers, decoder scratch space, and existing
63    /// reader caches are additional memory.
64    pub fn with_chunk_buffer_size_bytes(mut self, bytes: usize) -> Self {
65        self.chunk_buffer_size_bytes = bytes;
66        self
67    }
68
69    /// Limits retained analysis findings; suppressed findings are counted.
70    /// Scanning continues after this limit is reached. Zero retains no findings.
71    pub fn with_maximum_findings(mut self, count: usize) -> Self {
72        self.maximum_findings = count;
73        self
74    }
75
76    /// Returns the requested worker limit.
77    pub fn parallelism(&self) -> usize {
78        self.parallelism
79    }
80
81    /// Returns the decoded batch budget.
82    pub fn chunk_buffer_size_bytes(&self) -> usize {
83        self.chunk_buffer_size_bytes
84    }
85
86    /// Returns the maximum number of retained analysis findings.
87    pub fn maximum_findings(&self) -> usize {
88        self.maximum_findings
89    }
90
91    pub(crate) fn validate(&self) -> Result<()> {
92        if !(1..=64).contains(&self.parallelism) || self.chunk_buffer_size_bytes == 0 {
93            return Err(std::io::Error::new(
94                std::io::ErrorKind::InvalidInput,
95                "verification requires 1..=64 workers and a nonzero chunk buffer budget",
96            )
97            .into());
98        }
99        if self.parallelism > 1 && !cfg!(feature = "parallel") {
100            return Err(EwfError::Unsupported("parallel feature is disabled".into()));
101        }
102        Ok(())
103    }
104}
105
106/// Hashes over every logical media byte, excluding final-chunk padding.
107#[derive(Debug, Clone, PartialEq, Eq)]
108#[cfg_attr(feature = "serde", derive(serde::Serialize))]
109#[non_exhaustive]
110pub struct ComputedHashes {
111    /// Computed MD5 digest.
112    pub md5: [u8; 16],
113    /// Computed SHA1 digest.
114    pub sha1: [u8; 20],
115    /// Computed SHA256 digest.
116    pub sha256: [u8; 32],
117}
118
119/// Digest algorithm used in a comparison.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121#[cfg_attr(feature = "serde", derive(serde::Serialize))]
122pub enum HashAlgorithm {
123    /// MD5.
124    Md5,
125    /// SHA1.
126    Sha1,
127    /// SHA256.
128    Sha256,
129}
130
131/// Origin of a reference digest.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133#[cfg_attr(feature = "serde", derive(serde::Serialize))]
134pub enum HashReference {
135    /// Digest embedded in the EWF image.
136    Stored,
137    /// Digest supplied independently by the caller.
138    External,
139}
140
141/// One comparison; external references never replace embedded references.
142#[derive(Debug, Clone, PartialEq, Eq)]
143#[cfg_attr(feature = "serde", derive(serde::Serialize))]
144#[non_exhaustive]
145pub struct HashComparison {
146    /// Digest algorithm.
147    pub algorithm: HashAlgorithm,
148    /// Reference origin.
149    pub reference: HashReference,
150    /// Expected digest bytes.
151    pub expected: Vec<u8>,
152    /// Computed digest bytes.
153    pub computed: Vec<u8>,
154    /// Whether the digests match.
155    pub matches: bool,
156}
157
158/// Progress delivered on the calling thread, in logical order.
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160#[cfg_attr(feature = "serde", derive(serde::Serialize))]
161#[non_exhaustive]
162pub struct VerifyProgress {
163    /// Logical bytes processed, including failed chunks during analysis.
164    pub bytes_processed: u64,
165    /// Logical bytes successfully decoded and validated.
166    pub bytes_verified: u64,
167    /// Total logical media size.
168    pub bytes_total: u64,
169    /// Chunks processed, including failures during analysis.
170    pub chunks_processed: u64,
171    /// Total logical chunk count.
172    pub chunks_total: u64,
173}
174
175/// Completed media verification. Mismatches are results, not I/O errors.
176#[derive(Debug, Clone, PartialEq, Eq)]
177#[cfg_attr(feature = "serde", derive(serde::Serialize))]
178#[non_exhaustive]
179pub struct VerificationReport {
180    /// Complete media digests.
181    pub hashes: ComputedHashes,
182    /// Comparisons with every available supported reference digest.
183    pub comparisons: Vec<HashComparison>,
184    /// Logical bytes verified.
185    pub bytes_verified: u64,
186}
187
188impl VerificationReport {
189    /// Returns `None` without a reference, otherwise whether all references match.
190    pub fn references_match(&self) -> Option<bool> {
191        (!self.comparisons.is_empty()).then(|| self.comparisons.iter().all(|item| item.matches))
192    }
193}
194
195pub(crate) struct MediaScan {
196    pub(crate) hashes: Option<ComputedHashes>,
197    pub(crate) progress: VerifyProgress,
198}
199
200impl Image {
201    /// Computes MD5, SHA1, and SHA256 hashes and compares embedded references.
202    /// Reads backing chunks without using cached or zero-filled recovery data.
203    /// Returns an error on corruption, I/O failure, or cancellation.
204    pub fn verify(&self) -> Result<VerifyResult> {
205        let report = self.verify_with_options(&VerifyOptions::default())?;
206        Ok(VerifyResult {
207            computed_md5: Some(report.hashes.md5),
208            computed_sha1: Some(report.hashes.sha1),
209            computed_sha256: Some(report.hashes.sha256),
210            md5_match: self.md5_hash().map(|hash| hash == report.hashes.md5),
211            sha1_match: self.sha1_hash().map(|hash| hash == report.hashes.sha1),
212            sha256_match: report
213                .comparisons
214                .iter()
215                .find(|item| {
216                    item.algorithm == HashAlgorithm::Sha256
217                        && item.reference == HashReference::Stored
218                })
219                .map(|item| item.matches),
220        })
221    }
222
223    /// Computes MD5, SHA1, and SHA256 and compares stored and external references.
224    /// Chunk corruption fails verification even if normal reads allow zero-fill.
225    pub fn verify_with_options(&self, options: &VerifyOptions) -> Result<VerificationReport> {
226        self.verify_with_progress(options, |_| ControlFlow::Continue(()))
227    }
228
229    /// Verifies media with an initial event and an event after every processed chunk.
230    /// Returning `Break(())` cancels this operation with [`EwfError::Aborted`],
231    /// without aborting other readers. Already-running worker reads finish before
232    /// the call returns. [`Image::signal_abort`] is also honored.
233    pub fn verify_with_progress(
234        &self,
235        options: &VerifyOptions,
236        progress: impl FnMut(VerifyProgress) -> ControlFlow<()>,
237    ) -> Result<VerificationReport> {
238        let scan = self.scan_media(options, progress, |_, error| Err(error))?;
239        let hashes = scan
240            .hashes
241            .expect("successful verification has complete hashes");
242        let comparisons = self.compare_hashes(&hashes, options);
243        Ok(VerificationReport {
244            hashes,
245            comparisons,
246            bytes_verified: scan.progress.bytes_verified,
247        })
248    }
249
250    pub(crate) fn compare_hashes(
251        &self,
252        hashes: &ComputedHashes,
253        options: &VerifyOptions,
254    ) -> Vec<HashComparison> {
255        let references = [
256            (
257                HashAlgorithm::Sha256,
258                HashReference::Stored,
259                self.info()
260                    .stored_hashes
261                    .hash_value("SHA256")
262                    .and_then(crate::types::parse_hex_array::<32>)
263                    .map(|v| v.to_vec()),
264                hashes.sha256.as_slice(),
265            ),
266            (
267                HashAlgorithm::Md5,
268                HashReference::Stored,
269                self.md5_hash().map(|v| v.to_vec()),
270                hashes.md5.as_slice(),
271            ),
272            (
273                HashAlgorithm::Sha1,
274                HashReference::Stored,
275                self.sha1_hash().map(|v| v.to_vec()),
276                hashes.sha1.as_slice(),
277            ),
278            (
279                HashAlgorithm::Md5,
280                HashReference::External,
281                options.expected_md5.map(|v| v.to_vec()),
282                hashes.md5.as_slice(),
283            ),
284            (
285                HashAlgorithm::Sha1,
286                HashReference::External,
287                options.expected_sha1.map(|v| v.to_vec()),
288                hashes.sha1.as_slice(),
289            ),
290            (
291                HashAlgorithm::Sha256,
292                HashReference::External,
293                options.expected_sha256.map(|v| v.to_vec()),
294                hashes.sha256.as_slice(),
295            ),
296        ];
297        references
298            .into_iter()
299            .filter_map(|(algorithm, reference, expected, computed)| {
300                expected.map(|expected| HashComparison {
301                    algorithm,
302                    reference,
303                    matches: expected == computed,
304                    expected,
305                    computed: computed.to_vec(),
306                })
307            })
308            .collect()
309    }
310
311    pub(crate) fn scan_media(
312        &self,
313        options: &VerifyOptions,
314        mut on_progress: impl FnMut(VerifyProgress) -> ControlFlow<()>,
315        mut on_error: impl FnMut(u64, EwfError) -> Result<()>,
316    ) -> Result<MediaScan> {
317        options.validate()?;
318        self.ensure_not_aborted()?;
319        let chunk_size = self.chunk_size();
320        let count = logical_chunk_count(self.media_size(), chunk_size)?;
321        let mut progress = VerifyProgress {
322            bytes_processed: 0,
323            bytes_verified: 0,
324            bytes_total: self.media_size(),
325            chunks_processed: 0,
326            chunks_total: count,
327        };
328        report_progress(self, &mut on_progress, progress)?;
329        let budget_chunks = (options.chunk_buffer_size_bytes as u64 / chunk_size).max(1);
330        let batch_size = budget_chunks.min(options.parallelism as u64 * 2).max(1);
331        #[cfg(feature = "parallel")]
332        let pool = if options.parallelism > 1 && count > 1 && batch_size > 1 {
333            Some(
334                rayon::ThreadPoolBuilder::new()
335                    .num_threads(options.parallelism.min(batch_size as usize))
336                    .build()
337                    .map_err(std::io::Error::other)?,
338            )
339        } else {
340            None
341        };
342        let mut md5 = Md5::new();
343        let mut sha1 = Sha1::new();
344        let mut sha256 = Sha256::new();
345        let mut failed = false;
346        let mut start = 0;
347        while start < count {
348            self.ensure_not_aborted()?;
349            let end = start.saturating_add(batch_size).min(count);
350            let indices: Vec<u64> = (start..end).collect();
351            #[cfg(feature = "parallel")]
352            let chunks: Vec<Result<Vec<u8>>> = if let Some(pool) = &pool {
353                use rayon::prelude::*;
354                pool.install(|| {
355                    indices
356                        .par_iter()
357                        .map(|&id| self.verification_chunk(id))
358                        .collect()
359                })
360            } else {
361                indices
362                    .iter()
363                    .map(|&id| self.verification_chunk(id))
364                    .collect()
365            };
366            #[cfg(not(feature = "parallel"))]
367            let chunks: Vec<Result<Vec<u8>>> = indices
368                .iter()
369                .map(|&id| self.verification_chunk(id))
370                .collect();
371            for (id, chunk) in indices.into_iter().zip(chunks) {
372                self.ensure_not_aborted()?;
373                let size = chunk_size.min(self.media_size() - progress.bytes_processed);
374                match chunk {
375                    Ok(bytes) if bytes.len() as u64 == size => {
376                        md5.update(&bytes);
377                        sha1.update(&bytes);
378                        sha256.update(&bytes);
379                        progress.bytes_verified += size;
380                    }
381                    Ok(_) => {
382                        failed = true;
383                        on_error(
384                            id,
385                            EwfError::Malformed(
386                                "decoded chunk length differs from logical media range".into(),
387                            ),
388                        )?;
389                    }
390                    Err(EwfError::Aborted) => return Err(EwfError::Aborted),
391                    Err(error) => {
392                        failed = true;
393                        on_error(id, error)?;
394                    }
395                }
396                progress.chunks_processed += 1;
397                progress.bytes_processed += size;
398                report_progress(self, &mut on_progress, progress)?;
399            }
400            start = end;
401        }
402        Ok(MediaScan {
403            hashes: (!failed).then(|| ComputedHashes {
404                md5: md5.finalize().into(),
405                sha1: sha1.finalize().into(),
406                sha256: sha256.finalize().into(),
407            }),
408            progress,
409        })
410    }
411}
412
413fn report_progress(
414    image: &Image,
415    callback: &mut impl FnMut(VerifyProgress) -> ControlFlow<()>,
416    progress: VerifyProgress,
417) -> Result<()> {
418    if callback(progress).is_break() {
419        return Err(EwfError::Aborted);
420    }
421    image.ensure_not_aborted()
422}