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}