Skip to main content

archive_core/
execute.rs

1//! Phase 2 of two-phase archive access (ADR 0008): the **peel executors** behind
2//! an [`AccessPlan`]. Where [`crate::plan::detect`] classifies the most-direct
3//! route without touching a payload, [`peel_archive_seekable`] *executes* that
4//! route and hands back the inner evidence as a **seekable handle** — never a
5//! whole-image `Vec<u8>`:
6//!
7//! - [`Access::InPlace`] (a Stored zip member / verbatim window) → a zero-copy
8//!   [`SubRange`] over the original bytes: no decompression, no member copy.
9//! - [`Access::SpillToTemp`] (a compressed member or a bare gzip/bzip2 wrapper) →
10//!   a single streamed decode to a temp file under [`std::env::temp_dir`],
11//!   returned as a [`TempBacked`] handle that RAII-deletes on drop.
12//! - [`Access::Zran`] (a Deflate/Deflate64 zip member) → a [`ZranBacked`]
13//!   checkpoint/stored-block seek index (`zip-forensic-core`): random `seek` +
14//!   `read` decode only the block(s) around the target offset, so the decompressed
15//!   member is never materialized in RAM or on disk. A member whose seek index
16//!   would exceed `limits.max_index_bytes` falls back to [`Access::SpillToTemp`].
17//!
18//! Every extraction is capped at `limits.max_total_inflated` and fails loud with
19//! [`ArchiveError::TooLarge`] past it — a decompression bomb never silently
20//! fills RAM or the temp volume.
21
22use std::io::{self, Read, Seek, SeekFrom, Write};
23use std::path::Path;
24use std::sync::Arc;
25
26use crate::archive::Archive;
27use crate::error::{ArchiveError, Result};
28use crate::plan::{detect, Access, AccessPlan, Codec};
29use crate::resolve::Limits;
30
31/// A seekable byte source: the common capability every peeled handle exposes so
32/// a consumer can take a `Box<dyn ReadSeek>` without knowing the backing store.
33pub trait ReadSeek: Read + Seek {}
34impl<T: Read + Seek> ReadSeek for T {}
35
36/// A zero-copy, seekable window `[offset, offset + len)` over a **shared** byte
37/// buffer. Reading never decompresses and never copies the member into a second
38/// buffer — the bytes come straight from the original archive image. The backing
39/// is an [`Arc`] so several windows (e.g. every segment of a split set) share one
40/// copy of the archive rather than cloning it per window.
41#[derive(Debug)]
42pub struct SubRange {
43    data: Arc<Vec<u8>>,
44    start: u64,
45    len: u64,
46    pos: u64,
47}
48
49impl SubRange {
50    /// Window an owned buffer to `[offset, offset + len)`. A test-only
51    /// convenience over [`SubRange::new_shared`]; production windows share an
52    /// [`Arc`] backing.
53    ///
54    /// # Errors
55    /// [`ArchiveError::TooLarge`] when `len` exceeds `cap`, or
56    /// [`ArchiveError::Read`] when the window falls outside `data` (a lying
57    /// member offset/size — never trusted).
58    #[cfg(test)]
59    fn new(data: Vec<u8>, offset: u64, len: u64, cap: u64) -> Result<Self> {
60        Self::new_shared(Arc::new(data), offset, len, cap)
61    }
62
63    /// Window a shared buffer to `[offset, offset + len)`. Several windows over
64    /// the same [`Arc`] share one copy of the bytes.
65    ///
66    /// # Errors
67    /// Same as [`SubRange::new`].
68    pub(crate) fn new_shared(data: Arc<Vec<u8>>, offset: u64, len: u64, cap: u64) -> Result<Self> {
69        if len > cap {
70            return Err(ArchiveError::TooLarge { cap });
71        }
72        offset
73            .checked_add(len)
74            .filter(|&e| e <= data.len() as u64)
75            .ok_or(ArchiveError::Read {
76                format: "in-place",
77                detail: format!(
78                    "member window [{offset}, {offset}+{len}) exceeds the {}-byte source",
79                    data.len()
80                ),
81            })?;
82        Ok(SubRange {
83            data,
84            start: offset,
85            len,
86            pos: 0,
87        })
88    }
89
90    /// The window's absolute start offset in the original bytes.
91    #[must_use]
92    pub fn offset(&self) -> u64 {
93        self.start
94    }
95
96    /// The window length in bytes.
97    #[must_use]
98    pub fn len(&self) -> u64 {
99        self.len
100    }
101
102    /// Whether the window is empty.
103    #[must_use]
104    pub fn is_empty(&self) -> bool {
105        self.len == 0
106    }
107}
108
109impl Read for SubRange {
110    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
111        let remaining = self.len - self.pos;
112        if remaining == 0 || buf.is_empty() {
113            return Ok(0);
114        }
115        let n = remaining.min(buf.len() as u64) as usize;
116        let start = self.start.saturating_add(self.pos) as usize;
117        // new() proves start+len <= data.len(), and pos <= len, so start+n never
118        // exceeds the buffer; the guard degrades gracefully if that ever breaks.
119        let Some(src) = self.data.get(start..start + n) else {
120            return Ok(0); // cov:unreachable: window bounds proven in new()
121        };
122        buf[..n].copy_from_slice(src);
123        self.pos += n as u64;
124        Ok(n)
125    }
126}
127
128impl Seek for SubRange {
129    fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
130        let target = match from {
131            SeekFrom::Start(o) => o,
132            SeekFrom::End(o) => offset_from(self.len, o)?,
133            SeekFrom::Current(o) => offset_from(self.pos, o)?,
134        };
135        // A window is bounded: clamp to `len` so reads stop at the window edge.
136        self.pos = target.min(self.len);
137        Ok(self.pos)
138    }
139}
140
141/// A seekable handle over a temp file holding a once-decoded stream. The temp
142/// file lives under [`std::env::temp_dir`] and is deleted when this value drops.
143#[derive(Debug)]
144pub struct TempBacked {
145    inner: tempfile::NamedTempFile,
146    len: u64,
147}
148
149impl TempBacked {
150    /// The decoded length spilled to the temp file, in bytes.
151    #[must_use]
152    pub fn len(&self) -> u64 {
153        self.len
154    }
155
156    /// Whether the spilled stream is empty.
157    #[must_use]
158    pub fn is_empty(&self) -> bool {
159        self.len == 0
160    }
161
162    /// The temp file's path (valid until this handle drops, which deletes it).
163    #[must_use]
164    pub fn path(&self) -> &Path {
165        self.inner.path()
166    }
167}
168
169impl Read for TempBacked {
170    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
171        self.inner.as_file_mut().read(buf)
172    }
173}
174
175impl Seek for TempBacked {
176    fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
177        self.inner.as_file_mut().seek(from)
178    }
179}
180
181/// A seekable handle over one Deflate/Deflate64 zip member via a checkpoint /
182/// stored-block seek index (`zip-forensic-core`). Random `seek` + `read` decode
183/// only the block(s) around the target offset — the **decompressed** member is
184/// never materialized in RAM or on disk. `zip-forensic-core`'s entry reader is
185/// file-based, so the still-**compressed** archive is held in a temp file
186/// (RAII-deleted on drop); the member is inflated on demand, never spilled.
187pub struct ZranBacked {
188    entry: zip_core::StoredZipEntry,
189    len: u64,
190    pos: u64,
191    // The COMPRESSED archive bytes backing `entry`. Declared last so it drops
192    // after `entry` — the reader's file handle closes before the temp is unlinked
193    // (Windows will not unlink a file with an open handle).
194    archive: tempfile::NamedTempFile,
195}
196
197impl ZranBacked {
198    /// The member's uncompressed length, in bytes.
199    #[must_use]
200    pub fn len(&self) -> u64 {
201        self.len
202    }
203
204    /// Whether the member is empty.
205    #[must_use]
206    pub fn is_empty(&self) -> bool {
207        self.len == 0
208    }
209}
210
211impl std::fmt::Debug for ZranBacked {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        f.debug_struct("ZranBacked")
214            .field("len", &self.len)
215            .field("pos", &self.pos)
216            .field("archive", &self.archive.path())
217            .finish_non_exhaustive()
218    }
219}
220
221impl Read for ZranBacked {
222    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
223        let n = self.entry.read_at(buf, self.pos)?;
224        self.pos = self.pos.saturating_add(n as u64);
225        Ok(n)
226    }
227}
228
229impl Seek for ZranBacked {
230    fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
231        let target = match from {
232            SeekFrom::Start(o) => o,
233            SeekFrom::End(o) => offset_from(self.len, o)?,
234            SeekFrom::Current(o) => offset_from(self.pos, o)?,
235        };
236        // Clamp to the member length so reads stop at its end (mirrors `SubRange`).
237        self.pos = target.min(self.len);
238        Ok(self.pos)
239    }
240}
241
242/// The peeled inner evidence as a seekable handle: a zero-copy in-place window, a
243/// zran checkpoint-indexed member, or a temp-backed decode. All implement
244/// [`Read`] + [`Seek`].
245#[derive(Debug)]
246#[non_exhaustive]
247pub enum PeeledSource {
248    /// A zero-copy window over the original bytes (no decompression).
249    InPlace(SubRange),
250    /// A Deflate/Deflate64 member read through a checkpoint/stored-block seek
251    /// index — random access with no full inflate and no temp spill.
252    Zran(ZranBacked),
253    /// A once-decoded stream spilled to a temp file (RAII-deleted on drop).
254    Temp(TempBacked),
255}
256
257impl PeeledSource {
258    /// The peeled member's length in bytes (the in-place window, the member's
259    /// uncompressed size, or the spilled length).
260    #[must_use]
261    pub fn len(&self) -> u64 {
262        match self {
263            PeeledSource::InPlace(s) => s.len(),
264            PeeledSource::Zran(z) => z.len(),
265            PeeledSource::Temp(t) => t.len(),
266        }
267    }
268
269    /// Whether the peeled member is empty.
270    #[must_use]
271    pub fn is_empty(&self) -> bool {
272        self.len() == 0
273    }
274}
275
276impl Read for PeeledSource {
277    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
278        match self {
279            PeeledSource::InPlace(s) => s.read(buf),
280            PeeledSource::Zran(z) => z.read(buf),
281            PeeledSource::Temp(t) => t.read(buf),
282        }
283    }
284}
285
286impl Seek for PeeledSource {
287    fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
288        match self {
289            PeeledSource::InPlace(s) => s.seek(from),
290            PeeledSource::Zran(z) => z.seek(from),
291            PeeledSource::Temp(t) => t.seek(from),
292        }
293    }
294}
295
296/// The outcome of the seekable peel executor — the streaming twin of
297/// [`crate::Peel`].
298#[derive(Debug)]
299#[non_exhaustive]
300pub enum PeelSource {
301    /// Not a wrapped/single-member image (a collection, split set, or raw
302    /// stream) — open the source directly.
303    NotPacked,
304    /// One peeled bare-wrapper stream, or the single extracted archive member,
305    /// as a seekable handle.
306    Inner(PeeledSource),
307}
308
309/// Peel a bare gz/bz2 wrapper, OR extract the single member of a one-member
310/// archive, to a **seekable handle** — streaming to a temp file when a decode is
311/// needed, or a zero-copy window when the member is stored verbatim. Multi-member
312/// archives (a collection) and split sets return [`PeelSource::NotPacked`], as
313/// does anything unrecognized.
314///
315/// Classification is content-authoritative via [`crate::plan::detect`]; the
316/// coincidental-magic guard (valid magic that does not decode) is handled there.
317///
318/// # Errors
319/// A decode/open/read failure from the underlying layer, or
320/// [`ArchiveError::TooLarge`] when the extracted output exceeds
321/// `limits.max_total_inflated`.
322pub fn peel_archive_seekable(
323    data: Vec<u8>,
324    _name: Option<&str>,
325    limits: &Limits,
326) -> Result<PeelSource> {
327    let cap = limits.max_total_inflated;
328    let plan = detect(&data)?;
329    // Share the archive bytes so a Member window (and, in phase 4, every segment
330    // of a split set) borrows one copy rather than cloning per handle.
331    let data = Arc::new(data);
332    match plan {
333        // A collection, split set, or raw stream is not a single wrapped image.
334        AccessPlan::Direct | AccessPlan::Collection { .. } | AccessPlan::SegmentSet { .. } => {
335            Ok(PeelSource::NotPacked)
336        }
337
338        // A bare gzip/bzip2 wrapper: stream the whole decode once to temp.
339        AccessPlan::Wrapper { codec, .. } => {
340            let temp = spill_wrapper_to_temp(&data, codec, cap)?;
341            Ok(PeelSource::Inner(PeeledSource::Temp(temp)))
342        }
343
344        // A single archive member, routed by its most-seekable access.
345        AccessPlan::Member {
346            format,
347            index,
348            name,
349            access,
350        } => Ok(PeelSource::Inner(execute_member_access(
351            format, &data, index, &name, &access, limits,
352        )?)),
353    }
354}
355
356/// Materialize one archive member's `access` as a seekable [`PeeledSource`],
357/// over the **shared** archive bytes: a Stored member is a zero-copy in-place
358/// window, a Deflate member is a zran checkpoint index (spilling only if the
359/// index would exceed `limits.max_index_bytes`), and anything else is a single
360/// streamed decode to temp. This is the per-member primitive both the single
361/// [`AccessPlan::Member`] path and phase-4 segment reassembly build on.
362///
363/// # Errors
364/// A decode/open/read failure from the underlying layer, or
365/// [`ArchiveError::TooLarge`] when the member exceeds `limits.max_total_inflated`.
366pub(crate) fn execute_member_access(
367    format: crate::Format,
368    data: &Arc<Vec<u8>>,
369    index: usize,
370    name: &str,
371    access: &Access,
372    limits: &Limits,
373) -> Result<PeeledSource> {
374    let cap = limits.max_total_inflated;
375    match *access {
376        // Stored/verbatim → a zero-copy window over the shared original bytes.
377        Access::InPlace { offset, len } => {
378            let sub = SubRange::new_shared(Arc::clone(data), offset, len, cap)?;
379            Ok(PeeledSource::InPlace(sub))
380        }
381        // Deflate/Deflate64 → a checkpoint/stored-block seek index (no full
382        // inflate, no temp spill); an over-cap index falls back to a spill.
383        Access::Zran => peel_zran(format, data, index, name, limits),
384        // A non-seekable codec (or a format exposing no in-archive offset) →
385        // a single streamed decode to temp.
386        Access::SpillToTemp => {
387            let temp = spill_member_to_temp(format, data, index, cap)?;
388            Ok(PeeledSource::Temp(temp))
389        }
390    }
391}
392
393/// Stream a bare gzip/bzip2 wrapper's whole decoded stream to a temp file.
394fn spill_wrapper_to_temp(data: &[u8], codec: Codec, cap: u64) -> Result<TempBacked> {
395    let reader: Box<dyn Read> = match codec {
396        Codec::Gzip => Box::new(flate2::read::GzDecoder::new(data)),
397        Codec::Bzip2 => Box::new(bzip2_rs::DecoderReader::new(data)),
398    };
399    spill(|out| copy_capped(reader, out, cap, codec_name(codec)))
400}
401
402/// Stream one archive member's decoded bytes to a temp file.
403fn spill_member_to_temp(
404    format: crate::Format,
405    data: &[u8],
406    index: usize,
407    cap: u64,
408) -> Result<TempBacked> {
409    // detect() already opened this archive format to build the Member plan, so
410    // open_with_format returns Some here; the guard fails loud if that breaks.
411    let Some(mut archive) = Archive::open_with_format(format, data)? else {
412        // cov:unreachable: Member plan implies an archive format
413        return Err(ArchiveError::Open {
414            format: "archive",
415            detail: "member plan produced for a non-archive format".to_string(),
416        });
417    };
418    spill(|out| archive.stream_member(index, out, cap))
419}
420
421/// Spacing between saved checkpoints, matching `zip-forensic-core`'s default of
422/// 8 MiB of decompressed output per checkpoint.
423const ZRAN_CHECKPOINT_INTERVAL: u64 = 8 * 1024 * 1024;
424
425/// Upper bound on one checkpoint's serialized window snapshot (~128 KiB).
426const ZRAN_CHECKPOINT_BYTES: u64 = 128 * 1024;
427
428/// Build a [`ZranBacked`] random-access handle for a Deflate/Deflate64 zip member,
429/// or fall back to a one-time temp spill when the seek index would exceed
430/// `limits.max_index_bytes` (design Q3 coverage gate). The **decompressed** member
431/// is never materialized on the zran path — only the compressed archive is held.
432fn peel_zran(
433    format: crate::Format,
434    data: &[u8],
435    index: usize,
436    name: &str,
437    limits: &Limits,
438) -> Result<PeeledSource> {
439    // zran random access is a zip-only capability (member_access only emits Zran
440    // for Deflate/Deflate64 zip members); anything else spills.
441    if format != crate::Format::Zip {
442        // cov:unreachable: only zip Deflate/Deflate64 members route to Zran
443        return spill_member_to_temp(format, data, index, limits.max_total_inflated)
444            .map(PeeledSource::Temp);
445    }
446
447    // Coverage gate: estimate the index memory from the member's declared
448    // uncompressed size and spill instead of building an unbounded index. Declining
449    // zran on a declared-huge member is always safe — the spill path enforces the
450    // observed-output cap, and a lying-small size is caught by the reader's own caps.
451    let size = member_uncompressed_size(format, data, index)?;
452    if estimate_zran_index_bytes(size) > limits.max_index_bytes as u64 {
453        return spill_member_to_temp(format, data, index, limits.max_total_inflated)
454            .map(PeeledSource::Temp);
455    }
456
457    // `zip-forensic-core`'s seekable entry reader is file-based, so the already-in-RAM
458    // COMPRESSED archive is written to a temp file. Reads seek into the deflate
459    // stream on demand; the decompressed member is never spilled.
460    let archive = write_archive_temp(data)?;
461    let entry = zip_core::open_entry(archive.path(), name).map_err(|e| ArchiveError::Read {
462        format: "zip-zran",
463        detail: e.to_string(),
464    })?;
465    let len = entry.len();
466    Ok(PeeledSource::Zran(ZranBacked {
467        entry,
468        len,
469        pos: 0,
470        archive,
471    }))
472}
473
474/// The declared uncompressed size of member `index`, read from the archive's
475/// member table (never a payload decode).
476fn member_uncompressed_size(format: crate::Format, data: &[u8], index: usize) -> Result<u64> {
477    let Some(archive) = Archive::open_with_format(format, data)? else {
478        // cov:unreachable: a Member plan implies an archive format
479        return Err(ArchiveError::Open {
480            format: "archive",
481            detail: "member plan produced for a non-archive format".to_string(),
482        });
483    };
484    let count = archive.entries().len();
485    archive
486        .entries()
487        .get(index)
488        .map(|e| e.size)
489        .ok_or(ArchiveError::IndexOutOfRange { index, count })
490}
491
492/// A conservative upper bound on the seek-index memory for a member of
493/// `uncompressed` bytes: one ~128 KiB checkpoint per 8 MiB of output. This
494/// over-estimates the (cheaper) stored-block index, so the gate errs toward
495/// spilling — never toward an unbounded index.
496fn estimate_zran_index_bytes(uncompressed: u64) -> u64 {
497    let checkpoints = uncompressed.div_ceil(ZRAN_CHECKPOINT_INTERVAL).max(1);
498    checkpoints.saturating_mul(ZRAN_CHECKPOINT_BYTES)
499}
500
501/// Write the still-compressed archive bytes to a fresh temp file so the file-based
502/// `zip-forensic-core` seekable reader can open it. RAII-deleted on drop.
503fn write_archive_temp(data: &[u8]) -> Result<tempfile::NamedTempFile> {
504    let mut tmp = tempfile::NamedTempFile::new().map_err(|e| ArchiveError::Read {
505        format: "zip-zran",
506        detail: e.to_string(),
507    })?;
508    tmp.write_all(data).map_err(|e| ArchiveError::Read {
509        format: "zip-zran",
510        detail: e.to_string(),
511    })?;
512    tmp.as_file_mut().flush().map_err(|e| ArchiveError::Read {
513        format: "zip-zran",
514        detail: e.to_string(),
515    })?;
516    Ok(tmp)
517}
518
519/// Create a fresh temp file under [`std::env::temp_dir`], run `write_into` to fill
520/// it (capped), then rewind it to the start and return a [`TempBacked`] handle.
521/// The temp file is deleted if `write_into` fails (RAII on the local).
522fn spill(write_into: impl FnOnce(&mut dyn Write) -> Result<u64>) -> Result<TempBacked> {
523    let mut tmp = tempfile::NamedTempFile::new().map_err(|e| ArchiveError::Read {
524        format: "temp-spill",
525        detail: e.to_string(),
526    })?;
527    let written = write_into(tmp.as_file_mut())?;
528    let file = tmp.as_file_mut();
529    file.flush().map_err(|e| ArchiveError::Read {
530        format: "temp-spill",
531        detail: e.to_string(),
532    })?;
533    file.seek(SeekFrom::Start(0))
534        .map_err(|e| ArchiveError::Read {
535            format: "temp-spill",
536            detail: e.to_string(),
537        })?;
538    Ok(TempBacked {
539        inner: tmp,
540        len: written,
541    })
542}
543
544/// Copy `reader` into `out` through a bounded buffer, capped at `cap`; fails loud
545/// with [`ArchiveError::TooLarge`] past it. Returns the bytes written.
546fn copy_capped(
547    reader: impl Read,
548    out: &mut dyn Write,
549    cap: u64,
550    format: &'static str,
551) -> Result<u64> {
552    let mut limited = reader.take(cap + 1);
553    let n = io::copy(&mut limited, out).map_err(|e| ArchiveError::Decode {
554        format,
555        detail: e.to_string(),
556    })?;
557    if n > cap {
558        return Err(ArchiveError::TooLarge { cap });
559    }
560    Ok(n)
561}
562
563/// A short, stable label for a codec (for diagnostics).
564fn codec_name(codec: Codec) -> &'static str {
565    match codec {
566        Codec::Gzip => "gzip",
567        Codec::Bzip2 => "bzip2",
568    }
569}
570
571/// Add a signed offset to a base position, failing on under/overflow.
572pub(crate) fn offset_from(base: u64, off: i64) -> io::Result<u64> {
573    let r = if off >= 0 {
574        base.checked_add(off as u64)
575    } else {
576        base.checked_sub(off.unsigned_abs())
577    };
578    r.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "seek position out of range"))
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use crate::plan::AccessPlan;
585    use flate2::write::GzEncoder;
586    use flate2::Compression;
587
588    const FX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/fixtures/");
589
590    fn load(name: &str) -> Vec<u8> {
591        std::fs::read(format!("{FX}{name}")).unwrap()
592    }
593
594    fn gzip(data: &[u8]) -> Vec<u8> {
595        let mut e = GzEncoder::new(Vec::new(), Compression::default());
596        e.write_all(data).unwrap();
597        e.finish().unwrap()
598    }
599
600    /// A deterministic, non-repeating byte pattern of length `n` (so a wrong
601    /// offset yields visibly wrong bytes, unlike an all-equal fill).
602    fn pattern(n: usize) -> Vec<u8> {
603        (0..n).map(|i| (i % 251) as u8).collect()
604    }
605
606    /// Hand-assemble a single-member ZIP whose one file member `name` holds
607    /// `payload` compressed with method 8 (Deflate) at `level`. `Compression::none()`
608    /// emits stored (`BTYPE=00`) deflate blocks — the byte-addressable fast path a
609    /// forensic `E01`-in-zip uses; a real compression level emits Huffman blocks.
610    fn deflate_zip(name: &str, payload: &[u8], level: Compression) -> Vec<u8> {
611        use flate2::write::DeflateEncoder;
612        let mut enc = DeflateEncoder::new(Vec::new(), level);
613        enc.write_all(payload).unwrap();
614        let comp = enc.finish().unwrap();
615        let mut crc = flate2::Crc::new();
616        crc.update(payload);
617        let crc = crc.sum();
618        let nb = name.as_bytes();
619        let (csz, usz, nlen) = (comp.len() as u32, payload.len() as u32, nb.len() as u16);
620
621        let mut z = Vec::new();
622        // Local file header.
623        z.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
624        z.extend_from_slice(&20u16.to_le_bytes()); // version needed
625        z.extend_from_slice(&0u16.to_le_bytes()); // flags
626        z.extend_from_slice(&8u16.to_le_bytes()); // method: deflate
627        z.extend_from_slice(&0u16.to_le_bytes()); // mod time
628        z.extend_from_slice(&0u16.to_le_bytes()); // mod date
629        z.extend_from_slice(&crc.to_le_bytes());
630        z.extend_from_slice(&csz.to_le_bytes());
631        z.extend_from_slice(&usz.to_le_bytes());
632        z.extend_from_slice(&nlen.to_le_bytes());
633        z.extend_from_slice(&0u16.to_le_bytes()); // extra len
634        z.extend_from_slice(nb);
635        z.extend_from_slice(&comp);
636        let cd_offset = z.len() as u32;
637        // Central directory file header.
638        z.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
639        z.extend_from_slice(&20u16.to_le_bytes()); // version made by
640        z.extend_from_slice(&20u16.to_le_bytes()); // version needed
641        z.extend_from_slice(&0u16.to_le_bytes()); // flags
642        z.extend_from_slice(&8u16.to_le_bytes()); // method
643        z.extend_from_slice(&0u16.to_le_bytes()); // mod time
644        z.extend_from_slice(&0u16.to_le_bytes()); // mod date
645        z.extend_from_slice(&crc.to_le_bytes());
646        z.extend_from_slice(&csz.to_le_bytes());
647        z.extend_from_slice(&usz.to_le_bytes());
648        z.extend_from_slice(&nlen.to_le_bytes());
649        z.extend_from_slice(&0u16.to_le_bytes()); // extra len
650        z.extend_from_slice(&0u16.to_le_bytes()); // comment len
651        z.extend_from_slice(&0u16.to_le_bytes()); // disk number start
652        z.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
653        z.extend_from_slice(&0u32.to_le_bytes()); // external attrs
654        z.extend_from_slice(&0u32.to_le_bytes()); // local header offset
655        z.extend_from_slice(nb);
656        let cd_size = z.len() as u32 - cd_offset;
657        // End of central directory.
658        z.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
659        z.extend_from_slice(&0u16.to_le_bytes()); // disk num
660        z.extend_from_slice(&0u16.to_le_bytes()); // disk with cd
661        z.extend_from_slice(&1u16.to_le_bytes()); // entries this disk
662        z.extend_from_slice(&1u16.to_le_bytes()); // total entries
663        z.extend_from_slice(&cd_size.to_le_bytes());
664        z.extend_from_slice(&cd_offset.to_le_bytes());
665        z.extend_from_slice(&0u16.to_le_bytes()); // comment len
666        z
667    }
668
669    // (Phase 3, a) A Deflate zip member → a Zran-backed seekable handle (NOT a
670    // temp spill): random reads at start / a stored-block boundary / the end / a
671    // backward re-seek all return the exact decompressed bytes, and a full read
672    // reproduces the member. Uses `Compression::none()` so the deflate stream is a
673    // run of stored blocks spanning several 64 KiB boundaries — the byte-addressable
674    // fast path with no full inflate.
675    #[test]
676    fn deflate_member_zran_random_access() {
677        let payload = pattern(200_000);
678        let data = deflate_zip("big.dd", &payload, Compression::none());
679        assert!(
680            matches!(
681                detect(&data).unwrap(),
682                AccessPlan::Member {
683                    access: Access::Zran,
684                    ..
685                }
686            ),
687            "a Deflate member routes to Zran"
688        );
689        let PeelSource::Inner(PeeledSource::Zran(mut z)) =
690            peel_archive_seekable(data, Some("big.dd.zip"), &Limits::default()).unwrap()
691        else {
692            panic!("expected a Zran-backed peel");
693        };
694        assert_eq!(z.len(), payload.len() as u64);
695        assert!(!z.is_empty());
696
697        // Start.
698        z.seek(SeekFrom::Start(0)).unwrap();
699        let mut head = vec![0u8; 4096];
700        z.read_exact(&mut head).unwrap();
701        assert_eq!(head, payload[..4096]);
702
703        // A read straddling a 65 535-byte stored-block boundary.
704        let mid = 65_535 - 10;
705        z.seek(SeekFrom::Start(mid as u64)).unwrap();
706        let mut span = vec![0u8; 5000];
707        z.read_exact(&mut span).unwrap();
708        assert_eq!(span, payload[mid..mid + 5000]);
709
710        // End-relative.
711        z.seek(SeekFrom::End(-100)).unwrap();
712        let mut tail = vec![0u8; 100];
713        z.read_exact(&mut tail).unwrap();
714        assert_eq!(tail, payload[payload.len() - 100..]);
715
716        // Backward re-seek.
717        z.seek(SeekFrom::Start(1234)).unwrap();
718        let mut back = vec![0u8; 2000];
719        z.read_exact(&mut back).unwrap();
720        assert_eq!(back, payload[1234..1234 + 2000]);
721
722        // Current-relative seek (skip forward from where the last read left off).
723        assert_eq!(z.seek(SeekFrom::Current(6)).unwrap(), 1234 + 2000 + 6);
724        let mut cur = vec![0u8; 50];
725        z.read_exact(&mut cur).unwrap();
726        assert_eq!(cur, payload[1234 + 2000 + 6..1234 + 2000 + 6 + 50]);
727
728        // A seek past the end clamps to the member length (bounded handle).
729        assert_eq!(
730            z.seek(SeekFrom::Start(u64::MAX)).unwrap(),
731            payload.len() as u64
732        );
733        let _ = format!("{z:?}"); // exercise the Debug view
734
735        // Full read from the start reproduces the member.
736        z.seek(SeekFrom::Start(0)).unwrap();
737        let mut all = Vec::new();
738        z.read_to_end(&mut all).unwrap();
739        assert_eq!(all, payload);
740    }
741
742    // (Phase 3, a2) A genuinely-compressed Deflate member (Huffman blocks, not
743    // stored) is still served by the Zran executor with correct random reads.
744    #[test]
745    fn genuinely_compressed_deflate_member_zran_reads_correctly() {
746        let payload = b"the quick brown fox jumps over the lazy dog\n".repeat(4000);
747        let data = deflate_zip("log.txt", &payload, Compression::best());
748        let PeelSource::Inner(PeeledSource::Zran(mut z)) =
749            peel_archive_seekable(data, Some("log.txt.zip"), &Limits::default()).unwrap()
750        else {
751            panic!("expected a Zran-backed peel");
752        };
753        assert_eq!(z.len(), payload.len() as u64);
754        let off = payload.len() / 2;
755        z.seek(SeekFrom::Start(off as u64)).unwrap();
756        let mut got = vec![0u8; 3000];
757        z.read_exact(&mut got).unwrap();
758        assert_eq!(got, payload[off..off + 3000]);
759    }
760
761    // (Phase 3, b/c) The Zran path yields the Zran variant, never a Temp spill of
762    // the decompressed member — the member is randomly accessed, never inflated to
763    // a temp file.
764    #[test]
765    fn zran_path_does_not_spill_decompressed_member() {
766        let payload = pattern(120_000);
767        let data = deflate_zip("img.dd", &payload, Compression::none());
768        match peel_archive_seekable(data, Some("img.dd.zip"), &Limits::default()).unwrap() {
769            PeelSource::Inner(PeeledSource::Zran(_)) => {}
770            other => panic!("expected Zran (no decompressed-member spill), got {other:?}"),
771        }
772    }
773
774    // (Phase 3, d) The index-size coverage gate: when a zran checkpoint index would
775    // exceed `max_index_bytes`, the executor falls back to a temp spill (which still
776    // yields the exact member bytes) rather than building an unbounded index.
777    #[test]
778    fn zran_index_cap_falls_back_to_spill() {
779        let payload = pattern(120_000);
780        let expected = payload.clone();
781        let data = deflate_zip("img.dd", &payload, Compression::none());
782        let limits = Limits {
783            max_index_bytes: 1,
784            ..Limits::default()
785        };
786        match peel_archive_seekable(data, Some("img.dd.zip"), &limits).unwrap() {
787            PeelSource::Inner(PeeledSource::Temp(mut t)) => {
788                let mut got = Vec::new();
789                t.read_to_end(&mut got).unwrap();
790                assert_eq!(
791                    got, expected,
792                    "spill fallback still yields the member bytes"
793                );
794            }
795            other => panic!("expected a Temp spill fallback, got {other:?}"),
796        }
797    }
798
799    /// The oracle bytes of the single file member of `archive`.
800    fn member_oracle(data: &[u8], name: &str, member: &str) -> Vec<u8> {
801        let mut a = Archive::open(data, Some(name)).unwrap().unwrap();
802        let idx = a
803            .entries()
804            .iter()
805            .position(|e| e.name == member && !e.is_dir)
806            .unwrap();
807        a.read(idx).unwrap()
808    }
809
810    // (a) A Stored zip member → a zero-copy InPlace sub-range: the handle reads
811    // the exact member bytes straight from the original image (no decompression,
812    // no copy), and its window matches the plan's [offset, len).
813    #[test]
814    fn stored_member_is_inplace_zero_copy() {
815        let data = load("stored_one.zip");
816        let AccessPlan::Member {
817            access: Access::InPlace { offset, len },
818            ..
819        } = detect(&data).unwrap()
820        else {
821            panic!("expected an InPlace Member plan");
822        };
823        let expected = member_oracle(&data, "stored_one.zip", "disk.dd");
824
825        match peel_archive_seekable(data.clone(), Some("stored_one.zip"), &Limits::default())
826            .unwrap()
827        {
828            PeelSource::Inner(PeeledSource::InPlace(mut sub)) => {
829                assert_eq!(sub.offset(), offset, "window offset matches the plan");
830                assert_eq!(sub.len(), len, "window len matches the plan");
831                let mut got = Vec::new();
832                sub.read_to_end(&mut got).unwrap();
833                assert_eq!(got, expected, "reads the exact member bytes");
834                // Zero-copy proof: the window is literally the original bytes at
835                // [offset, offset+len) — no decompression happened.
836                let s = offset as usize;
837                let e = (offset + len) as usize;
838                assert_eq!(got.as_slice(), &data[s..e]);
839                // Seekable within the window.
840                sub.seek(SeekFrom::Start(10)).unwrap();
841                let mut three = [0u8; 3];
842                sub.read_exact(&mut three).unwrap();
843                assert_eq!(&three[..], &data[s + 10..s + 13]);
844            }
845            other => panic!("expected InPlace, got {other:?}"),
846        }
847    }
848
849    // (b1) A Deflate zip member → SpillToTemp: a temp-backed handle whose full
850    // read equals the decompressed member, and whose temp file is removed on drop.
851    // (Phase 3) The committed `deflate_one.zip` Deflate member now peels to a Zran
852    // handle (Phase 2 spilled it to temp; Phase 3 seeks it in place), reading the
853    // exact decompressed member bytes.
854    #[test]
855    fn deflate_member_reads_via_zran() {
856        let data = load("deflate_one.zip");
857        let expected = member_oracle(&data, "deflate_one.zip", "big.dd");
858        match peel_archive_seekable(data, Some("deflate_one.zip"), &Limits::default()).unwrap() {
859            PeelSource::Inner(PeeledSource::Zran(mut z)) => {
860                assert_eq!(z.len(), expected.len() as u64);
861                let mut got = Vec::new();
862                z.read_to_end(&mut got).unwrap();
863                assert_eq!(got, expected, "zran reads the exact decompressed member");
864            }
865            other => panic!("expected Zran, got {other:?}"),
866        }
867    }
868
869    // (b2) A bare gzip wrapper → SpillToTemp of the whole decompressed stream,
870    // and the handle is seekable.
871    #[test]
872    fn bare_gzip_wrapper_spills_full_stream_to_temp() {
873        let inner = b"raw disk sector bytes, not an archive at all ".repeat(50);
874        let gz = gzip(&inner);
875        match peel_archive_seekable(gz, Some("disk.dd.gz"), &Limits::default()).unwrap() {
876            PeelSource::Inner(PeeledSource::Temp(mut t)) => {
877                assert_eq!(t.len(), inner.len() as u64);
878                assert!(!t.is_empty());
879                let mut got = Vec::new();
880                t.read_to_end(&mut got).unwrap();
881                assert_eq!(got, inner);
882                // Seekable: jump back into the middle and read.
883                t.seek(SeekFrom::Start(5)).unwrap();
884                let mut chunk = [0u8; 4];
885                t.read_exact(&mut chunk).unwrap();
886                assert_eq!(&chunk[..], &inner[5..9]);
887            }
888            other => panic!("expected Temp, got {other:?}"),
889        }
890    }
891
892    // (c1) An over-cap decompression fails loud (bomb guard) on the spill path.
893    #[test]
894    fn over_cap_spill_fails_loud() {
895        let gz = gzip(&vec![0xAB_u8; 10_000]);
896        let limits = Limits {
897            max_total_inflated: 500,
898            ..Limits::default()
899        };
900        match peel_archive_seekable(gz, Some("x.gz"), &limits) {
901            Err(ArchiveError::TooLarge { cap }) => assert_eq!(cap, 500),
902            other => panic!("expected TooLarge, got {other:?}"),
903        }
904    }
905
906    // (c2) An over-cap in-place window fails loud before yielding a handle.
907    #[test]
908    fn over_cap_inplace_fails_loud() {
909        let data = load("stored_one.zip"); // 4096-byte stored member
910        let limits = Limits {
911            max_total_inflated: 100,
912            ..Limits::default()
913        };
914        match peel_archive_seekable(data, Some("stored_one.zip"), &limits) {
915            Err(ArchiveError::TooLarge { cap }) => assert_eq!(cap, 100),
916            other => panic!("expected TooLarge, got {other:?}"),
917        }
918    }
919
920    // Raw bytes are not packed → open directly.
921    #[test]
922    fn raw_bytes_are_not_packed() {
923        match peel_archive_seekable(
924            b"\x00\x01\x02 not packed".to_vec(),
925            Some("x.raw"),
926            &Limits::default(),
927        )
928        .unwrap()
929        {
930            PeelSource::NotPacked => {}
931            other => panic!("expected NotPacked, got {other:?}"),
932        }
933    }
934
935    // A multi-member archive is a collection, not a wrapped image → NotPacked.
936    #[test]
937    fn multi_member_zip_is_not_packed() {
938        let data = load("payload.zip");
939        assert!(matches!(
940            peel_archive_seekable(data, Some("payload.zip"), &Limits::default()).unwrap(),
941            PeelSource::NotPacked
942        ));
943    }
944
945    // A split segment set is one logical image the caller reassembles → NotPacked.
946    #[test]
947    fn segment_set_is_not_packed() {
948        let data = load("seg_ewf.zip");
949        assert!(matches!(
950            peel_archive_seekable(data, Some("seg_ewf.zip"), &Limits::default()).unwrap(),
951            PeelSource::NotPacked
952        ));
953    }
954
955    // A compressed-tar single member → SpillToTemp (tar exposes no in-archive
956    // offset), reading the decompressed member.
957    #[test]
958    fn targz_single_member_spills_to_temp() {
959        let mut b = tar::Builder::new(Vec::new());
960        let payload = b"RAW-IMAGE-BYTES-in-a-tar".repeat(10);
961        let mut h = tar::Header::new_gnu();
962        h.set_size(payload.len() as u64);
963        h.set_mode(0o644);
964        h.set_cksum();
965        b.append_data(&mut h, "disk.img", payload.as_slice())
966            .unwrap();
967        let tar = b.into_inner().unwrap();
968        let gz = gzip(&tar);
969        match peel_archive_seekable(gz, Some("disk.tgz"), &Limits::default()).unwrap() {
970            PeelSource::Inner(PeeledSource::Temp(mut t)) => {
971                let mut got = Vec::new();
972                t.read_to_end(&mut got).unwrap();
973                assert_eq!(got, payload);
974            }
975            other => panic!("expected Temp, got {other:?}"),
976        }
977    }
978
979    // A bare bzip2 wrapper → SpillToTemp of the whole decompressed stream. Uses
980    // the committed payload.bz2 fixture (no in-tree bzip2 encoder) to exercise the
981    // bzip2 decoder arm of the wrapper spill.
982    #[test]
983    fn bare_bzip2_wrapper_spills_to_temp() {
984        let expected = "archive-detour bzip2 test payload — the quick brown fox\n"
985            .repeat(30)
986            .into_bytes();
987        match peel_archive_seekable(load("payload.bz2"), Some("payload.bz2"), &Limits::default())
988            .unwrap()
989        {
990            PeelSource::Inner(PeeledSource::Temp(mut t)) => {
991                let mut got = Vec::new();
992                t.read_to_end(&mut got).unwrap();
993                assert_eq!(got, expected);
994            }
995            other => panic!("expected Temp, got {other:?}"),
996        }
997    }
998
999    // Full Seek semantics on a SubRange window: End- and Current-relative seeks
1000    // (both directions), clamping past the window edge, and a loud error on an
1001    // out-of-range negative seek.
1002    #[test]
1003    fn subrange_seek_end_and_current() {
1004        let data: Vec<u8> = (0u8..50).collect();
1005        // Window bytes [10, 30) → values 10..30.
1006        let mut sub = SubRange::new(data, 10, 20, u64::MAX).unwrap();
1007        assert_eq!(sub.offset(), 10);
1008        assert!(!sub.is_empty());
1009
1010        // End-relative.
1011        assert_eq!(sub.seek(SeekFrom::End(-4)).unwrap(), 16);
1012        let mut four = [0u8; 4];
1013        sub.read_exact(&mut four).unwrap();
1014        assert_eq!(four, [26, 27, 28, 29]);
1015
1016        // Current-relative, both directions.
1017        assert_eq!(sub.seek(SeekFrom::Current(-4)).unwrap(), 16);
1018        sub.seek(SeekFrom::Start(0)).unwrap();
1019        assert_eq!(sub.seek(SeekFrom::Current(5)).unwrap(), 5);
1020
1021        // Seeking past the window edge clamps to len.
1022        assert_eq!(sub.seek(SeekFrom::Start(999)).unwrap(), 20);
1023
1024        // An out-of-range negative seek fails loud.
1025        assert!(sub.seek(SeekFrom::Current(-999)).is_err());
1026    }
1027
1028    // A window that overruns the source is rejected (a lying member offset/size
1029    // is never trusted), and a zero-length window reports empty.
1030    #[test]
1031    fn subrange_rejects_overrun_and_reports_empty() {
1032        let overrun = SubRange::new(vec![0u8; 10], 5, 20, u64::MAX);
1033        assert!(matches!(overrun, Err(ArchiveError::Read { .. })));
1034
1035        let empty = SubRange::new(vec![0u8; 10], 4, 0, u64::MAX).unwrap();
1036        assert!(empty.is_empty());
1037        assert_eq!(empty.len(), 0);
1038    }
1039
1040    // The public trait-object surface: read and seek through `PeeledSource`
1041    // itself (and a `Box<dyn ReadSeek>`), not the inner handle — this is how a
1042    // consumer that erases the backing store uses the peel.
1043    #[test]
1044    fn peeled_source_reads_and_seeks_as_trait_object() {
1045        let data = load("stored_one.zip");
1046        let expected = member_oracle(&data, "stored_one.zip", "disk.dd");
1047        let PeelSource::Inner(source) =
1048            peel_archive_seekable(data, Some("stored_one.zip"), &Limits::default()).unwrap()
1049        else {
1050            panic!("expected an Inner source");
1051        };
1052        // Drive Read+Seek through the erased handle.
1053        let mut handle: Box<dyn ReadSeek> = Box::new(source);
1054        handle.seek(SeekFrom::Start(0)).unwrap();
1055        let mut got = Vec::new();
1056        handle.read_to_end(&mut got).unwrap();
1057        assert_eq!(got, expected);
1058    }
1059}