Skip to main content

archive_core/
archive.rs

1//! Member reading for the four archive formats — `.tgz`/`.tbz2` (+ plain
2//! `ustar`), `.zip`/`.clbx`, and `.7z`.
3//!
4//! An [`Archive`] lists ([`entries`](Archive::entries)) and extracts
5//! ([`read`](Archive::read)) members over an in-memory byte slice. Backends are
6//! reused, never reimplemented: the tar family decompresses its outer layer with
7//! [`crate::peel`]'s gzip/bzip2 decoders and walks members with the `tar` crate;
8//! ZIP uses the fleet `zip-forensic-core` reader; 7z uses `sevenz-rust2`. Every
9//! extraction is capped at [`crate::peel::MAX_INFLATED`]; a declared member size
10//! is never trusted for allocation, and any backend error fails loud.
11
12use crate::detect::{sniff, Format};
13use crate::error::{ArchiveError, Result};
14use crate::peel::MAX_INFLATED;
15use crate::plan::Access;
16
17/// One member of an archive.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ArchiveEntry {
20    /// Member path within the archive (as recorded — evidence, not sanitized).
21    pub name: String,
22    /// Uncompressed size in bytes, as declared by the archive metadata.
23    pub size: u64,
24    /// Whether the entry names a directory.
25    pub is_dir: bool,
26}
27
28/// A decoded, listable archive over an in-memory byte slice.
29pub struct Archive {
30    format: Format,
31    entries: Vec<ArchiveEntry>,
32    backend: Backend,
33}
34
35/// The concrete reader behind an [`Archive`]. One variant per reused backend.
36// The 7z `ArchiveReader` is larger than the tar/zip variants, but exactly one
37// `Backend` exists per `Archive` and they are never held in a collection, so the
38// per-value size gap the lint guards against is immaterial here.
39#[allow(clippy::large_enum_variant)]
40enum Backend {
41    /// The **compressed** archive bytes plus the outer tar format. Members are
42    /// listed and extracted by streaming a fresh decompressor over these bytes,
43    /// so the whole decompressed tar is never materialized in RAM.
44    Tar { compressed: Vec<u8>, outer: Format },
45    /// The fleet ZIP reader over the archive bytes.
46    Zip {
47        archive: zip_core::ZipArchive<std::io::Cursor<Vec<u8>>>,
48    },
49    /// The `sevenz-rust2` reader over the archive bytes.
50    SevenZip {
51        reader: sevenz_rust2::ArchiveReader<std::io::Cursor<Vec<u8>>>,
52    },
53}
54
55impl Archive {
56    /// Open `data` as one of the four archive formats, returning `Ok(None)` when
57    /// it is not an archive (a bare wrapper or unrecognized input). `name` is an
58    /// optional file-name hint used only to distinguish a compressed tar
59    /// (`.tgz`/`.tbz2`) from a bare gzip/bzip2 stream.
60    ///
61    /// # Errors
62    /// [`ArchiveError::Open`] if the archive directory cannot be parsed (a
63    /// malformed outer compression layer surfaces here while streaming the tar
64    /// listing). The tar family is listed by streaming, so no whole-archive
65    /// inflate happens at open; the [`crate::peel::MAX_INFLATED`] cap is enforced
66    /// per member in [`read`](Archive::read).
67    pub fn open(data: &[u8], name: Option<&str>) -> Result<Option<Archive>> {
68        Self::open_with_format(sniff(name, data), data)
69    }
70
71    /// Open `data` under an already-determined `format`, bypassing the
72    /// name-based [`sniff`] — returns `Ok(None)` when `format` is not an archive.
73    /// Phase-1 [`crate::detect`] uses this after classifying the format
74    /// content-authoritatively (so a bare-compressed tar peeled to a known
75    /// `TarGz`/`TarBz2` opens without a name hint).
76    ///
77    /// # Errors
78    /// Same as [`open`](Archive::open): [`ArchiveError::Open`] if the archive
79    /// directory cannot be parsed.
80    pub(crate) fn open_with_format(format: Format, data: &[u8]) -> Result<Option<Archive>> {
81        match format {
82            Format::Tar | Format::TarGz | Format::TarBz2 => {
83                Self::open_tar(format, data.to_vec()).map(Some)
84            }
85            Format::Zip => Self::open_zip(data).map(Some),
86            Format::SevenZip => Self::open_7z(data).map(Some),
87            _ => Ok(None),
88        }
89    }
90
91    /// The most-seekable [`Access`] for member `index`, chosen from the member
92    /// table without decompressing (ADR 0008, rule 4). A `Stored`/uncompressed
93    /// zip member is `InPlace` (zero-copy sub-range); `Deflate`/`Deflate64` is
94    /// `Zran`; every other codec — and tar/7z members, which expose no
95    /// in-archive offset or use a non-seekable codec — is `SpillToTemp`.
96    ///
97    /// # Errors
98    /// [`ArchiveError::IndexOutOfRange`] for a bad index, or [`ArchiveError::Read`]
99    /// if a zip member's local header cannot be read.
100    pub fn member_access(&mut self, index: usize) -> Result<Access> {
101        let count = self.entries.len();
102        if index >= count {
103            return Err(ArchiveError::IndexOutOfRange { index, count });
104        }
105        match &mut self.backend {
106            Backend::Zip { archive } => {
107                let f = archive.by_index(index).map_err(|e| ArchiveError::Read {
108                    format: "zip",
109                    // cov:unreachable: open_zip already read this index's header, and
110                    // the index is bounds-checked above — a re-read cannot fail.
111                    detail: e.to_string(),
112                })?;
113                Ok(match f.compression() {
114                    zip_core::CompressionMethod::Stored => Access::InPlace {
115                        offset: f.data_start(),
116                        len: f.compressed_size(),
117                    },
118                    zip_core::CompressionMethod::Deflated
119                    | zip_core::CompressionMethod::Deflate64 => Access::Zran,
120                    _ => Access::SpillToTemp,
121                })
122            }
123            // The tar reader exposes no in-archive member offset, and 7z members
124            // are non-seekable codecs (LZMA/LZMA2) — both spill to temp.
125            Backend::Tar { .. } | Backend::SevenZip { .. } => Ok(Access::SpillToTemp),
126        }
127    }
128
129    /// The archive's format.
130    #[must_use]
131    pub fn format(&self) -> Format {
132        self.format
133    }
134
135    /// The archive's member list, in archive order.
136    #[must_use]
137    pub fn entries(&self) -> &[ArchiveEntry] {
138        &self.entries
139    }
140
141    /// Extract the bytes of the member at `index`, capped at
142    /// [`crate::peel::MAX_INFLATED`].
143    ///
144    /// # Errors
145    /// [`ArchiveError::IndexOutOfRange`] for a bad index, [`ArchiveError::Read`]
146    /// on a backend/codec failure, or [`ArchiveError::TooLarge`] past the cap.
147    pub fn read(&mut self, index: usize) -> Result<Vec<u8>> {
148        let count = self.entries.len();
149        if index >= count {
150            return Err(ArchiveError::IndexOutOfRange { index, count });
151        }
152        // 7z extracts by name; capture it (and the declared size for the
153        // pre-alloc cap) before borrowing the backend mutably.
154        let (name, declared_size) = {
155            let e = &self.entries[index];
156            (e.name.clone(), e.size)
157        };
158        match &mut self.backend {
159            Backend::Tar { compressed, outer } => {
160                extract_tar_member_streaming(compressed, *outer, index, MAX_INFLATED)
161            }
162            Backend::Zip { archive } => read_zip_member(archive, index),
163            Backend::SevenZip { reader } => read_7z_member(reader, &name, declared_size),
164        }
165    }
166
167    /// Stream the bytes of member `index` into `out`, capped at `cap`. The member
168    /// is copied through a bounded buffer — never fully materialized in a `Vec` —
169    /// so a multi-GB inner image spills to the caller's writer (a temp file)
170    /// without holding it in RAM. Fails loud with [`ArchiveError::TooLarge`] past
171    /// `cap`. Returns the number of bytes written.
172    ///
173    /// The one exception is a 7z member: `sevenz-rust2` exposes no streaming
174    /// extract, so its bytes pass through a transient `Vec` before the write.
175    ///
176    /// # Errors
177    /// [`ArchiveError::IndexOutOfRange`] for a bad index, [`ArchiveError::Read`]
178    /// on a backend/codec failure, or [`ArchiveError::TooLarge`] past `cap`.
179    pub fn stream_member(
180        &mut self,
181        index: usize,
182        out: &mut dyn std::io::Write,
183        cap: u64,
184    ) -> Result<u64> {
185        let count = self.entries.len();
186        if index >= count {
187            return Err(ArchiveError::IndexOutOfRange { index, count });
188        }
189        let (name, declared_size) = {
190            let e = &self.entries[index];
191            (e.name.clone(), e.size)
192        };
193        match &mut self.backend {
194            Backend::Tar { compressed, outer } => {
195                stream_tar_member(compressed, *outer, index, out, cap)
196            }
197            Backend::Zip { archive } => stream_zip_member(archive, index, out, cap),
198            Backend::SevenZip { reader } => {
199                let bytes = read_7z_member(reader, &name, declared_size)?;
200                if bytes.len() as u64 > cap {
201                    return Err(ArchiveError::TooLarge { cap });
202                }
203                out.write_all(&bytes).map_err(|e| ArchiveError::Read {
204                    format: "7z",
205                    detail: e.to_string(),
206                })?;
207                Ok(bytes.len() as u64)
208            }
209        }
210    }
211
212    /// Build a tar [`Archive`] over the still-**compressed** `compressed` bytes,
213    /// listing members by streaming a fresh decompressor. The `tar` crate skips
214    /// each member's body *through* the decompressor, so no member data is
215    /// buffered while listing.
216    fn open_tar(outer: Format, compressed: Vec<u8>) -> Result<Archive> {
217        let entries = {
218            let mut ar = tar::Archive::new(tar_stream(&compressed, outer));
219            let iter = ar.entries().map_err(|e| ArchiveError::Open {
220                format: "tar",
221                detail: e.to_string(),
222            })?;
223            let mut entries = Vec::new();
224            for entry in iter {
225                let entry = entry.map_err(|e| ArchiveError::Open {
226                    format: "tar",
227                    detail: e.to_string(),
228                })?;
229                let name = entry.path().map_or_else(
230                    |_| String::from_utf8_lossy(&entry.path_bytes()).into_owned(),
231                    |p| p.to_string_lossy().into_owned(),
232                );
233                let header = entry.header();
234                let size = header.size().map_err(|e| ArchiveError::Open {
235                    format: "tar",
236                    detail: e.to_string(),
237                })?;
238                entries.push(ArchiveEntry {
239                    name,
240                    size,
241                    is_dir: header.entry_type().is_dir(),
242                });
243            }
244            entries
245        };
246        Ok(Archive {
247            format: outer,
248            entries,
249            backend: Backend::Tar { compressed, outer },
250        })
251    }
252
253    /// Build a ZIP [`Archive`] via the fleet `zip-forensic-core` reader.
254    fn open_zip(data: &[u8]) -> Result<Archive> {
255        let mut archive =
256            zip_core::ZipArchive::new(std::io::Cursor::new(data.to_vec())).map_err(|e| {
257                ArchiveError::Open {
258                    format: "zip",
259                    detail: e.to_string(),
260                }
261            })?;
262        let count = archive.len();
263        let mut entries = Vec::with_capacity(count);
264        for i in 0..count {
265            let f = archive.by_index(i).map_err(|e| ArchiveError::Open {
266                format: "zip",
267                detail: e.to_string(),
268            })?;
269            entries.push(ArchiveEntry {
270                name: f.name().to_string(),
271                size: f.size(),
272                is_dir: f.is_dir(),
273            });
274        }
275        Ok(Archive {
276            format: Format::Zip,
277            entries,
278            backend: Backend::Zip { archive },
279        })
280    }
281
282    /// Build a 7z [`Archive`] via `sevenz-rust2`.
283    fn open_7z(data: &[u8]) -> Result<Archive> {
284        let reader = sevenz_rust2::ArchiveReader::new(
285            std::io::Cursor::new(data.to_vec()),
286            sevenz_rust2::Password::empty(),
287        )
288        .map_err(|e| ArchiveError::Open {
289            format: "7z",
290            detail: e.to_string(),
291        })?;
292        let entries = reader
293            .archive()
294            .files
295            .iter()
296            .map(|f| ArchiveEntry {
297                name: f.name.clone(),
298                size: f.size,
299                is_dir: f.is_directory,
300            })
301            .collect();
302        Ok(Archive {
303            format: Format::SevenZip,
304            entries,
305            backend: Backend::SevenZip { reader },
306        })
307    }
308}
309
310/// A fresh streaming `Read` over the compressed archive bytes for the given
311/// outer tar format. Each call re-wraps `compressed` from the start, so the
312/// decompressor holds only a bounded window — never the whole decompressed tar.
313fn tar_stream(compressed: &[u8], outer: Format) -> Box<dyn std::io::Read + '_> {
314    let cursor = std::io::Cursor::new(compressed);
315    match outer {
316        Format::TarGz => Box::new(flate2::read::GzDecoder::new(cursor)),
317        Format::TarBz2 => Box::new(bzip2_rs::DecoderReader::new(cursor)),
318        // Plain `Format::Tar` (and, defensively, any non-tar caller) reads the
319        // bytes straight through with no decompression layer.
320        _ => Box::new(cursor),
321    }
322}
323
324/// Stream the `index`-th tar member's bytes over a fresh decompressor, capped at
325/// `cap`. The `tar` crate skips prior members' bodies *through* the stream, so
326/// only this one member is ever read into memory — the whole decompressed tar is
327/// never materialized. Fails loud with [`ArchiveError::TooLarge`] past `cap`.
328/// CRC-agnostic (tar has no per-member data checksum).
329fn extract_tar_member_streaming(
330    compressed: &[u8],
331    outer: Format,
332    index: usize,
333    cap: u64,
334) -> Result<Vec<u8>> {
335    use std::io::Read;
336    let mut ar = tar::Archive::new(tar_stream(compressed, outer));
337    let mut iter = ar.entries().map_err(|e| ArchiveError::Read {
338        format: "tar",
339        detail: e.to_string(),
340    })?;
341    let entry = iter
342        .nth(index)
343        .ok_or(ArchiveError::IndexOutOfRange {
344            index,
345            count: index,
346        })?
347        .map_err(|e| ArchiveError::Read {
348            format: "tar",
349            detail: e.to_string(),
350        })?;
351    let mut out = Vec::new();
352    let mut limited = entry.take(cap + 1);
353    limited
354        .read_to_end(&mut out)
355        .map_err(|e| ArchiveError::Read {
356            format: "tar",
357            detail: e.to_string(),
358        })?;
359    if out.len() as u64 > cap {
360        return Err(ArchiveError::TooLarge { cap });
361    }
362    Ok(out)
363}
364
365/// Extract the `index`-th ZIP member, capped. The fleet reader verifies CRC-32
366/// at EOF and fails loud on mismatch.
367fn read_zip_member(
368    archive: &mut zip_core::ZipArchive<std::io::Cursor<Vec<u8>>>,
369    index: usize,
370) -> Result<Vec<u8>> {
371    use std::io::Read;
372    let zf = archive.by_index(index).map_err(|e| ArchiveError::Read {
373        format: "zip",
374        detail: e.to_string(),
375    })?;
376    let mut out = Vec::new();
377    let mut limited = zf.take(MAX_INFLATED + 1);
378    limited
379        .read_to_end(&mut out)
380        .map_err(|e| ArchiveError::Read {
381            format: "zip",
382            detail: e.to_string(),
383        })?;
384    if out.len() as u64 > MAX_INFLATED {
385        return Err(ArchiveError::TooLarge { cap: MAX_INFLATED });
386    }
387    Ok(out)
388}
389
390/// Extract a 7z member by name. `sevenz-rust2` decodes the whole member; an
391/// unsupported-codec member surfaces as a loud [`ArchiveError::Read`] carrying
392/// the backend's diagnostic (never a silent skip). The declared size is checked
393/// against the cap before decoding, and the output length after.
394fn read_7z_member(
395    reader: &mut sevenz_rust2::ArchiveReader<std::io::Cursor<Vec<u8>>>,
396    name: &str,
397    declared_size: u64,
398) -> Result<Vec<u8>> {
399    if declared_size > MAX_INFLATED {
400        return Err(ArchiveError::TooLarge { cap: MAX_INFLATED });
401    }
402    let out = reader.read_file(name).map_err(|e| ArchiveError::Read {
403        format: "7z",
404        detail: e.to_string(),
405    })?;
406    if out.len() as u64 > MAX_INFLATED {
407        return Err(ArchiveError::TooLarge { cap: MAX_INFLATED });
408    }
409    Ok(out)
410}
411
412/// Copy `reader` into `out` through a bounded buffer, capped at `cap`. Reads one
413/// byte past the cap so an over-cap stream is *detected*, not silently truncated;
414/// fails loud with [`ArchiveError::TooLarge`]. Returns the bytes written.
415fn copy_capped(
416    reader: impl std::io::Read,
417    out: &mut dyn std::io::Write,
418    cap: u64,
419    format: &'static str,
420) -> Result<u64> {
421    let mut limited = reader.take(cap + 1);
422    let n = std::io::copy(&mut limited, out).map_err(|e| ArchiveError::Read {
423        format,
424        detail: e.to_string(),
425    })?;
426    if n > cap {
427        return Err(ArchiveError::TooLarge { cap });
428    }
429    Ok(n)
430}
431
432/// Stream the `index`-th tar member into `out`, capped. Mirrors
433/// [`extract_tar_member_streaming`], writing to a sink instead of a `Vec` so the
434/// member never lands in RAM whole.
435fn stream_tar_member(
436    compressed: &[u8],
437    outer: Format,
438    index: usize,
439    out: &mut dyn std::io::Write,
440    cap: u64,
441) -> Result<u64> {
442    let mut ar = tar::Archive::new(tar_stream(compressed, outer));
443    let mut iter = ar.entries().map_err(|e| ArchiveError::Read {
444        format: "tar",
445        detail: e.to_string(),
446    })?;
447    let entry = iter
448        .nth(index)
449        .ok_or(ArchiveError::IndexOutOfRange {
450            index,
451            count: index,
452        })?
453        .map_err(|e| ArchiveError::Read {
454            format: "tar",
455            detail: e.to_string(),
456        })?;
457    copy_capped(entry, out, cap, "tar")
458}
459
460/// Stream the `index`-th ZIP member into `out`, capped. The fleet reader verifies
461/// CRC-32 at EOF and fails loud on mismatch.
462fn stream_zip_member(
463    archive: &mut zip_core::ZipArchive<std::io::Cursor<Vec<u8>>>,
464    index: usize,
465    out: &mut dyn std::io::Write,
466    cap: u64,
467) -> Result<u64> {
468    let zf = archive.by_index(index).map_err(|e| ArchiveError::Read {
469        format: "zip",
470        detail: e.to_string(),
471    })?;
472    copy_capped(zf, out, cap, "zip")
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use std::io::Write;
479
480    /// Build an uncompressed `ustar` archive from `(name, bytes)` members.
481    fn build_tar(members: &[(&str, Vec<u8>)]) -> Vec<u8> {
482        let mut b = tar::Builder::new(Vec::new());
483        for (name, data) in members {
484            let mut h = tar::Header::new_gnu();
485            h.set_size(data.len() as u64);
486            h.set_mode(0o644);
487            h.set_cksum();
488            b.append_data(&mut h, name, data.as_slice()).unwrap();
489        }
490        b.into_inner().unwrap()
491    }
492
493    fn gzip(data: &[u8]) -> Vec<u8> {
494        let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
495        e.write_all(data).unwrap();
496        e.finish().unwrap()
497    }
498
499    // The load-bearing streaming property: `cap = 1200` is LESS than the combined
500    // decompressed tar (two members × 512-byte header + 1024-byte padded data,
501    // plus 1024 bytes of end blocks ≈ 3584 bytes) but MORE than either single
502    // member (1000 bytes). A whole-tar materialization under this cap would
503    // exceed it and fail `TooLarge`; the streaming per-member extract must
504    // succeed because the whole decompressed tar is never held at once.
505    #[test]
506    fn streaming_targz_extracts_each_member_under_whole_tar_cap() {
507        let a = vec![0xAA_u8; 1000];
508        let b = vec![0xBB_u8; 1000];
509        let tar = build_tar(&[("a.bin", a.clone()), ("b.bin", b.clone())]);
510        assert!(
511            tar.len() as u64 > 1200,
512            "combined tar ({}) must exceed the per-member cap",
513            tar.len()
514        );
515        let targz = gzip(&tar);
516        assert_eq!(
517            extract_tar_member_streaming(&targz, Format::TarGz, 0, 1200).unwrap(),
518            a
519        );
520        assert_eq!(
521            extract_tar_member_streaming(&targz, Format::TarGz, 1, 1200).unwrap(),
522            b
523        );
524    }
525
526    #[test]
527    fn streaming_plain_tar_extracts_member() {
528        let a = vec![0x11_u8; 800];
529        let b = vec![0x22_u8; 800];
530        let tar = build_tar(&[("a.bin", a.clone()), ("b.bin", b.clone())]);
531        assert_eq!(
532            extract_tar_member_streaming(&tar, Format::Tar, 0, MAX_INFLATED).unwrap(),
533            a
534        );
535        assert_eq!(
536            extract_tar_member_streaming(&tar, Format::Tar, 1, MAX_INFLATED).unwrap(),
537            b
538        );
539    }
540
541    #[test]
542    fn streaming_member_over_cap_fails_loud() {
543        let targz = gzip(&build_tar(&[("a.bin", vec![0xAA_u8; 1000])]));
544        match extract_tar_member_streaming(&targz, Format::TarGz, 0, 500) {
545            Err(ArchiveError::TooLarge { cap }) => assert_eq!(cap, 500),
546            other => panic!("expected TooLarge, got {other:?}"),
547        }
548    }
549
550    #[test]
551    fn streaming_bad_index_fails_loud() {
552        let tar = build_tar(&[("only.bin", vec![0x33_u8; 10])]);
553        assert!(matches!(
554            extract_tar_member_streaming(&tar, Format::Tar, 99, MAX_INFLATED),
555            Err(ArchiveError::IndexOutOfRange { .. })
556        ));
557    }
558
559    // bzip2 uses the identical streaming code path; drive it through the public
560    // `Archive` so the `TarBz2` arm is exercised in both `open_tar` (listing) and
561    // `read` (extraction).
562    const PAYLOAD_TBZ2: &[u8] = include_bytes!("../../tests/data/fixtures/payload.tbz2");
563
564    #[test]
565    fn member_access_out_of_range_fails_loud() {
566        let mut a = Archive::open(PAYLOAD_TBZ2, Some("payload.tbz2"))
567            .unwrap()
568            .unwrap();
569        assert!(matches!(
570            a.member_access(9999),
571            Err(ArchiveError::IndexOutOfRange { .. })
572        ));
573    }
574
575    #[test]
576    fn streaming_tbz2_reads_member_via_same_path() {
577        let mut a = Archive::open(PAYLOAD_TBZ2, Some("payload.tbz2"))
578            .unwrap()
579            .unwrap();
580        assert_eq!(a.format(), Format::TarBz2);
581        let ia = a
582            .entries()
583            .iter()
584            .position(|e| e.name == "a.txt" && !e.is_dir)
585            .unwrap();
586        assert_eq!(a.read(ia).unwrap(), b"alpha member contents\n");
587    }
588
589    const FX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/fixtures/");
590
591    fn load(name: &str) -> Vec<u8> {
592        std::fs::read(format!("{FX}{name}")).unwrap()
593    }
594
595    fn member_index(a: &Archive, name: &str) -> usize {
596        a.entries()
597            .iter()
598            .position(|e| e.name == name && !e.is_dir)
599            .unwrap_or_else(|| panic!("member {name} not found"))
600    }
601
602    // `stream_member` writes a tar member's bytes to a sink and returns the count,
603    // without ever materializing the whole decompressed tar.
604    #[test]
605    fn stream_member_tar_writes_bytes_and_count() {
606        let payload = vec![0xCD_u8; 700];
607        let tar = build_tar(&[("only.bin", payload.clone())]);
608        let mut a = Archive::open(&tar, Some("x.tar")).unwrap().unwrap();
609        let mut sink = Vec::new();
610        let n = a.stream_member(0, &mut sink, MAX_INFLATED).unwrap();
611        assert_eq!(n, payload.len() as u64);
612        assert_eq!(sink, payload);
613    }
614
615    // A tar member streamed under a cap smaller than its size fails loud via
616    // `copy_capped`'s over-cap guard (never a silent truncation).
617    #[test]
618    fn stream_member_tar_over_cap_fails_loud() {
619        let tar = build_tar(&[("big.bin", vec![0x7E_u8; 1000])]);
620        let mut a = Archive::open(&tar, Some("x.tar")).unwrap().unwrap();
621        let mut sink = Vec::new();
622        match a.stream_member(0, &mut sink, 100) {
623            Err(ArchiveError::TooLarge { cap }) => assert_eq!(cap, 100),
624            other => panic!("expected TooLarge, got {other:?}"),
625        }
626    }
627
628    // A bad index into `stream_member` fails loud before any backend dispatch.
629    #[test]
630    fn stream_member_out_of_range_fails_loud() {
631        let tar = build_tar(&[("only.bin", vec![0x33_u8; 10])]);
632        let mut a = Archive::open(&tar, Some("x.tar")).unwrap().unwrap();
633        let mut sink = Vec::new();
634        assert!(matches!(
635            a.stream_member(99, &mut sink, MAX_INFLATED),
636            Err(ArchiveError::IndexOutOfRange { .. })
637        ));
638    }
639
640    // The 7z streaming arm decodes a member and writes it through the transient
641    // `Vec` seam (sevenz-rust2 has no streaming extract), returning the count.
642    #[test]
643    fn stream_member_7z_writes_bytes() {
644        let data = load("payload.7z");
645        let mut a = Archive::open(&data, Some("payload.7z")).unwrap().unwrap();
646        let ia = member_index(&a, "a.txt");
647        let mut sink = Vec::new();
648        let n = a.stream_member(ia, &mut sink, MAX_INFLATED).unwrap();
649        assert_eq!(sink, b"alpha member contents\n");
650        assert_eq!(n, sink.len() as u64);
651    }
652
653    // A 7z member streamed under a cap smaller than the decoded size fails loud
654    // (the decoded-length check on the 7z arm).
655    #[test]
656    fn stream_member_7z_over_cap_fails_loud() {
657        let data = load("payload.7z");
658        let mut a = Archive::open(&data, Some("payload.7z")).unwrap().unwrap();
659        let ia = member_index(&a, "a.txt");
660        let mut sink = Vec::new();
661        match a.stream_member(ia, &mut sink, 3) {
662            Err(ArchiveError::TooLarge { cap }) => assert_eq!(cap, 3),
663            other => panic!("expected TooLarge, got {other:?}"),
664        }
665    }
666
667    // A ZIP member streams to the sink byte-for-byte via the fleet reader.
668    #[test]
669    fn stream_member_zip_writes_bytes() {
670        let data = load("payload.zip");
671        let mut a = Archive::open(&data, Some("payload.zip")).unwrap().unwrap();
672        let ia = member_index(&a, "a.txt");
673        let mut sink = Vec::new();
674        let n = a.stream_member(ia, &mut sink, MAX_INFLATED).unwrap();
675        assert_eq!(sink, b"alpha member contents\n");
676        assert_eq!(n, sink.len() as u64);
677    }
678
679    // A zip payload behind the `PK\x03\x04` magic that is not a valid archive
680    // fails loud with a byte-carrying Open error — never a silent empty listing.
681    #[test]
682    fn corrupt_zip_open_fails_loud() {
683        let mut bytes = b"PK\x03\x04".to_vec();
684        bytes.extend_from_slice(&[0u8; 64]);
685        match Archive::open(&bytes, Some("x.zip")) {
686            Err(ArchiveError::Open { format, .. }) => assert_eq!(format, "zip"),
687            Err(e) => panic!("expected a zip Open error, got a different error: {e:?}"),
688            Ok(_) => panic!("expected a loud zip Open error, got Ok"),
689        }
690    }
691
692    // 7z magic followed by garbage fails loud at open (the sevenz-rust2 header
693    // parse errors out), not a silent None.
694    #[test]
695    fn corrupt_7z_open_fails_loud() {
696        let mut bytes = vec![0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C];
697        bytes.extend_from_slice(&[0u8; 64]);
698        match Archive::open(&bytes, Some("x.7z")) {
699            Err(ArchiveError::Open { format, .. }) => assert_eq!(format, "7z"),
700            Err(e) => panic!("expected a 7z Open error, got a different error: {e:?}"),
701            Ok(_) => panic!("expected a loud 7z Open error, got Ok"),
702        }
703    }
704
705    // `member_access` classifies a Stored zip member as a zero-copy InPlace window
706    // and a Deflated member as a Zran seek index (ADR 0008 access ladder).
707    #[test]
708    fn member_access_classifies_zip_stored_and_deflated() {
709        let mut stored = Archive::open(&load("stored_one.zip"), Some("stored_one.zip"))
710            .unwrap()
711            .unwrap();
712        assert!(matches!(
713            stored.member_access(0).unwrap(),
714            Access::InPlace { .. }
715        ));
716        let mut deflated = Archive::open(&load("deflate_one.zip"), Some("deflate_one.zip"))
717            .unwrap()
718            .unwrap();
719        assert!(matches!(deflated.member_access(0).unwrap(), Access::Zran));
720    }
721
722    /// A single-member STORED zip whose recorded CRC-32 is deliberately wrong, so
723    /// the fleet reader's EOF CRC check must reject it on extract.
724    fn stored_zip_bad_crc(name: &str, payload: &[u8]) -> Vec<u8> {
725        let nb = name.as_bytes();
726        let mut crc = flate2::Crc::new();
727        crc.update(payload);
728        let bad_crc = crc.sum() ^ 0xFFFF_FFFF; // guaranteed != the real CRC
729        let (sz, nlen) = (payload.len() as u32, nb.len() as u16);
730        let mut z = Vec::new();
731        // Local file header (method 0 = stored).
732        z.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
733        z.extend_from_slice(&20u16.to_le_bytes()); // version needed
734        z.extend_from_slice(&0u16.to_le_bytes()); // flags
735        z.extend_from_slice(&0u16.to_le_bytes()); // method: stored
736        z.extend_from_slice(&0u16.to_le_bytes()); // mod time
737        z.extend_from_slice(&0u16.to_le_bytes()); // mod date
738        z.extend_from_slice(&bad_crc.to_le_bytes());
739        z.extend_from_slice(&sz.to_le_bytes()); // compressed size
740        z.extend_from_slice(&sz.to_le_bytes()); // uncompressed size
741        z.extend_from_slice(&nlen.to_le_bytes());
742        z.extend_from_slice(&0u16.to_le_bytes()); // extra len
743        z.extend_from_slice(nb);
744        z.extend_from_slice(payload);
745        let cd_offset = z.len() as u32;
746        // Central directory header.
747        let mut central = Vec::new();
748        central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
749        central.extend_from_slice(&20u16.to_le_bytes()); // version made by
750        central.extend_from_slice(&20u16.to_le_bytes()); // version needed
751        central.extend_from_slice(&0u16.to_le_bytes()); // flags
752        central.extend_from_slice(&0u16.to_le_bytes()); // method
753        central.extend_from_slice(&0u16.to_le_bytes()); // mod time
754        central.extend_from_slice(&0u16.to_le_bytes()); // mod date
755        central.extend_from_slice(&bad_crc.to_le_bytes());
756        central.extend_from_slice(&sz.to_le_bytes());
757        central.extend_from_slice(&sz.to_le_bytes());
758        central.extend_from_slice(&nlen.to_le_bytes());
759        central.extend_from_slice(&0u16.to_le_bytes()); // extra len
760        central.extend_from_slice(&0u16.to_le_bytes()); // comment len
761        central.extend_from_slice(&0u16.to_le_bytes()); // disk number start
762        central.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
763        central.extend_from_slice(&0u32.to_le_bytes()); // external attrs
764        central.extend_from_slice(&0u32.to_le_bytes()); // local header offset
765        central.extend_from_slice(nb);
766        let cd_size = central.len() as u32;
767        z.extend_from_slice(&central);
768        // End of central directory.
769        z.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
770        z.extend_from_slice(&0u16.to_le_bytes()); // disk num
771        z.extend_from_slice(&0u16.to_le_bytes()); // disk with cd
772        z.extend_from_slice(&1u16.to_le_bytes()); // entries this disk
773        z.extend_from_slice(&1u16.to_le_bytes()); // total entries
774        z.extend_from_slice(&cd_size.to_le_bytes());
775        z.extend_from_slice(&cd_offset.to_le_bytes());
776        z.extend_from_slice(&0u16.to_le_bytes()); // comment len
777        z
778    }
779
780    // A zip member with a wrong CRC-32 opens fine (the directory is intact) but
781    // must fail LOUD on extraction — both `read` and `stream_member` reject it via
782    // the fleet reader's EOF CRC check, never returning corrupt bytes silently.
783    #[test]
784    fn zip_member_bad_crc_fails_loud_on_read_and_stream() {
785        let data = stored_zip_bad_crc("blob.bin", b"corruption-detected-by-crc");
786        let mut a = Archive::open(&data, Some("x.zip")).unwrap().unwrap();
787        assert_eq!(a.entries().len(), 1);
788        match a.read(0) {
789            Err(ArchiveError::Read { format, .. }) => assert_eq!(format, "zip"),
790            other => panic!("expected a loud zip Read error on read, got {other:?}"),
791        }
792        let mut sink = Vec::new();
793        match a.stream_member(0, &mut sink, MAX_INFLATED) {
794            Err(ArchiveError::Read { format, .. }) => assert_eq!(format, "zip"),
795            other => panic!("expected a loud zip Read error on stream, got {other:?}"),
796        }
797    }
798}