Skip to main content

ewf_forensic/
integrity.rs

1use flate2::read::ZlibDecoder;
2use md5::{Digest as _, Md5};
3use sha1::{Digest as _, Sha1};
4use std::io::Read as _;
5
6// ── EWF v1 constants ─────────────────────────────────────────────────────────
7
8const EVF_SIGNATURE: [u8; 8] = [0x45, 0x56, 0x46, 0x09, 0x0d, 0x0a, 0xff, 0x00];
9const FILE_HEADER_SIZE: usize = 13;
10pub(crate) const SECTION_DESCRIPTOR_SIZE: usize = 76;
11const VOLUME_DATA_MIN: usize = 24;
12
13const KNOWN_TYPES: &[&str] = &[
14    "header", "header2", "volume", "disk", "table", "table2", "sectors", "hash", "digest",
15    "error2", "session", "done", "next", "data", "ltree", "ltreedata",
16];
17
18// ── EWF v2 constants ─────────────────────────────────────────────────────────
19
20const EVF2_SIGNATURE: [u8; 8] = [0x45, 0x56, 0x46, 0x32, 0x0d, 0x0a, 0x81, 0x00];
21const LEF2_SIGNATURE: [u8; 8] = [0x4c, 0x45, 0x46, 0x32, 0x0d, 0x0a, 0x81, 0x00];
22const EVF2_FILE_HEADER_SIZE: usize = 32;
23const EVF2_SECTION_DESCRIPTOR_SIZE: usize = 64;
24const EVF2_DATA_FLAG_ENCRYPTED: u32 = 0x0000_0002;
25const EVF2_TYPE_MD5_HASH: u32 = 0x08;
26const EVF2_TYPE_SHA1_HASH: u32 = 0x09;
27const EVF2_TYPE_DONE: u32 = 0x0F;
28const EVF2_TYPE_NEXT: u32 = 0x0D;
29
30// ── Public types ──────────────────────────────────────────────────────────────
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum Severity {
34    Info,
35    Warning,
36    Error,
37    Critical,
38}
39
40#[derive(Debug, Clone)]
41pub enum EwfIntegrityAnomaly {
42    // ── EWF v1 ───────────────────────────────────────────────────────────────
43    InvalidSignature,
44    SegmentNumberZero,
45    SectionDescriptorCrcMismatch {
46        offset: u64,
47        section_type: String,
48        computed: u32,
49        stored: u32,
50    },
51    SectionChainBroken {
52        at_offset: u64,
53        next_offset: u64,
54    },
55    SectionGapNonZero {
56        gap_offset: u64,
57        gap_size: u64,
58    },
59    VolumeSectionMissing,
60    UnknownSectionType {
61        offset: u64,
62        type_name: String,
63    },
64    DoneSectionMissing,
65    ChunkSizeInvalid {
66        sectors_per_chunk: u32,
67        bytes_per_sector: u32,
68    },
69    SectorCountMismatch {
70        declared: u64,
71        expected: u64,
72    },
73    BytesPerSectorInvalid {
74        bytes_per_sector: u32,
75    },
76    TableChunkCountMismatch {
77        in_volume: u32,
78        in_table: u32,
79    },
80    TableEntryOutOfBounds {
81        chunk_index: u32,
82        entry_offset: u64,
83        file_size: u64,
84    },
85    TableEntryOutsideSectorsRange {
86        chunk_index: u32,
87        entry_offset: u64,
88        sectors_start: u64,
89        sectors_end: u64,
90    },
91    SectionGapZero {
92        gap_offset: u64,
93        gap_size: u64,
94    },
95    HashMismatch {
96        computed: [u8; 16],
97        stored: [u8; 16],
98    },
99    HashSectionMissing,
100    // ── Multi-segment ─────────────────────────────────────────────────────────
101    /// Segment number does not match the expected sequential position.
102    SegmentOutOfOrder {
103        segment_number: u16,
104        expected: u16,
105    },
106    // ── SHA-1 from EWF v1 digest section ─────────────────────────────────────
107    /// Computed SHA-1 of all sector data does not match the stored SHA-1 in the digest section.
108    DigestSha1Mismatch {
109        computed: [u8; 20],
110        stored: [u8; 20],
111    },
112    // ── External reference hash ───────────────────────────────────────────────
113    /// Computed MD5 does not match an externally supplied reference (e.g. chain-of-custody form).
114    ExternalMd5Mismatch {
115        computed: [u8; 16],
116        expected: [u8; 16],
117    },
118    /// Computed SHA-1 does not match an externally supplied reference.
119    ExternalSha1Mismatch {
120        computed: [u8; 20],
121        expected: [u8; 20],
122    },
123    // ── EWF v2 ───────────────────────────────────────────────────────────────
124    /// A section's stored data_integrity_hash does not match MD5 of the section body.
125    Ewf2SectionDataHashMismatch {
126        offset: u64,
127        section_type_id: u32,
128        computed: [u8; 16],
129        stored: [u8; 16],
130    },
131    /// An encrypted section was found; its content cannot be verified.
132    Ewf2EncryptedSection {
133        offset: u64,
134    },
135    /// No MD5 or SHA-1 hash section found in the final EWF v2 segment.
136    Ewf2HashSectionMissing,
137}
138
139impl EwfIntegrityAnomaly {
140    pub fn severity(&self) -> Severity {
141        match self {
142            Self::InvalidSignature => Severity::Critical,
143            Self::SegmentNumberZero => Severity::Error,
144            Self::SectionDescriptorCrcMismatch { .. } => Severity::Error,
145            Self::SectionChainBroken { .. } => Severity::Critical,
146            Self::SectionGapNonZero { .. } => Severity::Warning,
147            Self::VolumeSectionMissing => Severity::Critical,
148            Self::UnknownSectionType { .. } => Severity::Warning,
149            Self::DoneSectionMissing => Severity::Warning,
150            Self::ChunkSizeInvalid { .. } => Severity::Error,
151            Self::SectorCountMismatch { .. } => Severity::Error,
152            Self::BytesPerSectorInvalid { .. } => Severity::Error,
153            Self::TableChunkCountMismatch { .. } => Severity::Error,
154            Self::TableEntryOutOfBounds { .. } => Severity::Error,
155            Self::TableEntryOutsideSectorsRange { .. } => Severity::Error,
156            Self::SectionGapZero { .. } => Severity::Info,
157            Self::HashMismatch { .. } => Severity::Error,
158            Self::HashSectionMissing => Severity::Warning,
159            Self::SegmentOutOfOrder { .. } => Severity::Error,
160            Self::DigestSha1Mismatch { .. } => Severity::Error,
161            Self::ExternalMd5Mismatch { .. } => Severity::Critical,
162            Self::ExternalSha1Mismatch { .. } => Severity::Critical,
163            Self::Ewf2SectionDataHashMismatch { .. } => Severity::Error,
164            Self::Ewf2EncryptedSection { .. } => Severity::Warning,
165            Self::Ewf2HashSectionMissing => Severity::Warning,
166        }
167    }
168}
169
170// ── Public entry point ────────────────────────────────────────────────────────
171
172pub struct EwfIntegrity<'a> {
173    segments: Vec<&'a [u8]>,
174    expected_md5: Option<[u8; 16]>,
175    expected_sha1: Option<[u8; 20]>,
176}
177
178impl<'a> EwfIntegrity<'a> {
179    /// Analyse a single-segment E01 or Ex01 file.
180    pub fn new(data: &'a [u8]) -> Self {
181        Self {
182            segments: vec![data],
183            expected_md5: None,
184            expected_sha1: None,
185        }
186    }
187
188    /// Analyse a multi-segment image. Pass segments in order: E01, E02, E03 …
189    pub fn from_segments(segs: &[&'a [u8]]) -> Self {
190        Self {
191            segments: segs.to_vec(),
192            expected_md5: None,
193            expected_sha1: None,
194        }
195    }
196
197    /// Compare the computed MD5 against an externally-sourced reference
198    /// (e.g., a chain-of-custody form). Mismatch → `ExternalMd5Mismatch` (Critical).
199    pub fn with_expected_md5(mut self, hash: [u8; 16]) -> Self {
200        self.expected_md5 = Some(hash);
201        self
202    }
203
204    /// Compare the computed SHA-1 against an externally-sourced reference.
205    /// Mismatch → `ExternalSha1Mismatch` (Critical).
206    pub fn with_expected_sha1(mut self, hash: [u8; 20]) -> Self {
207        self.expected_sha1 = Some(hash);
208        self
209    }
210
211    pub fn analyse(&self) -> Vec<EwfIntegrityAnomaly> {
212        let first = self.segments.first().copied().unwrap_or(&[]);
213        if first.len() >= 8
214            && (first[0..8] == EVF2_SIGNATURE || first[0..8] == LEF2_SIGNATURE)
215        {
216            return self.analyse_all_ewf2();
217        }
218        self.analyse_all_ewf1()
219    }
220
221    // ── EWF v1 ───────────────────────────────────────────────────────────────
222
223    fn analyse_all_ewf1(&self) -> Vec<EwfIntegrityAnomaly> {
224        let mut issues = Vec::new();
225        let n = self.segments.len();
226        let multi = n > 1;
227        let mut geometry: Option<VolumeGeometry> = None;
228        let mut all_sections: Vec<Vec<Section>> = Vec::with_capacity(n);
229
230        for (idx, &data) in self.segments.iter().enumerate() {
231            let expected_seg_num = (idx + 1) as u16;
232            let is_last = idx == n - 1;
233            let file_size = data.len() as u64;
234
235            if data.len() < FILE_HEADER_SIZE {
236                issues.push(EwfIntegrityAnomaly::SectionChainBroken {
237                    at_offset: 0,
238                    next_offset: 0,
239                });
240                all_sections.push(Vec::new());
241                continue;
242            }
243
244            if data[0..8] != EVF_SIGNATURE {
245                issues.push(EwfIntegrityAnomaly::InvalidSignature);
246            }
247
248            let seg_num = u16::from_le_bytes(data[9..11].try_into().unwrap());
249            if seg_num == 0 {
250                issues.push(EwfIntegrityAnomaly::SegmentNumberZero);
251            } else if seg_num != expected_seg_num {
252                issues.push(EwfIntegrityAnomaly::SegmentOutOfOrder {
253                    segment_number: seg_num,
254                    expected: expected_seg_num,
255                });
256            }
257
258            let sections = walk_sections_v1(data, &mut issues);
259
260            // Volume geometry — only from first segment
261            if idx == 0 {
262                match sections
263                    .iter()
264                    .find(|s| s.type_name == "volume" || s.type_name == "disk")
265                {
266                    None => issues.push(EwfIntegrityAnomaly::VolumeSectionMissing),
267                    Some(v) => geometry = check_volume_v1(data, v.offset, &mut issues),
268                }
269            }
270
271            // Table integrity — only check chunk count mismatch in single-segment mode
272            let vol_count = if !multi && idx == 0 {
273                geometry.as_ref().map(|g| g.chunk_count)
274            } else {
275                None
276            };
277            let sectors_range = sections
278                .iter()
279                .find(|s| s.type_name == "sectors")
280                .map(|s| (s.offset + SECTION_DESCRIPTOR_SIZE as u64, s.offset + s.size));
281            if let Some(table) = sections.iter().find(|s| s.type_name == "table") {
282                check_table_v1(
283                    data,
284                    table.offset,
285                    vol_count,
286                    file_size,
287                    sectors_range,
288                    &mut issues,
289                );
290            }
291
292            // Done section expected only in the last segment
293            if is_last && !sections.iter().any(|s| s.type_name == "done") {
294                issues.push(EwfIntegrityAnomaly::DoneSectionMissing);
295            }
296
297            all_sections.push(sections);
298        }
299
300        // Hash verification spans all segments
301        if let Some(geom) = &geometry {
302            check_hash_all_segments(
303                &self.segments,
304                &all_sections,
305                geom,
306                self.expected_md5,
307                self.expected_sha1,
308                &mut issues,
309            );
310        }
311
312        issues
313    }
314
315    // ── EWF v2 ───────────────────────────────────────────────────────────────
316
317    fn analyse_all_ewf2(&self) -> Vec<EwfIntegrityAnomaly> {
318        let mut issues = Vec::new();
319        let n = self.segments.len();
320
321        for (idx, &data) in self.segments.iter().enumerate() {
322            let expected_seg_num = (idx + 1) as u32;
323
324            if data.len() < EVF2_FILE_HEADER_SIZE {
325                issues.push(EwfIntegrityAnomaly::SectionChainBroken {
326                    at_offset: 0,
327                    next_offset: 0,
328                });
329                continue;
330            }
331
332            if data[0..8] != EVF2_SIGNATURE && data[0..8] != LEF2_SIGNATURE {
333                issues.push(EwfIntegrityAnomaly::InvalidSignature);
334            }
335
336            let seg_num = u32::from_le_bytes(data[12..16].try_into().unwrap());
337            if seg_num == 0 {
338                issues.push(EwfIntegrityAnomaly::SegmentNumberZero);
339            } else if seg_num != expected_seg_num {
340                issues.push(EwfIntegrityAnomaly::SegmentOutOfOrder {
341                    segment_number: seg_num as u16,
342                    expected: expected_seg_num as u16,
343                });
344            }
345
346            let mut pos = EVF2_FILE_HEADER_SIZE;
347            let mut has_hash = false;
348
349            loop {
350                if pos + EVF2_SECTION_DESCRIPTOR_SIZE > data.len() {
351                    break;
352                }
353                let desc = &data[pos..pos + EVF2_SECTION_DESCRIPTOR_SIZE];
354                let section_type = u32::from_le_bytes(desc[0..4].try_into().unwrap());
355                let data_flags = u32::from_le_bytes(desc[4..8].try_into().unwrap());
356                let data_size = u64::from_le_bytes(desc[16..24].try_into().unwrap()) as usize;
357                let padding_size = u32::from_le_bytes(desc[28..32].try_into().unwrap()) as usize;
358                let stored_hash: [u8; 16] = desc[32..48].try_into().unwrap();
359
360                let body_start = pos + EVF2_SECTION_DESCRIPTOR_SIZE;
361                let body_end = body_start.saturating_add(data_size);
362
363                if data_flags & EVF2_DATA_FLAG_ENCRYPTED != 0 {
364                    // Cannot verify encrypted content — report and skip hash check
365                    issues.push(EwfIntegrityAnomaly::Ewf2EncryptedSection {
366                        offset: pos as u64,
367                    });
368                } else if stored_hash != [0u8; 16] {
369                    // Verify section body integrity against stored MD5
370                    if let Some(body) = data.get(body_start..body_end) {
371                        let computed: [u8; 16] = Md5::digest(body).into();
372                        if computed != stored_hash {
373                            issues.push(EwfIntegrityAnomaly::Ewf2SectionDataHashMismatch {
374                                offset: pos as u64,
375                                section_type_id: section_type,
376                                computed,
377                                stored: stored_hash,
378                            });
379                        }
380                    }
381                }
382
383                if section_type == EVF2_TYPE_MD5_HASH || section_type == EVF2_TYPE_SHA1_HASH {
384                    has_hash = true;
385                }
386
387                if section_type == EVF2_TYPE_DONE || section_type == EVF2_TYPE_NEXT {
388                    break;
389                }
390
391                let next_pos = body_end.saturating_add(padding_size);
392                if next_pos <= pos {
393                    issues.push(EwfIntegrityAnomaly::SectionChainBroken {
394                        at_offset: pos as u64,
395                        next_offset: next_pos as u64,
396                    });
397                    break;
398                }
399                pos = next_pos;
400            }
401
402            if idx == n - 1 && !has_hash {
403                issues.push(EwfIntegrityAnomaly::Ewf2HashSectionMissing);
404            }
405        }
406
407        issues
408    }
409}
410
411// ── Private helpers ───────────────────────────────────────────────────────────
412
413struct Section {
414    type_name: String,
415    offset: u64,
416    size: u64,
417}
418
419struct VolumeGeometry {
420    chunk_count: u32,
421    sectors_per_chunk: u32,
422    bytes_per_sector: u32,
423    sector_count: u64,
424}
425
426fn walk_sections_v1(data: &[u8], issues: &mut Vec<EwfIntegrityAnomaly>) -> Vec<Section> {
427    let file_size = data.len() as u64;
428    let mut sections = Vec::new();
429    let mut pos = FILE_HEADER_SIZE as u64;
430
431    loop {
432        let off = pos as usize;
433        if off + SECTION_DESCRIPTOR_SIZE > data.len() {
434            break;
435        }
436        let desc = &data[off..off + SECTION_DESCRIPTOR_SIZE];
437
438        let type_end = desc[..16].iter().position(|&b| b == 0).unwrap_or(16);
439        let type_name = String::from_utf8_lossy(&desc[..type_end]).into_owned();
440
441        let stored_crc = u32::from_le_bytes(desc[72..76].try_into().unwrap());
442        let computed_crc = adler32(&desc[..72]);
443        if computed_crc != stored_crc {
444            issues.push(EwfIntegrityAnomaly::SectionDescriptorCrcMismatch {
445                offset: pos,
446                section_type: type_name.clone(),
447                computed: computed_crc,
448                stored: stored_crc,
449            });
450        }
451
452        if !KNOWN_TYPES.contains(&type_name.as_str()) {
453            issues.push(EwfIntegrityAnomaly::UnknownSectionType {
454                offset: pos,
455                type_name: type_name.clone(),
456            });
457        }
458
459        let next = u64::from_le_bytes(desc[16..24].try_into().unwrap());
460        let section_size = u64::from_le_bytes(desc[24..32].try_into().unwrap());
461        let section_end = pos.saturating_add(section_size);
462
463        sections.push(Section {
464            type_name: type_name.clone(),
465            offset: pos,
466            size: section_size,
467        });
468
469        // "done" and "next" both terminate a segment's chain
470        if type_name == "done" || type_name == "next" {
471            break;
472        }
473
474        if next == 0 || next > file_size || next <= pos {
475            issues.push(EwfIntegrityAnomaly::SectionChainBroken {
476                at_offset: pos,
477                next_offset: next,
478            });
479            break;
480        }
481
482        if next > section_end {
483            let gap_offset = section_end;
484            let gap_size = next - section_end;
485            let non_zero = data
486                .get(section_end as usize..next as usize)
487                .map(|s| s.iter().any(|&b| b != 0))
488                .unwrap_or(false);
489            if non_zero {
490                issues.push(EwfIntegrityAnomaly::SectionGapNonZero { gap_offset, gap_size });
491            } else {
492                issues.push(EwfIntegrityAnomaly::SectionGapZero { gap_offset, gap_size });
493            }
494        }
495
496        pos = next;
497    }
498
499    sections
500}
501
502fn check_volume_v1(
503    data: &[u8],
504    desc_offset: u64,
505    issues: &mut Vec<EwfIntegrityAnomaly>,
506) -> Option<VolumeGeometry> {
507    let data_start = (desc_offset as usize) + SECTION_DESCRIPTOR_SIZE;
508    if data.len() < data_start + VOLUME_DATA_MIN {
509        return None;
510    }
511    let vol = &data[data_start..];
512
513    let chunk_count = u32::from_le_bytes(vol[4..8].try_into().unwrap());
514    let sectors_per_chunk = u32::from_le_bytes(vol[8..12].try_into().unwrap());
515    let bytes_per_sector = u32::from_le_bytes(vol[12..16].try_into().unwrap());
516    let sector_count = u64::from_le_bytes(vol[16..24].try_into().unwrap());
517
518    if bytes_per_sector != 512 && bytes_per_sector != 4096 {
519        issues.push(EwfIntegrityAnomaly::BytesPerSectorInvalid { bytes_per_sector });
520    }
521    if sectors_per_chunk == 0 || !sectors_per_chunk.is_power_of_two() {
522        issues.push(EwfIntegrityAnomaly::ChunkSizeInvalid {
523            sectors_per_chunk,
524            bytes_per_sector,
525        });
526    }
527
528    let max_sectors = u64::from(chunk_count) * u64::from(sectors_per_chunk);
529    let min_sectors = max_sectors.saturating_sub(u64::from(sectors_per_chunk));
530    if sectors_per_chunk.is_power_of_two() {
531        let out_of_range =
532            sector_count > max_sectors || (chunk_count > 0 && sector_count <= min_sectors);
533        if out_of_range {
534            issues.push(EwfIntegrityAnomaly::SectorCountMismatch {
535                declared: sector_count,
536                expected: max_sectors,
537            });
538        }
539    }
540
541    Some(VolumeGeometry {
542        chunk_count,
543        sectors_per_chunk,
544        bytes_per_sector,
545        sector_count,
546    })
547}
548
549fn check_table_v1(
550    data: &[u8],
551    desc_offset: u64,
552    volume_chunk_count: Option<u32>,
553    file_size: u64,
554    sectors_range: Option<(u64, u64)>,
555    issues: &mut Vec<EwfIntegrityAnomaly>,
556) {
557    let data_start = (desc_offset as usize) + SECTION_DESCRIPTOR_SIZE;
558    if data.len() < data_start + 24 {
559        return;
560    }
561    let tbl = &data[data_start..];
562    let entry_count = u32::from_le_bytes(tbl[0..4].try_into().unwrap());
563    let base_offset = u64::from_le_bytes(tbl[8..16].try_into().unwrap());
564
565    if let Some(vol_count) = volume_chunk_count {
566        if entry_count != vol_count {
567            issues.push(EwfIntegrityAnomaly::TableChunkCountMismatch {
568                in_volume: vol_count,
569                in_table: entry_count,
570            });
571        }
572    }
573
574    let entries_start = data_start + 24;
575    for i in 0..entry_count {
576        let entry_off = entries_start + (i as usize) * 4;
577        if entry_off + 4 > data.len() {
578            break;
579        }
580        let raw = u32::from_le_bytes(data[entry_off..entry_off + 4].try_into().unwrap());
581        let chunk_rel = u64::from(raw & 0x7FFF_FFFF);
582        let absolute = base_offset.saturating_add(chunk_rel);
583        if absolute >= file_size {
584            issues.push(EwfIntegrityAnomaly::TableEntryOutOfBounds {
585                chunk_index: i,
586                entry_offset: absolute,
587                file_size,
588            });
589        } else if let Some((sec_start, sec_end)) = sectors_range {
590            if absolute < sec_start || absolute >= sec_end {
591                issues.push(EwfIntegrityAnomaly::TableEntryOutsideSectorsRange {
592                    chunk_index: i,
593                    entry_offset: absolute,
594                    sectors_start: sec_start,
595                    sectors_end: sec_end,
596                });
597            }
598        }
599    }
600}
601
602/// Extract `(chunk_start, chunk_end, compressed)` for every chunk in one segment's table.
603fn iter_segment_chunks(data: &[u8], sections: &[Section]) -> Vec<(usize, usize, bool)> {
604    let table = match sections.iter().find(|s| s.type_name == "table") {
605        Some(s) => s,
606        None => return Vec::new(),
607    };
608    let sectors = match sections.iter().find(|s| s.type_name == "sectors") {
609        Some(s) => s,
610        None => return Vec::new(),
611    };
612
613    let tbl_data_start = (table.offset as usize) + SECTION_DESCRIPTOR_SIZE;
614    if data.len() < tbl_data_start + 24 {
615        return Vec::new();
616    }
617    let tbl = &data[tbl_data_start..];
618    let entry_count = u32::from_le_bytes(tbl[0..4].try_into().unwrap()) as usize;
619    let base_offset = u64::from_le_bytes(tbl[8..16].try_into().unwrap()) as usize;
620    let entries_start = tbl_data_start + 24;
621    let sectors_body_end = (sectors.offset + sectors.size) as usize;
622
623    let mut chunks = Vec::with_capacity(entry_count);
624    for i in 0..entry_count {
625        let entry_off = entries_start + i * 4;
626        if entry_off + 4 > data.len() {
627            break;
628        }
629        let raw = u32::from_le_bytes(data[entry_off..entry_off + 4].try_into().unwrap());
630        let compressed = raw & 0x8000_0000 != 0;
631        let rel = (raw & 0x7FFF_FFFF) as usize;
632        let start = base_offset + rel;
633
634        let end = if i + 1 < entry_count {
635            let next_off = entries_start + (i + 1) * 4;
636            if next_off + 4 > data.len() {
637                break;
638            }
639            let next_raw = u32::from_le_bytes(data[next_off..next_off + 4].try_into().unwrap());
640            let next_rel = (next_raw & 0x7FFF_FFFF) as usize;
641            base_offset + next_rel
642        } else {
643            sectors_body_end.min(data.len())
644        };
645
646        if start >= end || end > data.len() {
647            break;
648        }
649        chunks.push((start, end, compressed));
650    }
651    chunks
652}
653
654/// Hash all chunk data across all segments, verify against stored and external hashes.
655fn check_hash_all_segments(
656    segments: &[&[u8]],
657    all_sections: &[Vec<Section>],
658    geom: &VolumeGeometry,
659    expected_md5: Option<[u8; 16]>,
660    expected_sha1: Option<[u8; 20]>,
661    issues: &mut Vec<EwfIntegrityAnomaly>,
662) {
663    let chunk_size = u64::from(geom.sectors_per_chunk) * u64::from(geom.bytes_per_sector);
664    let total_bytes = geom.sector_count * u64::from(geom.bytes_per_sector);
665    let mut bytes_remaining = total_bytes;
666
667    let mut md5_h = Md5::new();
668    let mut sha1_h = Sha1::new();
669
670    'outer: for (&seg_data, sections) in segments.iter().zip(all_sections.iter()) {
671        for (start, end, compressed) in iter_segment_chunks(seg_data, sections) {
672            if bytes_remaining == 0 {
673                break 'outer;
674            }
675            let to_hash = bytes_remaining.min(chunk_size) as usize;
676            let raw = &seg_data[start..end];
677
678            if compressed {
679                let limit = (to_hash as u64).saturating_add(1);
680                let mut decompressed = Vec::with_capacity(to_hash);
681                if ZlibDecoder::new(raw)
682                    .take(limit)
683                    .read_to_end(&mut decompressed)
684                    .is_err()
685                {
686                    bytes_remaining = bytes_remaining.saturating_sub(to_hash as u64);
687                    continue;
688                }
689                let slice = &decompressed[..decompressed.len().min(to_hash)];
690                md5_h.update(slice);
691                sha1_h.update(slice);
692            } else {
693                let slice = &raw[..raw.len().min(to_hash)];
694                md5_h.update(slice);
695                sha1_h.update(slice);
696            }
697            bytes_remaining = bytes_remaining.saturating_sub(to_hash as u64);
698        }
699    }
700
701    let computed_md5: [u8; 16] = md5_h.finalize().into();
702    let computed_sha1: [u8; 20] = sha1_h.finalize().into();
703
704    let last_sections = match all_sections.last() {
705        Some(s) => s,
706        None => return,
707    };
708    let last_data = match segments.last() {
709        Some(d) => d,
710        None => return,
711    };
712
713    // Stored MD5 from the EWF hash section
714    match last_sections.iter().find(|s| s.type_name == "hash") {
715        Some(hash_sec) => {
716            let body_start = (hash_sec.offset as usize) + SECTION_DESCRIPTOR_SIZE;
717            if let Some(stored_slice) = last_data.get(body_start..body_start + 16) {
718                let stored: [u8; 16] = stored_slice.try_into().unwrap();
719                if computed_md5 != stored {
720                    issues.push(EwfIntegrityAnomaly::HashMismatch {
721                        computed: computed_md5,
722                        stored,
723                    });
724                }
725            }
726        }
727        None => issues.push(EwfIntegrityAnomaly::HashSectionMissing),
728    }
729
730    // Stored SHA-1 from the EWF digest section (layout: 16-byte MD5, then 20-byte SHA-1)
731    if let Some(digest_sec) = last_sections.iter().find(|s| s.type_name == "digest") {
732        let body_start = (digest_sec.offset as usize) + SECTION_DESCRIPTOR_SIZE;
733        if let Some(sha1_slice) = last_data.get(body_start + 16..body_start + 36) {
734            let stored: [u8; 20] = sha1_slice.try_into().unwrap();
735            // All-zero stored SHA-1 means "not set" — skip comparison
736            if stored != [0u8; 20] && computed_sha1 != stored {
737                issues.push(EwfIntegrityAnomaly::DigestSha1Mismatch {
738                    computed: computed_sha1,
739                    stored,
740                });
741            }
742        }
743    }
744
745    // External reference hashes (supplied by caller, e.g. from chain of custody)
746    if let Some(expected) = expected_md5 {
747        if computed_md5 != expected {
748            issues.push(EwfIntegrityAnomaly::ExternalMd5Mismatch {
749                computed: computed_md5,
750                expected,
751            });
752        }
753    }
754    if let Some(expected) = expected_sha1 {
755        if computed_sha1 != expected {
756            issues.push(EwfIntegrityAnomaly::ExternalSha1Mismatch {
757                computed: computed_sha1,
758                expected,
759            });
760        }
761    }
762}
763
764pub(crate) fn adler32(data: &[u8]) -> u32 {
765    const MOD: u32 = 65521;
766    let mut s1: u32 = 1;
767    let mut s2: u32 = 0;
768    for &b in data {
769        s1 = (s1 + u32::from(b)) % MOD;
770        s2 = (s2 + s1) % MOD;
771    }
772    (s2 << 16) | s1
773}