Skip to main content

ewf_forensic/
recover.rs

1//! Tolerant EWF recovery — the ewf-forensic equivalent of libewf's `ewfrecover`.
2//!
3//! [`EwfRecover`] reads a **corrupt / truncated / incomplete** EWF v1 image the
4//! way `ewfrecover` does: it recovers every readable sector and emits a flat raw
5//! copy to a **NEW** output path, zero-filling the chunks it cannot recover and
6//! reporting exactly what was recovered vs lost. It is *read-only-safe by
7//! construction* — it opens segment files read-only (memory-mapped) and writes
8//! only to the caller-provided output path, never to the source.
9//!
10//! ## Why a separate path from the reader
11//!
12//! [`ewf::EwfReader`] is a *strict* reader: a single bad chunk (failed
13//! decompression, an out-of-range table entry, a truncated segment) surfaces as
14//! an error and aborts the read. That is correct for verification, but useless
15//! for recovery — an examiner with a partially-corrupt image wants *every* good
16//! sector, not an all-or-nothing failure. `EwfRecover` instead degrades per
17//! chunk: primary `table` → `table2` fallback → zero-fill, and **never aborts
18//! the whole recovery on one bad chunk**.
19//!
20//! ## Recovery strategy (per chunk index `0..chunk_count`)
21//!
22//! 1. Locate the chunk via the segment's primary `table` section (base offset +
23//!    per-entry relative offset, bit-31 = compressed).
24//! 2. Read + decode it. If the entry is out of range, the segment is truncated
25//!    past the chunk data, or a compressed chunk fails to inflate, fall back to
26//!    the `table2` section (libewf's redundant copy) and retry.
27//! 3. If `table2` also fails (or is absent), **zero-fill** `chunk_size` bytes and
28//!    record the chunk index as lost. Continue to the next chunk.
29//!
30//! Every read is bounds-checked; there are no panics, no `unwrap`/`expect` in
31//! this module, and it inherits the crate's `unsafe`-restricted posture (the one
32//! `unsafe` site is the audited read-only mmap, shared with the integrity path).
33
34use std::fs::File;
35use std::io::{self, Read, Write};
36use std::path::{Path, PathBuf};
37
38use ewf::sections::{
39    adler32, EwfVolume, SectionDescriptor, TableEntry, TableHeader, EVF_SIGNATURE,
40    FILE_HEADER_SIZE, SECTION_DESCRIPTOR_SIZE, TABLE_HEADER_SIZE,
41};
42use flate2::read::ZlibDecoder;
43use memmap2::Mmap;
44
45/// Outcome of a recovery run — the accounting an examiner needs to defend what
46/// was and was not salvaged from a corrupt image.
47///
48/// The three recovery counts partition every chunk: each is either recovered
49/// from the primary table, recovered from the redundant `table2`, or zero-filled
50/// (lost). `chunks_recovered_primary + chunks_recovered_table2 + chunks_zero_filled
51/// == chunks_total` always holds.
52#[derive(Debug, Clone, PartialEq, Eq)]
53#[non_exhaustive]
54pub struct RecoveryReport {
55    /// Logical size of the recovered raw image in bytes (`sector_count *
56    /// bytes_per_sector`). The output file is exactly this long.
57    pub image_size: u64,
58    /// Chunk size in bytes (`sectors_per_chunk * bytes_per_sector`).
59    pub chunk_size: u64,
60    /// Total number of chunks the volume geometry declares.
61    pub chunks_total: usize,
62    /// Chunks recovered from the primary `table` section.
63    pub chunks_recovered_primary: usize,
64    /// Chunks recovered from the redundant `table2` section after the primary
65    /// entry failed.
66    pub chunks_recovered_table2: usize,
67    /// Chunks that could not be recovered from either table and were zero-filled.
68    pub chunks_zero_filled: usize,
69    /// Chunks whose sector data was physically present and emitted but whose
70    /// stored Adler-32 did not match (recoverable-but-suspect data). These are
71    /// counted among the recovered chunks — the bytes are exported, not lost —
72    /// but flagged so an examiner knows the sectors are checksum-suspect.
73    pub chunks_crc_flagged: usize,
74    /// Total logical bytes recovered from real chunk data (never counts
75    /// zero-filled regions).
76    pub bytes_recovered: u64,
77    /// Total logical bytes zero-filled for unrecoverable chunks.
78    pub bytes_zero_filled: u64,
79    /// File offset (in the source segment) at which the segment was found
80    /// truncated, if truncation was detected; `None` for an untruncated image.
81    pub truncation_offset: Option<u64>,
82    /// Indices of every chunk that was zero-filled (lost), in ascending order.
83    pub lost_chunks: Vec<usize>,
84    /// Indices of every chunk emitted with a checksum mismatch, ascending.
85    pub crc_flagged_chunks: Vec<usize>,
86}
87
88/// Read-only-safe tolerant EWF recovery.
89///
90/// Construct with [`from_path`](Self::from_path) (auto-discovers multi-segment
91/// siblings) or [`from_paths`](Self::from_paths), then call
92/// [`recover_to_raw`](Self::recover_to_raw) to emit a recovered flat image.
93pub struct EwfRecover {
94    segment_paths: Vec<PathBuf>,
95}
96
97impl EwfRecover {
98    /// Recover from a single segment or an auto-discovered multi-segment image.
99    ///
100    /// If `path` matches the EWF numbered-segment pattern (`E01`, `E02`, …) the
101    /// consecutive siblings in the same directory are discovered and included.
102    #[must_use]
103    pub fn from_path(path: impl AsRef<Path>) -> Self {
104        Self {
105            segment_paths: discover_segments(path.as_ref()),
106        }
107    }
108
109    /// Recover from an explicit ordered list of segment paths.
110    #[must_use]
111    pub fn from_paths(paths: &[impl AsRef<Path>]) -> Self {
112        Self {
113            segment_paths: paths.iter().map(|p| p.as_ref().to_path_buf()).collect(),
114        }
115    }
116
117    /// Recover the image to a flat raw file at `out_path`, returning the
118    /// [`RecoveryReport`].
119    ///
120    /// The source is never modified; `out_path` must differ from every source
121    /// segment (a caller-provided NEW path). Unreadable chunks are zero-filled so
122    /// the output always spans the full logical image.
123    ///
124    /// # Errors
125    ///
126    /// Returns [`io::Error`] if a segment cannot be opened/mapped, if the image
127    /// is too corrupt to establish geometry (no parseable volume section — a
128    /// bootstrap failure, surfaced loudly rather than as a silent empty result),
129    /// or if the output file cannot be written.
130    pub fn recover_to_raw(&self, out_path: impl AsRef<Path>) -> io::Result<RecoveryReport> {
131        if self.segment_paths.is_empty() {
132            return Err(io::Error::new(
133                io::ErrorKind::InvalidInput,
134                "no EWF segments to recover",
135            ));
136        }
137
138        // Map every segment read-only. The OS pages on demand, so large evidence
139        // files are handled without loading them into RAM.
140        let mmaps = self
141            .segment_paths
142            .iter()
143            .map(|p| {
144                let file = File::open(p)?;
145                // SAFETY: read-only mmap of an immutable evidence file, identical
146                // to the integrity path's audited mmap sites.
147                #[allow(unsafe_code)]
148                unsafe {
149                    Mmap::map(&file)
150                }
151            })
152            .collect::<io::Result<Vec<Mmap>>>()?;
153        let segments: Vec<&[u8]> = mmaps.iter().map(std::convert::AsRef::as_ref).collect();
154
155        recover_segments(&segments, out_path.as_ref())
156    }
157}
158
159/// One parsed section descriptor (type + byte range) within a segment.
160struct Section {
161    type_name: String,
162    offset: u64,
163    size: u64,
164}
165
166/// Volume geometry needed to drive recovery.
167struct Geometry {
168    chunk_count: u32,
169    sectors_per_chunk: u32,
170    bytes_per_sector: u32,
171    sector_count: u64,
172}
173
174/// Walk a segment's section-descriptor chain tolerantly, returning every parsed
175/// section plus the offset at which the chain broke off (truncation / dangling
176/// `next`), if any. Unlike the strict reader, a broken chain does not fail — the
177/// sections parsed so far are returned so their chunk data can still be
178/// recovered.
179fn walk_sections(data: &[u8]) -> (Vec<Section>, Option<u64>) {
180    let file_size = data.len() as u64;
181    let mut sections = Vec::new();
182    let mut pos = FILE_HEADER_SIZE as u64;
183    let mut truncation: Option<u64> = None;
184
185    loop {
186        let off = pos as usize;
187        if off.saturating_add(SECTION_DESCRIPTOR_SIZE) > data.len() {
188            // Not enough bytes left for another descriptor: the segment was cut
189            // mid-structure. Record where.
190            if pos < file_size {
191                truncation = Some(pos);
192            }
193            break;
194        }
195        let raw = &data[off..off.saturating_add(SECTION_DESCRIPTOR_SIZE)];
196        let Ok(desc) = SectionDescriptor::parse(raw, pos) else {
197            // cov:unreachable: the length guard above slices `raw` to exactly
198            // SECTION_DESCRIPTOR_SIZE bytes, and SectionDescriptor::parse only
199            // fails on a shorter buffer — this arm is a defensive backstop.
200            truncation = Some(pos);
201            break;
202        };
203        let next = desc.next;
204        let section_size = desc.section_size;
205        let type_name = desc.section_type;
206
207        sections.push(Section {
208            type_name: type_name.clone(),
209            offset: pos,
210            size: section_size,
211        });
212
213        if type_name == "done" || type_name == "next" {
214            break;
215        }
216
217        // A `next` that points past EOF or backwards is a broken/truncated chain.
218        if next == 0 || next <= pos {
219            break;
220        }
221        if next > file_size {
222            truncation = Some(next);
223            break;
224        }
225        pos = next;
226    }
227
228    (sections, truncation)
229}
230
231/// Extract the volume geometry from a segment's `volume`/`disk` section.
232fn read_geometry(data: &[u8], sections: &[Section]) -> Option<Geometry> {
233    let vol = sections
234        .iter()
235        .find(|s| s.type_name == "volume" || s.type_name == "disk")?;
236    let data_start = (vol.offset as usize).saturating_add(SECTION_DESCRIPTOR_SIZE);
237    let body_len = (vol.size as usize).saturating_sub(SECTION_DESCRIPTOR_SIZE);
238    let vol_end = data_start.saturating_add(body_len).min(data.len());
239    let body = data.get(data_start..vol_end)?;
240    let parsed = EwfVolume::parse(body).ok()?;
241    if parsed.sectors_per_chunk == 0 || parsed.bytes_per_sector == 0 {
242        return None;
243    }
244    Some(Geometry {
245        chunk_count: parsed.chunk_count,
246        sectors_per_chunk: parsed.sectors_per_chunk,
247        bytes_per_sector: parsed.bytes_per_sector,
248        sector_count: parsed.sector_count,
249    })
250}
251
252/// A table section's decoded header + the file offset of its entry array.
253struct TableRef {
254    entry_count: usize,
255    base_offset: u64,
256    entries_file_offset: usize,
257}
258
259/// Parse the header of a named table section (`table` or `table2`) in a segment.
260fn table_ref(data: &[u8], sections: &[Section], name: &str) -> Option<TableRef> {
261    let sec = sections.iter().find(|s| s.type_name == name)?;
262    let hdr_start = (sec.offset as usize).saturating_add(SECTION_DESCRIPTOR_SIZE);
263    let hdr = data.get(hdr_start..hdr_start.saturating_add(TABLE_HEADER_SIZE))?;
264    let header = TableHeader::parse(hdr).ok()?;
265    Some(TableRef {
266        entry_count: header.entry_count as usize,
267        base_offset: header.base_offset,
268        entries_file_offset: hdr_start.saturating_add(TABLE_HEADER_SIZE),
269    })
270}
271
272/// The `sectors` section's data end offset (for last-chunk size back-fill).
273fn sectors_data_end(sections: &[Section], data_len: usize) -> Option<usize> {
274    let sec = sections.iter().find(|s| s.type_name == "sectors")?;
275    Some((sec.offset.saturating_add(sec.size) as usize).min(data_len))
276}
277
278/// Decode one table entry (`compressed`, absolute file offset) at index `i`.
279fn entry_at(data: &[u8], t: &TableRef, i: usize) -> Option<(bool, u64)> {
280    let off = t.entries_file_offset.saturating_add(i.saturating_mul(4));
281    let bytes = data.get(off..off.saturating_add(4))?;
282    let e = TableEntry::parse(bytes).ok()?;
283    Some((
284        e.compressed,
285        t.base_offset.saturating_add(u64::from(e.chunk_offset)),
286    ))
287}
288
289/// Resolve chunk `i`'s `(start, end, compressed)` byte range from a table.
290///
291/// `end` is the next entry's start (or the sectors-data end for the last entry),
292/// mirroring the reader's boundary logic. Returns `None` when the entry (or its
293/// data) is out of range / truncated — the caller then tries the fallback table.
294fn chunk_range(
295    data: &[u8],
296    t: &TableRef,
297    i: usize,
298    sectors_end: Option<usize>,
299) -> Option<(usize, usize, bool)> {
300    let (compressed, abs) = entry_at(data, t, i)?;
301    let start = abs as usize;
302    let end = if i.saturating_add(1) < t.entry_count {
303        let (_, next_abs) = entry_at(data, t, i.saturating_add(1))?;
304        next_abs as usize
305    } else {
306        sectors_end.unwrap_or(data.len())
307    };
308    if start >= end || end > data.len() {
309        return None;
310    }
311    Some((start, end, compressed))
312}
313
314/// Decode a chunk's raw byte range into up to `chunk_size` logical bytes,
315/// returning `(bytes, crc_ok)`.
316///
317/// `None` means **no recoverable bytes exist** (a compressed stream that will
318/// not inflate) — the caller then tries `table2`, and failing that zero-fills.
319/// `Some((bytes, crc_ok))` means the sector bytes are physically present;
320/// `crc_ok == false` flags a checksum mismatch on data that is nonetheless
321/// emitted. This mirrors libewf `ewfexport`, which exports the physically-present
322/// sectors of a CRC-flagged uncompressed chunk rather than discarding them —
323/// zero-filling would throw away recoverable evidence.
324fn decode_chunk(raw: &[u8], compressed: bool, chunk_size: usize) -> Option<(Vec<u8>, bool)> {
325    if compressed {
326        let mut out = Vec::with_capacity(chunk_size.min(raw.len().saturating_mul(4).max(1)));
327        // Bound the inflate to one chunk_size (+1 to detect overrun) so a
328        // malicious/garbage stream cannot balloon memory. A compressed chunk is
329        // self-checksummed by zlib's internal Adler-32: if it inflates, the data
330        // is good (crc_ok = true); if not, there are no usable bytes → None.
331        let limit = (chunk_size as u64).saturating_add(1);
332        ZlibDecoder::new(raw)
333            .take(limit)
334            .read_to_end(&mut out)
335            .ok()?;
336        if out.is_empty() {
337            return None;
338        }
339        out.truncate(chunk_size);
340        Some((out, true))
341    } else {
342        // Uncompressed: `chunk_size` sector bytes, optionally followed by a
343        // 4-byte little-endian Adler-32 over those bytes. The bytes are present
344        // regardless of the checksum, so always emit them — only flag crc_ok.
345        let has_trailing_crc = raw.len() >= chunk_size.saturating_add(4);
346        let crc_ok = if has_trailing_crc {
347            let stored = u32::from_le_bytes([
348                raw[chunk_size],
349                raw[chunk_size + 1],
350                raw[chunk_size + 2],
351                raw[chunk_size + 3],
352            ]);
353            adler32(&raw[..chunk_size]) == stored
354        } else {
355            // No trailing CRC present (or a short final chunk): nothing to check.
356            true
357        };
358        let take = raw.len().min(chunk_size);
359        Some((raw[..take].to_vec(), crc_ok))
360    }
361}
362
363/// Which segment holds global chunk `idx`, given each segment's table entry
364/// count — plus that chunk's local index within the segment.
365fn locate_chunk(seg_entry_counts: &[usize], idx: usize) -> Option<(usize, usize)> {
366    let mut running = 0usize;
367    for (seg_idx, &count) in seg_entry_counts.iter().enumerate() {
368        if idx < running.saturating_add(count) {
369            return Some((seg_idx, idx.saturating_sub(running)));
370        }
371        running = running.saturating_add(count);
372    }
373    None
374}
375
376/// The core recovery over already-mapped segment byte slices.
377fn recover_segments(segments: &[&[u8]], out_path: &Path) -> io::Result<RecoveryReport> {
378    // Reject an image with no parseable signature/volume up front — a bootstrap
379    // failure must be loud, never a silently-empty "recovery".
380    let first = segments.first().copied().unwrap_or(&[]);
381    if first.len() < FILE_HEADER_SIZE || !first.starts_with(&EVF_SIGNATURE) {
382        return Err(io::Error::new(
383            io::ErrorKind::InvalidData,
384            format!(
385                "not an EWF v1 image: first segment is {} bytes, signature {:02x?}",
386                first.len(),
387                first
388                    .get(..FILE_HEADER_SIZE.min(first.len()))
389                    .unwrap_or(&[])
390            ),
391        ));
392    }
393
394    // Walk every segment's sections; capture the first truncation offset seen.
395    let mut all_sections: Vec<Vec<Section>> = Vec::with_capacity(segments.len());
396    let mut truncation_offset: Option<u64> = None;
397    for seg in segments {
398        let (sections, trunc) = walk_sections(seg);
399        if truncation_offset.is_none() {
400            truncation_offset = trunc;
401        }
402        all_sections.push(sections);
403    }
404
405    // Geometry comes from segment 0's volume/disk section — the bootstrap value
406    // every downstream step depends on.
407    let geom = read_geometry(first, &all_sections[0]).ok_or_else(|| {
408        io::Error::new(
409            io::ErrorKind::InvalidData,
410            "no parseable volume/disk section: cannot establish image geometry",
411        )
412    })?;
413
414    let chunk_size =
415        u64::from(geom.sectors_per_chunk).saturating_mul(u64::from(geom.bytes_per_sector));
416    let image_size = geom
417        .sector_count
418        .saturating_mul(u64::from(geom.bytes_per_sector));
419    let chunk_size_usize = chunk_size as usize;
420    let total_chunks = geom.chunk_count as usize;
421
422    // Per-segment primary/fallback table refs + sectors-data ends.
423    let mut primary: Vec<Option<TableRef>> = Vec::with_capacity(segments.len());
424    let mut fallback: Vec<Option<TableRef>> = Vec::with_capacity(segments.len());
425    let mut sec_ends: Vec<Option<usize>> = Vec::with_capacity(segments.len());
426    let mut seg_entry_counts: Vec<usize> = Vec::with_capacity(segments.len());
427    for (seg, sections) in segments.iter().zip(all_sections.iter()) {
428        let p = table_ref(seg, sections, "table");
429        let f = table_ref(seg, sections, "table2");
430        // The number of chunks this segment contributes is its primary table's
431        // entry count (table2 mirrors it); fall back to table2's count if the
432        // primary header is unreadable.
433        let count = p.as_ref().or(f.as_ref()).map_or(0, |t| t.entry_count);
434        seg_entry_counts.push(count);
435        primary.push(p);
436        fallback.push(f);
437        sec_ends.push(sectors_data_end(sections, seg.len()));
438    }
439
440    let mut out = io::BufWriter::new(File::create(out_path)?);
441
442    let mut recovered_primary = 0usize;
443    let mut recovered_table2 = 0usize;
444    let mut zero_filled = 0usize;
445    let mut crc_flagged = 0usize;
446    let mut bytes_recovered = 0u64;
447    let mut bytes_zero_filled = 0u64;
448    let mut lost_chunks: Vec<usize> = Vec::new();
449    let mut crc_flagged_chunks: Vec<usize> = Vec::new();
450
451    let mut bytes_remaining = image_size;
452
453    // Attempt to decode chunk `local` of segment `seg_idx` from a specific table.
454    let decode_from = |table: Option<&TableRef>, seg_idx: usize, local: usize| {
455        let seg = segments[seg_idx];
456        let sec_end = sec_ends[seg_idx];
457        table.and_then(|t| {
458            chunk_range(seg, t, local, sec_end)
459                .and_then(|(s, e, c)| decode_chunk(&seg[s..e], c, chunk_size_usize))
460        })
461    };
462
463    for idx in 0..total_chunks {
464        if bytes_remaining == 0 {
465            break;
466        }
467        let logical = bytes_remaining.min(chunk_size) as usize;
468
469        // (bytes, via_table2, crc_ok). Try the primary table; on a CRC-flagged or
470        // missing result, consult table2 and prefer whichever yields good data.
471        let decoded: Option<(Vec<u8>, bool, bool)> = match locate_chunk(&seg_entry_counts, idx) {
472            Some((seg_idx, local)) => {
473                let from_primary = decode_from(primary[seg_idx].as_ref(), seg_idx, local);
474                match from_primary {
475                    // Primary is good — done.
476                    Some((bytes, true)) => Some((bytes, false, true)),
477                    // Primary present but CRC-flagged, or absent: try table2.
478                    other => {
479                        let from_t2 = decode_from(fallback[seg_idx].as_ref(), seg_idx, local);
480                        // `other` here is only ever `Some((_, false))` (primary
481                        // CRC-flagged) or `None` — the primary-good case was
482                        // handled above.
483                        match (other, from_t2) {
484                            // table2 recovers good data → prefer it.
485                            (_, Some((bytes, true))) => Some((bytes, true, true)),
486                            // Keep primary's (present) bytes, flagged CRC-suspect.
487                            (Some((bytes, _)), _) => Some((bytes, false, false)),
488                            // Only table2 has (CRC-flagged) bytes.
489                            (None, Some((bytes, false))) => Some((bytes, true, false)),
490                            // Neither table yields any bytes.
491                            (None, None) => None,
492                        }
493                    }
494                }
495            }
496            None => None,
497        };
498
499        if let Some((mut bytes, via_table2, crc_ok)) = decoded {
500            // Trim/pad to the logical length this chunk backs.
501            if bytes.len() > logical {
502                bytes.truncate(logical);
503            } else if bytes.len() < logical {
504                bytes.resize(logical, 0);
505            }
506            out.write_all(&bytes)?;
507            bytes_recovered = bytes_recovered.saturating_add(logical as u64);
508            if via_table2 {
509                recovered_table2 = recovered_table2.saturating_add(1);
510            } else {
511                recovered_primary = recovered_primary.saturating_add(1);
512            }
513            if !crc_ok {
514                crc_flagged = crc_flagged.saturating_add(1);
515                crc_flagged_chunks.push(idx);
516            }
517        } else {
518            // No recoverable bytes: zero-fill this chunk's logical span.
519            write_zeros(&mut out, logical)?;
520            zero_filled = zero_filled.saturating_add(1);
521            bytes_zero_filled = bytes_zero_filled.saturating_add(logical as u64);
522            lost_chunks.push(idx);
523        }
524        bytes_remaining = bytes_remaining.saturating_sub(logical as u64);
525    }
526
527    // If the geometry's chunk count under-covers the logical size (a truncated
528    // volume can leave bytes_remaining > 0), zero-fill the rest so the output is
529    // always exactly image_size long.
530    while bytes_remaining > 0 {
531        let logical = bytes_remaining.min(chunk_size) as usize;
532        write_zeros(&mut out, logical)?;
533        bytes_zero_filled = bytes_zero_filled.saturating_add(logical as u64);
534        bytes_remaining = bytes_remaining.saturating_sub(logical as u64);
535    }
536
537    out.flush()?;
538
539    Ok(RecoveryReport {
540        image_size,
541        chunk_size,
542        chunks_total: total_chunks,
543        chunks_recovered_primary: recovered_primary,
544        chunks_recovered_table2: recovered_table2,
545        chunks_zero_filled: zero_filled,
546        chunks_crc_flagged: crc_flagged,
547        bytes_recovered,
548        bytes_zero_filled,
549        truncation_offset,
550        lost_chunks,
551        crc_flagged_chunks,
552    })
553}
554
555/// Write `n` zero bytes to `w` in bounded blocks (no huge single allocation).
556fn write_zeros(w: &mut impl Write, n: usize) -> io::Result<()> {
557    const BLOCK: usize = 8 * 1024;
558    let zeros = [0u8; BLOCK];
559    let mut left = n;
560    while left > 0 {
561        let take = left.min(BLOCK);
562        w.write_all(&zeros[..take])?;
563        left = left.saturating_sub(take);
564    }
565    Ok(())
566}
567
568/// Discover consecutive EWF v1 segment siblings (`E01`, `E02`, … `EZZ`) starting
569/// from `base`. Mirrors the discovery used by the integrity path so a first
570/// segment auto-includes its chain. If `base` has no recognisable EWF extension,
571/// only `base` itself is returned.
572fn discover_segments(base: &Path) -> Vec<PathBuf> {
573    let Some(ext) = base.extension().and_then(|e| e.to_str()) else {
574        return vec![base.to_path_buf()];
575    };
576    // Only auto-discover for the v1 `.E01` family (case-insensitive). Anything
577    // else is treated as a single explicit segment.
578    let lower = ext.to_ascii_lowercase();
579    if lower.len() != 3
580        || !lower.starts_with('e')
581        || !lower[1..].chars().all(|c| c.is_ascii_digit())
582    {
583        return vec![base.to_path_buf()];
584    }
585    let upper = ext.chars().next().is_some_and(|c| c.is_ascii_uppercase());
586    let mut out = vec![base.to_path_buf()];
587    let mut n = 2u32;
588    loop {
589        let e = if upper {
590            format!("E{n:02}")
591        } else {
592            format!("e{n:02}")
593        };
594        let candidate = base.with_extension(&e);
595        if candidate.exists() {
596            out.push(candidate);
597            n = n.saturating_add(1);
598        } else {
599            break;
600        }
601    }
602    out
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608    use flate2::write::ZlibEncoder;
609    use flate2::Compression;
610
611    const CHUNK_SIZE: usize = 32768;
612    const SECTORS_PER_CHUNK: u32 = 64;
613    const BYTES_PER_SECTOR: u32 = 512;
614
615    /// Build a single-chunk EWF v1 image (`volume`→`table`[→`table2`]→`sectors`
616    /// →`done`) carrying one compressed chunk of `data` (padded to chunk size).
617    /// If `corrupt_stream`, the compressed bytes are mangled so inflate fails.
618    /// If `add_table2`, a `table2` section is emitted mirroring `table`.
619    fn build_compressed_e01(data: &[u8], corrupt_stream: bool, add_table2: bool) -> Vec<u8> {
620        let mut padded = data.to_vec();
621        padded.resize(CHUNK_SIZE, 0);
622        let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
623        enc.write_all(&padded).unwrap();
624        let mut compressed = enc.finish().unwrap();
625        if corrupt_stream {
626            // Corrupt the middle of the zlib stream (keep the 2-byte header so it
627            // is still recognised as zlib, but the deflate body / Adler fails).
628            let mid = compressed.len() / 2;
629            compressed[mid] ^= 0xFF;
630        }
631
632        let sector_count = u64::from(CHUNK_SIZE as u32 / BYTES_PER_SECTOR);
633        let mut f = Vec::new();
634
635        // File header (13).
636        f.extend_from_slice(&EVF_SIGNATURE);
637        f.push(0x01);
638        f.extend_from_slice(&1u16.to_le_bytes());
639        f.extend_from_slice(&0u16.to_le_bytes());
640
641        // Layout offsets.
642        let vol_desc = FILE_HEADER_SIZE as u64;
643        let vol_data = vol_desc + SECTION_DESCRIPTOR_SIZE as u64;
644        let tbl_desc = vol_data + 94;
645        let tbl_hdr = tbl_desc + SECTION_DESCRIPTOR_SIZE as u64;
646        let tbl_entries = tbl_hdr + 24;
647        let after_tbl = tbl_entries + 4;
648        // Optional table2 mirrors the same header+entry.
649        let (tbl2_desc, tbl2_hdr, tbl2_entries, after_tbl2) = if add_table2 {
650            let d = after_tbl;
651            let h = d + SECTION_DESCRIPTOR_SIZE as u64;
652            let e = h + 24;
653            (Some(d), h, e, e + 4)
654        } else {
655            (None, 0, 0, after_tbl)
656        };
657        let sec_desc = after_tbl2;
658        let sec_data = sec_desc + SECTION_DESCRIPTOR_SIZE as u64;
659        let done_desc = sec_data + compressed.len() as u64;
660
661        // Volume descriptor + body.
662        let mut vd = [0u8; SECTION_DESCRIPTOR_SIZE];
663        vd[..6].copy_from_slice(b"volume");
664        vd[16..24].copy_from_slice(&tbl_desc.to_le_bytes());
665        vd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64 + 94).to_le_bytes());
666        f.extend_from_slice(&vd);
667        let mut vb = [0u8; 94];
668        vb[0..4].copy_from_slice(&1u32.to_le_bytes()); // media_type = fixed
669        vb[4..8].copy_from_slice(&1u32.to_le_bytes()); // chunk_count = 1
670        vb[8..12].copy_from_slice(&SECTORS_PER_CHUNK.to_le_bytes());
671        vb[12..16].copy_from_slice(&BYTES_PER_SECTOR.to_le_bytes());
672        vb[16..24].copy_from_slice(&sector_count.to_le_bytes());
673        f.extend_from_slice(&vb);
674
675        // Emit a table section (descriptor + 24-byte header + one 4-byte entry).
676        let emit_table = |f: &mut Vec<u8>, name: &[u8], next: u64| {
677            let mut td = [0u8; SECTION_DESCRIPTOR_SIZE];
678            td[..name.len()].copy_from_slice(name);
679            td[16..24].copy_from_slice(&next.to_le_bytes());
680            td[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64 + 24 + 4).to_le_bytes());
681            f.extend_from_slice(&td);
682            let mut th = [0u8; 24];
683            th[0..4].copy_from_slice(&1u32.to_le_bytes()); // entry_count
684            th[8..16].copy_from_slice(&sec_data.to_le_bytes()); // base_offset
685            f.extend_from_slice(&th);
686            f.extend_from_slice(&0x8000_0000u32.to_le_bytes()); // compressed, rel 0
687        };
688        emit_table(&mut f, b"table", tbl2_desc.unwrap_or(sec_desc));
689        if let Some(_d) = tbl2_desc {
690            emit_table(&mut f, b"table2", sec_desc);
691        }
692
693        // Sectors descriptor + compressed data.
694        let mut sd = [0u8; SECTION_DESCRIPTOR_SIZE];
695        sd[..7].copy_from_slice(b"sectors");
696        sd[16..24].copy_from_slice(&done_desc.to_le_bytes());
697        sd[24..32].copy_from_slice(
698            &(SECTION_DESCRIPTOR_SIZE as u64 + compressed.len() as u64).to_le_bytes(),
699        );
700        f.extend_from_slice(&sd);
701        f.extend_from_slice(&compressed);
702
703        // Done.
704        let mut dd = [0u8; SECTION_DESCRIPTOR_SIZE];
705        dd[..4].copy_from_slice(b"done");
706        dd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64).to_le_bytes());
707        f.extend_from_slice(&dd);
708
709        // Suppress unused-warnings for the table2 offset helpers.
710        let _ = (tbl2_hdr, tbl2_entries, after_tbl2);
711        f
712    }
713
714    fn recover_bytes(image: &[u8]) -> (RecoveryReport, Vec<u8>) {
715        let dir = tempfile::tempdir().unwrap();
716        let src = dir.path().join("img.E01");
717        std::fs::write(&src, image).unwrap();
718        let out = dir.path().join("out.raw");
719        let report = EwfRecover::from_path(&src).recover_to_raw(&out).unwrap();
720        let raw = std::fs::read(&out).unwrap();
721        (report, raw)
722    }
723
724    #[test]
725    fn compressed_chunk_recovers() {
726        let img = build_compressed_e01(b"hello compressed world", false, false);
727        let (report, raw) = recover_bytes(&img);
728        assert_eq!(report.chunks_total, 1);
729        assert_eq!(report.chunks_recovered_primary, 1);
730        assert_eq!(report.chunks_zero_filled, 0);
731        assert_eq!(raw.len(), CHUNK_SIZE);
732        assert_eq!(&raw[..22], b"hello compressed world");
733    }
734
735    #[test]
736    fn corrupt_compressed_chunk_zero_fills() {
737        // A compressed stream that will not inflate yields NO recoverable bytes
738        // → zero-fill (this is the compressed-path counterpart to the
739        // uncompressed CRC-flag pass-through).
740        let img = build_compressed_e01(b"data that becomes garbage", true, false);
741        let (report, raw) = recover_bytes(&img);
742        assert_eq!(report.chunks_zero_filled, 1, "broken zlib must zero-fill");
743        assert_eq!(report.lost_chunks, vec![0]);
744        assert_eq!(raw.len(), CHUNK_SIZE);
745        assert!(raw.iter().all(|&b| b == 0), "lost chunk is all zeros");
746    }
747
748    #[test]
749    fn table2_recovers_when_primary_stream_broken() {
750        // Primary `table` points at a broken stream; `table2` mirrors it — here
751        // both point at the SAME (broken) data, so the outcome is still a
752        // zero-fill, but this exercises the table2-consultation path.
753        let img = build_compressed_e01(b"x", true, true);
754        let (report, _raw) = recover_bytes(&img);
755        assert_eq!(report.chunks_zero_filled, 1);
756    }
757
758    #[test]
759    fn table2_present_clean_recovers_from_primary() {
760        let img = build_compressed_e01(b"good data via primary", false, true);
761        let (report, raw) = recover_bytes(&img);
762        assert_eq!(report.chunks_recovered_primary, 1);
763        assert_eq!(report.chunks_recovered_table2, 0);
764        assert_eq!(&raw[..21], b"good data via primary");
765    }
766
767    #[test]
768    fn from_paths_and_empty_error() {
769        // Explicit path list works.
770        let img = build_compressed_e01(b"z", false, false);
771        let dir = tempfile::tempdir().unwrap();
772        let p = dir.path().join("explicit.E01");
773        std::fs::write(&p, &img).unwrap();
774        let out = dir.path().join("o.raw");
775        let r = EwfRecover::from_paths(&[&p]).recover_to_raw(&out).unwrap();
776        assert_eq!(r.chunks_total, 1);
777
778        // Empty path list is a loud error, not a silent empty result.
779        let empty: [&Path; 0] = [];
780        let err = EwfRecover::from_paths(&empty)
781            .recover_to_raw(dir.path().join("none.raw"))
782            .unwrap_err();
783        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
784    }
785
786    #[test]
787    fn not_an_ewf_image_errors() {
788        let dir = tempfile::tempdir().unwrap();
789        let p = dir.path().join("garbage.bin");
790        std::fs::write(&p, b"not an ewf file at all").unwrap();
791        let err = EwfRecover::from_paths(&[&p])
792            .recover_to_raw(dir.path().join("o.raw"))
793            .unwrap_err();
794        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
795    }
796
797    #[test]
798    fn valid_signature_but_no_volume_errors() {
799        // Signature present, but the section chain has no volume/disk → geometry
800        // bootstrap fails loudly.
801        let mut f = Vec::new();
802        f.extend_from_slice(&EVF_SIGNATURE);
803        f.push(0x01);
804        f.extend_from_slice(&1u16.to_le_bytes());
805        f.extend_from_slice(&0u16.to_le_bytes());
806        // A lone `done` descriptor, no volume.
807        let mut dd = [0u8; SECTION_DESCRIPTOR_SIZE];
808        dd[..4].copy_from_slice(b"done");
809        dd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64).to_le_bytes());
810        f.extend_from_slice(&dd);
811        let dir = tempfile::tempdir().unwrap();
812        let p = dir.path().join("novol.E01");
813        std::fs::write(&p, &f).unwrap();
814        let err = EwfRecover::from_paths(&[&p])
815            .recover_to_raw(dir.path().join("o.raw"))
816            .unwrap_err();
817        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
818    }
819
820    #[test]
821    fn walk_sections_flags_short_descriptor_truncation() {
822        // A file header followed by fewer than 76 bytes → a descriptor cannot be
823        // read; truncation is flagged at the header end.
824        let mut f = Vec::new();
825        f.extend_from_slice(&EVF_SIGNATURE);
826        f.push(0x01);
827        f.extend_from_slice(&1u16.to_le_bytes());
828        f.extend_from_slice(&0u16.to_le_bytes());
829        f.extend_from_slice(&[0u8; 10]); // short — not a full descriptor
830        let (sections, trunc) = walk_sections(&f);
831        assert!(sections.is_empty());
832        assert_eq!(trunc, Some(FILE_HEADER_SIZE as u64));
833    }
834
835    #[test]
836    fn walk_sections_flags_next_past_eof() {
837        // A volume descriptor whose `next` points past EOF → truncation flagged
838        // at that offset.
839        let mut f = Vec::new();
840        f.extend_from_slice(&EVF_SIGNATURE);
841        f.push(0x01);
842        f.extend_from_slice(&1u16.to_le_bytes());
843        f.extend_from_slice(&0u16.to_le_bytes());
844        let mut vd = [0u8; SECTION_DESCRIPTOR_SIZE];
845        vd[..6].copy_from_slice(b"volume");
846        vd[16..24].copy_from_slice(&9_999_999u64.to_le_bytes()); // next past EOF
847        vd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64 + 94).to_le_bytes());
848        f.extend_from_slice(&vd);
849        let (sections, trunc) = walk_sections(&f);
850        assert_eq!(sections.len(), 1);
851        assert_eq!(trunc, Some(9_999_999));
852    }
853
854    #[test]
855    fn decode_uncompressed_bad_crc_still_emits() {
856        // Uncompressed chunk + wrong trailing Adler-32: bytes emitted, crc_ok=false.
857        let mut raw = vec![0xABu8; CHUNK_SIZE];
858        raw.extend_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); // wrong CRC
859        let (bytes, crc_ok) = decode_chunk(&raw, false, CHUNK_SIZE).unwrap();
860        assert_eq!(bytes.len(), CHUNK_SIZE);
861        assert!(!crc_ok);
862    }
863
864    #[test]
865    fn decode_uncompressed_good_crc_ok() {
866        let sectors = vec![0x5Au8; CHUNK_SIZE];
867        let crc = adler32(&sectors);
868        let mut raw = sectors.clone();
869        raw.extend_from_slice(&crc.to_le_bytes());
870        let (bytes, crc_ok) = decode_chunk(&raw, false, CHUNK_SIZE).unwrap();
871        assert_eq!(bytes, sectors);
872        assert!(crc_ok);
873    }
874
875    #[test]
876    fn decode_uncompressed_short_final_chunk() {
877        // A short final chunk (no trailing CRC, fewer than chunk_size bytes) is
878        // emitted verbatim with crc_ok=true.
879        let raw = vec![0x11u8; 100];
880        let (bytes, crc_ok) = decode_chunk(&raw, false, CHUNK_SIZE).unwrap();
881        assert_eq!(bytes.len(), 100);
882        assert!(crc_ok);
883    }
884
885    #[test]
886    fn locate_chunk_spans_segments() {
887        let counts = [3usize, 2, 4];
888        assert_eq!(locate_chunk(&counts, 0), Some((0, 0)));
889        assert_eq!(locate_chunk(&counts, 2), Some((0, 2)));
890        assert_eq!(locate_chunk(&counts, 3), Some((1, 0)));
891        assert_eq!(locate_chunk(&counts, 4), Some((1, 1)));
892        assert_eq!(locate_chunk(&counts, 5), Some((2, 0)));
893        assert_eq!(locate_chunk(&counts, 8), Some((2, 3)));
894        assert_eq!(locate_chunk(&counts, 9), None);
895    }
896
897    #[test]
898    fn discover_segments_non_ewf_extension_single() {
899        let p = Path::new("/tmp/whatever.bin");
900        assert_eq!(discover_segments(p), vec![p.to_path_buf()]);
901    }
902
903    #[test]
904    fn discover_segments_no_extension_single() {
905        let p = Path::new("/tmp/noext");
906        assert_eq!(discover_segments(p), vec![p.to_path_buf()]);
907    }
908
909    #[test]
910    fn discover_segments_lowercase_e01_single_when_no_siblings() {
911        let dir = tempfile::tempdir().unwrap();
912        let p = dir.path().join("img.e01");
913        std::fs::write(&p, b"x").unwrap();
914        // No e02 sibling → just the one.
915        assert_eq!(discover_segments(&p), vec![p]);
916    }
917
918    // ── direct helper coverage for the tolerant/defensive arms ───────────────
919
920    #[test]
921    fn read_geometry_rejects_zero_geometry() {
922        // A volume body with sectors_per_chunk = 0 → geometry rejected (None).
923        let mut f = Vec::new();
924        f.extend_from_slice(&EVF_SIGNATURE);
925        f.push(0x01);
926        f.extend_from_slice(&1u16.to_le_bytes());
927        f.extend_from_slice(&0u16.to_le_bytes());
928        let mut vd = [0u8; SECTION_DESCRIPTOR_SIZE];
929        vd[..6].copy_from_slice(b"volume");
930        let next = FILE_HEADER_SIZE as u64 + SECTION_DESCRIPTOR_SIZE as u64 + 94;
931        vd[16..24].copy_from_slice(&next.to_le_bytes());
932        vd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64 + 94).to_le_bytes());
933        f.extend_from_slice(&vd);
934        let mut vb = [0u8; 94];
935        vb[0..4].copy_from_slice(&1u32.to_le_bytes());
936        vb[4..8].copy_from_slice(&1u32.to_le_bytes());
937        // sectors_per_chunk left 0 → invalid
938        vb[12..16].copy_from_slice(&BYTES_PER_SECTOR.to_le_bytes());
939        f.extend_from_slice(&vb);
940        let (sections, _) = walk_sections(&f);
941        assert!(read_geometry(&f, &sections).is_none());
942    }
943
944    #[test]
945    fn chunk_range_out_of_bounds_is_none() {
946        // A single-entry table whose base_offset + rel points past the data end.
947        let data = vec![0u8; 200];
948        let t = TableRef {
949            entry_count: 1,
950            base_offset: 10_000, // past end
951            entries_file_offset: 0,
952        };
953        // The entry bytes at offset 0: compressed bit set, rel 0.
954        let mut data = data;
955        data[0..4].copy_from_slice(&0x8000_0000u32.to_le_bytes());
956        assert!(chunk_range(&data, &t, 0, Some(200)).is_none());
957    }
958
959    #[test]
960    fn decode_compressed_empty_output_is_none() {
961        // A zlib stream that inflates to zero bytes → treated as no usable data.
962        let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
963        enc.write_all(b"").unwrap();
964        let empty_stream = enc.finish().unwrap();
965        assert!(decode_chunk(&empty_stream, true, CHUNK_SIZE).is_none());
966    }
967
968    /// Build a single-uncompressed-chunk E01 where `table` points at garbage but
969    /// `table2` points at the real (good) chunk — exercising the table2-good
970    /// recovery arm. Geometry `chunk_count`/`sector_count` are caller-set to also
971    /// drive the over/under-cover zero-fill paths.
972    fn build_uncompressed_table2_good(chunk_count: u32, sector_count: u64) -> Vec<u8> {
973        let sectors = vec![0x7Eu8; CHUNK_SIZE];
974        let crc = adler32(&sectors);
975
976        let mut f = Vec::new();
977        f.extend_from_slice(&EVF_SIGNATURE);
978        f.push(0x01);
979        f.extend_from_slice(&1u16.to_le_bytes());
980        f.extend_from_slice(&0u16.to_le_bytes());
981
982        let vol_desc = FILE_HEADER_SIZE as u64;
983        let vol_data = vol_desc + SECTION_DESCRIPTOR_SIZE as u64;
984        let tbl_desc = vol_data + 94;
985        let tbl2_desc = tbl_desc + SECTION_DESCRIPTOR_SIZE as u64 + 24 + 4;
986        let sec_desc = tbl2_desc + SECTION_DESCRIPTOR_SIZE as u64 + 24 + 4;
987        let sec_data = sec_desc + SECTION_DESCRIPTOR_SIZE as u64;
988        let chunk_len = CHUNK_SIZE as u64 + 4; // sectors + trailing CRC
989        let done_desc = sec_data + chunk_len;
990
991        // Volume.
992        let mut vd = [0u8; SECTION_DESCRIPTOR_SIZE];
993        vd[..6].copy_from_slice(b"volume");
994        vd[16..24].copy_from_slice(&tbl_desc.to_le_bytes());
995        vd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64 + 94).to_le_bytes());
996        f.extend_from_slice(&vd);
997        let mut vb = [0u8; 94];
998        vb[0..4].copy_from_slice(&1u32.to_le_bytes());
999        vb[4..8].copy_from_slice(&chunk_count.to_le_bytes());
1000        vb[8..12].copy_from_slice(&SECTORS_PER_CHUNK.to_le_bytes());
1001        vb[12..16].copy_from_slice(&BYTES_PER_SECTOR.to_le_bytes());
1002        vb[16..24].copy_from_slice(&sector_count.to_le_bytes());
1003        f.extend_from_slice(&vb);
1004
1005        // table (garbage base_offset) → table2 (correct base_offset).
1006        let emit = |f: &mut Vec<u8>, name: &[u8], next: u64, base: u64| {
1007            let mut td = [0u8; SECTION_DESCRIPTOR_SIZE];
1008            td[..name.len()].copy_from_slice(name);
1009            td[16..24].copy_from_slice(&next.to_le_bytes());
1010            td[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64 + 24 + 4).to_le_bytes());
1011            f.extend_from_slice(&td);
1012            let mut th = [0u8; 24];
1013            th[0..4].copy_from_slice(&1u32.to_le_bytes());
1014            th[8..16].copy_from_slice(&base.to_le_bytes());
1015            f.extend_from_slice(&th);
1016            f.extend_from_slice(&0u32.to_le_bytes()); // uncompressed, rel 0
1017        };
1018        emit(&mut f, b"table", tbl2_desc, 9_000_000); // garbage → out of range
1019        emit(&mut f, b"table2", sec_desc, sec_data); // correct
1020
1021        // Sectors: the real chunk + trailing CRC.
1022        let mut sd = [0u8; SECTION_DESCRIPTOR_SIZE];
1023        sd[..7].copy_from_slice(b"sectors");
1024        sd[16..24].copy_from_slice(&done_desc.to_le_bytes());
1025        sd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64 + chunk_len).to_le_bytes());
1026        f.extend_from_slice(&sd);
1027        f.extend_from_slice(&sectors);
1028        f.extend_from_slice(&crc.to_le_bytes());
1029
1030        let mut dd = [0u8; SECTION_DESCRIPTOR_SIZE];
1031        dd[..4].copy_from_slice(b"done");
1032        dd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64).to_le_bytes());
1033        f.extend_from_slice(&dd);
1034        f
1035    }
1036
1037    #[test]
1038    fn table2_recovers_good_data_when_primary_out_of_range() {
1039        // 1 chunk, exact geometry: primary points out of range, table2 rescues.
1040        let img = build_uncompressed_table2_good(1, u64::from(SECTORS_PER_CHUNK));
1041        let (report, raw) = recover_bytes(&img);
1042        assert_eq!(report.chunks_recovered_table2, 1, "table2 must rescue");
1043        assert_eq!(report.chunks_recovered_primary, 0);
1044        assert_eq!(report.chunks_zero_filled, 0);
1045        assert_eq!(raw.len(), CHUNK_SIZE);
1046        assert!(raw.iter().all(|&b| b == 0x7E));
1047    }
1048
1049    #[test]
1050    fn table2_crc_flagged_when_primary_absent() {
1051        // Primary out of range → None; table2 points at present-but-CRC-suspect
1052        // uncompressed data (we corrupt the trailing Adler-32). The bytes are
1053        // still emitted, via table2, flagged CRC-suspect.
1054        let mut img = build_uncompressed_table2_good(1, u64::from(SECTORS_PER_CHUNK));
1055        // The trailing 4-byte CRC sits just before the final 76-byte `done`
1056        // descriptor. Flip it so the Adler-32 no longer matches.
1057        let crc_pos = img.len() - SECTION_DESCRIPTOR_SIZE - 4;
1058        for b in &mut img[crc_pos..crc_pos + 4] {
1059            *b ^= 0xFF;
1060        }
1061        let (report, raw) = recover_bytes(&img);
1062        assert_eq!(
1063            report.chunks_recovered_table2, 1,
1064            "table2 still supplies data"
1065        );
1066        assert_eq!(report.chunks_recovered_primary, 0);
1067        assert_eq!(
1068            report.chunks_zero_filled, 0,
1069            "present data is not zero-filled"
1070        );
1071        assert_eq!(
1072            report.chunks_crc_flagged, 1,
1073            "table2 data flagged CRC-suspect"
1074        );
1075        assert_eq!(report.crc_flagged_chunks, vec![0]);
1076        assert!(raw.iter().all(|&b| b == 0x7E));
1077    }
1078
1079    #[test]
1080    fn geometry_undercover_zero_fills_tail() {
1081        // chunk_count=1 but sector_count spans 2 chunks → after the one recovered
1082        // chunk, the post-loop zero-fills the remaining logical bytes.
1083        let img = build_uncompressed_table2_good(1, u64::from(SECTORS_PER_CHUNK) * 2);
1084        let (report, raw) = recover_bytes(&img);
1085        assert_eq!(report.image_size, (CHUNK_SIZE * 2) as u64);
1086        assert_eq!(raw.len(), CHUNK_SIZE * 2);
1087        // First chunk recovered (via table2), second half zero-filled.
1088        assert!(raw[..CHUNK_SIZE].iter().all(|&b| b == 0x7E));
1089        assert!(raw[CHUNK_SIZE..].iter().all(|&b| b == 0));
1090        assert!(report.bytes_zero_filled >= CHUNK_SIZE as u64);
1091    }
1092
1093    #[test]
1094    fn walk_sections_breaks_on_next_zero_nonterminal() {
1095        // A `volume` (non-terminal) descriptor with next == 0 → chain ends
1096        // without truncation (line 215-216 break).
1097        let mut f = Vec::new();
1098        f.extend_from_slice(&EVF_SIGNATURE);
1099        f.push(0x01);
1100        f.extend_from_slice(&1u16.to_le_bytes());
1101        f.extend_from_slice(&0u16.to_le_bytes());
1102        let mut vd = [0u8; SECTION_DESCRIPTOR_SIZE];
1103        vd[..6].copy_from_slice(b"volume");
1104        vd[16..24].copy_from_slice(&0u64.to_le_bytes()); // next = 0, non-terminal
1105        vd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64 + 94).to_le_bytes());
1106        f.extend_from_slice(&vd);
1107        f.extend_from_slice(&[0u8; 94]);
1108        let (sections, trunc) = walk_sections(&f);
1109        assert_eq!(sections.len(), 1);
1110        assert_eq!(trunc, None, "next==0 ends the chain, not a truncation");
1111    }
1112
1113    /// Build a single-chunk uncompressed E01 whose sectors region carries
1114    /// `chunk_body` bytes (which may be shorter or longer than one logical chunk)
1115    /// with a caller-chosen geometry — used to drive the trim/pad arms.
1116    fn build_uncompressed_sized(chunk_body: &[u8], sector_count: u64) -> Vec<u8> {
1117        let mut f = Vec::new();
1118        f.extend_from_slice(&EVF_SIGNATURE);
1119        f.push(0x01);
1120        f.extend_from_slice(&1u16.to_le_bytes());
1121        f.extend_from_slice(&0u16.to_le_bytes());
1122
1123        let vol_desc = FILE_HEADER_SIZE as u64;
1124        let vol_data = vol_desc + SECTION_DESCRIPTOR_SIZE as u64;
1125        let tbl_desc = vol_data + 94;
1126        let sec_desc = tbl_desc + SECTION_DESCRIPTOR_SIZE as u64 + 24 + 4;
1127        let sec_data = sec_desc + SECTION_DESCRIPTOR_SIZE as u64;
1128        let done_desc = sec_data + chunk_body.len() as u64;
1129
1130        let mut vd = [0u8; SECTION_DESCRIPTOR_SIZE];
1131        vd[..6].copy_from_slice(b"volume");
1132        vd[16..24].copy_from_slice(&tbl_desc.to_le_bytes());
1133        vd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64 + 94).to_le_bytes());
1134        f.extend_from_slice(&vd);
1135        let mut vb = [0u8; 94];
1136        vb[0..4].copy_from_slice(&1u32.to_le_bytes());
1137        vb[4..8].copy_from_slice(&1u32.to_le_bytes()); // chunk_count = 1
1138        vb[8..12].copy_from_slice(&SECTORS_PER_CHUNK.to_le_bytes());
1139        vb[12..16].copy_from_slice(&BYTES_PER_SECTOR.to_le_bytes());
1140        vb[16..24].copy_from_slice(&sector_count.to_le_bytes());
1141        f.extend_from_slice(&vb);
1142
1143        let mut td = [0u8; SECTION_DESCRIPTOR_SIZE];
1144        td[..5].copy_from_slice(b"table");
1145        td[16..24].copy_from_slice(&sec_desc.to_le_bytes());
1146        td[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64 + 24 + 4).to_le_bytes());
1147        f.extend_from_slice(&td);
1148        let mut th = [0u8; 24];
1149        th[0..4].copy_from_slice(&1u32.to_le_bytes());
1150        th[8..16].copy_from_slice(&sec_data.to_le_bytes());
1151        f.extend_from_slice(&th);
1152        f.extend_from_slice(&0u32.to_le_bytes()); // uncompressed, rel 0
1153
1154        let mut sd = [0u8; SECTION_DESCRIPTOR_SIZE];
1155        sd[..7].copy_from_slice(b"sectors");
1156        sd[16..24].copy_from_slice(&done_desc.to_le_bytes());
1157        sd[24..32].copy_from_slice(
1158            &(SECTION_DESCRIPTOR_SIZE as u64 + chunk_body.len() as u64).to_le_bytes(),
1159        );
1160        f.extend_from_slice(&sd);
1161        f.extend_from_slice(chunk_body);
1162
1163        let mut dd = [0u8; SECTION_DESCRIPTOR_SIZE];
1164        dd[..4].copy_from_slice(b"done");
1165        dd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64).to_le_bytes());
1166        f.extend_from_slice(&dd);
1167        f
1168    }
1169
1170    #[test]
1171    fn chunk_longer_than_logical_is_truncated() {
1172        // sector_count spans only 32 sectors (half a chunk) but the sectors body
1173        // holds a full chunk_size → decoded bytes (chunk_size) > logical (16384),
1174        // exercising the truncate arm.
1175        let body = vec![0x42u8; CHUNK_SIZE];
1176        let img = build_uncompressed_sized(&body, u64::from(SECTORS_PER_CHUNK) / 2);
1177        let (report, raw) = recover_bytes(&img);
1178        assert_eq!(report.image_size, (CHUNK_SIZE / 2) as u64);
1179        assert_eq!(raw.len(), CHUNK_SIZE / 2);
1180        assert!(raw.iter().all(|&b| b == 0x42));
1181    }
1182
1183    #[test]
1184    fn chunk_shorter_than_logical_is_padded() {
1185        // The sectors body holds only 100 bytes but the logical chunk is
1186        // chunk_size → decoded bytes (100) < logical, exercising the resize/pad
1187        // arm; the remainder is zero-padded.
1188        let body = vec![0x24u8; 100];
1189        let img = build_uncompressed_sized(&body, u64::from(SECTORS_PER_CHUNK));
1190        let (report, raw) = recover_bytes(&img);
1191        assert_eq!(report.image_size, CHUNK_SIZE as u64);
1192        assert_eq!(raw.len(), CHUNK_SIZE);
1193        assert!(raw[..100].iter().all(|&b| b == 0x24));
1194        assert!(
1195            raw[100..].iter().all(|&b| b == 0),
1196            "short chunk zero-padded"
1197        );
1198    }
1199
1200    #[test]
1201    fn geometry_overcover_stops_at_image_size() {
1202        // chunk_count=2 but sector_count spans only 1 chunk → the loop breaks when
1203        // bytes_remaining hits 0 before the second chunk index.
1204        let img = build_uncompressed_table2_good(2, u64::from(SECTORS_PER_CHUNK));
1205        let (report, raw) = recover_bytes(&img);
1206        assert_eq!(report.image_size, CHUNK_SIZE as u64);
1207        assert_eq!(raw.len(), CHUNK_SIZE);
1208        // Only one chunk's worth was emitted despite chunk_count=2.
1209        assert_eq!(
1210            report.chunks_recovered_primary
1211                + report.chunks_recovered_table2
1212                + report.chunks_zero_filled,
1213            1
1214        );
1215    }
1216}