Skip to main content

ewf_image/
integrity.rs

1use std::ops::ControlFlow;
2use std::path::Path;
3
4use crate::{
5    ComputedHashes, EwfError, EwfPassword, HashAlgorithm, HashComparison, HashReference, Image,
6    OpenStrictness, Result, SectionInfo, SectionKind, VerifyOptions, VerifyProgress,
7};
8
9/// Severity of a reported integrity condition.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize))]
12pub enum IntegritySeverity {
13    /// Context affecting interpretation of evidence.
14    Warning,
15    /// Invalid structure, unreadable media, or a failed reference comparison.
16    Error,
17}
18
19/// Stable categories for findings; diagnostic text is not a machine interface.
20#[derive(Debug, Clone, PartialEq, Eq)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize))]
22#[non_exhaustive]
23pub enum IntegrityFindingKind {
24    /// Opening rejected malformed image structure.
25    InvalidStructure,
26    /// Input signature was not recognized.
27    InvalidSignature,
28    /// A required feature is unavailable.
29    UnsupportedFeature,
30    /// Encrypted input needs a password.
31    PasswordRequired,
32    /// Supplied password was rejected or decrypted content failed validation.
33    DecryptionFailed,
34    /// A logical chunk could not be read and validated.
35    ChunkUnreadable,
36    /// Matching primary and redundant chunk tables contain different entries.
37    RedundantTableMismatch,
38    /// Redundant-table checking could not complete.
39    TableCheckUnavailable,
40    /// No supported embedded media hash was found.
41    MissingStoredHashes,
42    /// Acquisition recorded unreadable source sectors.
43    AcquisitionErrors,
44    /// Acquisition lacks its completed terminal marker.
45    IncompleteAcquisition,
46    /// The caller opened using lenient structural validation.
47    LenientOpen,
48    /// One complete media digest differs from its reference.
49    HashMismatch {
50        /// Digest algorithm.
51        algorithm: HashAlgorithm,
52        /// Reference origin.
53        reference: HashReference,
54    },
55}
56
57/// One finding with optional source and logical locations.
58#[derive(Debug, Clone, PartialEq, Eq)]
59#[cfg_attr(feature = "serde", derive(serde::Serialize))]
60#[non_exhaustive]
61pub struct IntegrityFinding {
62    /// Typed finding category.
63    pub kind: IntegrityFindingKind,
64    /// Finding severity.
65    pub severity: IntegritySeverity,
66    /// Human-readable diagnostic, without passwords or derived keys.
67    pub message: String,
68    /// Zero-based source segment index, when known.
69    pub segment_index: Option<usize>,
70    /// Source segment byte offset, when known.
71    pub segment_offset: Option<u64>,
72    /// Logical chunk index, when known.
73    pub chunk_index: Option<u64>,
74    /// Logical media byte offset, when known.
75    pub logical_offset: Option<u64>,
76}
77
78/// Coverage of the logical media pass, separate from the findings' severity.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80#[cfg_attr(feature = "serde", derive(serde::Serialize))]
81pub enum MediaScanStatus {
82    /// Every logical byte was decoded and validated; reference hashes may differ.
83    Complete,
84    /// All chunks were attempted, but some could not be validated.
85    Incomplete,
86    /// Opening failed before logical media could be scanned.
87    Unavailable,
88}
89
90/// Bounded integrity findings plus explicit scan coverage.
91#[derive(Debug, Clone, PartialEq, Eq)]
92#[cfg_attr(feature = "serde", derive(serde::Serialize))]
93#[non_exhaustive]
94pub struct IntegrityReport {
95    /// Media scan coverage.
96    pub media_status: MediaScanStatus,
97    /// Successfully validated logical bytes.
98    pub bytes_verified: u64,
99    /// Declared logical size, when opening succeeded.
100    pub bytes_total: Option<u64>,
101    /// Complete media digests. Never hashes of a stream with omitted or substituted bytes.
102    pub hashes: Option<ComputedHashes>,
103    /// Available comparisons; empty if media scanning was incomplete.
104    pub comparisons: Vec<HashComparison>,
105    /// Retained findings in deterministic check and logical-chunk order.
106    pub findings: Vec<IntegrityFinding>,
107    /// Number of findings omitted by the caller's retention limit.
108    pub suppressed_findings: u64,
109    /// Total error findings, including suppressed findings.
110    pub error_count: u64,
111    /// Total warning findings, including suppressed findings.
112    pub warning_count: u64,
113}
114
115impl IntegrityReport {
116    fn new() -> Self {
117        Self {
118            media_status: MediaScanStatus::Unavailable,
119            bytes_verified: 0,
120            bytes_total: None,
121            hashes: None,
122            comparisons: Vec::new(),
123            findings: Vec::new(),
124            suppressed_findings: 0,
125            error_count: 0,
126            warning_count: 0,
127        }
128    }
129
130    fn push(&mut self, finding: IntegrityFinding, limit: usize) {
131        match finding.severity {
132            IntegritySeverity::Error => self.error_count += 1,
133            IntegritySeverity::Warning => self.warning_count += 1,
134        }
135        if self.findings.len() < limit {
136            self.findings.push(finding);
137        } else {
138            self.suppressed_findings += 1;
139        }
140    }
141}
142
143fn finding(
144    kind: IntegrityFindingKind,
145    severity: IntegritySeverity,
146    message: impl Into<String>,
147) -> IntegrityFinding {
148    IntegrityFinding {
149        kind,
150        severity,
151        message: message.into(),
152        segment_index: None,
153        segment_offset: None,
154        chunk_index: None,
155        logical_offset: None,
156    }
157}
158
159/// Opens strictly, then scans media and redundant tables.
160///
161/// A structural opening
162/// failure is returned as one finding with unavailable media coverage; this API
163/// does not resynchronize a broken descriptor chain. Filesystem I/O errors and
164/// invalid scan options remain errors.
165pub fn analyze_path(path: impl AsRef<Path>, options: &VerifyOptions) -> Result<IntegrityReport> {
166    options.validate()?;
167    analyze_open_result(Image::open(path), options)
168}
169
170/// Opens strictly and analyzes with cooperative media-scan cancellation.
171///
172/// Opening and redundant-table inspection precede media progress. Retains at
173/// most 16 segment handles and disables the decoded chunk cache. Structural
174/// opening failures become findings, as with [`analyze_path`].
175pub fn analyze_path_with_progress(
176    path: impl AsRef<Path>,
177    options: &VerifyOptions,
178    progress: impl FnMut(VerifyProgress) -> ControlFlow<()>,
179) -> Result<IntegrityReport> {
180    options.validate()?;
181    let opened = Image::open_with_options(
182        path,
183        crate::OpenOptions::default()
184            .with_chunk_cache_size(0)
185            .with_maximum_open_handles(Some(16)),
186    );
187    analyze_open_with_progress(opened, options, progress)
188}
189
190/// Password-aware counterpart to [`analyze_path`]. Password failures are reported
191/// without exposing password or key material.
192pub fn analyze_path_with_password(
193    path: impl AsRef<Path>,
194    options: &VerifyOptions,
195    password: &EwfPassword,
196) -> Result<IntegrityReport> {
197    options.validate()?;
198    analyze_open_result(Image::open_with_password(path, password), options)
199}
200
201/// Password-aware counterpart to [`analyze_path_with_progress`].
202pub fn analyze_path_with_progress_and_password(
203    path: impl AsRef<Path>,
204    options: &VerifyOptions,
205    password: &EwfPassword,
206    progress: impl FnMut(VerifyProgress) -> ControlFlow<()>,
207) -> Result<IntegrityReport> {
208    options.validate()?;
209    let opened = Image::open_with_options_and_password(
210        path,
211        crate::OpenOptions::default()
212            .with_chunk_cache_size(0)
213            .with_maximum_open_handles(Some(16)),
214        password,
215    );
216    analyze_open_with_progress(opened, options, progress)
217}
218
219fn analyze_open_result(opened: Result<Image>, options: &VerifyOptions) -> Result<IntegrityReport> {
220    analyze_open_with_progress(opened, options, |_| ControlFlow::Continue(()))
221}
222
223fn analyze_open_with_progress(
224    opened: Result<Image>,
225    options: &VerifyOptions,
226    progress: impl FnMut(VerifyProgress) -> ControlFlow<()>,
227) -> Result<IntegrityReport> {
228    match opened {
229        Ok(image) => image.analyze_with_progress(options, progress),
230        Err(error) => {
231            let kind = match &error {
232                EwfError::Malformed(_) | EwfError::BufferTooShort { .. } => {
233                    IntegrityFindingKind::InvalidStructure
234                }
235                EwfError::InvalidSignature => IntegrityFindingKind::InvalidSignature,
236                EwfError::Unsupported(_) => IntegrityFindingKind::UnsupportedFeature,
237                EwfError::PasswordRequired => IntegrityFindingKind::PasswordRequired,
238                EwfError::PasswordRejected | EwfError::DecryptionValidationFailed => {
239                    IntegrityFindingKind::DecryptionFailed
240                }
241                EwfError::Io(io) if io.kind() == std::io::ErrorKind::UnexpectedEof => {
242                    IntegrityFindingKind::InvalidStructure
243                }
244                _ => return Err(error),
245            };
246            let mut report = IntegrityReport::new();
247            report.push(
248                finding(kind, IntegritySeverity::Error, error.to_string()),
249                options.maximum_findings(),
250            );
251            Ok(report)
252        }
253    }
254}
255
256impl Image {
257    /// Collects media failures, redundant-table disagreements, acquisition context,
258    /// and digest mismatches. Continues after chunk failures without substituting
259    /// zero bytes. Structural validation is the validation performed during open.
260    pub fn analyze(&self, options: &VerifyOptions) -> Result<IntegrityReport> {
261        self.analyze_with_progress(options, |_| ControlFlow::Continue(()))
262    }
263
264    /// Analyzes with media progress and per-operation cancellation. Progress begins
265    /// after redundant-table checks. A cancelled scan returns `Aborted`, not a
266    /// successful partial report.
267    pub fn analyze_with_progress(
268        &self,
269        options: &VerifyOptions,
270        progress: impl FnMut(VerifyProgress) -> ControlFlow<()>,
271    ) -> Result<IntegrityReport> {
272        options.validate()?;
273        self.ensure_not_aborted()?;
274        let limit = options.maximum_findings();
275        let mut report = IntegrityReport::new();
276        report.bytes_total = Some(self.media_size());
277        if self.open_strictness() == OpenStrictness::Lenient {
278            report.push(
279                finding(
280                    IntegrityFindingKind::LenientOpen,
281                    IntegritySeverity::Warning,
282                    "image was opened with lenient structural validation",
283                ),
284                limit,
285            );
286        }
287        if !self.info().acquisition_complete {
288            report.push(
289                finding(
290                    IntegrityFindingKind::IncompleteAcquisition,
291                    IntegritySeverity::Warning,
292                    "acquisition is incomplete",
293                ),
294                limit,
295            );
296        }
297        if !self.acquisition_errors().is_empty() {
298            report.push(
299                finding(
300                    IntegrityFindingKind::AcquisitionErrors,
301                    IntegritySeverity::Warning,
302                    format!(
303                        "{} acquisition error ranges are recorded",
304                        self.acquisition_errors().len()
305                    ),
306                ),
307                limit,
308            );
309        }
310        if self.md5_hash().is_none()
311            && self.sha1_hash().is_none()
312            && self.hash_value("SHA256").is_none()
313        {
314            report.push(
315                finding(
316                    IntegrityFindingKind::MissingStoredHashes,
317                    IntegritySeverity::Warning,
318                    "no embedded MD5, SHA1, or SHA256 reference is available",
319                ),
320                limit,
321            );
322        }
323        self.analyze_redundant_tables(&mut report, limit)?;
324        let scan = self.scan_media(options, progress, |id, error| {
325            let mut item = finding(
326                IntegrityFindingKind::ChunkUnreadable,
327                IntegritySeverity::Error,
328                error.to_string(),
329            );
330            item.chunk_index = Some(id);
331            item.logical_offset = Some(id * self.chunk_size());
332            // Location lookup can itself fail for a damaged table entry.
333            item.segment_index = self.segment_filename_for_chunk(id).ok().and_then(|path| {
334                self.segment_filenames()
335                    .iter()
336                    .position(|candidate| candidate == path)
337            });
338            report.push(item, limit);
339            Ok(())
340        })?;
341        report.bytes_verified = scan.progress.bytes_verified;
342        report.media_status = if scan.hashes.is_some() {
343            MediaScanStatus::Complete
344        } else {
345            MediaScanStatus::Incomplete
346        };
347        if let Some(hashes) = &scan.hashes {
348            report.comparisons = self.compare_hashes(hashes, options);
349            let mismatches: Vec<_> = report
350                .comparisons
351                .iter()
352                .filter(|item| !item.matches)
353                .map(|item| {
354                    finding(
355                        IntegrityFindingKind::HashMismatch {
356                            algorithm: item.algorithm,
357                            reference: item.reference,
358                        },
359                        IntegritySeverity::Error,
360                        format!(
361                            "{:?} differs from {:?} reference",
362                            item.algorithm, item.reference
363                        ),
364                    )
365                })
366                .collect();
367            for item in mismatches {
368                report.push(item, limit);
369            }
370        }
371        report.hashes = scan.hashes;
372        Ok(report)
373    }
374
375    fn analyze_redundant_tables(&self, report: &mut IntegrityReport, limit: usize) -> Result<()> {
376        let mut primary: Option<&SectionInfo> = None;
377        for section in self.sections() {
378            self.ensure_not_aborted()?;
379            match &section.kind {
380                SectionKind::Ewf1(name) if name == "table" => primary = Some(section),
381                SectionKind::Ewf1(name) if name == "table2" => {
382                    if let Some(table) = primary
383                        .take()
384                        .filter(|table| table.segment_index == section.segment_index)
385                    {
386                        match self.compare_table_entries(table, section) {
387                            Ok(Some(offset)) => {
388                                let mut item = finding(
389                                    IntegrityFindingKind::RedundantTableMismatch,
390                                    IntegritySeverity::Error,
391                                    "primary and redundant chunk table entries differ",
392                                );
393                                item.segment_index = Some(section.segment_index);
394                                item.segment_offset = Some(offset);
395                                report.push(item, limit);
396                            }
397                            Err(EwfError::Aborted) => return Err(EwfError::Aborted),
398                            Err(error) => {
399                                let mut item = finding(
400                                    IntegrityFindingKind::TableCheckUnavailable,
401                                    IntegritySeverity::Error,
402                                    error.to_string(),
403                                );
404                                item.segment_index = Some(section.segment_index);
405                                item.segment_offset = Some(section.descriptor_offset);
406                                report.push(item, limit);
407                            }
408                            Ok(None) => {}
409                        }
410                    }
411                }
412                _ => {}
413            }
414        }
415        Ok(())
416    }
417
418    fn compare_table_entries(
419        &self,
420        primary: &SectionInfo,
421        redundant: &SectionInfo,
422    ) -> Result<Option<u64>> {
423        let left =
424            self.read_segment_range(primary.segment_index, primary.data_offset, 24, false)?;
425        let right =
426            self.read_segment_range(redundant.segment_index, redundant.data_offset, 24, false)?;
427        // A table2 with different geometry may be a unique range, not a mirror.
428        if left[..4] != right[..4] || left[8..16] != right[8..16] {
429            return Ok(None);
430        }
431        let size = u64::from(u32::from_le_bytes(
432            left[..4].try_into().expect("header read"),
433        )) * 4;
434        if size > primary.data_size.saturating_sub(24)
435            || size > redundant.data_size.saturating_sub(24)
436        {
437            return Err(EwfError::Malformed(
438                "redundant table entries exceed section payload".into(),
439            ));
440        }
441        let mut offset = 0;
442        while offset < size {
443            self.ensure_not_aborted()?;
444            let take = (size - offset).min(64 * 1024);
445            let a = self.read_segment_range(
446                primary.segment_index,
447                primary.data_offset + 24 + offset,
448                take,
449                false,
450            )?;
451            let b = self.read_segment_range(
452                redundant.segment_index,
453                redundant.data_offset + 24 + offset,
454                take,
455                false,
456            )?;
457            if let Some(difference) = a.iter().zip(&b).position(|(a, b)| a != b) {
458                return Ok(Some(
459                    redundant.data_offset + 24 + offset + difference as u64,
460                ));
461            }
462            offset += take;
463        }
464        Ok(None)
465    }
466}