use std::io::{Cursor, Read};
use serde::Deserialize;
use super::ConvertError;
pub const CONTAINER_ZIP_ID: &str = "container-zip";
pub const CONTAINER_ZIP_VERSION: &str = "1.0.0";
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ContainerLimits {
pub max_depth: usize,
pub max_children: usize,
pub max_expanded_bytes: u64,
pub max_member_size: u64,
}
impl Default for ContainerLimits {
fn default() -> Self {
ContainerLimits {
max_depth: 4,
max_children: 1000,
max_expanded_bytes: 1_073_741_824,
max_member_size: 268_435_456,
}
}
}
#[derive(Debug, Clone)]
pub struct MemberMeta {
pub name: String,
pub safe_path: Option<String>,
pub declared_size: u64,
pub is_dir: bool,
pub is_symlink: bool,
pub encrypted: bool,
}
pub enum Inflated {
Ok(Vec<u8>),
TooLarge(u64),
Error(String),
}
pub struct ZipContainer<'a> {
archive: zip::ZipArchive<Cursor<&'a [u8]>>,
}
fn open_error(detail: impl std::fmt::Display) -> ConvertError {
ConvertError {
code: "container-unreadable",
message: format!("zip central directory unreadable: {detail}"),
}
}
impl<'a> ZipContainer<'a> {
pub fn open(bytes: &'a [u8]) -> Result<Self, ConvertError> {
let archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(open_error)?;
Ok(ZipContainer { archive })
}
pub fn len(&self) -> usize {
self.archive.len()
}
pub fn is_empty(&self) -> bool {
self.archive.is_empty()
}
pub fn listing(&mut self) -> Result<String, ConvertError> {
let mut out = String::new();
for index in 0..self.archive.len() {
let entry = self.archive.by_index(index).map_err(open_error)?;
out.push_str(&escape_listing_name(entry.name()));
out.push('\t');
out.push_str(&entry.size().to_string());
out.push('\n');
}
Ok(out)
}
pub fn meta(&mut self, index: usize) -> Result<MemberMeta, ConvertError> {
let entry = self.archive.by_index(index).map_err(open_error)?;
let safe_path = classify_member_name(entry.name());
Ok(MemberMeta {
name: entry.name().to_string(),
safe_path,
declared_size: entry.size(),
is_dir: entry.is_dir(),
is_symlink: entry.is_symlink(),
encrypted: entry.encrypted(),
})
}
pub fn inflate(&mut self, index: usize, cap: u64) -> Inflated {
let entry = match self.archive.by_index(index) {
Ok(entry) => entry,
Err(e) => return Inflated::Error(e.to_string()),
};
let mut buffer = Vec::new();
match entry.take(cap + 1).read_to_end(&mut buffer) {
Ok(_) => {
if buffer.len() as u64 > cap {
Inflated::TooLarge(buffer.len() as u64)
} else {
Inflated::Ok(buffer)
}
}
Err(e) => Inflated::Error(e.to_string()),
}
}
}
pub fn classify_member_name(raw: &str) -> Option<String> {
if raw.is_empty() {
return None;
}
if raw.chars().any(|c| c.is_control()) {
return None;
}
if raw.starts_with('/') || raw.starts_with('\\') {
return None;
}
let drive = raw.as_bytes();
if drive.len() >= 2 && drive[0].is_ascii_alphabetic() && drive[1] == b':' {
return None;
}
let mut segments = Vec::new();
for segment in raw.split(['/', '\\']) {
if segment.is_empty() || segment == "." || segment == ".." {
return None;
}
segments.push(segment);
}
if segments.is_empty() {
return None;
}
Some(segments.join("/"))
}
fn escape_listing_name(name: &str) -> String {
let mut out = String::with_capacity(name.len());
for c in name.chars() {
match c {
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
c => out.push(c),
}
}
out
}
pub fn preflight_entry_count(bytes: &[u8]) -> Option<u64> {
const EOCD_SIG: [u8; 4] = [0x50, 0x4b, 0x05, 0x06];
const LOCATOR_SIG: [u8; 4] = [0x50, 0x4b, 0x06, 0x07];
const ZIP64_EOCD_SIG: [u8; 4] = [0x50, 0x4b, 0x06, 0x06];
let scan_start = bytes.len().saturating_sub(22 + 0xFFFF);
let window = &bytes[scan_start..];
let mut eocd = None;
let upper = window.len().saturating_sub(4);
for start in (0..=upper).rev() {
if window[start..start + 4] == EOCD_SIG {
eocd = Some(scan_start + start);
break;
}
}
let eocd = eocd?;
if eocd + 22 > bytes.len() {
return None;
}
let total = u16::from_le_bytes([bytes[eocd + 10], bytes[eocd + 11]]) as u64;
if total != 0xFFFF {
return Some(total);
}
if eocd < 20 {
return Some(total);
}
let locator = eocd - 20;
if bytes[locator..locator + 4] != LOCATOR_SIG {
return Some(total);
}
let z64_offset = u64::from_le_bytes(bytes[locator + 8..locator + 16].try_into().ok()?) as usize;
if z64_offset + 40 > bytes.len() || bytes[z64_offset..z64_offset + 4] != ZIP64_EOCD_SIG {
return Some(total);
}
Some(u64::from_le_bytes(
bytes[z64_offset + 32..z64_offset + 40].try_into().ok()?,
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use zip::write::SimpleFileOptions;
fn zip_with(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, bytes) in entries {
writer
.start_file(*name, SimpleFileOptions::default())
.unwrap();
writer.write_all(bytes).unwrap();
}
writer.finish().unwrap().into_inner()
}
#[test]
fn listing_names_every_entry_with_its_declared_size() {
let bytes = zip_with(&[("a.txt", b"hello"), ("dir/b.txt", b"hi")]);
let Ok(mut container) = ZipContainer::open(&bytes) else {
panic!("open failed")
};
assert_eq!(container.len(), 2);
let listing = container.listing().unwrap();
assert_eq!(listing, "a.txt\t5\ndir/b.txt\t2\n");
}
#[test]
fn a_normal_member_has_a_safe_path() {
let bytes = zip_with(&[("inner/report.txt", b"x")]);
let Ok(mut container) = ZipContainer::open(&bytes) else {
panic!("open failed")
};
let meta = container.meta(0).unwrap();
assert_eq!(meta.safe_path.as_deref(), Some("inner/report.txt"));
assert!(!meta.is_dir && !meta.is_symlink && !meta.encrypted);
}
#[test]
fn a_traversal_member_has_no_safe_path() {
let bytes = zip_with(&[("../escape.txt", b"x")]);
let Ok(mut container) = ZipContainer::open(&bytes) else {
panic!("open failed")
};
let meta = container.meta(0).unwrap();
assert_eq!(meta.safe_path, None);
}
#[test]
fn inflation_stops_one_byte_past_the_cap() {
let big = [b'x'; 100];
let bytes = zip_with(&[("big.txt", &big)]);
let Ok(mut container) = ZipContainer::open(&bytes) else {
panic!("open failed")
};
match container.inflate(0, 10) {
Inflated::TooLarge(n) => assert_eq!(n, 11),
_ => panic!("expected TooLarge"),
}
match container.inflate(0, 1000) {
Inflated::Ok(data) => assert_eq!(data.len(), 100),
_ => panic!("expected Ok"),
}
}
#[test]
fn member_name_validation_refuses_unsafe_shapes() {
assert_eq!(
classify_member_name("inner/report.txt").as_deref(),
Some("inner/report.txt")
);
assert_eq!(classify_member_name("a\\b.txt").as_deref(), Some("a/b.txt"));
assert_eq!(classify_member_name("../escape.txt"), None);
assert_eq!(classify_member_name("/abs.txt"), None);
assert_eq!(classify_member_name("C:\\win.txt"), None);
assert_eq!(classify_member_name("line1\nline2.txt"), None);
assert_eq!(classify_member_name("tab\there.txt"), None);
assert_eq!(classify_member_name(""), None);
assert_eq!(classify_member_name("dir/"), None);
}
#[test]
fn listing_escapes_control_characters_in_names() {
let bytes = zip_with(&[("a\tb.txt", b"x"), ("c\nd.txt", b"y")]);
let Ok(mut container) = ZipContainer::open(&bytes) else {
panic!("open failed")
};
let listing = container.listing().unwrap();
assert!(listing.contains("a\\tb.txt\t1\n"), "{listing:?}");
assert!(listing.contains("c\\nd.txt\t1\n"), "{listing:?}");
assert_eq!(listing.lines().count(), 2);
}
#[test]
fn preflight_reads_the_entry_count_from_the_eocd() {
let bytes = zip_with(&[("a.txt", b"1"), ("b.txt", b"2"), ("c.txt", b"3")]);
assert_eq!(preflight_entry_count(&bytes), Some(3));
assert_eq!(preflight_entry_count(b"not a zip"), None);
}
#[test]
fn a_non_zip_fails_to_open() {
let err = match ZipContainer::open(b"not a zip at all") {
Ok(_) => panic!("expected an open error"),
Err(e) => e,
};
assert_eq!(err.code, "container-unreadable");
}
}