use crate::archive::Archive;
use crate::detect::sniff;
use crate::error::{ArchiveError, Result};
use crate::peel::{peel_bytes, PeelOutcome};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Node {
File { name: String, bytes: Vec<u8> },
Dir { name: String },
}
#[derive(Debug, Clone, Copy)]
pub struct Limits {
pub max_depth: usize,
pub max_total_inflated: u64,
pub max_entries: usize,
pub max_index_bytes: usize,
}
impl Default for Limits {
fn default() -> Self {
Limits {
max_depth: 8,
max_total_inflated: 4 << 30, max_entries: 1_000_000,
max_index_bytes: 512 << 20, }
}
}
struct Budget {
total_inflated: u64,
entries: usize,
}
impl Budget {
fn add_inflated(&mut self, n: usize, limits: &Limits) -> Result<()> {
self.total_inflated = self.total_inflated.saturating_add(n as u64);
if self.total_inflated > limits.max_total_inflated {
return Err(ArchiveError::TotalInflatedExceeded {
cap: limits.max_total_inflated,
});
}
Ok(())
}
fn add_entry(&mut self, limits: &Limits) -> Result<()> {
self.entries = self.entries.saturating_add(1);
if self.entries > limits.max_entries {
return Err(ArchiveError::TooManyEntries {
max: limits.max_entries,
});
}
Ok(())
}
}
pub fn resolve(data: &[u8], name: Option<&str>, limits: &Limits) -> Result<Vec<Node>> {
let mut out = Vec::new();
let mut budget = Budget {
total_inflated: 0,
entries: 0,
};
let chain = name.unwrap_or("<input>").to_string();
resolve_into(data, name, limits, 0, &chain, &mut budget, &mut out)?;
Ok(out)
}
#[allow(clippy::too_many_arguments)]
fn resolve_into(
data: &[u8],
name: Option<&str>,
limits: &Limits,
depth: usize,
chain: &str,
budget: &mut Budget,
out: &mut Vec<Node>,
) -> Result<()> {
if depth > limits.max_depth {
return Err(ArchiveError::DepthExceeded {
max: limits.max_depth,
chain: chain.to_string(),
});
}
let format = sniff(name, data);
if format.is_compression_wrapper() {
let inner = match peel_bytes(data, name)? {
PeelOutcome::Peeled { inner, .. } => inner,
PeelOutcome::NotPacked => {
out.push(leaf(name, data));
return Ok(());
}
};
budget.add_inflated(inner.len(), limits)?;
let inner_name = strip_compression_ext(name);
let child = format!("{chain} -> {}", inner_name.as_deref().unwrap_or("<peeled>"));
resolve_into(
&inner,
inner_name.as_deref(),
limits,
depth + 1,
&child,
budget,
out,
)?;
return Ok(());
}
if format.is_archive() {
let mut archive = Archive::open(data, name)?.ok_or_else(|| ArchiveError::Open {
format: "archive",
detail: format!("{format:?} sniffed as an archive but did not open"),
})?;
let members = archive.entries().to_vec();
for (i, entry) in members.iter().enumerate() {
budget.add_entry(limits)?;
if entry.is_dir {
out.push(Node::Dir {
name: entry.name.clone(),
});
continue;
}
let bytes = archive.read(i)?;
budget.add_inflated(bytes.len(), limits)?;
let child = format!("{chain} -> {}", entry.name);
resolve_into(
&bytes,
Some(&entry.name),
limits,
depth + 1,
&child,
budget,
out,
)?;
}
return Ok(());
}
out.push(leaf(name, data));
Ok(())
}
fn leaf(name: Option<&str>, data: &[u8]) -> Node {
Node::File {
name: name.unwrap_or_default().to_string(),
bytes: data.to_vec(),
}
}
fn strip_compression_ext(name: Option<&str>) -> Option<String> {
let name = name?;
let lower = name.to_ascii_lowercase();
for ext in [".gz", ".bz2", ".z"] {
if lower.ends_with(ext) {
return Some(name[..name.len() - ext.len()].to_string());
}
}
Some(name.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use flate2::write::GzEncoder;
use flate2::Compression;
use std::io::Write;
fn gzip(data: &[u8]) -> Vec<u8> {
let mut e = GzEncoder::new(Vec::new(), Compression::fast());
e.write_all(data).unwrap();
e.finish().unwrap()
}
fn tar_dir_and_file() -> Vec<u8> {
let mut b = tar::Builder::new(Vec::new());
let mut hd = tar::Header::new_gnu();
hd.set_entry_type(tar::EntryType::Directory);
hd.set_size(0);
hd.set_mode(0o755);
hd.set_cksum();
b.append_data(&mut hd, "d/", std::io::empty()).unwrap();
let mut hf = tar::Header::new_gnu();
hf.set_size(5);
hf.set_mode(0o644);
hf.set_cksum();
b.append_data(&mut hf, "d/f.txt", &b"hello"[..]).unwrap();
b.into_inner().unwrap()
}
#[test]
fn resolve_records_directory_entries() {
let nodes = resolve(&tar_dir_and_file(), Some("x.tar"), &Limits::default()).unwrap();
assert!(
nodes
.iter()
.any(|n| matches!(n, Node::Dir { name } if name == "d/")),
"the directory entry must be recorded, got {nodes:?}"
);
assert!(nodes.iter().any(
|n| matches!(n, Node::File { name, bytes } if name == "d/f.txt" && bytes == b"hello")
));
}
#[test]
fn nested_bomb_in_archive_member_propagates_error() {
let mut cur = b"deep".to_vec();
for _ in 0..10 {
cur = gzip(&cur);
}
let mut b = tar::Builder::new(Vec::new());
let mut h = tar::Header::new_gnu();
h.set_size(cur.len() as u64);
h.set_mode(0o644);
h.set_cksum();
b.append_data(&mut h, "bomb.gz", cur.as_slice()).unwrap();
let tar = b.into_inner().unwrap();
let err = resolve(&tar, Some("x.tar"), &Limits::default()).unwrap_err();
assert!(
format!("{err}").contains("depth"),
"expected a depth error propagated from the member, got: {err}"
);
}
}