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/// Ceiling on the per-chunk byte size a volume section may declare.
315///
316/// The size is `sectors_per_chunk * bytes_per_sector` and both factors are
317/// image-declared, so the product is attacker-chosen and sizes a buffer. EWF
318/// writers use 32 KiB by default and 16 MiB at the largest.
319const MAX_CHUNK_SIZE: u64 = 64 * 1024 * 1024;
320
321/// Decode a chunk's raw byte range into up to `chunk_size` logical bytes,
322/// returning `(bytes, crc_ok)`.
323///
324/// `None` means **no recoverable bytes exist** (a compressed stream that will
325/// not inflate) — the caller then tries `table2`, and failing that zero-fills.
326/// `Some((bytes, crc_ok))` means the sector bytes are physically present;
327/// `crc_ok == false` flags a checksum mismatch on data that is nonetheless
328/// emitted. This mirrors libewf `ewfexport`, which exports the physically-present
329/// sectors of a CRC-flagged uncompressed chunk rather than discarding them —
330/// zero-filling would throw away recoverable evidence.
331fn decode_chunk(raw: &[u8], compressed: bool, chunk_size: usize) -> Option<(Vec<u8>, bool)> {
332    if compressed {
333        let mut out = Vec::with_capacity(chunk_size.min(raw.len().saturating_mul(4).max(1)));
334        // Bound the inflate to one chunk_size (+1 to detect overrun) so a
335        // malicious/garbage stream cannot balloon memory. A compressed chunk is
336        // self-checksummed by zlib's internal Adler-32: if it inflates, the data
337        // is good (crc_ok = true); if not, there are no usable bytes → None.
338        let limit = (chunk_size as u64).saturating_add(1);
339        ZlibDecoder::new(raw)
340            .take(limit)
341            .read_to_end(&mut out)
342            .ok()?;
343        if out.is_empty() {
344            return None;
345        }
346        out.truncate(chunk_size);
347        Some((out, true))
348    } else {
349        // Uncompressed: `chunk_size` sector bytes, optionally followed by a
350        // 4-byte little-endian Adler-32 over those bytes. The bytes are present
351        // regardless of the checksum, so always emit them — only flag crc_ok.
352        let has_trailing_crc = raw.len() >= chunk_size.saturating_add(4);
353        let crc_ok = if has_trailing_crc {
354            let stored = u32::from_le_bytes([
355                raw[chunk_size],
356                raw[chunk_size + 1],
357                raw[chunk_size + 2],
358                raw[chunk_size + 3],
359            ]);
360            adler32(&raw[..chunk_size]) == stored
361        } else {
362            // No trailing CRC present (or a short final chunk): nothing to check.
363            true
364        };
365        let take = raw.len().min(chunk_size);
366        Some((raw[..take].to_vec(), crc_ok))
367    }
368}
369
370/// Which segment holds global chunk `idx`, given each segment's table entry
371/// count — plus that chunk's local index within the segment.
372fn locate_chunk(seg_entry_counts: &[usize], idx: usize) -> Option<(usize, usize)> {
373    let mut running = 0usize;
374    for (seg_idx, &count) in seg_entry_counts.iter().enumerate() {
375        if idx < running.saturating_add(count) {
376            return Some((seg_idx, idx.saturating_sub(running)));
377        }
378        running = running.saturating_add(count);
379    }
380    None
381}
382
383/// The core recovery over already-mapped segment byte slices.
384fn recover_segments(segments: &[&[u8]], out_path: &Path) -> io::Result<RecoveryReport> {
385    // Reject an image with no parseable signature/volume up front — a bootstrap
386    // failure must be loud, never a silently-empty "recovery".
387    let first = segments.first().copied().unwrap_or(&[]);
388    if first.len() < FILE_HEADER_SIZE || !first.starts_with(&EVF_SIGNATURE) {
389        return Err(io::Error::new(
390            io::ErrorKind::InvalidData,
391            format!(
392                "not an EWF v1 image: first segment is {} bytes, signature {:02x?}",
393                first.len(),
394                first
395                    .get(..FILE_HEADER_SIZE.min(first.len()))
396                    .unwrap_or(&[])
397            ),
398        ));
399    }
400
401    // Walk every segment's sections; capture the first truncation offset seen.
402    let mut all_sections: Vec<Vec<Section>> = Vec::with_capacity(segments.len());
403    let mut truncation_offset: Option<u64> = None;
404    for seg in segments {
405        let (sections, trunc) = walk_sections(seg);
406        if truncation_offset.is_none() {
407            truncation_offset = trunc;
408        }
409        all_sections.push(sections);
410    }
411
412    // Geometry comes from segment 0's volume/disk section — the bootstrap value
413    // every downstream step depends on.
414    let geom = read_geometry(first, &all_sections[0]).ok_or_else(|| {
415        io::Error::new(
416            io::ErrorKind::InvalidData,
417            "no parseable volume/disk section: cannot establish image geometry",
418        )
419    })?;
420
421    let chunk_size =
422        u64::from(geom.sectors_per_chunk).saturating_mul(u64::from(geom.bytes_per_sector));
423    // Both factors come from the volume section, so their product does too, and
424    // it sizes the per-chunk buffer below (`bytes.resize(logical, 0)`).
425    // `saturating_mul` keeps it from wrapping but still yields a number near
426    // u64::MAX, which is then handed to the allocator -- observed as a request
427    // for 2.8 exabytes. EnCase writes 64 x 512 = 32 KiB by default and at most
428    // 32768 x 512 = 16 MiB, so this ceiling is generous headroom.
429    if chunk_size == 0 || chunk_size > MAX_CHUNK_SIZE {
430        return Err(io::Error::new(
431            io::ErrorKind::InvalidData,
432            format!(
433                "volume declares {} sectors/chunk x {} bytes/sector = {chunk_size} bytes per \
434                 chunk, outside the plausible range 1..={MAX_CHUNK_SIZE}",
435                geom.sectors_per_chunk, geom.bytes_per_sector
436            ),
437        ));
438    }
439    let image_size = geom
440        .sector_count
441        .saturating_mul(u64::from(geom.bytes_per_sector));
442    let chunk_size_usize = chunk_size as usize;
443    let total_chunks = geom.chunk_count as usize;
444
445    // Per-segment primary/fallback table refs + sectors-data ends.
446    let mut primary: Vec<Option<TableRef>> = Vec::with_capacity(segments.len());
447    let mut fallback: Vec<Option<TableRef>> = Vec::with_capacity(segments.len());
448    let mut sec_ends: Vec<Option<usize>> = Vec::with_capacity(segments.len());
449    let mut seg_entry_counts: Vec<usize> = Vec::with_capacity(segments.len());
450    for (seg, sections) in segments.iter().zip(all_sections.iter()) {
451        let p = table_ref(seg, sections, "table");
452        let f = table_ref(seg, sections, "table2");
453        // The number of chunks this segment contributes is its primary table's
454        // entry count (table2 mirrors it); fall back to table2's count if the
455        // primary header is unreadable.
456        let count = p.as_ref().or(f.as_ref()).map_or(0, |t| t.entry_count);
457        seg_entry_counts.push(count);
458        primary.push(p);
459        fallback.push(f);
460        sec_ends.push(sectors_data_end(sections, seg.len()));
461    }
462
463    let mut out = io::BufWriter::new(File::create(out_path)?);
464
465    let mut recovered_primary = 0usize;
466    let mut recovered_table2 = 0usize;
467    let mut zero_filled = 0usize;
468    let mut crc_flagged = 0usize;
469    let mut bytes_recovered = 0u64;
470    let mut bytes_zero_filled = 0u64;
471    let mut lost_chunks: Vec<usize> = Vec::new();
472    let mut crc_flagged_chunks: Vec<usize> = Vec::new();
473
474    let mut bytes_remaining = image_size;
475
476    // Attempt to decode chunk `local` of segment `seg_idx` from a specific table.
477    let decode_from = |table: Option<&TableRef>, seg_idx: usize, local: usize| {
478        let seg = segments[seg_idx];
479        let sec_end = sec_ends[seg_idx];
480        table.and_then(|t| {
481            chunk_range(seg, t, local, sec_end)
482                .and_then(|(s, e, c)| decode_chunk(&seg[s..e], c, chunk_size_usize))
483        })
484    };
485
486    for idx in 0..total_chunks {
487        if bytes_remaining == 0 {
488            break;
489        }
490        let logical = bytes_remaining.min(chunk_size) as usize;
491
492        // (bytes, via_table2, crc_ok). Try the primary table; on a CRC-flagged or
493        // missing result, consult table2 and prefer whichever yields good data.
494        let decoded: Option<(Vec<u8>, bool, bool)> = match locate_chunk(&seg_entry_counts, idx) {
495            Some((seg_idx, local)) => {
496                let from_primary = decode_from(primary[seg_idx].as_ref(), seg_idx, local);
497                match from_primary {
498                    // Primary is good — done.
499                    Some((bytes, true)) => Some((bytes, false, true)),
500                    // Primary present but CRC-flagged, or absent: try table2.
501                    other => {
502                        let from_t2 = decode_from(fallback[seg_idx].as_ref(), seg_idx, local);
503                        // `other` here is only ever `Some((_, false))` (primary
504                        // CRC-flagged) or `None` — the primary-good case was
505                        // handled above.
506                        match (other, from_t2) {
507                            // table2 recovers good data → prefer it.
508                            (_, Some((bytes, true))) => Some((bytes, true, true)),
509                            // Keep primary's (present) bytes, flagged CRC-suspect.
510                            (Some((bytes, _)), _) => Some((bytes, false, false)),
511                            // Only table2 has (CRC-flagged) bytes.
512                            (None, Some((bytes, false))) => Some((bytes, true, false)),
513                            // Neither table yields any bytes.
514                            (None, None) => None,
515                        }
516                    }
517                }
518            }
519            None => None,
520        };
521
522        if let Some((mut bytes, via_table2, crc_ok)) = decoded {
523            // Trim/pad to the logical length this chunk backs.
524            if bytes.len() > logical {
525                bytes.truncate(logical);
526            } else if bytes.len() < logical {
527                bytes.resize(logical, 0);
528            }
529            out.write_all(&bytes)?;
530            bytes_recovered = bytes_recovered.saturating_add(logical as u64);
531            if via_table2 {
532                recovered_table2 = recovered_table2.saturating_add(1);
533            } else {
534                recovered_primary = recovered_primary.saturating_add(1);
535            }
536            if !crc_ok {
537                crc_flagged = crc_flagged.saturating_add(1);
538                crc_flagged_chunks.push(idx);
539            }
540        } else {
541            // No recoverable bytes: zero-fill this chunk's logical span.
542            write_zeros(&mut out, logical)?;
543            zero_filled = zero_filled.saturating_add(1);
544            bytes_zero_filled = bytes_zero_filled.saturating_add(logical as u64);
545            lost_chunks.push(idx);
546        }
547        bytes_remaining = bytes_remaining.saturating_sub(logical as u64);
548    }
549
550    // If the geometry's chunk count under-covers the logical size (a truncated
551    // volume can leave bytes_remaining > 0), zero-fill the rest so the output is
552    // always exactly image_size long.
553    while bytes_remaining > 0 {
554        let logical = bytes_remaining.min(chunk_size) as usize;
555        write_zeros(&mut out, logical)?;
556        bytes_zero_filled = bytes_zero_filled.saturating_add(logical as u64);
557        bytes_remaining = bytes_remaining.saturating_sub(logical as u64);
558    }
559
560    out.flush()?;
561
562    Ok(RecoveryReport {
563        image_size,
564        chunk_size,
565        chunks_total: total_chunks,
566        chunks_recovered_primary: recovered_primary,
567        chunks_recovered_table2: recovered_table2,
568        chunks_zero_filled: zero_filled,
569        chunks_crc_flagged: crc_flagged,
570        bytes_recovered,
571        bytes_zero_filled,
572        truncation_offset,
573        lost_chunks,
574        crc_flagged_chunks,
575    })
576}
577
578/// Write `n` zero bytes to `w` in bounded blocks (no huge single allocation).
579fn write_zeros(w: &mut impl Write, n: usize) -> io::Result<()> {
580    const BLOCK: usize = 8 * 1024;
581    let zeros = [0u8; BLOCK];
582    let mut left = n;
583    while left > 0 {
584        let take = left.min(BLOCK);
585        w.write_all(&zeros[..take])?;
586        left = left.saturating_sub(take);
587    }
588    Ok(())
589}
590
591/// Discover consecutive EWF v1 segment siblings (`E01`, `E02`, … `EZZ`) starting
592/// from `base`. Mirrors the discovery used by the integrity path so a first
593/// segment auto-includes its chain. If `base` has no recognisable EWF extension,
594/// only `base` itself is returned.
595fn discover_segments(base: &Path) -> Vec<PathBuf> {
596    let Some(ext) = base.extension().and_then(|e| e.to_str()) else {
597        return vec![base.to_path_buf()];
598    };
599    // Only auto-discover for the v1 `.E01` family (case-insensitive). Anything
600    // else is treated as a single explicit segment.
601    let lower = ext.to_ascii_lowercase();
602    if lower.len() != 3
603        || !lower.starts_with('e')
604        || !lower[1..].chars().all(|c| c.is_ascii_digit())
605    {
606        return vec![base.to_path_buf()];
607    }
608    let upper = ext.chars().next().is_some_and(|c| c.is_ascii_uppercase());
609    let mut out = vec![base.to_path_buf()];
610    let mut n = 2u32;
611    loop {
612        let e = if upper {
613            format!("E{n:02}")
614        } else {
615            format!("e{n:02}")
616        };
617        let candidate = base.with_extension(&e);
618        if candidate.exists() {
619            out.push(candidate);
620            n = n.saturating_add(1);
621        } else {
622            break;
623        }
624    }
625    out
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631    use flate2::write::ZlibEncoder;
632    use flate2::Compression;
633
634    const CHUNK_SIZE: usize = 32768;
635    const SECTORS_PER_CHUNK: u32 = 64;
636    const BYTES_PER_SECTOR: u32 = 512;
637
638    /// Build a single-chunk EWF v1 image (`volume`→`table`[→`table2`]→`sectors`
639    /// →`done`) carrying one compressed chunk of `data` (padded to chunk size).
640    /// If `corrupt_stream`, the compressed bytes are mangled so inflate fails.
641    /// If `add_table2`, a `table2` section is emitted mirroring `table`.
642    fn build_compressed_e01(data: &[u8], corrupt_stream: bool, add_table2: bool) -> Vec<u8> {
643        let mut padded = data.to_vec();
644        padded.resize(CHUNK_SIZE, 0);
645        let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
646        enc.write_all(&padded).unwrap();
647        let mut compressed = enc.finish().unwrap();
648        if corrupt_stream {
649            // Corrupt the middle of the zlib stream (keep the 2-byte header so it
650            // is still recognised as zlib, but the deflate body / Adler fails).
651            let mid = compressed.len() / 2;
652            compressed[mid] ^= 0xFF;
653        }
654
655        let sector_count = u64::from(CHUNK_SIZE as u32 / BYTES_PER_SECTOR);
656        let mut f = Vec::new();
657
658        // File header (13).
659        f.extend_from_slice(&EVF_SIGNATURE);
660        f.push(0x01);
661        f.extend_from_slice(&1u16.to_le_bytes());
662        f.extend_from_slice(&0u16.to_le_bytes());
663
664        // Layout offsets.
665        let vol_desc = FILE_HEADER_SIZE as u64;
666        let vol_data = vol_desc + SECTION_DESCRIPTOR_SIZE as u64;
667        let tbl_desc = vol_data + 94;
668        let tbl_hdr = tbl_desc + SECTION_DESCRIPTOR_SIZE as u64;
669        let tbl_entries = tbl_hdr + 24;
670        let after_tbl = tbl_entries + 4;
671        // Optional table2 mirrors the same header+entry.
672        let (tbl2_desc, tbl2_hdr, tbl2_entries, after_tbl2) = if add_table2 {
673            let d = after_tbl;
674            let h = d + SECTION_DESCRIPTOR_SIZE as u64;
675            let e = h + 24;
676            (Some(d), h, e, e + 4)
677        } else {
678            (None, 0, 0, after_tbl)
679        };
680        let sec_desc = after_tbl2;
681        let sec_data = sec_desc + SECTION_DESCRIPTOR_SIZE as u64;
682        let done_desc = sec_data + compressed.len() as u64;
683
684        // Volume descriptor + body.
685        let mut vd = [0u8; SECTION_DESCRIPTOR_SIZE];
686        vd[..6].copy_from_slice(b"volume");
687        vd[16..24].copy_from_slice(&tbl_desc.to_le_bytes());
688        vd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64 + 94).to_le_bytes());
689        f.extend_from_slice(&vd);
690        let mut vb = [0u8; 94];
691        vb[0..4].copy_from_slice(&1u32.to_le_bytes()); // media_type = fixed
692        vb[4..8].copy_from_slice(&1u32.to_le_bytes()); // chunk_count = 1
693        vb[8..12].copy_from_slice(&SECTORS_PER_CHUNK.to_le_bytes());
694        vb[12..16].copy_from_slice(&BYTES_PER_SECTOR.to_le_bytes());
695        vb[16..24].copy_from_slice(&sector_count.to_le_bytes());
696        f.extend_from_slice(&vb);
697
698        // Emit a table section (descriptor + 24-byte header + one 4-byte entry).
699        let emit_table = |f: &mut Vec<u8>, name: &[u8], next: u64| {
700            let mut td = [0u8; SECTION_DESCRIPTOR_SIZE];
701            td[..name.len()].copy_from_slice(name);
702            td[16..24].copy_from_slice(&next.to_le_bytes());
703            td[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64 + 24 + 4).to_le_bytes());
704            f.extend_from_slice(&td);
705            let mut th = [0u8; 24];
706            th[0..4].copy_from_slice(&1u32.to_le_bytes()); // entry_count
707            th[8..16].copy_from_slice(&sec_data.to_le_bytes()); // base_offset
708            f.extend_from_slice(&th);
709            f.extend_from_slice(&0x8000_0000u32.to_le_bytes()); // compressed, rel 0
710        };
711        emit_table(&mut f, b"table", tbl2_desc.unwrap_or(sec_desc));
712        if let Some(_d) = tbl2_desc {
713            emit_table(&mut f, b"table2", sec_desc);
714        }
715
716        // Sectors descriptor + compressed data.
717        let mut sd = [0u8; SECTION_DESCRIPTOR_SIZE];
718        sd[..7].copy_from_slice(b"sectors");
719        sd[16..24].copy_from_slice(&done_desc.to_le_bytes());
720        sd[24..32].copy_from_slice(
721            &(SECTION_DESCRIPTOR_SIZE as u64 + compressed.len() as u64).to_le_bytes(),
722        );
723        f.extend_from_slice(&sd);
724        f.extend_from_slice(&compressed);
725
726        // Done.
727        let mut dd = [0u8; SECTION_DESCRIPTOR_SIZE];
728        dd[..4].copy_from_slice(b"done");
729        dd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64).to_le_bytes());
730        f.extend_from_slice(&dd);
731
732        // Suppress unused-warnings for the table2 offset helpers.
733        let _ = (tbl2_hdr, tbl2_entries, after_tbl2);
734        f
735    }
736
737    fn recover_bytes(image: &[u8]) -> (RecoveryReport, Vec<u8>) {
738        let dir = tempfile::tempdir().unwrap();
739        let src = dir.path().join("img.E01");
740        std::fs::write(&src, image).unwrap();
741        let out = dir.path().join("out.raw");
742        let report = EwfRecover::from_path(&src).recover_to_raw(&out).unwrap();
743        let raw = std::fs::read(&out).unwrap();
744        (report, raw)
745    }
746
747    #[test]
748    fn compressed_chunk_recovers() {
749        let img = build_compressed_e01(b"hello compressed world", false, false);
750        let (report, raw) = recover_bytes(&img);
751        assert_eq!(report.chunks_total, 1);
752        assert_eq!(report.chunks_recovered_primary, 1);
753        assert_eq!(report.chunks_zero_filled, 0);
754        assert_eq!(raw.len(), CHUNK_SIZE);
755        assert_eq!(&raw[..22], b"hello compressed world");
756    }
757
758    #[test]
759    fn corrupt_compressed_chunk_zero_fills() {
760        // A compressed stream that will not inflate yields NO recoverable bytes
761        // → zero-fill (this is the compressed-path counterpart to the
762        // uncompressed CRC-flag pass-through).
763        let img = build_compressed_e01(b"data that becomes garbage", true, false);
764        let (report, raw) = recover_bytes(&img);
765        assert_eq!(report.chunks_zero_filled, 1, "broken zlib must zero-fill");
766        assert_eq!(report.lost_chunks, vec![0]);
767        assert_eq!(raw.len(), CHUNK_SIZE);
768        assert!(raw.iter().all(|&b| b == 0), "lost chunk is all zeros");
769    }
770
771    #[test]
772    fn table2_recovers_when_primary_stream_broken() {
773        // Primary `table` points at a broken stream; `table2` mirrors it — here
774        // both point at the SAME (broken) data, so the outcome is still a
775        // zero-fill, but this exercises the table2-consultation path.
776        let img = build_compressed_e01(b"x", true, true);
777        let (report, _raw) = recover_bytes(&img);
778        assert_eq!(report.chunks_zero_filled, 1);
779    }
780
781    #[test]
782    fn table2_present_clean_recovers_from_primary() {
783        let img = build_compressed_e01(b"good data via primary", false, true);
784        let (report, raw) = recover_bytes(&img);
785        assert_eq!(report.chunks_recovered_primary, 1);
786        assert_eq!(report.chunks_recovered_table2, 0);
787        assert_eq!(&raw[..21], b"good data via primary");
788    }
789
790    #[test]
791    fn from_paths_and_empty_error() {
792        // Explicit path list works.
793        let img = build_compressed_e01(b"z", false, false);
794        let dir = tempfile::tempdir().unwrap();
795        let p = dir.path().join("explicit.E01");
796        std::fs::write(&p, &img).unwrap();
797        let out = dir.path().join("o.raw");
798        let r = EwfRecover::from_paths(&[&p]).recover_to_raw(&out).unwrap();
799        assert_eq!(r.chunks_total, 1);
800
801        // Empty path list is a loud error, not a silent empty result.
802        let empty: [&Path; 0] = [];
803        let err = EwfRecover::from_paths(&empty)
804            .recover_to_raw(dir.path().join("none.raw"))
805            .unwrap_err();
806        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
807    }
808
809    #[test]
810    fn not_an_ewf_image_errors() {
811        let dir = tempfile::tempdir().unwrap();
812        let p = dir.path().join("garbage.bin");
813        std::fs::write(&p, b"not an ewf file at all").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 valid_signature_but_no_volume_errors() {
822        // Signature present, but the section chain has no volume/disk → geometry
823        // bootstrap fails loudly.
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        // A lone `done` descriptor, no volume.
830        let mut dd = [0u8; SECTION_DESCRIPTOR_SIZE];
831        dd[..4].copy_from_slice(b"done");
832        dd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64).to_le_bytes());
833        f.extend_from_slice(&dd);
834        let dir = tempfile::tempdir().unwrap();
835        let p = dir.path().join("novol.E01");
836        std::fs::write(&p, &f).unwrap();
837        let err = EwfRecover::from_paths(&[&p])
838            .recover_to_raw(dir.path().join("o.raw"))
839            .unwrap_err();
840        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
841    }
842
843    #[test]
844    fn walk_sections_flags_short_descriptor_truncation() {
845        // A file header followed by fewer than 76 bytes → a descriptor cannot be
846        // read; truncation is flagged at the header end.
847        let mut f = Vec::new();
848        f.extend_from_slice(&EVF_SIGNATURE);
849        f.push(0x01);
850        f.extend_from_slice(&1u16.to_le_bytes());
851        f.extend_from_slice(&0u16.to_le_bytes());
852        f.extend_from_slice(&[0u8; 10]); // short — not a full descriptor
853        let (sections, trunc) = walk_sections(&f);
854        assert!(sections.is_empty());
855        assert_eq!(trunc, Some(FILE_HEADER_SIZE as u64));
856    }
857
858    #[test]
859    fn walk_sections_flags_next_past_eof() {
860        // A volume descriptor whose `next` points past EOF → truncation flagged
861        // at that offset.
862        let mut f = Vec::new();
863        f.extend_from_slice(&EVF_SIGNATURE);
864        f.push(0x01);
865        f.extend_from_slice(&1u16.to_le_bytes());
866        f.extend_from_slice(&0u16.to_le_bytes());
867        let mut vd = [0u8; SECTION_DESCRIPTOR_SIZE];
868        vd[..6].copy_from_slice(b"volume");
869        vd[16..24].copy_from_slice(&9_999_999u64.to_le_bytes()); // next past EOF
870        vd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64 + 94).to_le_bytes());
871        f.extend_from_slice(&vd);
872        let (sections, trunc) = walk_sections(&f);
873        assert_eq!(sections.len(), 1);
874        assert_eq!(trunc, Some(9_999_999));
875    }
876
877    #[test]
878    fn decode_uncompressed_bad_crc_still_emits() {
879        // Uncompressed chunk + wrong trailing Adler-32: bytes emitted, crc_ok=false.
880        let mut raw = vec![0xABu8; CHUNK_SIZE];
881        raw.extend_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); // wrong CRC
882        let (bytes, crc_ok) = decode_chunk(&raw, false, CHUNK_SIZE).unwrap();
883        assert_eq!(bytes.len(), CHUNK_SIZE);
884        assert!(!crc_ok);
885    }
886
887    #[test]
888    fn decode_uncompressed_good_crc_ok() {
889        let sectors = vec![0x5Au8; CHUNK_SIZE];
890        let crc = adler32(&sectors);
891        let mut raw = sectors.clone();
892        raw.extend_from_slice(&crc.to_le_bytes());
893        let (bytes, crc_ok) = decode_chunk(&raw, false, CHUNK_SIZE).unwrap();
894        assert_eq!(bytes, sectors);
895        assert!(crc_ok);
896    }
897
898    #[test]
899    fn decode_uncompressed_short_final_chunk() {
900        // A short final chunk (no trailing CRC, fewer than chunk_size bytes) is
901        // emitted verbatim with crc_ok=true.
902        let raw = vec![0x11u8; 100];
903        let (bytes, crc_ok) = decode_chunk(&raw, false, CHUNK_SIZE).unwrap();
904        assert_eq!(bytes.len(), 100);
905        assert!(crc_ok);
906    }
907
908    #[test]
909    fn locate_chunk_spans_segments() {
910        let counts = [3usize, 2, 4];
911        assert_eq!(locate_chunk(&counts, 0), Some((0, 0)));
912        assert_eq!(locate_chunk(&counts, 2), Some((0, 2)));
913        assert_eq!(locate_chunk(&counts, 3), Some((1, 0)));
914        assert_eq!(locate_chunk(&counts, 4), Some((1, 1)));
915        assert_eq!(locate_chunk(&counts, 5), Some((2, 0)));
916        assert_eq!(locate_chunk(&counts, 8), Some((2, 3)));
917        assert_eq!(locate_chunk(&counts, 9), None);
918    }
919
920    #[test]
921    fn discover_segments_non_ewf_extension_single() {
922        let p = Path::new("/tmp/whatever.bin");
923        assert_eq!(discover_segments(p), vec![p.to_path_buf()]);
924    }
925
926    #[test]
927    fn discover_segments_no_extension_single() {
928        let p = Path::new("/tmp/noext");
929        assert_eq!(discover_segments(p), vec![p.to_path_buf()]);
930    }
931
932    #[test]
933    fn discover_segments_lowercase_e01_single_when_no_siblings() {
934        let dir = tempfile::tempdir().unwrap();
935        let p = dir.path().join("img.e01");
936        std::fs::write(&p, b"x").unwrap();
937        // No e02 sibling → just the one.
938        assert_eq!(discover_segments(&p), vec![p]);
939    }
940
941    // ── direct helper coverage for the tolerant/defensive arms ───────────────
942
943    #[test]
944    fn read_geometry_rejects_zero_geometry() {
945        // A volume body with sectors_per_chunk = 0 → geometry rejected (None).
946        let mut f = Vec::new();
947        f.extend_from_slice(&EVF_SIGNATURE);
948        f.push(0x01);
949        f.extend_from_slice(&1u16.to_le_bytes());
950        f.extend_from_slice(&0u16.to_le_bytes());
951        let mut vd = [0u8; SECTION_DESCRIPTOR_SIZE];
952        vd[..6].copy_from_slice(b"volume");
953        let next = FILE_HEADER_SIZE as u64 + SECTION_DESCRIPTOR_SIZE as u64 + 94;
954        vd[16..24].copy_from_slice(&next.to_le_bytes());
955        vd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64 + 94).to_le_bytes());
956        f.extend_from_slice(&vd);
957        let mut vb = [0u8; 94];
958        vb[0..4].copy_from_slice(&1u32.to_le_bytes());
959        vb[4..8].copy_from_slice(&1u32.to_le_bytes());
960        // sectors_per_chunk left 0 → invalid
961        vb[12..16].copy_from_slice(&BYTES_PER_SECTOR.to_le_bytes());
962        f.extend_from_slice(&vb);
963        let (sections, _) = walk_sections(&f);
964        assert!(read_geometry(&f, &sections).is_none());
965    }
966
967    #[test]
968    fn chunk_range_out_of_bounds_is_none() {
969        // A single-entry table whose base_offset + rel points past the data end.
970        let data = vec![0u8; 200];
971        let t = TableRef {
972            entry_count: 1,
973            base_offset: 10_000, // past end
974            entries_file_offset: 0,
975        };
976        // The entry bytes at offset 0: compressed bit set, rel 0.
977        let mut data = data;
978        data[0..4].copy_from_slice(&0x8000_0000u32.to_le_bytes());
979        assert!(chunk_range(&data, &t, 0, Some(200)).is_none());
980    }
981
982    #[test]
983    fn decode_compressed_empty_output_is_none() {
984        // A zlib stream that inflates to zero bytes → treated as no usable data.
985        let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
986        enc.write_all(b"").unwrap();
987        let empty_stream = enc.finish().unwrap();
988        assert!(decode_chunk(&empty_stream, true, CHUNK_SIZE).is_none());
989    }
990
991    /// Build a single-uncompressed-chunk E01 where `table` points at garbage but
992    /// `table2` points at the real (good) chunk — exercising the table2-good
993    /// recovery arm. Geometry `chunk_count`/`sector_count` are caller-set to also
994    /// drive the over/under-cover zero-fill paths.
995    fn build_uncompressed_table2_good(chunk_count: u32, sector_count: u64) -> Vec<u8> {
996        let sectors = vec![0x7Eu8; CHUNK_SIZE];
997        let crc = adler32(&sectors);
998
999        let mut f = Vec::new();
1000        f.extend_from_slice(&EVF_SIGNATURE);
1001        f.push(0x01);
1002        f.extend_from_slice(&1u16.to_le_bytes());
1003        f.extend_from_slice(&0u16.to_le_bytes());
1004
1005        let vol_desc = FILE_HEADER_SIZE as u64;
1006        let vol_data = vol_desc + SECTION_DESCRIPTOR_SIZE as u64;
1007        let tbl_desc = vol_data + 94;
1008        let tbl2_desc = tbl_desc + SECTION_DESCRIPTOR_SIZE as u64 + 24 + 4;
1009        let sec_desc = tbl2_desc + SECTION_DESCRIPTOR_SIZE as u64 + 24 + 4;
1010        let sec_data = sec_desc + SECTION_DESCRIPTOR_SIZE as u64;
1011        let chunk_len = CHUNK_SIZE as u64 + 4; // sectors + trailing CRC
1012        let done_desc = sec_data + chunk_len;
1013
1014        // Volume.
1015        let mut vd = [0u8; SECTION_DESCRIPTOR_SIZE];
1016        vd[..6].copy_from_slice(b"volume");
1017        vd[16..24].copy_from_slice(&tbl_desc.to_le_bytes());
1018        vd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64 + 94).to_le_bytes());
1019        f.extend_from_slice(&vd);
1020        let mut vb = [0u8; 94];
1021        vb[0..4].copy_from_slice(&1u32.to_le_bytes());
1022        vb[4..8].copy_from_slice(&chunk_count.to_le_bytes());
1023        vb[8..12].copy_from_slice(&SECTORS_PER_CHUNK.to_le_bytes());
1024        vb[12..16].copy_from_slice(&BYTES_PER_SECTOR.to_le_bytes());
1025        vb[16..24].copy_from_slice(&sector_count.to_le_bytes());
1026        f.extend_from_slice(&vb);
1027
1028        // table (garbage base_offset) → table2 (correct base_offset).
1029        let emit = |f: &mut Vec<u8>, name: &[u8], next: u64, base: u64| {
1030            let mut td = [0u8; SECTION_DESCRIPTOR_SIZE];
1031            td[..name.len()].copy_from_slice(name);
1032            td[16..24].copy_from_slice(&next.to_le_bytes());
1033            td[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64 + 24 + 4).to_le_bytes());
1034            f.extend_from_slice(&td);
1035            let mut th = [0u8; 24];
1036            th[0..4].copy_from_slice(&1u32.to_le_bytes());
1037            th[8..16].copy_from_slice(&base.to_le_bytes());
1038            f.extend_from_slice(&th);
1039            f.extend_from_slice(&0u32.to_le_bytes()); // uncompressed, rel 0
1040        };
1041        emit(&mut f, b"table", tbl2_desc, 9_000_000); // garbage → out of range
1042        emit(&mut f, b"table2", sec_desc, sec_data); // correct
1043
1044        // Sectors: the real chunk + trailing CRC.
1045        let mut sd = [0u8; SECTION_DESCRIPTOR_SIZE];
1046        sd[..7].copy_from_slice(b"sectors");
1047        sd[16..24].copy_from_slice(&done_desc.to_le_bytes());
1048        sd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64 + chunk_len).to_le_bytes());
1049        f.extend_from_slice(&sd);
1050        f.extend_from_slice(&sectors);
1051        f.extend_from_slice(&crc.to_le_bytes());
1052
1053        let mut dd = [0u8; SECTION_DESCRIPTOR_SIZE];
1054        dd[..4].copy_from_slice(b"done");
1055        dd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64).to_le_bytes());
1056        f.extend_from_slice(&dd);
1057        f
1058    }
1059
1060    #[test]
1061    fn table2_recovers_good_data_when_primary_out_of_range() {
1062        // 1 chunk, exact geometry: primary points out of range, table2 rescues.
1063        let img = build_uncompressed_table2_good(1, u64::from(SECTORS_PER_CHUNK));
1064        let (report, raw) = recover_bytes(&img);
1065        assert_eq!(report.chunks_recovered_table2, 1, "table2 must rescue");
1066        assert_eq!(report.chunks_recovered_primary, 0);
1067        assert_eq!(report.chunks_zero_filled, 0);
1068        assert_eq!(raw.len(), CHUNK_SIZE);
1069        assert!(raw.iter().all(|&b| b == 0x7E));
1070    }
1071
1072    #[test]
1073    fn table2_crc_flagged_when_primary_absent() {
1074        // Primary out of range → None; table2 points at present-but-CRC-suspect
1075        // uncompressed data (we corrupt the trailing Adler-32). The bytes are
1076        // still emitted, via table2, flagged CRC-suspect.
1077        let mut img = build_uncompressed_table2_good(1, u64::from(SECTORS_PER_CHUNK));
1078        // The trailing 4-byte CRC sits just before the final 76-byte `done`
1079        // descriptor. Flip it so the Adler-32 no longer matches.
1080        let crc_pos = img.len() - SECTION_DESCRIPTOR_SIZE - 4;
1081        for b in &mut img[crc_pos..crc_pos + 4] {
1082            *b ^= 0xFF;
1083        }
1084        let (report, raw) = recover_bytes(&img);
1085        assert_eq!(
1086            report.chunks_recovered_table2, 1,
1087            "table2 still supplies data"
1088        );
1089        assert_eq!(report.chunks_recovered_primary, 0);
1090        assert_eq!(
1091            report.chunks_zero_filled, 0,
1092            "present data is not zero-filled"
1093        );
1094        assert_eq!(
1095            report.chunks_crc_flagged, 1,
1096            "table2 data flagged CRC-suspect"
1097        );
1098        assert_eq!(report.crc_flagged_chunks, vec![0]);
1099        assert!(raw.iter().all(|&b| b == 0x7E));
1100    }
1101
1102    #[test]
1103    fn geometry_undercover_zero_fills_tail() {
1104        // chunk_count=1 but sector_count spans 2 chunks → after the one recovered
1105        // chunk, the post-loop zero-fills the remaining logical bytes.
1106        let img = build_uncompressed_table2_good(1, u64::from(SECTORS_PER_CHUNK) * 2);
1107        let (report, raw) = recover_bytes(&img);
1108        assert_eq!(report.image_size, (CHUNK_SIZE * 2) as u64);
1109        assert_eq!(raw.len(), CHUNK_SIZE * 2);
1110        // First chunk recovered (via table2), second half zero-filled.
1111        assert!(raw[..CHUNK_SIZE].iter().all(|&b| b == 0x7E));
1112        assert!(raw[CHUNK_SIZE..].iter().all(|&b| b == 0));
1113        assert!(report.bytes_zero_filled >= CHUNK_SIZE as u64);
1114    }
1115
1116    #[test]
1117    fn walk_sections_breaks_on_next_zero_nonterminal() {
1118        // A `volume` (non-terminal) descriptor with next == 0 → chain ends
1119        // without truncation (line 215-216 break).
1120        let mut f = Vec::new();
1121        f.extend_from_slice(&EVF_SIGNATURE);
1122        f.push(0x01);
1123        f.extend_from_slice(&1u16.to_le_bytes());
1124        f.extend_from_slice(&0u16.to_le_bytes());
1125        let mut vd = [0u8; SECTION_DESCRIPTOR_SIZE];
1126        vd[..6].copy_from_slice(b"volume");
1127        vd[16..24].copy_from_slice(&0u64.to_le_bytes()); // next = 0, non-terminal
1128        vd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64 + 94).to_le_bytes());
1129        f.extend_from_slice(&vd);
1130        f.extend_from_slice(&[0u8; 94]);
1131        let (sections, trunc) = walk_sections(&f);
1132        assert_eq!(sections.len(), 1);
1133        assert_eq!(trunc, None, "next==0 ends the chain, not a truncation");
1134    }
1135
1136    /// Build a single-chunk uncompressed E01 whose sectors region carries
1137    /// `chunk_body` bytes (which may be shorter or longer than one logical chunk)
1138    /// with a caller-chosen geometry — used to drive the trim/pad arms.
1139    fn build_uncompressed_sized(chunk_body: &[u8], sector_count: u64) -> Vec<u8> {
1140        let mut f = Vec::new();
1141        f.extend_from_slice(&EVF_SIGNATURE);
1142        f.push(0x01);
1143        f.extend_from_slice(&1u16.to_le_bytes());
1144        f.extend_from_slice(&0u16.to_le_bytes());
1145
1146        let vol_desc = FILE_HEADER_SIZE as u64;
1147        let vol_data = vol_desc + SECTION_DESCRIPTOR_SIZE as u64;
1148        let tbl_desc = vol_data + 94;
1149        let sec_desc = tbl_desc + SECTION_DESCRIPTOR_SIZE as u64 + 24 + 4;
1150        let sec_data = sec_desc + SECTION_DESCRIPTOR_SIZE as u64;
1151        let done_desc = sec_data + chunk_body.len() as u64;
1152
1153        let mut vd = [0u8; SECTION_DESCRIPTOR_SIZE];
1154        vd[..6].copy_from_slice(b"volume");
1155        vd[16..24].copy_from_slice(&tbl_desc.to_le_bytes());
1156        vd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64 + 94).to_le_bytes());
1157        f.extend_from_slice(&vd);
1158        let mut vb = [0u8; 94];
1159        vb[0..4].copy_from_slice(&1u32.to_le_bytes());
1160        vb[4..8].copy_from_slice(&1u32.to_le_bytes()); // chunk_count = 1
1161        vb[8..12].copy_from_slice(&SECTORS_PER_CHUNK.to_le_bytes());
1162        vb[12..16].copy_from_slice(&BYTES_PER_SECTOR.to_le_bytes());
1163        vb[16..24].copy_from_slice(&sector_count.to_le_bytes());
1164        f.extend_from_slice(&vb);
1165
1166        let mut td = [0u8; SECTION_DESCRIPTOR_SIZE];
1167        td[..5].copy_from_slice(b"table");
1168        td[16..24].copy_from_slice(&sec_desc.to_le_bytes());
1169        td[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64 + 24 + 4).to_le_bytes());
1170        f.extend_from_slice(&td);
1171        let mut th = [0u8; 24];
1172        th[0..4].copy_from_slice(&1u32.to_le_bytes());
1173        th[8..16].copy_from_slice(&sec_data.to_le_bytes());
1174        f.extend_from_slice(&th);
1175        f.extend_from_slice(&0u32.to_le_bytes()); // uncompressed, rel 0
1176
1177        let mut sd = [0u8; SECTION_DESCRIPTOR_SIZE];
1178        sd[..7].copy_from_slice(b"sectors");
1179        sd[16..24].copy_from_slice(&done_desc.to_le_bytes());
1180        sd[24..32].copy_from_slice(
1181            &(SECTION_DESCRIPTOR_SIZE as u64 + chunk_body.len() as u64).to_le_bytes(),
1182        );
1183        f.extend_from_slice(&sd);
1184        f.extend_from_slice(chunk_body);
1185
1186        let mut dd = [0u8; SECTION_DESCRIPTOR_SIZE];
1187        dd[..4].copy_from_slice(b"done");
1188        dd[24..32].copy_from_slice(&(SECTION_DESCRIPTOR_SIZE as u64).to_le_bytes());
1189        f.extend_from_slice(&dd);
1190        f
1191    }
1192
1193    #[test]
1194    fn chunk_longer_than_logical_is_truncated() {
1195        // sector_count spans only 32 sectors (half a chunk) but the sectors body
1196        // holds a full chunk_size → decoded bytes (chunk_size) > logical (16384),
1197        // exercising the truncate arm.
1198        let body = vec![0x42u8; CHUNK_SIZE];
1199        let img = build_uncompressed_sized(&body, u64::from(SECTORS_PER_CHUNK) / 2);
1200        let (report, raw) = recover_bytes(&img);
1201        assert_eq!(report.image_size, (CHUNK_SIZE / 2) as u64);
1202        assert_eq!(raw.len(), CHUNK_SIZE / 2);
1203        assert!(raw.iter().all(|&b| b == 0x42));
1204    }
1205
1206    #[test]
1207    fn chunk_shorter_than_logical_is_padded() {
1208        // The sectors body holds only 100 bytes but the logical chunk is
1209        // chunk_size → decoded bytes (100) < logical, exercising the resize/pad
1210        // arm; the remainder is zero-padded.
1211        let body = vec![0x24u8; 100];
1212        let img = build_uncompressed_sized(&body, u64::from(SECTORS_PER_CHUNK));
1213        let (report, raw) = recover_bytes(&img);
1214        assert_eq!(report.image_size, CHUNK_SIZE as u64);
1215        assert_eq!(raw.len(), CHUNK_SIZE);
1216        assert!(raw[..100].iter().all(|&b| b == 0x24));
1217        assert!(
1218            raw[100..].iter().all(|&b| b == 0),
1219            "short chunk zero-padded"
1220        );
1221    }
1222
1223    #[test]
1224    fn geometry_overcover_stops_at_image_size() {
1225        // chunk_count=2 but sector_count spans only 1 chunk → the loop breaks when
1226        // bytes_remaining hits 0 before the second chunk index.
1227        let img = build_uncompressed_table2_good(2, u64::from(SECTORS_PER_CHUNK));
1228        let (report, raw) = recover_bytes(&img);
1229        assert_eq!(report.image_size, CHUNK_SIZE as u64);
1230        assert_eq!(raw.len(), CHUNK_SIZE);
1231        // Only one chunk's worth was emitted despite chunk_count=2.
1232        assert_eq!(
1233            report.chunks_recovered_primary
1234                + report.chunks_recovered_table2
1235                + report.chunks_zero_filled,
1236            1
1237        );
1238    }
1239}