#[cfg(any(
feature = "archives",
feature = "hwpx",
feature = "iwork",
feature = "office",
feature = "excel"
))]
use std::io::{Read, Seek};
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[serde(default, deny_unknown_fields)]
pub struct SecurityLimits {
pub max_archive_size: usize,
pub max_compression_ratio: usize,
pub max_files_in_archive: usize,
pub max_nesting_depth: usize,
pub max_entity_length: usize,
pub max_content_size: usize,
pub max_iterations: usize,
pub max_xml_depth: usize,
pub max_table_cells: usize,
pub max_pages: Option<usize>,
}
impl Default for SecurityLimits {
fn default() -> Self {
Self {
max_archive_size: 500 * 1024 * 1024,
max_compression_ratio: 100,
max_files_in_archive: 10_000,
max_nesting_depth: 1024,
max_entity_length: 1024 * 1024,
max_content_size: 100 * 1024 * 1024,
max_iterations: 10_000_000,
max_xml_depth: 1024,
max_table_cells: 100_000,
max_pages: None,
}
}
}
#[cfg_attr(alef, alef(skip))]
#[derive(Debug, Clone)]
pub enum SecurityError {
ZipBombDetected {
compressed_size: u64,
uncompressed_size: u64,
ratio: f64,
},
ArchiveTooLarge {
size: u64,
max: usize,
},
TooManyFiles {
count: usize,
max: usize,
},
NestingTooDeep {
depth: usize,
max: usize,
},
ContentTooLarge {
size: usize,
max: usize,
},
EntityTooLong {
length: usize,
max: usize,
},
TooManyIterations {
count: usize,
max: usize,
},
XmlDepthExceeded {
depth: usize,
max: usize,
},
TooManyCells {
cells: usize,
max: usize,
},
TooManyPages {
count: usize,
max: usize,
},
UnreadableEntry {
index: usize,
reason: String,
},
}
impl std::fmt::Display for SecurityError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
SecurityError::ZipBombDetected {
compressed_size,
uncompressed_size,
ratio,
} => {
write!(
f,
"Potential ZIP bomb detected: compressed {}B -> uncompressed {}B (ratio: {:.1}:1)",
compressed_size, uncompressed_size, ratio
)
}
SecurityError::ArchiveTooLarge { size, max } => {
write!(f, "Archive too large: {} bytes (max: {} bytes)", size, max)
}
SecurityError::TooManyFiles { count, max } => {
write!(f, "Archive has too many files: {} (max: {})", count, max)
}
SecurityError::NestingTooDeep { depth, max } => {
write!(f, "Nesting too deep: {} levels (max: {})", depth, max)
}
SecurityError::ContentTooLarge { size, max } => {
write!(f, "Content too large: {} bytes (max: {} bytes)", size, max)
}
SecurityError::EntityTooLong { length, max } => {
write!(f, "Entity too long: {} chars (max: {})", length, max)
}
SecurityError::TooManyIterations { count, max } => {
write!(f, "Too many iterations: {} (max: {})", count, max)
}
SecurityError::XmlDepthExceeded { depth, max } => {
write!(f, "XML depth exceeded: {} (max: {})", depth, max)
}
SecurityError::TooManyCells { cells, max } => {
write!(
f,
"Table cell limit exceeded: observed {} cells, but \
`security_limits.max_table_cells` is {}. If this input is trusted, raise \
`security_limits.max_table_cells`; otherwise reduce or split the table.",
cells, max
)
}
SecurityError::TooManyPages { count, max } => {
write!(
f,
"Document has too many pages: {} (max: {}). Raise `security_limits.max_pages` \
if this document is legitimate, or split it before extraction.",
count, max
)
}
SecurityError::UnreadableEntry { index, reason } => {
write!(
f,
"Archive entry {} could not be read for security accounting: {}",
index, reason
)
}
}
}
}
impl std::error::Error for SecurityError {}
#[cfg(any(feature = "office", feature = "pdf", feature = "iwork", feature = "ocr"))]
pub(crate) fn enforce_page_count(count: usize, max_pages: Option<usize>) -> Result<(), SecurityError> {
match max_pages {
Some(max) if count > max => Err(SecurityError::TooManyPages { count, max }),
_ => Ok(()),
}
}
#[cfg(any(
feature = "archives",
feature = "hwpx",
feature = "iwork",
feature = "office",
feature = "excel"
))]
#[cfg_attr(alef, alef(skip))]
pub struct ZipBombValidator {
limits: SecurityLimits,
}
#[cfg(any(
feature = "archives",
feature = "hwpx",
feature = "iwork",
feature = "office",
feature = "excel"
))]
impl ZipBombValidator {
const MEMBER_RATIO_FLOOR: u64 = 1024 * 1024;
pub(crate) fn new(limits: SecurityLimits) -> Self {
Self { limits }
}
pub(crate) fn validate<R: Read + Seek>(&self, archive: &mut zip::ZipArchive<R>) -> Result<(), SecurityError> {
let file_count = archive.len();
if file_count > self.limits.max_files_in_archive {
return Err(SecurityError::TooManyFiles {
count: file_count,
max: self.limits.max_files_in_archive,
});
}
let max_archive_size = self.limits.max_archive_size as u64;
let max_compression_ratio = self.limits.max_compression_ratio as f64;
let mut total_uncompressed: u64 = 0;
let mut total_compressed: u64 = 0;
for index in 0..file_count {
let (compressed_size, uncompressed_size) = match archive.by_index_raw(index) {
Ok(file) => (file.compressed_size(), file.size()),
Err(error) => {
return Err(SecurityError::UnreadableEntry {
index,
reason: error.to_string(),
});
}
};
total_uncompressed = total_uncompressed.saturating_add(uncompressed_size);
total_compressed = total_compressed.saturating_add(compressed_size);
if uncompressed_size > 0 && (compressed_size == 0 || uncompressed_size >= Self::MEMBER_RATIO_FLOOR) {
let ratio = if compressed_size == 0 {
f64::INFINITY
} else {
uncompressed_size as f64 / compressed_size as f64
};
if ratio > max_compression_ratio {
return Err(SecurityError::ZipBombDetected {
compressed_size,
uncompressed_size,
ratio,
});
}
}
if total_uncompressed > max_archive_size {
return Err(SecurityError::ArchiveTooLarge {
size: total_uncompressed,
max: self.limits.max_archive_size,
});
}
}
if total_compressed > 0 {
let ratio = total_uncompressed as f64 / total_compressed as f64;
if ratio > max_compression_ratio {
return Err(SecurityError::ZipBombDetected {
compressed_size: total_compressed,
uncompressed_size: total_uncompressed,
ratio,
});
}
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub(crate) struct StringGrowthValidator {
max_size: usize,
current_size: usize,
}
impl StringGrowthValidator {
pub(crate) fn new(max_size: usize) -> Self {
Self {
max_size,
current_size: 0,
}
}
pub(crate) fn check_append(&mut self, len: usize) -> Result<(), SecurityError> {
self.current_size = self.current_size.saturating_add(len);
if self.current_size > self.max_size {
Err(SecurityError::ContentTooLarge {
size: self.current_size,
max: self.max_size,
})
} else {
Ok(())
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct IterationValidator {
max_iterations: usize,
current_count: usize,
}
impl IterationValidator {
pub(crate) fn new(max_iterations: usize) -> Self {
Self {
max_iterations,
current_count: 0,
}
}
pub(crate) fn check_iteration(&mut self) -> Result<(), SecurityError> {
self.current_count = self.current_count.saturating_add(1);
if self.current_count > self.max_iterations {
Err(SecurityError::TooManyIterations {
count: self.current_count,
max: self.max_iterations,
})
} else {
Ok(())
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct DepthValidator {
max_depth: usize,
current_depth: usize,
}
impl DepthValidator {
pub(crate) fn new(max_depth: usize) -> Self {
Self {
max_depth,
current_depth: 0,
}
}
pub(crate) fn push(&mut self) -> Result<(), SecurityError> {
self.current_depth = self.current_depth.saturating_add(1);
if self.current_depth > self.max_depth {
Err(SecurityError::NestingTooDeep {
depth: self.current_depth,
max: self.max_depth,
})
} else {
Ok(())
}
}
pub(crate) fn pop(&mut self) {
if self.current_depth > 0 {
self.current_depth -= 1;
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct EntityValidator {
max_length: usize,
}
impl EntityValidator {
pub(crate) fn new(max_length: usize) -> Self {
Self { max_length }
}
pub(crate) fn validate(&self, content: &str) -> Result<(), SecurityError> {
if content.len() > self.max_length {
Err(SecurityError::EntityTooLong {
length: content.len(),
max: self.max_length,
})
} else {
Ok(())
}
}
#[cfg(any(feature = "xml", feature = "office"))]
pub(crate) fn check_attr(&self, _name: &str, value: &str) -> Result<(), SecurityError> {
self.validate(value)
}
}
#[derive(Debug, Clone)]
pub(crate) struct TableValidator {
max_cells: usize,
current_cells: usize,
}
impl TableValidator {
pub(crate) fn new(max_cells: usize) -> Self {
Self {
max_cells,
current_cells: 0,
}
}
pub(crate) fn add_cells(&mut self, count: usize) -> Result<(), SecurityError> {
self.current_cells = self.current_cells.saturating_add(count);
if self.current_cells > self.max_cells {
Err(SecurityError::TooManyCells {
cells: self.current_cells,
max: self.max_cells,
})
} else {
Ok(())
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct SecurityBudget {
pub(crate) depth: DepthValidator,
pub(crate) iteration: IterationValidator,
pub(crate) entity: EntityValidator,
pub(crate) growth: StringGrowthValidator,
pub(crate) table: TableValidator,
}
impl SecurityBudget {
pub(crate) fn from_limits(limits: &SecurityLimits) -> Self {
Self {
depth: DepthValidator::new(limits.max_xml_depth.min(limits.max_nesting_depth)),
iteration: IterationValidator::new(limits.max_iterations),
entity: EntityValidator::new(limits.max_entity_length),
growth: StringGrowthValidator::new(limits.max_content_size),
table: TableValidator::new(limits.max_table_cells),
}
}
#[cfg(feature = "iwork")]
pub(crate) fn for_iwork(limits: &SecurityLimits) -> Self {
let mut budget = Self::from_limits(limits);
budget.depth = DepthValidator::new(limits.max_nesting_depth);
budget
}
pub(crate) fn from_config(config: &crate::core::config::ExtractionConfig) -> Self {
let owned: SecurityLimits;
let limits: &SecurityLimits = match config.security_limits.as_ref() {
Some(l) => l,
None => {
owned = SecurityLimits::default();
&owned
}
};
Self::from_limits(limits)
}
#[cfg(any(feature = "xml", feature = "office"))]
pub(crate) fn with_defaults() -> Self {
Self::from_limits(&SecurityLimits::default())
}
pub(crate) fn step(&mut self) -> Result<(), SecurityError> {
self.iteration.check_iteration()
}
pub(crate) fn enter(&mut self) -> Result<(), SecurityError> {
self.depth.push()
}
pub(crate) fn leave(&mut self) {
self.depth.pop();
}
#[cfg(feature = "office")]
pub(crate) fn depth_limit(&self) -> usize {
self.depth.max_depth
}
pub(crate) fn account_text(&mut self, len: usize) -> Result<(), SecurityError> {
self.growth.check_append(len)
}
#[cfg(any(feature = "xml", feature = "office"))]
pub(crate) fn check_attr(&self, name: &str, value: &str) -> Result<(), SecurityError> {
self.entity.check_attr(name, value)
}
pub(crate) fn check_entity(&self, value: &str) -> Result<(), SecurityError> {
self.entity.validate(value)
}
pub(crate) fn add_cells(&mut self, count: usize) -> Result<(), SecurityError> {
self.table.add_cells(count)
}
}
#[cfg(any(feature = "office", test))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PathTraversalError {
EscapesRoot,
InvalidByte,
DriveOrUncPrefix,
EmptyResult,
}
#[cfg(any(feature = "office", test))]
impl std::fmt::Display for PathTraversalError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::EscapesRoot => write!(f, "path escapes the container root"),
Self::InvalidByte => write!(f, "path contains a NUL byte"),
Self::DriveOrUncPrefix => write!(f, "path carries a drive letter or UNC prefix"),
Self::EmptyResult => write!(f, "path resolves to no entry"),
}
}
}
#[cfg(any(feature = "office", test))]
impl std::error::Error for PathTraversalError {}
#[cfg(any(feature = "office", test))]
pub(crate) fn resolve_container_entry(base: &str, target: &str) -> Result<String, PathTraversalError> {
if target.contains('\0') {
return Err(PathTraversalError::InvalidByte);
}
let normalized_target = target.replace('\\', "/");
if is_drive_or_unc_prefixed(&normalized_target) {
return Err(PathTraversalError::DriveOrUncPrefix);
}
let mut stack: Vec<&str> = Vec::new();
let effective: &str = match normalized_target.strip_prefix('/') {
Some(root_relative) => root_relative,
None => {
for segment in base.split('/') {
push_segment(&mut stack, segment)?;
}
normalized_target.as_str()
}
};
for segment in effective.split('/') {
push_segment(&mut stack, segment)?;
}
if stack.is_empty() {
return Err(PathTraversalError::EmptyResult);
}
Ok(stack.join("/"))
}
#[cfg(any(feature = "office", test))]
fn push_segment<'a>(stack: &mut Vec<&'a str>, segment: &'a str) -> Result<(), PathTraversalError> {
match segment {
"" | "." => {}
".." => {
if stack.pop().is_none() {
return Err(PathTraversalError::EscapesRoot);
}
}
_ => stack.push(segment),
}
Ok(())
}
#[cfg(any(feature = "office", test))]
fn is_drive_or_unc_prefixed(path: &str) -> bool {
let bytes = path.as_bytes();
path.starts_with("//") || (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "office")]
fn archive_with_compressible_entry(entry_size: usize) -> zip::ZipArchive<std::io::Cursor<Vec<u8>>> {
use std::io::{Cursor, Write};
use zip::write::SimpleFileOptions;
const STORED_BALLAST_SIZE: usize = 4 * 1024 * 1024;
let mut bytes = Vec::new();
{
let mut writer = zip::ZipWriter::new(Cursor::new(&mut bytes));
let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
writer.start_file("ballast.bin", stored).expect("start stored entry");
writer
.write_all(&vec![0x5a; STORED_BALLAST_SIZE])
.expect("write stored ballast");
let deflated = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
writer
.start_file("compact.bin", deflated)
.expect("start deflated entry");
writer.write_all(&vec![0; entry_size]).expect("write compact entry");
writer.finish().expect("finish ZIP");
}
zip::ZipArchive::new(Cursor::new(bytes)).expect("open ZIP")
}
#[cfg(feature = "office")]
#[test]
fn zip_ratio_allows_small_highly_compressible_entry() {
let mut archive = archive_with_compressible_entry(337 * 1024);
assert!(
ZipBombValidator::new(SecurityLimits::default())
.validate(&mut archive)
.is_ok()
);
}
#[cfg(feature = "office")]
#[test]
fn zip_ratio_rejects_large_highly_compressible_entry() {
let mut archive = archive_with_compressible_entry(4 * 1024 * 1024);
assert!(matches!(
ZipBombValidator::new(SecurityLimits::default()).validate(&mut archive),
Err(SecurityError::ZipBombDetected { uncompressed_size, .. })
if uncompressed_size == 4 * 1024 * 1024
));
}
#[test]
fn test_default_limits() {
let limits = SecurityLimits::default();
assert_eq!(limits.max_archive_size, 500 * 1024 * 1024);
assert_eq!(limits.max_nesting_depth, 1024);
assert_eq!(limits.max_entity_length, 1024 * 1024);
assert_eq!(limits.max_table_cells, 100_000);
}
#[test]
fn test_default_max_pages_is_unlimited() {
assert_eq!(SecurityLimits::default().max_pages, None);
}
#[test]
fn test_too_many_pages_display_names_the_limit() {
let error = SecurityError::TooManyPages {
count: 4_000,
max: 1_000,
};
let message = error.to_string();
assert!(
message.contains("4000"),
"message must name the observed count: {message}"
);
assert!(
message.contains("1000"),
"message must name the configured max: {message}"
);
assert!(
message.contains("max_pages"),
"message must name the limit that was hit: {message}"
);
}
#[test]
fn test_string_growth_validator_basic() {
let mut v = StringGrowthValidator::new(100);
assert!(v.check_append(50).is_ok());
assert_eq!(v.current_size, 50);
assert!(v.check_append(50).is_ok());
assert_eq!(v.current_size, 100);
assert!(matches!(
v.check_append(1),
Err(SecurityError::ContentTooLarge { size: 101, max: 100 })
));
}
#[test]
fn test_string_growth_validator_saturates_on_overflow() {
let mut v = StringGrowthValidator::new(usize::MAX - 10);
assert!(v.check_append(usize::MAX).is_err(), "saturating add cannot wrap");
}
#[test]
fn test_iteration_validator_basic() {
let mut v = IterationValidator::new(3);
assert!(v.check_iteration().is_ok());
assert!(v.check_iteration().is_ok());
assert!(v.check_iteration().is_ok());
assert!(matches!(
v.check_iteration(),
Err(SecurityError::TooManyIterations { count: 4, max: 3 })
));
}
#[test]
fn test_depth_validator_push_pop() {
let mut v = DepthValidator::new(3);
assert!(v.push().is_ok());
assert!(v.push().is_ok());
assert!(v.push().is_ok());
assert_eq!(v.current_depth, 3);
assert!(matches!(
v.push(),
Err(SecurityError::NestingTooDeep { depth: 4, max: 3 })
));
v.pop();
assert_eq!(v.current_depth, 3);
}
#[test]
fn test_depth_validator_pop_saturates_at_zero() {
let mut v = DepthValidator::new(10);
v.pop();
v.pop();
assert_eq!(v.current_depth, 0, "underflow is impossible");
}
#[test]
fn test_entity_validator() {
let v = EntityValidator::new(10);
assert!(v.validate("short").is_ok());
assert!(v.validate("0123456789").is_ok());
assert!(matches!(
v.validate("01234567890"),
Err(SecurityError::EntityTooLong { length: 11, max: 10 })
));
#[cfg(any(feature = "xml", feature = "office"))]
{
assert!(v.check_attr("href", "http://x").is_ok());
assert!(v.check_attr("data", &"x".repeat(50)).is_err());
}
}
#[test]
fn test_table_validator() {
let mut v = TableValidator::new(10);
assert!(v.add_cells(5).is_ok());
assert_eq!(v.current_cells, 5);
assert!(v.add_cells(5).is_ok());
assert_eq!(v.current_cells, 10);
let error = v
.add_cells(1)
.expect_err("the eleventh cell must exceed a ten-cell budget");
assert!(matches!(&error, SecurityError::TooManyCells { cells: 11, max: 10 }));
assert_eq!(
error.to_string(),
"Table cell limit exceeded: observed 11 cells, but `security_limits.max_table_cells` is 10. \
If this input is trusted, raise `security_limits.max_table_cells`; otherwise reduce or split the table."
);
}
#[test]
fn test_security_budget_depth_uses_the_tighter_of_the_two_configured_limits() {
let nesting_is_tighter = SecurityLimits {
max_xml_depth: 1024,
max_nesting_depth: 5,
..SecurityLimits::default()
};
assert_eq!(
SecurityBudget::from_limits(&nesting_is_tighter).depth.max_depth,
5,
"a tightened max_nesting_depth must not be discarded in favour of max_xml_depth"
);
let xml_is_tighter = SecurityLimits {
max_xml_depth: 3,
max_nesting_depth: 1024,
..SecurityLimits::default()
};
assert_eq!(
SecurityBudget::from_limits(&xml_is_tighter).depth.max_depth,
3,
"a tightened max_xml_depth must not be discarded in favour of max_nesting_depth"
);
assert_eq!(
SecurityBudget::from_limits(&SecurityLimits::default()).depth.max_depth,
1024,
"both defaults are 1024, so the default budget is unchanged"
);
}
#[test]
fn parent_relative_target_in_bounds_pops_into_the_root() {
assert_eq!(resolve_container_entry("a", "../x"), Ok("x".to_string()));
}
#[test]
fn parent_relative_target_out_of_bounds_at_the_root_is_rejected() {
assert_eq!(
resolve_container_entry("", "../x"),
Err(PathTraversalError::EscapesRoot)
);
}
#[test]
fn double_parent_within_a_single_level_base_escapes() {
assert_eq!(
resolve_container_entry("", "a/../../x"),
Err(PathTraversalError::EscapesRoot)
);
}
#[test]
fn double_parent_within_a_two_level_base_is_in_bounds() {
assert_eq!(resolve_container_entry("root", "a/../../x"), Ok("x".to_string()));
}
#[test]
fn absolute_target_is_root_relative_and_ignores_base() {
assert_eq!(resolve_container_entry("word", "/abs/x"), Ok("abs/x".to_string()));
}
#[test]
fn windows_drive_letter_target_is_rejected() {
assert_eq!(
resolve_container_entry("word", "C:\\x"),
Err(PathTraversalError::DriveOrUncPrefix)
);
}
#[test]
fn unc_style_target_is_rejected() {
assert_eq!(
resolve_container_entry("word", "\\\\server\\share\\x"),
Err(PathTraversalError::DriveOrUncPrefix)
);
}
#[test]
fn backslash_traversal_is_normalised_the_same_as_forward_slash() {
assert_eq!(
resolve_container_entry("", "a\\..\\..\\x"),
Err(PathTraversalError::EscapesRoot)
);
}
#[test]
fn dot_segments_are_transparent_to_in_bounds_traversal() {
assert_eq!(resolve_container_entry("", "a/./../x"), Ok("x".to_string()));
}
#[test]
fn four_dots_is_a_literal_component_not_a_traversal_token() {
assert_eq!(resolve_container_entry("", "....//x"), Ok("..../x".to_string()));
}
#[test]
fn bare_dotdot_against_a_one_level_base_has_no_file_left_to_resolve() {
assert_eq!(resolve_container_entry("a", ".."), Err(PathTraversalError::EmptyResult));
}
#[test]
fn bare_dotdot_against_the_root_escapes() {
assert_eq!(resolve_container_entry("", ".."), Err(PathTraversalError::EscapesRoot));
}
#[test]
fn empty_components_are_skipped() {
assert_eq!(resolve_container_entry("", "a//b"), Ok("a/b".to_string()));
}
#[test]
fn trailing_dotdot_resolves_to_the_base_directory_itself() {
assert_eq!(resolve_container_entry("root", "a/.."), Ok("root".to_string()));
}
#[test]
fn nul_byte_is_rejected_outright() {
assert_eq!(
resolve_container_entry("word", "media/\0image1.png"),
Err(PathTraversalError::InvalidByte)
);
}
#[test]
fn percent_encoded_traversal_is_never_decoded_by_this_function() {
assert_eq!(
resolve_container_entry("base", "%2e%2e%2f"),
Ok("base/%2e%2e%2f".to_string())
);
}
#[test]
fn multibyte_character_after_dotdot_is_a_literal_segment_not_a_slice_panic() {
assert_eq!(
resolve_container_entry("base", "..\u{1F600}/x"),
Ok("base/..\u{1F600}/x".to_string())
);
}
#[test]
fn docx_word_relative_target_climbs_to_the_package_root_media_directory() {
assert_eq!(
resolve_container_entry("word", "../media/image1.png"),
Ok("media/image1.png".to_string())
);
}
#[test]
fn docx_word_relative_target_that_truly_escapes_the_package_still_errors() {
assert_eq!(
resolve_container_entry("word", "../../../etc/passwd"),
Err(PathTraversalError::EscapesRoot)
);
}
#[test]
fn docx_absolute_target_reroots_to_the_package_relative_name() {
assert_eq!(
resolve_container_entry("word", "/media/image1.png"),
Ok("media/image1.png".to_string())
);
}
#[cfg(feature = "office")]
fn incompressible(len: usize) -> Vec<u8> {
let mut state = 0x9E37_79B9u32;
(0..len)
.map(|_| {
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
(state >> 24) as u8
})
.collect()
}
#[cfg(feature = "office")]
fn deflated_archive(members: &[(&str, Vec<u8>)]) -> zip::ZipArchive<std::io::Cursor<Vec<u8>>> {
use std::io::Write;
let mut cursor = std::io::Cursor::new(Vec::new());
{
let mut writer = zip::ZipWriter::new(&mut cursor);
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
for (name, bytes) in members {
writer.start_file(*name, options).expect("start_file");
writer.write_all(bytes).expect("write");
}
writer.finish().expect("finish");
}
cursor.set_position(0);
zip::ZipArchive::new(cursor).expect("archive")
}
#[cfg(feature = "office")]
#[test]
fn zip_bomb_ratio_cap_ignores_small_members_and_keeps_large_ones() {
let validator = ZipBombValidator::new(SecurityLimits::default());
let mut small = deflated_archive(&[
("blank.jpg", vec![b'A'; 200 * 1024]),
("photo.jpg", incompressible(2 * 1024 * 1024)),
]);
validator
.validate(&mut small)
.expect("a 200 KiB member past the ratio cap is not a bomb");
let mut large = deflated_archive(&[
("bomb.bin", vec![b'A'; 8 * 1024 * 1024]),
("photo.jpg", incompressible(2 * 1024 * 1024)),
]);
let error = validator
.validate(&mut large)
.expect_err("an 8 MiB member past the ratio cap is rejected");
assert!(matches!(error, SecurityError::ZipBombDetected { .. }), "{error}");
}
}