1use crate::archive::Archive;
12use crate::detect::sniff;
13use crate::error::{ArchiveError, Result};
14use crate::peel::{peel_bytes, PeelOutcome};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum Node {
19 File { name: String, bytes: Vec<u8> },
21 Dir { name: String },
23}
24
25#[derive(Debug, Clone, Copy)]
29pub struct Limits {
30 pub max_depth: usize,
32 pub max_total_inflated: u64,
34 pub max_entries: usize,
36 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, max_entries: 1_000_000,
48 max_index_bytes: 512 << 20, }
50 }
51}
52
53struct 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
81pub 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 let inner = match peel_bytes(data, name)? {
122 PeelOutcome::Peeled { inner, .. } => inner,
123 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
178fn 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
186fn 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}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use flate2::write::GzEncoder;
205 use flate2::Compression;
206 use std::io::Write;
207
208 fn gzip(data: &[u8]) -> Vec<u8> {
209 let mut e = GzEncoder::new(Vec::new(), Compression::fast());
210 e.write_all(data).unwrap();
211 e.finish().unwrap()
212 }
213
214 fn tar_dir_and_file() -> Vec<u8> {
217 let mut b = tar::Builder::new(Vec::new());
218 let mut hd = tar::Header::new_gnu();
219 hd.set_entry_type(tar::EntryType::Directory);
220 hd.set_size(0);
221 hd.set_mode(0o755);
222 hd.set_cksum();
223 b.append_data(&mut hd, "d/", std::io::empty()).unwrap();
224 let mut hf = tar::Header::new_gnu();
225 hf.set_size(5);
226 hf.set_mode(0o644);
227 hf.set_cksum();
228 b.append_data(&mut hf, "d/f.txt", &b"hello"[..]).unwrap();
229 b.into_inner().unwrap()
230 }
231
232 #[test]
235 fn resolve_records_directory_entries() {
236 let nodes = resolve(&tar_dir_and_file(), Some("x.tar"), &Limits::default()).unwrap();
237 assert!(
238 nodes
239 .iter()
240 .any(|n| matches!(n, Node::Dir { name } if name == "d/")),
241 "the directory entry must be recorded, got {nodes:?}"
242 );
243 assert!(nodes.iter().any(
244 |n| matches!(n, Node::File { name, bytes } if name == "d/f.txt" && bytes == b"hello")
245 ));
246 }
247
248 #[test]
252 fn nested_bomb_in_archive_member_propagates_error() {
253 let mut cur = b"deep".to_vec();
254 for _ in 0..10 {
255 cur = gzip(&cur);
256 }
257 let mut b = tar::Builder::new(Vec::new());
258 let mut h = tar::Header::new_gnu();
259 h.set_size(cur.len() as u64);
260 h.set_mode(0o644);
261 h.set_cksum();
262 b.append_data(&mut h, "bomb.gz", cur.as_slice()).unwrap();
263 let tar = b.into_inner().unwrap();
264
265 let err = resolve(&tar, Some("x.tar"), &Limits::default()).unwrap_err();
266 assert!(
267 format!("{err}").contains("depth"),
268 "expected a depth error propagated from the member, got: {err}"
269 );
270 }
271}