use std::collections::BTreeMap;
use std::io::{Cursor, Read};
use zip::ZipArchive;
use crate::OfficeError;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ZipLimits {
pub max_entries: usize,
pub max_entry_bytes: u64,
pub max_total_bytes: u64,
pub max_ratio: u64,
}
impl ZipLimits {
#[must_use]
pub const fn office() -> Self {
Self {
max_entries: 4_096,
max_entry_bytes: 64 * 1024 * 1024,
max_total_bytes: 256 * 1024 * 1024,
max_ratio: 200,
}
}
}
pub fn read_zip_entries(
bytes: &[u8],
limits: &ZipLimits,
) -> Result<BTreeMap<String, Vec<u8>>, OfficeError> {
let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(zip_error)?;
if archive.len() > limits.max_entries {
return Err(package_too_large(
"entry count",
format!(
"archive declares {} entries; limit is {}",
archive.len(),
limits.max_entries
),
));
}
let mut total = 0_u64;
let mut entries = BTreeMap::new();
for index in 0..archive.len() {
let mut file = archive.by_index(index).map_err(zip_error)?;
if file.is_dir() {
continue;
}
let name = file.name().replace('\\', "/");
let declared = file.size();
let compressed = file.compressed_size();
reject_declared_entry_size(&name, declared, limits)?;
reject_compression_ratio(&name, declared, compressed, limits)?;
reject_declared_total(&name, total, declared, limits)?;
let mut data = Vec::new();
let read_limit = limits.max_entry_bytes.saturating_add(1).min(
limits
.max_total_bytes
.saturating_sub(total)
.saturating_add(1),
);
(&mut file)
.take(read_limit)
.read_to_end(&mut data)
.map_err(|err| {
OfficeError::Kernel(format!("could not read zip entry {name}: {err}"))
})?;
let actual = data.len() as u64;
if actual > limits.max_entry_bytes {
return Err(package_too_large(
"entry bytes",
format!("entry {name} expands past {} bytes", limits.max_entry_bytes),
));
}
total = total.checked_add(actual).ok_or_else(|| {
package_too_large(
"total bytes",
format!("entry {name} overflows total byte accounting"),
)
})?;
if total > limits.max_total_bytes {
return Err(package_too_large(
"total bytes",
format!(
"archive expands past {} bytes at entry {name}",
limits.max_total_bytes
),
));
}
entries.insert(name, data);
}
Ok(entries)
}
fn reject_declared_entry_size(
name: &str,
declared: u64,
limits: &ZipLimits,
) -> Result<(), OfficeError> {
if declared > limits.max_entry_bytes {
return Err(package_too_large(
"entry bytes",
format!(
"entry {name} declares {declared} bytes; limit is {}",
limits.max_entry_bytes
),
));
}
Ok(())
}
fn reject_declared_total(
name: &str,
total: u64,
declared: u64,
limits: &ZipLimits,
) -> Result<(), OfficeError> {
if total.saturating_add(declared) > limits.max_total_bytes {
return Err(package_too_large(
"total bytes",
format!(
"archive declares more than {} bytes at entry {name}",
limits.max_total_bytes
),
));
}
Ok(())
}
fn reject_compression_ratio(
name: &str,
declared: u64,
compressed: u64,
limits: &ZipLimits,
) -> Result<(), OfficeError> {
if declared == 0 {
return Ok(());
}
let ratio_limit = compressed.saturating_mul(limits.max_ratio);
if compressed == 0 || declared > ratio_limit {
return Err(package_too_large(
"compression ratio",
format!(
"entry {name} declares {declared} bytes from {compressed} compressed bytes; ratio limit is {}",
limits.max_ratio
),
));
}
Ok(())
}
fn zip_error(error: zip::result::ZipError) -> OfficeError {
OfficeError::Kernel(format!("invalid ZIP package: {error}"))
}
fn package_too_large(limit: &'static str, detail: String) -> OfficeError {
OfficeError::PackageTooLarge { limit, detail }
}