Skip to main content

ewf_forensic/
integrity.rs

1use flate2::read::ZlibDecoder;
2use md5::{Digest as _, Md5};
3use sha1::Sha1;
4use sha2::Sha256;
5use std::fmt;
6use std::io::Read as _;
7
8// ── EWF v1 constants ─────────────────────────────────────────────────────────
9
10const EVF_SIGNATURE: [u8; 8] = [0x45, 0x56, 0x46, 0x09, 0x0d, 0x0a, 0xff, 0x00];
11/// DiskSig/Tableau "dvf" signature — a valid EWF v1 variant.
12const DVF_SIGNATURE: [u8; 8] = [0x64, 0x76, 0x66, 0x09, 0x0d, 0x0a, 0xff, 0x00];
13/// Logical Volume Format "LVF" signature — logical evidence images.
14const LVF_SIGNATURE: [u8; 8] = [0x4c, 0x56, 0x46, 0x09, 0x0d, 0x0a, 0xff, 0x00];
15
16const FILE_HEADER_SIZE: usize = 13;
17pub(crate) const SECTION_DESCRIPTOR_SIZE: usize = 76;
18const VOLUME_DATA_MIN: usize = 24;
19/// The standard ewf_data_t body size (per libewf). Adler-32 at byte 1048.
20const VOLUME_DATA_FULL: usize = 1052;
21/// Valid media_type values from the ewf_data_t struct.
22const VALID_MEDIA_TYPES: &[u8] = &[0x00, 0x01, 0x03, 0x0e, 0x10];
23
24const KNOWN_TYPES: &[&str] = &[
25    "header", "header2", "volume", "disk", "table", "table2", "sectors", "hash", "digest",
26    "error2", "session", "done", "next", "data", "ltree", "ltreedata",
27];
28
29// ── EWF v2 constants ─────────────────────────────────────────────────────────
30
31const EVF2_SIGNATURE: [u8; 8] = [0x45, 0x56, 0x46, 0x32, 0x0d, 0x0a, 0x81, 0x00];
32const LEF2_SIGNATURE: [u8; 8] = [0x4c, 0x45, 0x46, 0x32, 0x0d, 0x0a, 0x81, 0x00];
33const EVF2_FILE_HEADER_SIZE: usize = 32;
34const EVF2_SECTION_DESCRIPTOR_SIZE: usize = 64;
35const EVF2_DATA_FLAG_ENCRYPTED: u32 = 0x0000_0002;
36const EVF2_CHUNK_FLAG_COMPRESSED: u32 = 0x0000_0001;
37const EVF2_TYPE_MEDIA_INFO: u32 = 0x02;
38const EVF2_TYPE_CHUNK_TABLE: u32 = 0x04;
39const EVF2_TYPE_MD5_HASH: u32 = 0x08;
40const EVF2_TYPE_SHA1_HASH: u32 = 0x09;
41const EVF2_TYPE_SHA256_HASH: u32 = 0x0A;
42const EVF2_CHUNK_TABLE_HEADER_SIZE: usize = 32;
43const EVF2_CHUNK_TABLE_ENTRY_SIZE: usize = 16;
44
45// ── Public types ──────────────────────────────────────────────────────────────
46
47/// The canonical 5-level severity scale, shared across every SecurityRonin
48/// analyzer via [`forensicnomicon::report`].
49pub use forensicnomicon::report::Severity;
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
53pub enum EwfIntegrityAnomaly {
54    // ── EWF v1 ───────────────────────────────────────────────────────────────
55    InvalidSignature,
56    SegmentNumberZero,
57    SectionDescriptorCrcMismatch {
58        offset: u64,
59        section_type: String,
60        computed: u32,
61        stored: u32,
62    },
63    SectionChainBroken {
64        at_offset: u64,
65        next_offset: u64,
66    },
67    SectionGapNonZero {
68        gap_offset: u64,
69        gap_size: u64,
70    },
71    VolumeSectionMissing,
72    UnknownSectionType {
73        offset: u64,
74        type_name: String,
75    },
76    DoneSectionMissing,
77    /// No `sectors` section was found in this EWF v1 segment.
78    SectorsSectionMissing,
79    /// No `table` section was found in this EWF v1 segment.
80    TableSectionMissing,
81    ChunkSizeInvalid {
82        sectors_per_chunk: u32,
83        bytes_per_sector: u32,
84    },
85    SectorCountMismatch {
86        declared: u64,
87        expected: u64,
88    },
89    BytesPerSectorInvalid {
90        bytes_per_sector: u32,
91    },
92    TableChunkCountMismatch {
93        in_volume: u32,
94        in_table: u32,
95    },
96    TableHeaderAdler32Mismatch {
97        computed: u32,
98        stored: u32,
99    },
100    TableEntryOutOfBounds {
101        chunk_index: u32,
102        entry_offset: u64,
103        file_size: u64,
104    },
105    TableEntryOutsideSectorsRange {
106        chunk_index: u32,
107        entry_offset: u64,
108        sectors_start: u64,
109        sectors_end: u64,
110    },
111    SectionGapZero {
112        gap_offset: u64,
113        gap_size: u64,
114    },
115    HashMismatch {
116        computed: [u8; 16],
117        stored: [u8; 16],
118    },
119    HashSectionMissing,
120    /// `table2` body differs from `table` body — one of the redundant copies is corrupt.
121    Table2Mismatch {
122        /// Byte offset into the table body where the first difference was found.
123        offset: usize,
124    },
125    /// The `error2` section records acquisition errors (unreadable sectors).
126    BadSectorsPresent {
127        /// Number of error entries in the `error2` section.
128        count: u32,
129    },
130    // ── Multi-segment ─────────────────────────────────────────────────────────
131    /// Segment number does not match the expected sequential position.
132    SegmentOutOfOrder {
133        segment_number: u16,
134        expected: u16,
135    },
136    // ── SHA-1 from EWF v1 digest section ─────────────────────────────────────
137    /// Computed SHA-1 of all sector data does not match the stored SHA-1 in the digest section.
138    DigestSha1Mismatch {
139        computed: [u8; 20],
140        stored: [u8; 20],
141    },
142    /// Computed SHA-256 of all sector data does not match the stored SHA-256 in the hash section.
143    DigestSha256Mismatch {
144        computed: [u8; 32],
145        stored: [u8; 32],
146    },
147    // ── External reference hash ───────────────────────────────────────────────
148    /// Computed MD5 does not match an externally supplied reference (e.g. chain-of-custody form).
149    ExternalMd5Mismatch {
150        computed: [u8; 16],
151        expected: [u8; 16],
152    },
153    /// Computed SHA-1 does not match an externally supplied reference.
154    ExternalSha1Mismatch {
155        computed: [u8; 20],
156        expected: [u8; 20],
157    },
158    // ── EWF v2 ───────────────────────────────────────────────────────────────
159    /// A section's stored data_integrity_hash does not match MD5 of the section body.
160    Ewf2SectionDataHashMismatch {
161        offset: u64,
162        section_type_id: u32,
163        computed: [u8; 16],
164        stored: [u8; 16],
165    },
166    /// An encrypted section was found; its content cannot be verified.
167    Ewf2EncryptedSection {
168        offset: u64,
169    },
170    /// No MD5 or SHA-1 hash section found in the final EWF v2 segment.
171    Ewf2HashSectionMissing,
172    /// Adler-32 of the 1052-byte ewf_data_t body is wrong.
173    /// Only checked when the volume body is ≥ 1052 bytes (as in real acquisitions).
174    VolumeBodyCrcMismatch { computed: u32, stored: u32 },
175    /// `media_type` byte (offset 0 of ewf_data_t) is not a known valid value.
176    /// Valid: 0x00=removable, 0x01=fixed, 0x03=optical, 0x0e=LVF, 0x10=memory.
177    MediaTypeUnknown { media_type: u8 },
178    /// The set_identifier GUID (bytes 64-79 of ewf_data_t) differs between segments
179    /// of the same acquisition — indicates segments from different acquisitions were mixed.
180    SetIdentifierMismatch { segment: usize },
181    /// No media information (device information) section found in the EWF v2 image.
182    Ewf2MediaInfoMissing,
183    /// The Adler-32 checksum stored at the end of the EWF v2 chunk table body does not
184    /// match the Adler-32 computed over the chunk table entries.
185    Ewf2ChunkTableChecksumMismatch { computed: u32, stored: u32 },
186    /// The Adler-32 stored at the end of a chunk's byte range does not match
187    /// the Adler-32 computed over the chunk's raw (possibly compressed) bytes.
188    ChunkChecksumMismatch {
189        chunk_index: usize,
190        computed: u32,
191        stored: u32,
192    },
193    /// A compressed chunk's zlib stream could not be decompressed.
194    /// The chunk index identifies exactly which chunk is corrupt.
195    ChunkDecompressionError {
196        chunk_index: usize,
197    },
198    /// EWF v2 file header specifies a compression algorithm not supported by this tool.
199    UnsupportedCompressionAlgorithm {
200        /// Value from file header bytes [10..12].
201        method_id: u16,
202    },
203    /// Computed SHA-256 does not match an externally supplied reference.
204    ExternalSha256Mismatch {
205        computed: [u8; 32],
206        expected: [u8; 32],
207    },
208    /// The EWF v2 media information section body could not be decompressed (zlib
209    /// failure) or decoded as UTF-16LE.  The body is required to be a zlib-
210    /// compressed, BOM-prefixed UTF-16LE key=value table.
211    Ewf2MediaInfoParseFailed,
212}
213
214impl EwfIntegrityAnomaly {
215    pub fn severity(&self) -> Severity {
216        match self {
217            Self::InvalidSignature => Severity::Critical,
218            Self::SegmentNumberZero => Severity::High,
219            Self::SectionDescriptorCrcMismatch { .. } => Severity::High,
220            Self::SectionChainBroken { .. } => Severity::Critical,
221            Self::SectionGapNonZero { .. } => Severity::Medium,
222            Self::VolumeSectionMissing => Severity::Critical,
223            Self::UnknownSectionType { .. } => Severity::Medium,
224            Self::DoneSectionMissing => Severity::Medium,
225            Self::SectorsSectionMissing => Severity::High,
226            Self::TableSectionMissing => Severity::High,
227            Self::ChunkSizeInvalid { .. } => Severity::High,
228            Self::SectorCountMismatch { .. } => Severity::High,
229            Self::BytesPerSectorInvalid { .. } => Severity::High,
230            Self::TableChunkCountMismatch { .. } => Severity::High,
231            Self::TableHeaderAdler32Mismatch { .. } => Severity::High,
232            Self::TableEntryOutOfBounds { .. } => Severity::High,
233            Self::TableEntryOutsideSectorsRange { .. } => Severity::High,
234            Self::SectionGapZero { .. } => Severity::Info,
235            Self::HashMismatch { .. } => Severity::High,
236            Self::HashSectionMissing => Severity::Medium,
237            Self::Table2Mismatch { .. } => Severity::High,
238            Self::BadSectorsPresent { .. } => Severity::Medium,
239            Self::SegmentOutOfOrder { .. } => Severity::High,
240            Self::DigestSha1Mismatch { .. } => Severity::High,
241            Self::DigestSha256Mismatch { .. } => Severity::High,
242            Self::ExternalMd5Mismatch { .. } => Severity::Critical,
243            Self::ExternalSha1Mismatch { .. } => Severity::Critical,
244            Self::VolumeBodyCrcMismatch { .. } => Severity::High,
245            Self::MediaTypeUnknown { .. } => Severity::Medium,
246            Self::SetIdentifierMismatch { .. } => Severity::High,
247            Self::Ewf2SectionDataHashMismatch { .. } => Severity::High,
248            Self::Ewf2EncryptedSection { .. } => Severity::Medium,
249            Self::Ewf2HashSectionMissing => Severity::Medium,
250            Self::Ewf2MediaInfoMissing => Severity::Medium,
251            Self::Ewf2ChunkTableChecksumMismatch { .. } => Severity::High,
252            Self::ChunkChecksumMismatch { .. } => Severity::High,
253            Self::ChunkDecompressionError { .. } => Severity::High,
254            Self::UnsupportedCompressionAlgorithm { .. } => Severity::High,
255            Self::ExternalSha256Mismatch { .. } => Severity::Critical,
256            Self::Ewf2MediaInfoParseFailed => Severity::High,
257        }
258    }
259}
260
261impl fmt::Display for EwfIntegrityAnomaly {
262    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263        match self {
264            Self::InvalidSignature =>
265                write!(f, "invalid EWF signature — not a valid E01/Ex01 file"),
266            Self::SegmentNumberZero =>
267                write!(f, "segment number is zero (expected ≥ 1)"),
268            Self::SectionDescriptorCrcMismatch { offset, section_type, computed, stored } =>
269                write!(f, "section '{section_type}' at 0x{offset:x}: descriptor CRC mismatch (computed 0x{computed:08x}, stored 0x{stored:08x})"),
270            Self::SectionChainBroken { at_offset, next_offset } =>
271                write!(f, "section chain broken at 0x{at_offset:x}: next pointer 0x{next_offset:x} is invalid"),
272            Self::SectionGapNonZero { gap_offset, gap_size } =>
273                write!(f, "non-zero data in {gap_size}-byte gap at 0x{gap_offset:x} — possible hidden data"),
274            Self::VolumeSectionMissing =>
275                write!(f, "volume/disk section missing in segment 1"),
276            Self::UnknownSectionType { offset, type_name } =>
277                write!(f, "unknown section type '{type_name}' at 0x{offset:x}"),
278            Self::DoneSectionMissing =>
279                write!(f, "done section missing from final segment"),
280            Self::SectorsSectionMissing =>
281                write!(f, "sectors section missing — chunk data not found in segment"),
282            Self::TableSectionMissing =>
283                write!(f, "table section missing — chunk offset table not found in segment"),
284            Self::ChunkSizeInvalid { sectors_per_chunk, bytes_per_sector } =>
285                write!(f, "invalid chunk size: {sectors_per_chunk} sectors × {bytes_per_sector} bytes/sector"),
286            Self::SectorCountMismatch { declared, expected } =>
287                write!(f, "sector count mismatch: declared {declared}, expected {expected}"),
288            Self::BytesPerSectorInvalid { bytes_per_sector } =>
289                write!(f, "invalid bytes_per_sector: {bytes_per_sector} (expected 512 or 4096)"),
290            Self::TableChunkCountMismatch { in_volume, in_table } =>
291                write!(f, "chunk count mismatch: volume declares {in_volume}, table has {in_table}"),
292            Self::TableHeaderAdler32Mismatch { computed, stored } =>
293                write!(f, "table header Adler-32 mismatch: computed 0x{computed:08x}, stored 0x{stored:08x}"),
294            Self::TableEntryOutOfBounds { chunk_index, entry_offset, file_size } =>
295                write!(f, "table entry for chunk {chunk_index} points outside file: 0x{entry_offset:x} ≥ 0x{file_size:x}"),
296            Self::TableEntryOutsideSectorsRange { chunk_index, entry_offset, sectors_start, sectors_end } =>
297                write!(f, "table entry for chunk {chunk_index} at 0x{entry_offset:x} is outside sectors section [0x{sectors_start:x}..0x{sectors_end:x}]"),
298            Self::SectionGapZero { gap_offset, gap_size } =>
299                write!(f, "zero-padded {gap_size}-byte gap at 0x{gap_offset:x}"),
300            Self::HashMismatch { computed, stored } =>
301                write!(f, "MD5 mismatch: computed {}, stored {}", hex(computed), hex(stored)),
302            Self::HashSectionMissing =>
303                write!(f, "hash section missing — cannot verify MD5"),
304            Self::Table2Mismatch { offset } =>
305                write!(f, "table2 body differs from table at byte offset {offset} — one redundant copy is corrupt"),
306            Self::BadSectorsPresent { count } =>
307                write!(f, "error2 section reports {count} unreadable sector range(s) from acquisition"),
308            Self::SegmentOutOfOrder { segment_number, expected } =>
309                write!(f, "segment {segment_number} found where segment {expected} was expected"),
310            Self::DigestSha1Mismatch { computed, stored } =>
311                write!(f, "SHA-1 mismatch: computed {}, stored {}", hex(computed), hex(stored)),
312            Self::DigestSha256Mismatch { computed, stored } =>
313                write!(f, "SHA-256 mismatch: computed {}, stored {}", hex(computed), hex(stored)),
314            Self::ExternalMd5Mismatch { computed, expected } =>
315                write!(f, "MD5 does not match chain-of-custody reference: computed {}, expected {}", hex(computed), hex(expected)),
316            Self::ExternalSha1Mismatch { computed, expected } =>
317                write!(f, "SHA-1 does not match chain-of-custody reference: computed {}, expected {}", hex(computed), hex(expected)),
318            Self::ExternalSha256Mismatch { computed, expected } =>
319                write!(f, "SHA-256 does not match chain-of-custody reference: computed {}, expected {}", hex(computed), hex(expected)),
320            Self::Ewf2SectionDataHashMismatch { offset, section_type_id, computed, stored } =>
321                write!(f, "EWF v2 section (type 0x{section_type_id:02x}) at 0x{offset:x}: data integrity hash mismatch (computed {}, stored {})", hex(computed), hex(stored)),
322            Self::Ewf2EncryptedSection { offset } =>
323                write!(f, "EWF v2 encrypted section at 0x{offset:x} — content not verifiable"),
324            Self::Ewf2HashSectionMissing =>
325                write!(f, "EWF v2 hash section missing from final segment"),
326            Self::VolumeBodyCrcMismatch { computed, stored } =>
327                write!(f, "volume section body CRC mismatch (computed 0x{computed:08x}, stored 0x{stored:08x})"),
328            Self::MediaTypeUnknown { media_type } =>
329                write!(f, "unknown media_type 0x{media_type:02x}"),
330            Self::SetIdentifierMismatch { segment } =>
331                write!(f, "set_identifier GUID mismatch in segment {segment} — segments may be from different acquisitions"),
332            Self::Ewf2MediaInfoMissing =>
333                write!(f, "EWF v2 media information section missing"),
334            Self::Ewf2ChunkTableChecksumMismatch { computed, stored } =>
335                write!(f, "EWF v2 chunk table checksum mismatch (computed 0x{computed:08x}, stored 0x{stored:08x})"),
336            Self::ChunkChecksumMismatch { chunk_index, computed, stored } =>
337                write!(f, "chunk {chunk_index}: Adler-32 mismatch (computed 0x{computed:08x}, stored 0x{stored:08x})"),
338            Self::ChunkDecompressionError { chunk_index } =>
339                write!(f, "chunk {chunk_index}: zlib decompression failed — chunk data is corrupt"),
340            Self::UnsupportedCompressionAlgorithm { method_id } =>
341                write!(f, "EWF v2 file header specifies unsupported compression algorithm 0x{method_id:04x} — only deflate (0/1) is supported"),
342            Self::Ewf2MediaInfoParseFailed =>
343                write!(f, "EWF v2 media information section body could not be decompressed or decoded"),
344        }
345    }
346}
347
348fn hex(bytes: &[u8]) -> String {
349    bytes.iter().map(|b| format!("{b:02x}")).collect()
350}
351
352/// Snapshot of analysis progress, delivered to the callback passed to
353/// [`EwfIntegrity::analyse_with_progress`] after each chunk is processed.
354#[derive(Debug, Clone, PartialEq, Eq)]
355#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
356pub struct AnalysisProgress {
357    /// Number of chunks fully processed (hashed + Adler-32 verified) so far.
358    pub chunks_done: usize,
359    /// Total chunks in the current segment; `None` until the chunk table is parsed.
360    pub chunks_total: Option<usize>,
361    /// Total sector-data bytes processed so far.
362    pub bytes_done: u64,
363}
364
365/// The three hashes computed over all sector data in an EWF image.
366#[derive(Debug, Clone, PartialEq, Eq)]
367#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
368pub struct ComputedHashes {
369    pub md5: [u8; 16],
370    pub sha1: [u8; 20],
371    pub sha256: [u8; 32],
372}
373
374/// Acquisition metadata parsed from the zlib-compressed `header` section of an EWF v1 image.
375#[derive(Debug, Clone, PartialEq, Eq)]
376#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
377pub struct EwfHeaderMetadata {
378    pub description: String,
379    pub case_number: String,
380    pub evidence_number: String,
381    pub examiner_name: String,
382    pub acquisition_date: String,
383    pub system_date: String,
384    pub password_hash: String,
385    pub acquisition_software: String,
386}
387
388// ── Public entry point ────────────────────────────────────────────────────────
389
390pub struct EwfIntegrity<'a> {
391    segments: Vec<&'a [u8]>,
392    expected_md5: Option<[u8; 16]>,
393    expected_sha1: Option<[u8; 20]>,
394    expected_sha256: Option<[u8; 32]>,
395}
396
397impl<'a> EwfIntegrity<'a> {
398    /// Analyse a single-segment E01 or Ex01 file.
399    pub fn new(data: &'a [u8]) -> Self {
400        Self {
401            segments: vec![data],
402            expected_md5: None,
403            expected_sha1: None,
404            expected_sha256: None,
405        }
406    }
407
408    /// Analyse a multi-segment image. Pass segments in order: E01, E02, E03 …
409    pub fn from_segments(segs: &[&'a [u8]]) -> Self {
410        Self {
411            segments: segs.to_vec(),
412            expected_md5: None,
413            expected_sha1: None,
414            expected_sha256: None,
415        }
416    }
417
418    /// Compare the computed MD5 against an externally-sourced reference
419    /// (e.g., a chain-of-custody form). Mismatch → `ExternalMd5Mismatch` (Critical).
420    pub fn with_expected_md5(mut self, hash: [u8; 16]) -> Self {
421        self.expected_md5 = Some(hash);
422        self
423    }
424
425    /// Compare the computed SHA-1 against an externally-sourced reference.
426    /// Mismatch → `ExternalSha1Mismatch` (Critical).
427    pub fn with_expected_sha1(mut self, hash: [u8; 20]) -> Self {
428        self.expected_sha1 = Some(hash);
429        self
430    }
431
432    /// Compare the computed SHA-256 against an externally-sourced reference.
433    /// Mismatch → `ExternalSha256Mismatch` (Critical).
434    pub fn with_expected_sha256(mut self, hash: [u8; 32]) -> Self {
435        self.expected_sha256 = Some(hash);
436        self
437    }
438
439    /// Parse the zlib-compressed acquisition metadata from the `header` section.
440    ///
441    /// Returns `Some` on the first segment that contains a valid, decompressible
442    /// `header` section with a parseable key-value block.  Returns `None` if no
443    /// such section exists or any parse step fails.
444    pub fn header_metadata(&self) -> Option<EwfHeaderMetadata> {
445        for &data in &self.segments {
446            if let Some(meta) = parse_header_section(data) {
447                return Some(meta);
448            }
449        }
450        None
451    }
452
453    /// Compute MD5, SHA-1, and SHA-256 of all sector data without verifying stored hashes.
454    ///
455    /// Returns `None` if the image is unparseable (too short, invalid signature,
456    /// missing geometry, or no chunk table found in an EWF v2 image).
457    pub fn compute_hashes(&self) -> Option<ComputedHashes> {
458        let first = self.segments.first().copied().unwrap_or(&[]);
459        if first.len() >= 8
460            && (first[0..8] == EVF2_SIGNATURE || first[0..8] == LEF2_SIGNATURE)
461        {
462            return compute_hashes_ewf2(&self.segments);
463        }
464        compute_hashes_ewf1(&self.segments)
465    }
466
467    pub fn analyse(&self) -> Vec<EwfIntegrityAnomaly> {
468        let first = self.segments.first().copied().unwrap_or(&[]);
469        if first.len() >= 8
470            && (first[0..8] == EVF2_SIGNATURE || first[0..8] == LEF2_SIGNATURE)
471        {
472            return self.analyse_all_ewf2();
473        }
474        self.analyse_all_ewf1()
475    }
476
477    /// Analyse with a per-chunk progress callback.
478    ///
479    /// The callback receives an [`AnalysisProgress`] snapshot after each chunk
480    /// is processed.  The final call has `chunks_done == chunks_total` (for
481    /// EWF v2) or `chunks_done > 0` (for EWF v1).
482    ///
483    /// Returns the same anomaly list as [`analyse`][Self::analyse].
484    pub fn analyse_with_progress(
485        &self,
486        progress: impl FnMut(AnalysisProgress),
487    ) -> Vec<EwfIntegrityAnomaly> {
488        let first = self.segments.first().copied().unwrap_or(&[]);
489        if first.len() >= 8
490            && (first[0..8] == EVF2_SIGNATURE || first[0..8] == LEF2_SIGNATURE)
491        {
492            return self.analyse_all_ewf2_with_progress(progress);
493        }
494        self.analyse_all_ewf1_with_progress(progress)
495    }
496
497    // ── EWF v1 ───────────────────────────────────────────────────────────────
498
499    fn analyse_all_ewf1(&self) -> Vec<EwfIntegrityAnomaly> {
500        let mut issues = Vec::new();
501        let n = self.segments.len();
502        let multi = n > 1;
503        let mut geometry: Option<VolumeGeometry> = None;
504        let mut all_sections: Vec<Vec<Section>> = Vec::with_capacity(n);
505        let mut total_table_entries: u32 = 0;
506
507        for (idx, &data) in self.segments.iter().enumerate() {
508            let expected_seg_num = (idx + 1) as u16;
509            let is_last = idx == n - 1;
510            let file_size = data.len() as u64;
511
512            if data.len() < FILE_HEADER_SIZE {
513                issues.push(EwfIntegrityAnomaly::SectionChainBroken {
514                    at_offset: 0,
515                    next_offset: 0,
516                });
517                all_sections.push(Vec::new());
518                continue;
519            }
520
521            if data[0..8] != EVF_SIGNATURE
522                && data[0..8] != DVF_SIGNATURE
523                && data[0..8] != LVF_SIGNATURE
524            {
525                issues.push(EwfIntegrityAnomaly::InvalidSignature);
526            }
527
528            let seg_num = u16::from_le_bytes(data[9..11].try_into().unwrap());
529            if seg_num == 0 {
530                issues.push(EwfIntegrityAnomaly::SegmentNumberZero);
531            } else if seg_num != expected_seg_num {
532                issues.push(EwfIntegrityAnomaly::SegmentOutOfOrder {
533                    segment_number: seg_num,
534                    expected: expected_seg_num,
535                });
536            }
537
538            let sections = walk_sections_v1(data, &mut issues);
539
540            // Volume/disk geometry — required in segment 0; compared in later segments.
541            if let Some(vol_sec) = sections
542                .iter()
543                .find(|s| s.type_name == "volume" || s.type_name == "disk")
544            {
545                if idx == 0 {
546                    geometry = check_volume_v1(data, vol_sec.offset, vol_sec.size, &mut issues);
547                } else {
548                    // Later segments with a volume section: validate its GUID against seg 0.
549                    let later = check_volume_v1(data, vol_sec.offset, vol_sec.size, &mut issues);
550                    if let (Some(ref base), Some(ref later_geom)) = (&geometry, &later) {
551                        let base_guid = base.set_identifier;
552                        let later_guid = later_geom.set_identifier;
553                        let neither_zero =
554                            base_guid != [0u8; 16] && later_guid != [0u8; 16];
555                        if neither_zero && base_guid != later_guid {
556                            issues.push(EwfIntegrityAnomaly::SetIdentifierMismatch {
557                                segment: idx + 1,
558                            });
559                        }
560                    }
561                }
562            } else if idx == 0 {
563                issues.push(EwfIntegrityAnomaly::VolumeSectionMissing);
564            }
565
566            // Table integrity — single-segment: check per-entry-count vs volume directly.
567            // Multi-segment: accumulate for post-loop total comparison.
568            let vol_count = if !multi && idx == 0 {
569                geometry.as_ref().map(|g| g.chunk_count)
570            } else {
571                None
572            };
573            let sectors_section = sections.iter().find(|s| s.type_name == "sectors");
574            let sectors_range = sectors_section
575                .map(|s| (s.offset + SECTION_DESCRIPTOR_SIZE as u64, s.offset + s.size));
576            if sectors_section.is_none() {
577                issues.push(EwfIntegrityAnomaly::SectorsSectionMissing);
578            }
579            if let Some(table) = sections.iter().find(|s| s.type_name == "table") {
580                let data_start = (table.offset as usize) + SECTION_DESCRIPTOR_SIZE;
581                if data.len() >= data_start + 4 {
582                    let count = u32::from_le_bytes(data[data_start..data_start + 4].try_into().unwrap());
583                    total_table_entries = total_table_entries.saturating_add(count);
584                }
585                check_table_v1(
586                    data,
587                    table.offset,
588                    vol_count,
589                    file_size,
590                    sectors_range,
591                    &mut issues,
592                );
593            } else {
594                issues.push(EwfIntegrityAnomaly::TableSectionMissing);
595            }
596
597            // table2 consistency: when both table and table2 exist, bodies must match.
598            if let (Some(t1), Some(t2)) = (
599                sections.iter().find(|s| s.type_name == "table"),
600                sections.iter().find(|s| s.type_name == "table2"),
601            ) {
602                let b1_start = (t1.offset + SECTION_DESCRIPTOR_SIZE as u64) as usize;
603                let b1_end = (t1.offset + t1.size) as usize;
604                let b2_start = (t2.offset + SECTION_DESCRIPTOR_SIZE as u64) as usize;
605                let b2_end = (t2.offset + t2.size) as usize;
606                if let (Some(body1), Some(body2)) = (data.get(b1_start..b1_end), data.get(b2_start..b2_end)) {
607                    if body1.len() == body2.len() {
608                        if let Some(offset) = body1.iter().zip(body2).position(|(a, b)| a != b) {
609                            issues.push(EwfIntegrityAnomaly::Table2Mismatch { offset });
610                        }
611                    } else {
612                        issues.push(EwfIntegrityAnomaly::Table2Mismatch { offset: 0 });
613                    }
614                }
615            }
616
617            // error2 section: parse entry_count, warn if any unreadable sectors.
618            if let Some(e2) = sections.iter().find(|s| s.type_name == "error2") {
619                let body_start = (e2.offset + SECTION_DESCRIPTOR_SIZE as u64) as usize;
620                if body_start + 4 <= data.len() {
621                    let count = u32::from_le_bytes(data[body_start..body_start + 4].try_into().unwrap());
622                    if count > 0 {
623                        issues.push(EwfIntegrityAnomaly::BadSectorsPresent { count });
624                    }
625                }
626            }
627
628            // Done section expected only in the last segment
629            if is_last && !sections.iter().any(|s| s.type_name == "done") {
630                issues.push(EwfIntegrityAnomaly::DoneSectionMissing);
631            }
632
633            all_sections.push(sections);
634        }
635
636        // Multi-segment total chunk count vs sum of all table entry counts.
637        if multi {
638            if let Some(geom) = &geometry {
639                if total_table_entries != geom.chunk_count {
640                    issues.push(EwfIntegrityAnomaly::TableChunkCountMismatch {
641                        in_volume: geom.chunk_count,
642                        in_table: total_table_entries,
643                    });
644                }
645            }
646        }
647
648        // Hash verification spans all segments
649        if let Some(geom) = &geometry {
650            check_hash_all_segments(
651                &self.segments,
652                &all_sections,
653                geom,
654                self.expected_md5,
655                self.expected_sha1,
656                self.expected_sha256,
657                &mut issues,
658                &mut |_| {},
659            );
660        }
661
662        issues
663    }
664
665    // ── EWF v2 ───────────────────────────────────────────────────────────────
666
667    fn analyse_all_ewf2(&self) -> Vec<EwfIntegrityAnomaly> {
668        self.analyse_all_ewf2_with_progress(|_| {})
669    }
670
671    fn analyse_all_ewf2_impl(&self, progress: &mut dyn FnMut(AnalysisProgress)) -> Vec<EwfIntegrityAnomaly> {
672        let mut issues = Vec::new();
673        let n = self.segments.len();
674
675        // Stored hashes live in the FINAL segment and cover ALL segments' data.
676        let mut final_stored_md5:    Option<[u8; 16]> = None;
677        let mut final_stored_sha1:   Option<[u8; 20]> = None;
678        let mut final_stored_sha256: Option<[u8; 32]> = None;
679
680        for (idx, &data) in self.segments.iter().enumerate() {
681            let expected_seg_num = (idx + 1) as u32;
682
683            if data.len() < EVF2_FILE_HEADER_SIZE + EVF2_SECTION_DESCRIPTOR_SIZE {
684                issues.push(EwfIntegrityAnomaly::SectionChainBroken {
685                    at_offset: 0,
686                    next_offset: 0,
687                });
688                continue;
689            }
690
691            if data[0..8] != EVF2_SIGNATURE && data[0..8] != LEF2_SIGNATURE {
692                issues.push(EwfIntegrityAnomaly::InvalidSignature);
693            }
694
695            let seg_num = u32::from_le_bytes(data[12..16].try_into().unwrap());
696            if seg_num == 0 {
697                issues.push(EwfIntegrityAnomaly::SegmentNumberZero);
698            } else if seg_num != expected_seg_num {
699                issues.push(EwfIntegrityAnomaly::SegmentOutOfOrder {
700                    segment_number: seg_num as u16,
701                    expected: expected_seg_num as u16,
702                });
703            }
704
705            // compression_method at file header [10..12]: 0=none/deflate, 1=deflate.
706            // Values ≥ 2 indicate bzip2, lzma, or other algorithms not supported here.
707            let compression_method = u16::from_le_bytes(data[10..12].try_into().unwrap());
708            if compression_method > 1 {
709                issues.push(EwfIntegrityAnomaly::UnsupportedCompressionAlgorithm {
710                    method_id: compression_method,
711                });
712            }
713
714            // EWF v2: section body precedes its descriptor; the DONE/NEXT descriptor
715            // is the last 64 bytes of the segment. Walk backward via prev_section_offset.
716            let mut has_hash = false;
717            let mut has_media_info = false;
718            let mut chunk_table_body: Option<(usize, usize)> = None;
719            let mut stored_sector_md5: Option<[u8; 16]> = None;
720            let mut stored_sector_sha1: Option<[u8; 20]> = None;
721            let mut stored_sector_sha256: Option<[u8; 32]> = None;
722            let mut desc_offset = data.len().saturating_sub(EVF2_SECTION_DESCRIPTOR_SIZE);
723
724            loop {
725                if desc_offset + EVF2_SECTION_DESCRIPTOR_SIZE > data.len()
726                    || desc_offset < EVF2_FILE_HEADER_SIZE
727                {
728                    break;
729                }
730                let desc = &data[desc_offset..desc_offset + EVF2_SECTION_DESCRIPTOR_SIZE];
731                let section_type = u32::from_le_bytes(desc[0..4].try_into().unwrap());
732                let data_flags  = u32::from_le_bytes(desc[4..8].try_into().unwrap());
733                let prev_offset = u64::from_le_bytes(desc[8..16].try_into().unwrap()) as usize;
734                let data_size   = u64::from_le_bytes(desc[16..24].try_into().unwrap()) as usize;
735                let stored_hash: [u8; 16] = desc[32..48].try_into().unwrap();
736
737                // Body occupies [desc_offset - data_size .. desc_offset].
738                let body_end   = desc_offset;
739                let body_start = desc_offset.saturating_sub(data_size);
740
741                if data_flags & EVF2_DATA_FLAG_ENCRYPTED != 0 {
742                    issues.push(EwfIntegrityAnomaly::Ewf2EncryptedSection {
743                        offset: desc_offset as u64,
744                    });
745                } else {
746                    if stored_hash != [0u8; 16] {
747                        if let Some(body) = data.get(body_start..body_end) {
748                            let computed: [u8; 16] = Md5::digest(body).into();
749                            if computed != stored_hash {
750                                issues.push(EwfIntegrityAnomaly::Ewf2SectionDataHashMismatch {
751                                    offset: desc_offset as u64,
752                                    section_type_id: section_type,
753                                    computed,
754                                    stored: stored_hash,
755                                });
756                            }
757                        }
758                    }
759
760                    match section_type {
761                        EVF2_TYPE_MEDIA_INFO => {
762                            has_media_info = true;
763                            if let Some(body) = data.get(body_start..body_end) {
764                                if !parse_media_info_body(body) {
765                                    issues.push(EwfIntegrityAnomaly::Ewf2MediaInfoParseFailed);
766                                }
767                            } else {
768                                issues.push(EwfIntegrityAnomaly::Ewf2MediaInfoParseFailed);
769                            }
770                        }
771                        EVF2_TYPE_CHUNK_TABLE => {
772                            chunk_table_body = Some((body_start, body_end));
773                        }
774                        EVF2_TYPE_MD5_HASH => {
775                            has_hash = true;
776                            // Body[0..16] = MD5 of all sector data
777                            if data_size >= 16 {
778                                if let Some(body) = data.get(body_start..body_end) {
779                                    let mut h = [0u8; 16];
780                                    h.copy_from_slice(&body[..16]);
781                                    stored_sector_md5 = Some(h);
782                                }
783                            }
784                        }
785                        EVF2_TYPE_SHA1_HASH => {
786                            has_hash = true;
787                            if data_size >= 20 {
788                                if let Some(body) = data.get(body_start..body_end) {
789                                    let mut h = [0u8; 20];
790                                    h.copy_from_slice(&body[..20]);
791                                    stored_sector_sha1 = Some(h);
792                                }
793                            }
794                        }
795                        EVF2_TYPE_SHA256_HASH => {
796                            has_hash = true;
797                            if data_size >= 32 {
798                                if let Some(body) = data.get(body_start..body_end) {
799                                    let mut h = [0u8; 32];
800                                    h.copy_from_slice(&body[..32]);
801                                    stored_sector_sha256 = Some(h);
802                                }
803                            }
804                        }
805                        _ => {}
806                    }
807                }
808
809                if prev_offset == 0 {
810                    break;
811                }
812                desc_offset = prev_offset;
813            }
814
815            if idx == n - 1 && !has_hash {
816                issues.push(EwfIntegrityAnomaly::Ewf2HashSectionMissing);
817            }
818            if idx == 0 && !has_media_info {
819                issues.push(EwfIntegrityAnomaly::Ewf2MediaInfoMissing);
820            }
821
822            // Capture stored hashes from the final segment; they cover ALL segments' data.
823            if idx == n - 1 {
824                final_stored_md5    = stored_sector_md5;
825                final_stored_sha1   = stored_sector_sha1;
826                final_stored_sha256 = stored_sector_sha256;
827            }
828
829            // Per-chunk Adler-32 verification only; stored-hash comparison happens
830            // cross-segment after the loop to avoid false positives on multi-segment images.
831            if let Some((ct_start, ct_end)) = chunk_table_body {
832                verify_ewf2_sector_data(data, ct_start, ct_end, None, None, None, &mut issues, progress);
833            }
834        }
835
836        // Cross-segment hash comparison: compute hashes over ALL segments and compare
837        // with stored values from the final segment, plus any external reference hashes.
838        if let Some(computed) = compute_hashes_ewf2(&self.segments) {
839            if let Some(stored) = final_stored_md5 {
840                if computed.md5 != stored {
841                    issues.push(EwfIntegrityAnomaly::HashMismatch { computed: computed.md5, stored });
842                }
843            }
844            if let Some(stored) = final_stored_sha1 {
845                if computed.sha1 != stored {
846                    issues.push(EwfIntegrityAnomaly::DigestSha1Mismatch { computed: computed.sha1, stored });
847                }
848            }
849            if let Some(stored) = final_stored_sha256 {
850                if computed.sha256 != stored {
851                    issues.push(EwfIntegrityAnomaly::DigestSha256Mismatch { computed: computed.sha256, stored });
852                }
853            }
854            if let Some(expected) = self.expected_md5 {
855                if computed.md5 != expected {
856                    issues.push(EwfIntegrityAnomaly::ExternalMd5Mismatch { computed: computed.md5, expected });
857                }
858            }
859            if let Some(expected) = self.expected_sha1 {
860                if computed.sha1 != expected {
861                    issues.push(EwfIntegrityAnomaly::ExternalSha1Mismatch { computed: computed.sha1, expected });
862                }
863            }
864            if let Some(expected) = self.expected_sha256 {
865                if computed.sha256 != expected {
866                    issues.push(EwfIntegrityAnomaly::ExternalSha256Mismatch { computed: computed.sha256, expected });
867                }
868            }
869        }
870
871        issues
872    }
873
874    fn analyse_all_ewf1_with_progress(
875        &self,
876        mut progress: impl FnMut(AnalysisProgress),
877    ) -> Vec<EwfIntegrityAnomaly> {
878        let mut issues = Vec::new();
879        let n = self.segments.len();
880        let multi = n > 1;
881        let mut geometry: Option<VolumeGeometry> = None;
882        let mut all_sections: Vec<Vec<Section>> = Vec::with_capacity(n);
883        let mut total_table_entries: u32 = 0;
884
885        for (idx, &data) in self.segments.iter().enumerate() {
886            let expected_seg_num = (idx + 1) as u16;
887            let is_last = idx == n - 1;
888            let file_size = data.len() as u64;
889
890            if data.len() < FILE_HEADER_SIZE {
891                issues.push(EwfIntegrityAnomaly::SectionChainBroken { at_offset: 0, next_offset: 0 });
892                all_sections.push(Vec::new());
893                continue;
894            }
895            if data[0..8] != EVF_SIGNATURE && data[0..8] != DVF_SIGNATURE && data[0..8] != LVF_SIGNATURE {
896                issues.push(EwfIntegrityAnomaly::InvalidSignature);
897            }
898            let seg_num = u16::from_le_bytes(data[9..11].try_into().unwrap());
899            if seg_num == 0 {
900                issues.push(EwfIntegrityAnomaly::SegmentNumberZero);
901            } else if seg_num != expected_seg_num {
902                issues.push(EwfIntegrityAnomaly::SegmentOutOfOrder { segment_number: seg_num, expected: expected_seg_num });
903            }
904            let sections = walk_sections_v1(data, &mut issues);
905            if let Some(vol_sec) = sections.iter().find(|s| s.type_name == "volume" || s.type_name == "disk") {
906                if idx == 0 {
907                    geometry = check_volume_v1(data, vol_sec.offset, vol_sec.size, &mut issues);
908                } else {
909                    let later = check_volume_v1(data, vol_sec.offset, vol_sec.size, &mut issues);
910                    if let (Some(ref base), Some(ref later_geom)) = (&geometry, &later) {
911                        let base_guid = base.set_identifier;
912                        let later_guid = later_geom.set_identifier;
913                        if base_guid != [0u8; 16] && later_guid != [0u8; 16] && base_guid != later_guid {
914                            issues.push(EwfIntegrityAnomaly::SetIdentifierMismatch { segment: idx + 1 });
915                        }
916                    }
917                }
918            } else if idx == 0 {
919                issues.push(EwfIntegrityAnomaly::VolumeSectionMissing);
920            }
921            let vol_count = if !multi && idx == 0 { geometry.as_ref().map(|g| g.chunk_count) } else { None };
922            let sectors_section = sections.iter().find(|s| s.type_name == "sectors");
923            let sectors_range = sectors_section
924                .map(|s| (s.offset + SECTION_DESCRIPTOR_SIZE as u64, s.offset + s.size));
925            if sectors_section.is_none() {
926                issues.push(EwfIntegrityAnomaly::SectorsSectionMissing);
927            }
928            if let Some(table) = sections.iter().find(|s| s.type_name == "table") {
929                let data_start = (table.offset as usize) + SECTION_DESCRIPTOR_SIZE;
930                if data.len() >= data_start + 4 {
931                    let count = u32::from_le_bytes(data[data_start..data_start + 4].try_into().unwrap());
932                    total_table_entries = total_table_entries.saturating_add(count);
933                }
934                check_table_v1(data, table.offset, vol_count, file_size, sectors_range, &mut issues);
935            } else {
936                issues.push(EwfIntegrityAnomaly::TableSectionMissing);
937            }
938            if let (Some(t1), Some(t2)) = (
939                sections.iter().find(|s| s.type_name == "table"),
940                sections.iter().find(|s| s.type_name == "table2"),
941            ) {
942                let b1_start = (t1.offset + SECTION_DESCRIPTOR_SIZE as u64) as usize;
943                let b1_end = (t1.offset + t1.size) as usize;
944                let b2_start = (t2.offset + SECTION_DESCRIPTOR_SIZE as u64) as usize;
945                let b2_end = (t2.offset + t2.size) as usize;
946                if let (Some(body1), Some(body2)) = (data.get(b1_start..b1_end), data.get(b2_start..b2_end)) {
947                    if body1.len() == body2.len() {
948                        if let Some(offset) = body1.iter().zip(body2).position(|(a, b)| a != b) {
949                            issues.push(EwfIntegrityAnomaly::Table2Mismatch { offset });
950                        }
951                    } else {
952                        issues.push(EwfIntegrityAnomaly::Table2Mismatch { offset: 0 });
953                    }
954                }
955            }
956            if let Some(e2) = sections.iter().find(|s| s.type_name == "error2") {
957                let body_start = (e2.offset + SECTION_DESCRIPTOR_SIZE as u64) as usize;
958                if body_start + 4 <= data.len() {
959                    let count = u32::from_le_bytes(data[body_start..body_start + 4].try_into().unwrap());
960                    if count > 0 {
961                        issues.push(EwfIntegrityAnomaly::BadSectorsPresent { count });
962                    }
963                }
964            }
965            if is_last && !sections.iter().any(|s| s.type_name == "done") {
966                issues.push(EwfIntegrityAnomaly::DoneSectionMissing);
967            }
968            all_sections.push(sections);
969        }
970
971        if multi {
972            if let Some(geom) = &geometry {
973                if total_table_entries != geom.chunk_count {
974                    issues.push(EwfIntegrityAnomaly::TableChunkCountMismatch {
975                        in_volume: geom.chunk_count,
976                        in_table: total_table_entries,
977                    });
978                }
979            }
980        }
981
982        if let Some(geom) = &geometry {
983            check_hash_all_segments(
984                &self.segments, &all_sections, geom,
985                self.expected_md5, self.expected_sha1, self.expected_sha256,
986                &mut issues, &mut progress,
987            );
988        }
989        issues
990    }
991
992    fn analyse_all_ewf2_with_progress(
993        &self,
994        mut progress: impl FnMut(AnalysisProgress),
995    ) -> Vec<EwfIntegrityAnomaly> {
996        self.analyse_all_ewf2_impl(&mut progress)
997    }
998}
999
1000// ── Private helpers ───────────────────────────────────────────────────────────
1001
1002fn parse_header_section(data: &[u8]) -> Option<EwfHeaderMetadata> {
1003    if data.len() < FILE_HEADER_SIZE + SECTION_DESCRIPTOR_SIZE {
1004        return None;
1005    }
1006    let desc_off = FILE_HEADER_SIZE;
1007    let desc = &data[desc_off..desc_off + SECTION_DESCRIPTOR_SIZE];
1008    let type_end = desc[..16].iter().position(|&b| b == 0).unwrap_or(16);
1009    if &desc[..type_end] != b"header" {
1010        return None;
1011    }
1012    let section_size = u64::from_le_bytes(desc[24..32].try_into().unwrap()) as usize;
1013    let body_start = desc_off + SECTION_DESCRIPTOR_SIZE;
1014    let body_end = (desc_off + section_size).min(data.len());
1015    if body_start >= body_end {
1016        return None;
1017    }
1018    let compressed = &data[body_start..body_end];
1019
1020    let mut decoder = ZlibDecoder::new(compressed);
1021    let mut text = String::new();
1022    decoder.read_to_string(&mut text).ok()?;
1023
1024    parse_header_text(&text)
1025}
1026
1027fn parse_header_text(text: &str) -> Option<EwfHeaderMetadata> {
1028    // Format (CRLF or LF line endings):
1029    //   line 0: "1"
1030    //   line 1: "main"
1031    //   line 2: tab-delimited key names
1032    //   line 3: tab-delimited values
1033    let lines: Vec<&str> = text
1034        .lines()
1035        .map(|l| l.trim_end_matches('\r'))
1036        .filter(|l| !l.is_empty())
1037        .collect();
1038    if lines.len() < 4 {
1039        return None;
1040    }
1041    let keys: Vec<&str> = lines[2].split('\t').collect();
1042    let vals: Vec<&str> = lines[3].split('\t').collect();
1043
1044    let mut meta = EwfHeaderMetadata {
1045        description: String::new(),
1046        case_number: String::new(),
1047        evidence_number: String::new(),
1048        examiner_name: String::new(),
1049        acquisition_date: String::new(),
1050        system_date: String::new(),
1051        password_hash: String::new(),
1052        acquisition_software: String::new(),
1053    };
1054
1055    for (i, &key) in keys.iter().enumerate() {
1056        let val = vals.get(i).copied().unwrap_or("").to_owned();
1057        match key {
1058            "a" => meta.description = val,
1059            "c" => meta.case_number = val,
1060            "e" => meta.evidence_number = val,
1061            "t" => meta.examiner_name = val,
1062            "m" => meta.acquisition_date = val,
1063            "u" => meta.system_date = val,
1064            "p" => meta.password_hash = val,
1065            "r" => meta.acquisition_software = val,
1066            _ => {}
1067        }
1068    }
1069
1070    Some(meta)
1071}
1072
1073struct Section {
1074    type_name: String,
1075    offset: u64,
1076    size: u64,
1077}
1078
1079struct VolumeGeometry {
1080    chunk_count: u32,
1081    sectors_per_chunk: u32,
1082    bytes_per_sector: u32,
1083    sector_count: u64,
1084    /// set_identifier GUID from ewf_data_t[64..80]; all-zero = not present.
1085    set_identifier: [u8; 16],
1086}
1087
1088fn walk_sections_v1(data: &[u8], issues: &mut Vec<EwfIntegrityAnomaly>) -> Vec<Section> {
1089    let file_size = data.len() as u64;
1090    let mut sections = Vec::new();
1091    let mut pos = FILE_HEADER_SIZE as u64;
1092
1093    loop {
1094        let off = pos as usize;
1095        if off + SECTION_DESCRIPTOR_SIZE > data.len() {
1096            break;
1097        }
1098        let desc = &data[off..off + SECTION_DESCRIPTOR_SIZE];
1099
1100        let type_end = desc[..16].iter().position(|&b| b == 0).unwrap_or(16);
1101        let type_name = String::from_utf8_lossy(&desc[..type_end]).into_owned();
1102
1103        let stored_crc = u32::from_le_bytes(desc[72..76].try_into().unwrap());
1104        let computed_crc = adler32(&desc[..72]);
1105        if computed_crc != stored_crc {
1106            issues.push(EwfIntegrityAnomaly::SectionDescriptorCrcMismatch {
1107                offset: pos,
1108                section_type: type_name.clone(),
1109                computed: computed_crc,
1110                stored: stored_crc,
1111            });
1112        }
1113
1114        if !KNOWN_TYPES.contains(&type_name.as_str()) {
1115            issues.push(EwfIntegrityAnomaly::UnknownSectionType {
1116                offset: pos,
1117                type_name: type_name.clone(),
1118            });
1119        }
1120
1121        let next = u64::from_le_bytes(desc[16..24].try_into().unwrap());
1122        let section_size = u64::from_le_bytes(desc[24..32].try_into().unwrap());
1123        let section_end = pos.saturating_add(section_size);
1124
1125        sections.push(Section {
1126            type_name: type_name.clone(),
1127            offset: pos,
1128            size: section_size,
1129        });
1130
1131        // "done" and "next" both terminate a segment's chain
1132        if type_name == "done" || type_name == "next" {
1133            break;
1134        }
1135
1136        if next == 0 || next > file_size || next <= pos {
1137            issues.push(EwfIntegrityAnomaly::SectionChainBroken {
1138                at_offset: pos,
1139                next_offset: next,
1140            });
1141            break;
1142        }
1143
1144        if next > section_end {
1145            let gap_offset = section_end;
1146            let gap_size = next - section_end;
1147            let non_zero = data
1148                .get(section_end as usize..next as usize)
1149                .map(|s| s.iter().any(|&b| b != 0))
1150                .unwrap_or(false);
1151            if non_zero {
1152                issues.push(EwfIntegrityAnomaly::SectionGapNonZero { gap_offset, gap_size });
1153            } else {
1154                issues.push(EwfIntegrityAnomaly::SectionGapZero { gap_offset, gap_size });
1155            }
1156        }
1157
1158        pos = next;
1159    }
1160
1161    sections
1162}
1163
1164fn check_volume_v1(
1165    data: &[u8],
1166    desc_offset: u64,
1167    section_size: u64,
1168    issues: &mut Vec<EwfIntegrityAnomaly>,
1169) -> Option<VolumeGeometry> {
1170    let data_start = (desc_offset as usize) + SECTION_DESCRIPTOR_SIZE;
1171    if data.len() < data_start + VOLUME_DATA_MIN {
1172        return None;
1173    }
1174    let body_len = (section_size as usize).saturating_sub(SECTION_DESCRIPTOR_SIZE);
1175    let vol_end = (data_start + body_len).min(data.len());
1176    let vol = &data[data_start..vol_end];
1177
1178    // media_type: byte 0 of ewf_data_t (valid: 0x00/0x01/0x03/0x0e/0x10)
1179    let media_type = vol[0];
1180    if !VALID_MEDIA_TYPES.contains(&media_type) {
1181        issues.push(EwfIntegrityAnomaly::MediaTypeUnknown { media_type });
1182    }
1183
1184    let chunk_count = u32::from_le_bytes(vol[4..8].try_into().unwrap());
1185    let sectors_per_chunk = u32::from_le_bytes(vol[8..12].try_into().unwrap());
1186    let bytes_per_sector = u32::from_le_bytes(vol[12..16].try_into().unwrap());
1187    let sector_count = u64::from_le_bytes(vol[16..24].try_into().unwrap());
1188
1189    if bytes_per_sector != 512 && bytes_per_sector != 4096 {
1190        issues.push(EwfIntegrityAnomaly::BytesPerSectorInvalid { bytes_per_sector });
1191    }
1192    if sectors_per_chunk == 0 || !sectors_per_chunk.is_power_of_two() {
1193        issues.push(EwfIntegrityAnomaly::ChunkSizeInvalid {
1194            sectors_per_chunk,
1195            bytes_per_sector,
1196        });
1197    }
1198
1199    let max_sectors = u64::from(chunk_count) * u64::from(sectors_per_chunk);
1200    let min_sectors = max_sectors.saturating_sub(u64::from(sectors_per_chunk));
1201    if sectors_per_chunk.is_power_of_two() {
1202        let out_of_range =
1203            sector_count > max_sectors || (chunk_count > 0 && sector_count <= min_sectors);
1204        if out_of_range {
1205            issues.push(EwfIntegrityAnomaly::SectorCountMismatch {
1206                declared: sector_count,
1207                expected: max_sectors,
1208            });
1209        }
1210    }
1211
1212    // set_identifier GUID at ewf_data_t[64..80]
1213    let set_identifier: [u8; 16] = if vol.len() >= 80 {
1214        vol[64..80].try_into().unwrap()
1215    } else {
1216        [0u8; 16]
1217    };
1218
1219    // Adler-32 of ewf_data_t bytes 0..1048 stored at bytes 1048..1052.
1220    // Only present when the section body is ≥ VOLUME_DATA_FULL (1052) bytes.
1221    if vol.len() >= VOLUME_DATA_FULL {
1222        let stored_crc = u32::from_le_bytes(vol[1048..1052].try_into().unwrap());
1223        let computed_crc = adler32(&vol[..1048]);
1224        if computed_crc != stored_crc {
1225            issues.push(EwfIntegrityAnomaly::VolumeBodyCrcMismatch {
1226                computed: computed_crc,
1227                stored: stored_crc,
1228            });
1229        }
1230    }
1231
1232    Some(VolumeGeometry {
1233        chunk_count,
1234        sectors_per_chunk,
1235        bytes_per_sector,
1236        sector_count,
1237        set_identifier,
1238    })
1239}
1240
1241fn check_table_v1(
1242    data: &[u8],
1243    desc_offset: u64,
1244    volume_chunk_count: Option<u32>,
1245    file_size: u64,
1246    sectors_range: Option<(u64, u64)>,
1247    issues: &mut Vec<EwfIntegrityAnomaly>,
1248) {
1249    let data_start = (desc_offset as usize) + SECTION_DESCRIPTOR_SIZE;
1250    if data.len() < data_start + 24 {
1251        return;
1252    }
1253    let tbl = &data[data_start..];
1254    let entry_count = u32::from_le_bytes(tbl[0..4].try_into().unwrap());
1255    let base_offset = u64::from_le_bytes(tbl[8..16].try_into().unwrap());
1256
1257    // Table header Adler-32: covers bytes [0..16], stored at [16..20].
1258    // When stored = 0 the writer chose not to include the checksum; skip check.
1259    let stored_crc = u32::from_le_bytes(tbl[16..20].try_into().unwrap());
1260    if stored_crc != 0 {
1261        let computed_crc = adler32(&tbl[..16]);
1262        if computed_crc != stored_crc {
1263            issues.push(EwfIntegrityAnomaly::TableHeaderAdler32Mismatch {
1264                computed: computed_crc,
1265                stored: stored_crc,
1266            });
1267        }
1268    }
1269
1270    if let Some(vol_count) = volume_chunk_count {
1271        if entry_count != vol_count {
1272            issues.push(EwfIntegrityAnomaly::TableChunkCountMismatch {
1273                in_volume: vol_count,
1274                in_table: entry_count,
1275            });
1276        }
1277    }
1278
1279    let entries_start = data_start + 24;
1280    for i in 0..entry_count {
1281        let entry_off = entries_start + (i as usize) * 4;
1282        if entry_off + 4 > data.len() {
1283            break;
1284        }
1285        let raw = u32::from_le_bytes(data[entry_off..entry_off + 4].try_into().unwrap());
1286        let chunk_rel = u64::from(raw & 0x7FFF_FFFF);
1287        let absolute = base_offset.saturating_add(chunk_rel);
1288        if absolute >= file_size {
1289            issues.push(EwfIntegrityAnomaly::TableEntryOutOfBounds {
1290                chunk_index: i,
1291                entry_offset: absolute,
1292                file_size,
1293            });
1294        } else if let Some((sec_start, sec_end)) = sectors_range {
1295            if absolute < sec_start || absolute >= sec_end {
1296                issues.push(EwfIntegrityAnomaly::TableEntryOutsideSectorsRange {
1297                    chunk_index: i,
1298                    entry_offset: absolute,
1299                    sectors_start: sec_start,
1300                    sectors_end: sec_end,
1301                });
1302            }
1303        }
1304    }
1305}
1306
1307/// Extract `(chunk_start, chunk_end, compressed)` for every chunk in one segment's table.
1308fn iter_segment_chunks(data: &[u8], sections: &[Section]) -> Vec<(usize, usize, bool)> {
1309    let table = match sections.iter().find(|s| s.type_name == "table") {
1310        Some(s) => s,
1311        None => return Vec::new(),
1312    };
1313    let sectors = match sections.iter().find(|s| s.type_name == "sectors") {
1314        Some(s) => s,
1315        None => return Vec::new(),
1316    };
1317
1318    let tbl_data_start = (table.offset as usize) + SECTION_DESCRIPTOR_SIZE;
1319    if data.len() < tbl_data_start + 24 {
1320        return Vec::new();
1321    }
1322    let tbl = &data[tbl_data_start..];
1323    let entry_count = u32::from_le_bytes(tbl[0..4].try_into().unwrap()) as usize;
1324    let base_offset = u64::from_le_bytes(tbl[8..16].try_into().unwrap()) as usize;
1325    let entries_start = tbl_data_start + 24;
1326    let sectors_body_end = (sectors.offset + sectors.size) as usize;
1327
1328    let mut chunks = Vec::with_capacity(entry_count);
1329    for i in 0..entry_count {
1330        let entry_off = entries_start + i * 4;
1331        if entry_off + 4 > data.len() {
1332            break;
1333        }
1334        let raw = u32::from_le_bytes(data[entry_off..entry_off + 4].try_into().unwrap());
1335        let compressed = raw & 0x8000_0000 != 0;
1336        let rel = (raw & 0x7FFF_FFFF) as usize;
1337        let start = base_offset + rel;
1338
1339        let end = if i + 1 < entry_count {
1340            let next_off = entries_start + (i + 1) * 4;
1341            if next_off + 4 > data.len() {
1342                break;
1343            }
1344            let next_raw = u32::from_le_bytes(data[next_off..next_off + 4].try_into().unwrap());
1345            let next_rel = (next_raw & 0x7FFF_FFFF) as usize;
1346            base_offset + next_rel
1347        } else {
1348            sectors_body_end.min(data.len())
1349        };
1350
1351        if start >= end || end > data.len() {
1352            break;
1353        }
1354        chunks.push((start, end, compressed));
1355    }
1356    chunks
1357}
1358
1359/// Hash all chunk data across all segments, verify against stored and external hashes.
1360fn check_hash_all_segments(
1361    segments: &[&[u8]],
1362    all_sections: &[Vec<Section>],
1363    geom: &VolumeGeometry,
1364    expected_md5: Option<[u8; 16]>,
1365    expected_sha1: Option<[u8; 20]>,
1366    expected_sha256: Option<[u8; 32]>,
1367    issues: &mut Vec<EwfIntegrityAnomaly>,
1368    progress: &mut dyn FnMut(AnalysisProgress),
1369) {
1370    let chunk_size = u64::from(geom.sectors_per_chunk) * u64::from(geom.bytes_per_sector);
1371    let total_bytes = geom.sector_count * u64::from(geom.bytes_per_sector);
1372    let mut bytes_remaining = total_bytes;
1373
1374    let mut md5_h = Md5::new();
1375    let mut sha1_h = Sha1::new();
1376    let mut sha256_h = Sha256::new();
1377
1378    let chunk_size_usize = chunk_size as usize;
1379    let mut global_chunk_idx: usize = 0;
1380
1381    'outer: for (&seg_data, sections) in segments.iter().zip(all_sections.iter()) {
1382        for (start, end, compressed) in iter_segment_chunks(seg_data, sections) {
1383            if bytes_remaining == 0 {
1384                break 'outer;
1385            }
1386            let to_hash = bytes_remaining.min(chunk_size) as usize;
1387            let raw = &seg_data[start..end];
1388
1389            // Per-chunk Adler-32 (ewfverify parity).
1390            //
1391            // Compressed chunks are self-checksummed by the zlib stream (RFC 1950
1392            // appends its own big-endian Adler-32 internally); decompression failure
1393            // already catches corruption via the HashMismatch path.
1394            //
1395            // Uncompressed chunks MAY have a separate 4-byte little-endian Adler-32
1396            // appended by the acquisition tool. Presence is detected by
1397            // raw.len() > chunk_size (the chunk byte range includes extra bytes).
1398            let this_chunk_idx = global_chunk_idx;
1399            global_chunk_idx += 1;
1400
1401            let has_uncompressed_checksum = !compressed && (raw.len() > chunk_size_usize);
1402            if has_uncompressed_checksum && raw.len() >= chunk_size_usize + 4 {
1403                let crc_end = chunk_size_usize;
1404                let stored = u32::from_le_bytes(raw[crc_end..crc_end + 4].try_into().unwrap());
1405                let computed = adler32(&raw[..crc_end]);
1406                if computed != stored {
1407                    issues.push(EwfIntegrityAnomaly::ChunkChecksumMismatch {
1408                        chunk_index: this_chunk_idx,
1409                        computed,
1410                        stored,
1411                    });
1412                }
1413            }
1414
1415            if compressed {
1416                let limit = (to_hash as u64).saturating_add(1);
1417                let mut decompressed = Vec::with_capacity(to_hash);
1418                if ZlibDecoder::new(raw)
1419                    .take(limit)
1420                    .read_to_end(&mut decompressed)
1421                    .is_err()
1422                {
1423                    issues.push(EwfIntegrityAnomaly::ChunkDecompressionError {
1424                        chunk_index: this_chunk_idx,
1425                    });
1426                    bytes_remaining = bytes_remaining.saturating_sub(to_hash as u64);
1427                    continue;
1428                }
1429                let slice = &decompressed[..decompressed.len().min(to_hash)];
1430                md5_h.update(slice);
1431                sha1_h.update(slice);
1432                sha256_h.update(slice);
1433            } else {
1434                // For uncompressed chunks with trailing checksum, raw.len() = chunk_size + 4;
1435                // hash only the sector data (to_hash bytes), not the trailing checksum.
1436                let slice = &raw[..raw.len().min(to_hash)];
1437                md5_h.update(slice);
1438                sha1_h.update(slice);
1439                sha256_h.update(slice);
1440            }
1441            bytes_remaining = bytes_remaining.saturating_sub(to_hash as u64);
1442            progress(AnalysisProgress {
1443                chunks_done: global_chunk_idx,
1444                chunks_total: None,
1445                bytes_done: total_bytes - bytes_remaining,
1446            });
1447        }
1448    }
1449
1450    let computed_md5: [u8; 16] = md5_h.finalize().into();
1451    let computed_sha1: [u8; 20] = sha1_h.finalize().into();
1452    let computed_sha256: [u8; 32] = sha256_h.finalize().into();
1453
1454    let last_sections = match all_sections.last() {
1455        Some(s) => s,
1456        None => return,
1457    };
1458    let last_data = match segments.last() {
1459        Some(d) => d,
1460        None => return,
1461    };
1462
1463    // Stored MD5 from the EWF hash section
1464    match last_sections.iter().find(|s| s.type_name == "hash") {
1465        Some(hash_sec) => {
1466            let body_start = (hash_sec.offset as usize) + SECTION_DESCRIPTOR_SIZE;
1467            if let Some(stored_slice) = last_data.get(body_start..body_start + 16) {
1468                let stored: [u8; 16] = stored_slice.try_into().unwrap();
1469                if computed_md5 != stored {
1470                    issues.push(EwfIntegrityAnomaly::HashMismatch {
1471                        computed: computed_md5,
1472                        stored,
1473                    });
1474                }
1475            }
1476        }
1477        None => issues.push(EwfIntegrityAnomaly::HashSectionMissing),
1478    }
1479
1480    // Stored SHA-1 from the EWF digest section (layout: 16-byte MD5, then 20-byte SHA-1)
1481    if let Some(digest_sec) = last_sections.iter().find(|s| s.type_name == "digest") {
1482        let body_start = (digest_sec.offset as usize) + SECTION_DESCRIPTOR_SIZE;
1483        if let Some(sha1_slice) = last_data.get(body_start + 16..body_start + 36) {
1484            let stored: [u8; 20] = sha1_slice.try_into().unwrap();
1485            // All-zero stored SHA-1 means "not set" — skip comparison
1486            if stored != [0u8; 20] && computed_sha1 != stored {
1487                issues.push(EwfIntegrityAnomaly::DigestSha1Mismatch {
1488                    computed: computed_sha1,
1489                    stored,
1490                });
1491            }
1492        }
1493    }
1494
1495    // External reference hashes (supplied by caller, e.g. from chain of custody)
1496    if let Some(expected) = expected_md5 {
1497        if computed_md5 != expected {
1498            issues.push(EwfIntegrityAnomaly::ExternalMd5Mismatch {
1499                computed: computed_md5,
1500                expected,
1501            });
1502        }
1503    }
1504    if let Some(expected) = expected_sha1 {
1505        if computed_sha1 != expected {
1506            issues.push(EwfIntegrityAnomaly::ExternalSha1Mismatch {
1507                computed: computed_sha1,
1508                expected,
1509            });
1510        }
1511    }
1512    if let Some(expected) = expected_sha256 {
1513        if computed_sha256 != expected {
1514            issues.push(EwfIntegrityAnomaly::ExternalSha256Mismatch {
1515                computed: computed_sha256,
1516                expected,
1517            });
1518        }
1519    }
1520}
1521
1522/// Verify EWF v2 chunk data integrity and compare overall MD5 against stored value.
1523///
1524/// Chunk table entry layout (16 bytes each, starting at body offset 32):
1525///   [0..8]:   file_offset (u64 LE) — absolute position of chunk data in the file
1526///   [8..12]:  data_size (u32 LE) — raw_sector_bytes + 4 (Adler-32 trailer)
1527/// Attempt to zlib-decompress and UTF-16LE-decode a media_info section body.
1528///
1529/// Returns `true` if the body is a valid zlib stream that decodes as UTF-16LE
1530/// (with or without BOM), `false` on any failure.  An empty body is rejected.
1531fn parse_media_info_body(body: &[u8]) -> bool {
1532    if body.is_empty() {
1533        return false;
1534    }
1535    let mut decompressed = Vec::new();
1536    if ZlibDecoder::new(body).read_to_end(&mut decompressed).is_err() {
1537        return false;
1538    }
1539    // Strip BOM if present
1540    let text_bytes = if decompressed.starts_with(&[0xFF, 0xFE]) {
1541        &decompressed[2..]
1542    } else {
1543        &decompressed[..]
1544    };
1545    // Must be even-length for UTF-16LE
1546    if text_bytes.len() % 2 != 0 {
1547        return false;
1548    }
1549    let units: Vec<u16> = text_bytes
1550        .chunks_exact(2)
1551        .map(|b| u16::from_le_bytes([b[0], b[1]]))
1552        .collect();
1553    String::from_utf16(&units).is_ok()
1554}
1555
1556///   [12..16]: flags (u32 LE) — bit 0: compressed (zlib); other bits: reserved
1557///
1558/// On-disk chunk layout: [sector_data: raw_size bytes][adler32: 4 bytes][alignment pad]
1559fn verify_ewf2_sector_data(
1560    data: &[u8],
1561    ct_start: usize,
1562    ct_end: usize,
1563    stored_md5: Option<[u8; 16]>,
1564    stored_sha1: Option<[u8; 20]>,
1565    stored_sha256: Option<[u8; 32]>,
1566    issues: &mut Vec<EwfIntegrityAnomaly>,
1567    progress: &mut dyn FnMut(AnalysisProgress),
1568) -> Option<ComputedHashes> {
1569    let tbl = match data.get(ct_start..ct_end) {
1570        Some(t) => t,
1571        None => return None,
1572    };
1573    if tbl.len() < EVF2_CHUNK_TABLE_HEADER_SIZE + EVF2_CHUNK_TABLE_ENTRY_SIZE {
1574        return None;
1575    }
1576    let chunk_count = u64::from_le_bytes(tbl[8..16].try_into().unwrap()) as usize;
1577
1578    // Chunk table Adler-32: covers entries[0..chunk_count] immediately after the header.
1579    let checksum_off = EVF2_CHUNK_TABLE_HEADER_SIZE + chunk_count * EVF2_CHUNK_TABLE_ENTRY_SIZE;
1580    if checksum_off + 4 <= tbl.len() {
1581        let computed_cs = adler32(&tbl[EVF2_CHUNK_TABLE_HEADER_SIZE..checksum_off]);
1582        let stored_cs = u32::from_le_bytes(tbl[checksum_off..checksum_off + 4].try_into().unwrap());
1583        if computed_cs != stored_cs {
1584            issues.push(EwfIntegrityAnomaly::Ewf2ChunkTableChecksumMismatch {
1585                computed: computed_cs,
1586                stored: stored_cs,
1587            });
1588        }
1589    }
1590
1591    let mut md5_h   = Md5::new();
1592    let mut sha1_h  = Sha1::new();
1593    let mut sha256_h = Sha256::new();
1594
1595    for i in 0..chunk_count {
1596        let entry_off = EVF2_CHUNK_TABLE_HEADER_SIZE + i * EVF2_CHUNK_TABLE_ENTRY_SIZE;
1597        if entry_off + EVF2_CHUNK_TABLE_ENTRY_SIZE > tbl.len() {
1598            break;
1599        }
1600        let file_offset = u64::from_le_bytes(tbl[entry_off..entry_off + 8].try_into().unwrap()) as usize;
1601        let chunk_data_size = u32::from_le_bytes(tbl[entry_off + 8..entry_off + 12].try_into().unwrap()) as usize;
1602        let flags = u32::from_le_bytes(tbl[entry_off + 12..entry_off + 16].try_into().unwrap());
1603
1604        // data_size includes a 4-byte Adler-32 trailer; raw sector data precedes it.
1605        let raw_size = chunk_data_size.saturating_sub(4);
1606        let chunk_raw = match data.get(file_offset..file_offset + raw_size) {
1607            Some(r) => r,
1608            None => break,
1609        };
1610
1611        // Per-chunk Adler-32
1612        if chunk_data_size >= 4 {
1613            if let Some(crc_bytes) = data.get(file_offset + raw_size..file_offset + raw_size + 4) {
1614                let stored_crc = u32::from_le_bytes(crc_bytes.try_into().unwrap());
1615                let computed_crc = adler32(chunk_raw);
1616                if computed_crc != stored_crc {
1617                    issues.push(EwfIntegrityAnomaly::ChunkChecksumMismatch {
1618                        chunk_index: i,
1619                        computed: computed_crc,
1620                        stored: stored_crc,
1621                    });
1622                }
1623            }
1624        }
1625
1626        if flags & EVF2_CHUNK_FLAG_COMPRESSED != 0 {
1627            // Zlib-compressed chunk: decompress before hashing.
1628            let mut decompressed = Vec::with_capacity(raw_size);
1629            if ZlibDecoder::new(chunk_raw)
1630                .read_to_end(&mut decompressed)
1631                .is_err()
1632            {
1633                issues.push(EwfIntegrityAnomaly::ChunkDecompressionError { chunk_index: i });
1634                continue;
1635            }
1636            md5_h.update(&decompressed);
1637            sha1_h.update(&decompressed);
1638            sha256_h.update(&decompressed);
1639        } else {
1640            md5_h.update(chunk_raw);
1641            sha1_h.update(chunk_raw);
1642            sha256_h.update(chunk_raw);
1643        }
1644        progress(AnalysisProgress {
1645            chunks_done: i + 1,
1646            chunks_total: Some(chunk_count),
1647            bytes_done: ((i + 1) * raw_size) as u64,
1648        });
1649    }
1650
1651    let computed_md5:    [u8; 16] = md5_h.finalize().into();
1652    let computed_sha1:   [u8; 20] = sha1_h.finalize().into();
1653    let computed_sha256: [u8; 32] = sha256_h.finalize().into();
1654
1655    if let Some(stored) = stored_md5 {
1656        if computed_md5 != stored {
1657            issues.push(EwfIntegrityAnomaly::HashMismatch { computed: computed_md5, stored });
1658        }
1659    }
1660
1661    if let Some(stored) = stored_sha1 {
1662        if computed_sha1 != stored {
1663            issues.push(EwfIntegrityAnomaly::DigestSha1Mismatch {
1664                computed: computed_sha1,
1665                stored,
1666            });
1667        }
1668    }
1669
1670    if let Some(stored) = stored_sha256 {
1671        if computed_sha256 != stored {
1672            issues.push(EwfIntegrityAnomaly::DigestSha256Mismatch {
1673                computed: computed_sha256,
1674                stored,
1675            });
1676        }
1677    }
1678
1679    Some(ComputedHashes { md5: computed_md5, sha1: computed_sha1, sha256: computed_sha256 })
1680}
1681
1682/// Extract sector-data hashes from EWF v2 segments without full anomaly checking.
1683fn compute_hashes_ewf2(segments: &[&[u8]]) -> Option<ComputedHashes> {
1684    let mut md5_h   = Md5::new();
1685    let mut sha1_h  = Sha1::new();
1686    let mut sha256_h = Sha256::new();
1687    let mut found_chunks = false;
1688
1689    for &data in segments {
1690        if data.len() < EVF2_FILE_HEADER_SIZE + EVF2_SECTION_DESCRIPTOR_SIZE {
1691            continue;
1692        }
1693
1694        // Walk backward to find the chunk table section.
1695        let mut desc_offset = data.len().saturating_sub(EVF2_SECTION_DESCRIPTOR_SIZE);
1696        let mut chunk_table_body: Option<(usize, usize)> = None;
1697
1698        loop {
1699            if desc_offset + EVF2_SECTION_DESCRIPTOR_SIZE > data.len()
1700                || desc_offset < EVF2_FILE_HEADER_SIZE
1701            {
1702                break;
1703            }
1704            let desc = &data[desc_offset..desc_offset + EVF2_SECTION_DESCRIPTOR_SIZE];
1705            let section_type = u32::from_le_bytes(desc[0..4].try_into().unwrap());
1706            let data_flags   = u32::from_le_bytes(desc[4..8].try_into().unwrap());
1707            let prev_offset  = u64::from_le_bytes(desc[8..16].try_into().unwrap()) as usize;
1708            let data_size    = u64::from_le_bytes(desc[16..24].try_into().unwrap()) as usize;
1709            let body_end   = desc_offset;
1710            let body_start = desc_offset.saturating_sub(data_size);
1711
1712            if data_flags & EVF2_DATA_FLAG_ENCRYPTED == 0
1713                && section_type == EVF2_TYPE_CHUNK_TABLE
1714            {
1715                chunk_table_body = Some((body_start, body_end));
1716            }
1717
1718            if prev_offset == 0 { break; }
1719            desc_offset = prev_offset;
1720        }
1721
1722        let (ct_start, ct_end) = match chunk_table_body {
1723            Some(b) => b,
1724            None => continue,
1725        };
1726        let tbl = match data.get(ct_start..ct_end) {
1727            Some(t) => t,
1728            None => continue,
1729        };
1730        if tbl.len() < EVF2_CHUNK_TABLE_HEADER_SIZE + EVF2_CHUNK_TABLE_ENTRY_SIZE {
1731            continue;
1732        }
1733        let chunk_count = u64::from_le_bytes(tbl[8..16].try_into().unwrap()) as usize;
1734
1735        for i in 0..chunk_count {
1736            let entry_off = EVF2_CHUNK_TABLE_HEADER_SIZE + i * EVF2_CHUNK_TABLE_ENTRY_SIZE;
1737            if entry_off + EVF2_CHUNK_TABLE_ENTRY_SIZE > tbl.len() { break; }
1738            let file_offset = u64::from_le_bytes(tbl[entry_off..entry_off + 8].try_into().unwrap()) as usize;
1739            let chunk_data_size = u32::from_le_bytes(tbl[entry_off + 8..entry_off + 12].try_into().unwrap()) as usize;
1740            let flags = u32::from_le_bytes(tbl[entry_off + 12..entry_off + 16].try_into().unwrap());
1741            let raw_size = chunk_data_size.saturating_sub(4);
1742            let chunk_raw = match data.get(file_offset..file_offset + raw_size) {
1743                Some(r) => r,
1744                None => break,
1745            };
1746
1747            if flags & EVF2_CHUNK_FLAG_COMPRESSED != 0 {
1748                let mut decompressed = Vec::with_capacity(raw_size);
1749                if ZlibDecoder::new(chunk_raw)
1750                    .read_to_end(&mut decompressed)
1751                    .is_err()
1752                {
1753                    continue;
1754                }
1755                md5_h.update(&decompressed);
1756                sha1_h.update(&decompressed);
1757                sha256_h.update(&decompressed);
1758            } else {
1759                md5_h.update(chunk_raw);
1760                sha1_h.update(chunk_raw);
1761                sha256_h.update(chunk_raw);
1762            }
1763            found_chunks = true;
1764        }
1765    }
1766
1767    if !found_chunks {
1768        return None;
1769    }
1770    Some(ComputedHashes {
1771        md5:    md5_h.finalize().into(),
1772        sha1:   sha1_h.finalize().into(),
1773        sha256: sha256_h.finalize().into(),
1774    })
1775}
1776
1777/// Hash all sector data from EWF v1 segments without running anomaly checks.
1778/// This is the independent computation path for `compute_hashes()`.
1779fn compute_hashes_ewf1(segments: &[&[u8]]) -> Option<ComputedHashes> {
1780    let first = segments.first().copied()?;
1781    if first.len() < FILE_HEADER_SIZE {
1782        return None;
1783    }
1784    if first[0..8] != EVF_SIGNATURE && first[0..8] != DVF_SIGNATURE && first[0..8] != LVF_SIGNATURE {
1785        return None;
1786    }
1787
1788    let mut dummy = Vec::new();
1789    let sections_first = walk_sections_v1(first, &mut dummy);
1790    let vol_sec = sections_first
1791        .iter()
1792        .find(|s| s.type_name == "volume" || s.type_name == "disk")?;
1793    let geom = check_volume_v1(first, vol_sec.offset, vol_sec.size, &mut dummy)?;
1794
1795    let chunk_size = u64::from(geom.sectors_per_chunk) * u64::from(geom.bytes_per_sector);
1796    let total_bytes = geom.sector_count * u64::from(geom.bytes_per_sector);
1797    let mut bytes_remaining = total_bytes;
1798
1799    let mut md5_h = Md5::new();
1800    let mut sha1_h = Sha1::new();
1801    let mut sha256_h = Sha256::new();
1802
1803    let mut all_sections: Vec<Vec<Section>> = Vec::new();
1804    for &seg in segments {
1805        let mut d = Vec::new();
1806        all_sections.push(walk_sections_v1(seg, &mut d));
1807    }
1808
1809    'outer: for (&seg_data, sections) in segments.iter().zip(all_sections.iter()) {
1810        for (start, end, compressed) in iter_segment_chunks(seg_data, sections) {
1811            if bytes_remaining == 0 {
1812                break 'outer;
1813            }
1814            let to_hash = bytes_remaining.min(chunk_size) as usize;
1815            let raw = &seg_data[start..end];
1816
1817            if compressed {
1818                let limit = (to_hash as u64).saturating_add(1);
1819                let mut decompressed = Vec::with_capacity(to_hash);
1820                if ZlibDecoder::new(raw)
1821                    .take(limit)
1822                    .read_to_end(&mut decompressed)
1823                    .is_err()
1824                {
1825                    bytes_remaining = bytes_remaining.saturating_sub(to_hash as u64);
1826                    continue;
1827                }
1828                let slice = &decompressed[..decompressed.len().min(to_hash)];
1829                md5_h.update(slice);
1830                sha1_h.update(slice);
1831                sha256_h.update(slice);
1832            } else {
1833                let slice = &raw[..raw.len().min(to_hash)];
1834                md5_h.update(slice);
1835                sha1_h.update(slice);
1836                sha256_h.update(slice);
1837            }
1838            bytes_remaining = bytes_remaining.saturating_sub(to_hash as u64);
1839        }
1840    }
1841
1842    Some(ComputedHashes {
1843        md5: md5_h.finalize().into(),
1844        sha1: sha1_h.finalize().into(),
1845        sha256: sha256_h.finalize().into(),
1846    })
1847}
1848
1849pub(crate) fn adler32(data: &[u8]) -> u32 {
1850    const MOD: u32 = 65521;
1851    let mut s1: u32 = 1;
1852    let mut s2: u32 = 0;
1853    for &b in data {
1854        s1 = (s1 + u32::from(b)) % MOD;
1855        s2 = (s2 + s1) % MOD;
1856    }
1857    (s2 << 16) | s1
1858}
1859
1860
1861impl EwfIntegrityAnomaly {
1862    /// Stable, scheme-prefixed machine code for this anomaly.
1863    #[must_use]
1864    pub fn code(&self) -> &'static str {
1865        match self {
1866            Self::InvalidSignature => "EWF-INVALID-SIGNATURE",
1867            Self::SegmentNumberZero => "EWF-SEGMENT-NUMBER-ZERO",
1868            Self::SectionDescriptorCrcMismatch { .. } => "EWF-SECTION-DESCRIPTOR-CRC-MISMATCH",
1869            Self::SectionChainBroken { .. } => "EWF-SECTION-CHAIN-BROKEN",
1870            Self::SectionGapNonZero { .. } => "EWF-SECTION-GAP-NON-ZERO",
1871            Self::VolumeSectionMissing => "EWF-VOLUME-SECTION-MISSING",
1872            Self::UnknownSectionType { .. } => "EWF-UNKNOWN-SECTION-TYPE",
1873            Self::DoneSectionMissing => "EWF-DONE-SECTION-MISSING",
1874            Self::SectorsSectionMissing => "EWF-SECTORS-SECTION-MISSING",
1875            Self::TableSectionMissing => "EWF-TABLE-SECTION-MISSING",
1876            Self::ChunkSizeInvalid { .. } => "EWF-CHUNK-SIZE-INVALID",
1877            Self::SectorCountMismatch { .. } => "EWF-SECTOR-COUNT-MISMATCH",
1878            Self::BytesPerSectorInvalid { .. } => "EWF-BYTES-PER-SECTOR-INVALID",
1879            Self::TableChunkCountMismatch { .. } => "EWF-TABLE-CHUNK-COUNT-MISMATCH",
1880            Self::TableHeaderAdler32Mismatch { .. } => "EWF-TABLE-HEADER-ADLER32-MISMATCH",
1881            Self::TableEntryOutOfBounds { .. } => "EWF-TABLE-ENTRY-OUT-OF-BOUNDS",
1882            Self::TableEntryOutsideSectorsRange { .. } => "EWF-TABLE-ENTRY-OUTSIDE-SECTORS-RANGE",
1883            Self::SectionGapZero { .. } => "EWF-SECTION-GAP-ZERO",
1884            Self::HashMismatch { .. } => "EWF-HASH-MISMATCH",
1885            Self::HashSectionMissing => "EWF-HASH-SECTION-MISSING",
1886            Self::Table2Mismatch { .. } => "EWF-TABLE2-MISMATCH",
1887            Self::BadSectorsPresent { .. } => "EWF-BAD-SECTORS-PRESENT",
1888            Self::SegmentOutOfOrder { .. } => "EWF-SEGMENT-OUT-OF-ORDER",
1889            Self::DigestSha1Mismatch { .. } => "EWF-DIGEST-SHA1-MISMATCH",
1890            Self::DigestSha256Mismatch { .. } => "EWF-DIGEST-SHA256-MISMATCH",
1891            Self::ExternalMd5Mismatch { .. } => "EWF-EXTERNAL-MD5-MISMATCH",
1892            Self::ExternalSha1Mismatch { .. } => "EWF-EXTERNAL-SHA1-MISMATCH",
1893            Self::Ewf2SectionDataHashMismatch { .. } => "EWF-EWF2-SECTION-DATA-HASH-MISMATCH",
1894            Self::Ewf2EncryptedSection { .. } => "EWF-EWF2-ENCRYPTED-SECTION",
1895            Self::Ewf2HashSectionMissing => "EWF-EWF2-HASH-SECTION-MISSING",
1896            Self::VolumeBodyCrcMismatch { .. } => "EWF-VOLUME-BODY-CRC-MISMATCH",
1897            Self::MediaTypeUnknown { .. } => "EWF-MEDIA-TYPE-UNKNOWN",
1898            Self::SetIdentifierMismatch { .. } => "EWF-SET-IDENTIFIER-MISMATCH",
1899            Self::Ewf2MediaInfoMissing => "EWF-EWF2-MEDIA-INFO-MISSING",
1900            Self::Ewf2ChunkTableChecksumMismatch { .. } => "EWF-EWF2-CHUNK-TABLE-CHECKSUM-MISMATCH",
1901            Self::ChunkChecksumMismatch { .. } => "EWF-CHUNK-CHECKSUM-MISMATCH",
1902            Self::ChunkDecompressionError { .. } => "EWF-CHUNK-DECOMPRESSION-ERROR",
1903            Self::UnsupportedCompressionAlgorithm { .. } => "EWF-UNSUPPORTED-COMPRESSION-ALGORITHM",
1904            Self::ExternalSha256Mismatch { .. } => "EWF-EXTERNAL-SHA256-MISMATCH",
1905            Self::Ewf2MediaInfoParseFailed => "EWF-EWF2-MEDIA-INFO-PARSE-FAILED",
1906        }
1907    }
1908}
1909
1910impl forensicnomicon::report::Observation for EwfIntegrityAnomaly {
1911    fn severity(&self) -> Option<Severity> {
1912        Some(self.severity())
1913    }
1914    fn code(&self) -> &'static str {
1915        self.code()
1916    }
1917    fn note(&self) -> String {
1918        self.to_string()
1919    }
1920}