Skip to main content

ewf_forensic/
integrity.rs

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