Skip to main content

archive_core/
resolve.rs

1//! Recursive multi-layer peeling. [`resolve`] drives [`crate::peel`] and
2//! [`crate::Archive`] together so nested archive layers unwrap **by
3//! construction**, not as special cases: a bare gzip/bzip2 wrapper peels to one
4//! inner stream that is re-detected; each archive member is re-detected; any
5//! member/stream that is itself a archive layer recurses. So `foo.tbz.zip`
6//! resolves zip -> member `foo.tbz` -> tar -> leaf files, and `.gz.gz`,
7//! `.tar.gz`-in-`.zip`, `.zip`-in-`.7z` all fall out of the same loop.
8//!
9//! Bomb guards are mandatory and cumulative across the whole recursion.
10
11use crate::archive::Archive;
12use crate::detect::sniff;
13use crate::error::{ArchiveError, Result};
14use crate::peel::{peel_bytes, PeelOutcome};
15
16/// A leaf of a fully-resolved packing tree.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum Node {
19    /// A leaf file (not itself a recognized archive layer) and its bytes.
20    File { name: String, bytes: Vec<u8> },
21    /// A directory entry encountered inside an archive.
22    Dir { name: String },
23}
24
25/// Bomb guards for [`resolve`]. Every field is a hard cap that fails loud when
26/// tripped; the inflated-size cap is tracked cumulatively across all layers, not
27/// per layer.
28#[derive(Debug, Clone, Copy)]
29pub struct Limits {
30    /// Maximum nesting depth before giving up (archive-bomb nesting guard).
31    pub max_depth: usize,
32    /// Cumulative inflated bytes across the whole recursion.
33    pub max_total_inflated: u64,
34    /// Cumulative number of archive members across the whole recursion.
35    pub max_entries: usize,
36    /// Ceiling on a single member's zran checkpoint/stored-block seek index. A
37    /// member whose index would exceed this falls back to a one-time temp spill
38    /// rather than holding an unbounded index in RAM (design Q3 coverage gate).
39    pub max_index_bytes: usize,
40}
41
42impl Default for Limits {
43    fn default() -> Self {
44        Limits {
45            max_depth: 8,
46            max_total_inflated: 4 << 30, // 4 GiB
47            max_entries: 1_000_000,
48            max_index_bytes: 512 << 20, // 512 MiB (matches zip-forensic-core)
49        }
50    }
51}
52
53/// Running totals enforced across the whole recursion.
54struct Budget {
55    total_inflated: u64,
56    entries: usize,
57}
58
59impl Budget {
60    fn add_inflated(&mut self, n: usize, limits: &Limits) -> Result<()> {
61        self.total_inflated = self.total_inflated.saturating_add(n as u64);
62        if self.total_inflated > limits.max_total_inflated {
63            return Err(ArchiveError::TotalInflatedExceeded {
64                cap: limits.max_total_inflated,
65            });
66        }
67        Ok(())
68    }
69
70    fn add_entry(&mut self, limits: &Limits) -> Result<()> {
71        self.entries = self.entries.saturating_add(1);
72        if self.entries > limits.max_entries {
73            return Err(ArchiveError::TooManyEntries {
74                max: limits.max_entries,
75            });
76        }
77        Ok(())
78    }
79}
80
81/// Fully resolve `data` down through every archive layer to a flat list of leaf
82/// files (and the directory entries seen along the way).
83///
84/// # Errors
85/// A bomb-guard trip ([`ArchiveError::DepthExceeded`] /
86/// [`ArchiveError::TooManyEntries`] / [`ArchiveError::TotalInflatedExceeded`]),
87/// or any decode/open/read failure from an underlying layer.
88pub fn resolve(data: &[u8], name: Option<&str>, limits: &Limits) -> Result<Vec<Node>> {
89    let mut out = Vec::new();
90    let mut budget = Budget {
91        total_inflated: 0,
92        entries: 0,
93    };
94    let chain = name.unwrap_or("<input>").to_string();
95    resolve_into(data, name, limits, 0, &chain, &mut budget, &mut out)?;
96    Ok(out)
97}
98
99#[allow(clippy::too_many_arguments)]
100fn resolve_into(
101    data: &[u8],
102    name: Option<&str>,
103    limits: &Limits,
104    depth: usize,
105    chain: &str,
106    budget: &mut Budget,
107    out: &mut Vec<Node>,
108) -> Result<()> {
109    if depth > limits.max_depth {
110        return Err(ArchiveError::DepthExceeded {
111            max: limits.max_depth,
112            chain: chain.to_string(),
113        });
114    }
115
116    let format = sniff(name, data);
117
118    if format.is_compression_wrapper() {
119        // One bare gzip/bzip2 layer. peel_bytes is content-driven here (unlike
120        // the disk archive layer): resolve unwraps everything it recognizes.
121        let inner = match peel_bytes(data, name)? {
122            PeelOutcome::Peeled { inner, .. } => inner,
123            // cov:unreachable: is_compression_wrapper() guarantees a Peeled outcome
124            PeelOutcome::NotPacked => {
125                out.push(leaf(name, data));
126                return Ok(());
127            }
128        };
129        budget.add_inflated(inner.len(), limits)?;
130        let inner_name = strip_compression_ext(name);
131        let child = format!("{chain} -> {}", inner_name.as_deref().unwrap_or("<peeled>"));
132        resolve_into(
133            &inner,
134            inner_name.as_deref(),
135            limits,
136            depth + 1,
137            &child,
138            budget,
139            out,
140        )?;
141        return Ok(());
142    }
143
144    if format.is_archive() {
145        let mut archive = Archive::open(data, name)?.ok_or_else(|| ArchiveError::Open {
146            format: "archive",
147            detail: format!("{format:?} sniffed as an archive but did not open"),
148        })?;
149        let members = archive.entries().to_vec();
150        for (i, entry) in members.iter().enumerate() {
151            budget.add_entry(limits)?;
152            if entry.is_dir {
153                out.push(Node::Dir {
154                    name: entry.name.clone(),
155                });
156                continue;
157            }
158            let bytes = archive.read(i)?;
159            budget.add_inflated(bytes.len(), limits)?;
160            let child = format!("{chain} -> {}", entry.name);
161            resolve_into(
162                &bytes,
163                Some(&entry.name),
164                limits,
165                depth + 1,
166                &child,
167                budget,
168                out,
169            )?;
170        }
171        return Ok(());
172    }
173
174    out.push(leaf(name, data));
175    Ok(())
176}
177
178/// A leaf file node carrying `data`, named by the (possibly stripped) hint.
179fn leaf(name: Option<&str>, data: &[u8]) -> Node {
180    Node::File {
181        name: name.unwrap_or_default().to_string(),
182        bytes: data.to_vec(),
183    }
184}
185
186/// Strip one trailing bare-compression extension from `name` after a peel, so
187/// the inner stream is re-detected under its remaining name (`disk.dd.gz` ->
188/// `disk.dd`). Leaves tar-alias names (`.tbz`/`.tgz`) intact — those inner
189/// streams are re-detected by their `ustar` magic, not their name.
190fn strip_compression_ext(name: Option<&str>) -> Option<String> {
191    let name = name?;
192    let lower = name.to_ascii_lowercase();
193    for ext in [".gz", ".bz2", ".z"] {
194        if lower.ends_with(ext) {
195            return Some(name[..name.len() - ext.len()].to_string());
196        }
197    }
198    Some(name.to_string())
199}