archive_core/archive_layer.rs
1//! The unified **disk-image archive layer** entry point. A single function the
2//! image-opening consumers (disk-forensic, 4n6mount) call so the sniff-gate +
3//! peel/extract decision lives here once, instead of being duplicated in each.
4//!
5//! Unlike [`crate::resolve`] (content-driven, unwraps everything), this is for a
6//! single wrapped *evidence image*: it peels one bare gzip/bzip2 wrapper —
7//! guarded by the file name so a raw disk with coincidental magic is left
8//! alone — OR extracts the lone member of a one-member archive. A multi-member
9//! archive is a *collection*, not a wrapped image, so it is reported
10//! [`Peel::NotPacked`] and left to the caller. Each consumer keeps its own
11//! spill-to-tmp + recurse-into-container orchestration around this call.
12
13use crate::archive::Archive;
14use crate::detect::sniff;
15use crate::error::{ArchiveError, Result};
16use crate::peel::{peel_bytes, PeelOutcome};
17use crate::resolve::Limits;
18
19/// The outcome of peeling a disk-image archive layer.
20#[derive(Debug)]
21pub enum Peel {
22 /// Not a wrapped/single-member image — open `data` directly.
23 NotPacked,
24 /// One peeled bare-wrapper stream, or the single extracted archive member.
25 Inner(Vec<u8>),
26}
27
28/// Peel a bare gz/bz2 wrapper, OR extract the single member of a one-member
29/// archive, to inner bytes. Multi-member archives (a collection, not a wrapped
30/// image) return [`Peel::NotPacked`], as does anything unrecognized.
31///
32/// A bare-wrapper peel is guarded by the file **name**: a raw disk that happens
33/// to start with gzip/bzip2 magic but lacks a compression extension is left as
34/// [`Peel::NotPacked`] (the coincidental-magic guard). Archive extraction is
35/// keyed on magic alone, which is unambiguous for zip/7z/tar.
36///
37/// # Errors
38/// A decode/open/read failure from the underlying layer, or
39/// [`ArchiveError::TotalInflatedExceeded`] when the extracted bytes exceed
40/// `limits.max_total_inflated`.
41pub fn peel_archive(data: &[u8], name: Option<&str>, limits: &Limits) -> Result<Peel> {
42 let format = sniff(name, data);
43
44 if format.is_compression_wrapper() {
45 // Coincidental-magic guard: only peel when the name agrees it is packed.
46 if !has_compression_ext(name) {
47 return Ok(Peel::NotPacked);
48 }
49 let inner = match peel_bytes(data, name)? {
50 PeelOutcome::Peeled { inner, .. } => inner,
51 // cov:unreachable: is_compression_wrapper() guarantees a Peeled outcome
52 PeelOutcome::NotPacked => return Ok(Peel::NotPacked),
53 };
54 return cap(inner, limits);
55 }
56
57 if format.is_archive() {
58 let Some(mut archive) = Archive::open(data, name)? else {
59 // cov:unreachable: is_archive() guarantees open() returns Some
60 return Ok(Peel::NotPacked);
61 };
62 // A single *file* member (directories don't count) is a wrapped image;
63 // anything else is a collection left to the caller.
64 let file_indices: Vec<usize> = archive
65 .entries()
66 .iter()
67 .enumerate()
68 .filter(|(_, e)| !e.is_dir)
69 .map(|(i, _)| i)
70 .collect();
71 if let [only] = file_indices[..] {
72 let inner = archive.read(only)?;
73 return cap(inner, limits);
74 }
75 return Ok(Peel::NotPacked);
76 }
77
78 Ok(Peel::NotPacked)
79}
80
81/// Enforce the archive layer's cumulative inflated cap on a single extraction.
82fn cap(inner: Vec<u8>, limits: &Limits) -> Result<Peel> {
83 if inner.len() as u64 > limits.max_total_inflated {
84 return Err(ArchiveError::TotalInflatedExceeded {
85 cap: limits.max_total_inflated,
86 });
87 }
88 Ok(Peel::Inner(inner))
89}
90
91/// Does the file name carry a supported packed extension? This is the
92/// coincidental-magic guard for the bare-wrapper branch — a raw disk that starts
93/// with `1F 8B`/`BZh` magic but lacks a compression extension must open as raw.
94/// The list is the actually-supported trimmed format set (magic is definitive
95/// for the archive members, so only the bare `.gz`/`.bz2` case is load-bearing).
96/// Kept here as the single fleet copy the image-opening consumers share.
97fn has_compression_ext(name: Option<&str>) -> bool {
98 let Some(name) = name else {
99 return false;
100 };
101 let lower = name.to_ascii_lowercase();
102 [
103 ".gz", ".bz2", ".tgz", ".tar.gz", ".tbz2", ".tar.bz2", ".zip", ".clbx", ".7z",
104 ]
105 .iter()
106 .any(|e| lower.ends_with(e))
107}