Skip to main content

ewf_forensic/
integrity.rs

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