use super::*;
use std::io::Write;
fn make_zip_with_file(entry_path: &str, entry_data: &[u8]) -> Vec<u8> {
make_zip_with_files(&[(entry_path, entry_data)])
}
fn make_zip_with_files(entries: &[(&str, &[u8])]) -> Vec<u8> {
let buf = Cursor::new(Vec::new());
let mut zip = zip::ZipWriter::new(buf);
let options = zip::write::FileOptions::<()>::default().compression_method(zip::CompressionMethod::Stored);
for (entry_path, entry_data) in entries {
zip.start_file(*entry_path, options).unwrap();
zip.write_all(entry_data).unwrap();
}
zip.finish().unwrap().into_inner()
}
fn crc32_ieee(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &byte in data {
crc ^= byte as u32;
for _ in 0..8 {
let mask = (crc & 1).wrapping_neg();
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
}
}
!crc
}
fn make_forged_zip64_entry(entry_name: &str, payload: &[u8], forged_uncompressed_size: u64) -> Vec<u8> {
let name_bytes = entry_name.as_bytes();
let crc = crc32_ieee(payload);
let compressed_size = payload.len() as u32;
let mut out = Vec::new();
let local_header_start = out.len() as u32;
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); out.extend_from_slice(&20u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&crc.to_le_bytes()); out.extend_from_slice(&compressed_size.to_le_bytes()); out.extend_from_slice(&compressed_size.to_le_bytes()); out.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(name_bytes);
out.extend_from_slice(payload);
let mut zip64_extra = Vec::new();
zip64_extra.extend_from_slice(&0x0001u16.to_le_bytes()); zip64_extra.extend_from_slice(&8u16.to_le_bytes()); zip64_extra.extend_from_slice(&forged_uncompressed_size.to_le_bytes());
assert_eq!(zip64_extra.len(), 12);
let central_header_start = out.len() as u32;
out.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); out.extend_from_slice(&45u16.to_le_bytes()); out.extend_from_slice(&45u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&crc.to_le_bytes()); out.extend_from_slice(&compressed_size.to_le_bytes()); out.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); out.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes()); out.extend_from_slice(&(zip64_extra.len() as u16).to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u32.to_le_bytes()); out.extend_from_slice(&local_header_start.to_le_bytes()); out.extend_from_slice(name_bytes);
out.extend_from_slice(&zip64_extra);
let central_directory_size = out.len() as u32 - central_header_start;
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&1u16.to_le_bytes()); out.extend_from_slice(&1u16.to_le_bytes()); out.extend_from_slice(¢ral_directory_size.to_le_bytes());
out.extend_from_slice(¢ral_header_start.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out
}
const UNDETECTABLE_MIME_BYTES: &[u8] = &[0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87];
#[tokio::test]
async fn test_embedded_file_over_cap_skipped_with_warning() {
let data = b"Hello world! This is a test document.";
let zip_bytes = make_zip_with_file("word/embeddings/doc.txt", data);
let config = ExtractionConfig {
max_embedded_file_bytes: Some(10),
..Default::default()
};
let (children, warnings) = extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;
assert!(
children.is_empty(),
"oversized embedded file must not produce a child entry"
);
assert_eq!(warnings.len(), 1, "exactly one warning expected");
assert!(
warnings[0].message.contains("exceeds cap"),
"warning must mention cap: {}",
warnings[0].message
);
assert!(
warnings[0].message.contains("doc.txt"),
"warning must name the file: {}",
warnings[0].message
);
}
#[tokio::test]
async fn test_embedded_file_under_cap_proceeds_to_extraction() {
let data = b"Hello";
let zip_bytes = make_zip_with_file("word/embeddings/note.txt", data);
let config = ExtractionConfig {
max_embedded_file_bytes: Some(1024 * 1024),
..Default::default()
};
let (_children, warnings) = extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;
let cap_warnings: Vec<_> = warnings.iter().filter(|w| w.message.contains("exceeds cap")).collect();
assert!(cap_warnings.is_empty(), "no size-cap warning expected for small file");
}
#[tokio::test]
async fn test_embedded_file_no_cap_proceeds() {
let data = b"some content";
let zip_bytes = make_zip_with_file("word/embeddings/file.txt", data);
let config = ExtractionConfig {
max_embedded_file_bytes: None,
..Default::default()
};
let (_children, warnings) = extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;
let cap_warnings: Vec<_> = warnings.iter().filter(|w| w.message.contains("exceeds cap")).collect();
assert!(cap_warnings.is_empty(), "no size-cap warning when cap is None");
}
#[cfg(feature = "excel")]
fn make_ole_package(payload: &[u8]) -> Vec<u8> {
let cursor = Cursor::new(Vec::new());
let mut comp = cfb::CompoundFile::create(cursor).expect("create CFB container");
{
let mut stream = comp.create_stream("Package").expect("create Package stream");
stream.write_all(payload).unwrap();
}
comp.into_inner().into_inner()
}
fn make_ole_with_stream(stream_name: &str, payload: &[u8]) -> Vec<u8> {
let cursor = Cursor::new(Vec::new());
let mut comp = cfb::CompoundFile::create(cursor).expect("create CFB container");
{
let mut stream = comp.create_stream(stream_name).expect("create stream");
stream.write_all(payload).unwrap();
}
comp.into_inner().into_inner()
}
#[cfg(feature = "excel")]
fn test_documents_dir() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("test_documents")
}
#[cfg(feature = "excel")]
#[tokio::test]
async fn test_ole_package_stream_extracted_as_embedded_xlsx() {
let fixture = test_documents_dir().join("xlsx/excel_tiny_excel.xlsx");
if !fixture.exists() {
eprintln!(
"Skipping test: test_documents/ fixture not found at {}",
fixture.display()
);
return;
}
let xlsx_bytes = std::fs::read(&fixture).expect("read fixture xlsx");
let ole_bytes = make_ole_package(&xlsx_bytes);
let zip_bytes = make_zip_with_file("word/embeddings/oleObject1.bin", &ole_bytes);
let config = ExtractionConfig::default();
let (children, warnings) = extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;
assert_eq!(
children.len(),
1,
"the OLE Package stream must be unwrapped and recursively extracted; warnings: {:?}",
warnings
);
assert!(
children[0].mime_type.contains("spreadsheet") || children[0].mime_type.contains("excel"),
"expected an Excel MIME type, got '{}'",
children[0].mime_type
);
}
#[tokio::test]
async fn test_legacy_word_document_ole_stream_is_identified_not_skipped() {
let ole_bytes = make_ole_with_stream("WordDocument", b"not-a-real-fib-but-present");
let zip_bytes = make_zip_with_file("word/embeddings/oleObject2.bin", &ole_bytes);
let config = ExtractionConfig::default();
let (children, warnings) = extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;
assert!(children.is_empty());
assert_eq!(warnings.len(), 1, "expected exactly one warning: {:?}", warnings);
assert!(
!warnings[0].message.contains("format identification not supported"),
"a recognized WordDocument stream must not be reported as unidentifiable: {}",
warnings[0].message
);
}
#[tokio::test]
async fn test_unidentifiable_ole_container_still_warns() {
let ole_bytes = make_ole_with_stream("SomeUnknownStream", b"opaque binary data");
let zip_bytes = make_zip_with_file("word/embeddings/oleObject3.bin", &ole_bytes);
let config = ExtractionConfig::default();
let (children, warnings) = extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;
assert!(children.is_empty());
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("format identification not supported"));
assert!(warnings[0].message.contains("oleObject3.bin"));
}
#[tokio::test]
async fn test_undetectable_mime_now_warns_instead_of_silent_skip() {
let data = vec![0x80u8, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87];
let zip_bytes = make_zip_with_file("word/embeddings/mystery_blob", &data);
let config = ExtractionConfig::default();
let (children, warnings) = extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;
assert!(children.is_empty());
assert_eq!(warnings.len(), 1, "expected exactly one warning: {:?}", warnings);
assert!(
warnings[0].message.contains("mystery_blob"),
"warning must name the file: {}",
warnings[0].message
);
assert!(
warnings[0].message.contains("MIME type could not be determined"),
"warning must explain why the file was skipped: {}",
warnings[0].message
);
}
#[tokio::test]
async fn test_depth_exhausted_with_embeddings_present_warns() {
let data = b"Hello world! This is a test document.";
let zip_bytes = make_zip_with_file("word/embeddings/doc.txt", data);
let config = ExtractionConfig {
max_archive_depth: 0,
..Default::default()
};
let (children, warnings) = extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;
assert!(children.is_empty());
assert_eq!(warnings.len(), 1, "expected exactly one warning: {:?}", warnings);
assert!(
warnings[0].message.contains("max_archive_depth"),
"warning must explain why embeddings were skipped: {}",
warnings[0].message
);
}
#[tokio::test]
async fn test_depth_exhausted_with_no_embeddings_does_not_warn() {
let zip_bytes = make_zip_with_file("word/document.xml", b"<w:document/>");
let config = ExtractionConfig {
max_archive_depth: 0,
..Default::default()
};
let (children, warnings) = extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;
assert!(children.is_empty());
assert!(
warnings.is_empty(),
"no embeddings exist, so no depth warning should be emitted: {:?}",
warnings
);
}
#[tokio::test]
async fn test_embedded_objects_exceeding_max_files_in_archive_are_rejected() {
let entries: Vec<(String, Vec<u8>)> = (0..5)
.map(|i| (format!("word/embeddings/blob{i}"), UNDETECTABLE_MIME_BYTES.to_vec()))
.collect();
let entry_refs: Vec<(&str, &[u8])> = entries.iter().map(|(p, d)| (p.as_str(), d.as_slice())).collect();
let zip_bytes = make_zip_with_files(&entry_refs);
let config = ExtractionConfig {
security_limits: Some(crate::extractors::security::SecurityLimits {
max_files_in_archive: 2,
..Default::default()
}),
..Default::default()
};
let (children, warnings) = extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;
assert!(children.is_empty(), "undetectable-MIME entries never produce children");
let cap_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.message.contains("max_files_in_archive"))
.collect();
assert_eq!(
cap_warnings.len(),
1,
"expected exactly one cap warning: {:?}",
warnings
);
assert!(
cap_warnings[0].message.contains("Skipped 3"),
"warning must report the 3 skipped entries: {}",
cap_warnings[0].message
);
assert!(
cap_warnings[0].message.contains("max_files_in_archive (2)"),
"warning must name the limit that was hit: {}",
cap_warnings[0].message
);
let processed_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.message.contains("MIME type could not be determined"))
.collect();
assert_eq!(
processed_warnings.len(),
2,
"only max_files_in_archive (2) entries must be processed, not all 5: {:?}",
warnings
);
}
#[tokio::test]
async fn test_embedded_objects_just_under_max_files_in_archive_all_process() {
let entries: Vec<(String, Vec<u8>)> = (0..4)
.map(|i| (format!("word/embeddings/blob{i}"), UNDETECTABLE_MIME_BYTES.to_vec()))
.collect();
let entry_refs: Vec<(&str, &[u8])> = entries.iter().map(|(p, d)| (p.as_str(), d.as_slice())).collect();
let zip_bytes = make_zip_with_files(&entry_refs);
let config = ExtractionConfig {
security_limits: Some(crate::extractors::security::SecurityLimits {
max_files_in_archive: 5,
..Default::default()
}),
..Default::default()
};
let (_children, warnings) = extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;
let cap_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.message.contains("max_files_in_archive"))
.collect();
assert!(
cap_warnings.is_empty(),
"no cap warning expected when the count is under the limit: {:?}",
warnings
);
let processed_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.message.contains("MIME type could not be determined"))
.collect();
assert_eq!(
processed_warnings.len(),
4,
"all 4 entries must be processed when under the cap: {:?}",
warnings
);
}
#[tokio::test]
async fn test_legitimate_document_under_max_files_in_archive_extracts_successfully() {
let zip_bytes = make_zip_with_file("word/embeddings/note.txt", b"Hello, world!");
let config = ExtractionConfig {
security_limits: Some(crate::extractors::security::SecurityLimits {
max_files_in_archive: 10,
..Default::default()
}),
..Default::default()
};
let (children, warnings) = extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;
assert_eq!(
children.len(),
1,
"the single embedded file, well under the cap, must be extracted: {:?}",
warnings
);
assert!(
!warnings.iter().any(|w| w.message.contains("max_files_in_archive")),
"no cap warning expected: {:?}",
warnings
);
}
#[tokio::test]
async fn test_nested_container_enforces_max_files_in_archive_independently() {
let outer_entries: Vec<(String, Vec<u8>)> = (0..2)
.map(|i| (format!("word/embeddings/outer{i}"), UNDETECTABLE_MIME_BYTES.to_vec()))
.collect();
let outer_refs: Vec<(&str, &[u8])> = outer_entries.iter().map(|(p, d)| (p.as_str(), d.as_slice())).collect();
let outer_zip_bytes = make_zip_with_files(&outer_refs);
let inner_entries: Vec<(String, Vec<u8>)> = (0..5)
.map(|i| (format!("word/embeddings/inner{i}"), UNDETECTABLE_MIME_BYTES.to_vec()))
.collect();
let inner_refs: Vec<(&str, &[u8])> = inner_entries.iter().map(|(p, d)| (p.as_str(), d.as_slice())).collect();
let inner_zip_bytes = make_zip_with_files(&inner_refs);
let config = ExtractionConfig {
security_limits: Some(crate::extractors::security::SecurityLimits {
max_files_in_archive: 2,
..Default::default()
}),
..Default::default()
};
let (_outer_children, outer_warnings) =
extract_ooxml_embedded_objects(&outer_zip_bytes, "word/embeddings/", "outer", &config).await;
let (_inner_children, inner_warnings) =
extract_ooxml_embedded_objects(&inner_zip_bytes, "word/embeddings/", "inner", &config).await;
assert!(
!outer_warnings
.iter()
.any(|w| w.message.contains("max_files_in_archive")),
"outer container is exactly at the cap and must not warn: {:?}",
outer_warnings
);
let inner_cap_warnings: Vec<_> = inner_warnings
.iter()
.filter(|w| w.message.contains("max_files_in_archive"))
.collect();
assert_eq!(
inner_cap_warnings.len(),
1,
"inner container independently exceeds the same cap: {:?}",
inner_warnings
);
assert!(
inner_cap_warnings[0].message.contains("Skipped 3"),
"inner container's own 5 entries against a cap of 2 must skip 3: {}",
inner_cap_warnings[0].message
);
}
#[test]
fn test_clamp_declared_size_bounds_forged_declaration_to_cap() {
let forged_declared_size = 4u64 * 1024 * 1024 * 1024 * 1024; let cap = 50 * 1024 * 1024; assert_eq!(
clamp_declared_size(forged_declared_size, cap),
cap,
"a forged multi-terabyte declared size must be clamped to the configured cap"
);
}
#[test]
fn test_clamp_declared_size_passes_through_value_at_cap() {
let cap = 50 * 1024 * 1024;
assert_eq!(clamp_declared_size(cap, cap), cap);
}
#[test]
fn test_clamp_declared_size_passes_through_honest_value_under_cap() {
let cap = 50 * 1024 * 1024;
assert_eq!(clamp_declared_size(1024, cap), 1024);
}
#[tokio::test]
async fn test_forged_multi_terabyte_declared_size_does_not_overflow_allocation() {
let zip_bytes = make_forged_zip64_entry("word/embeddings/huge.bin", UNDETECTABLE_MIME_BYTES, u64::MAX);
let config = ExtractionConfig::default();
let (children, warnings) = extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;
assert!(
children.is_empty(),
"undetectable-MIME entry must never produce a child: {:?}",
children.len()
);
assert_eq!(
warnings.len(),
1,
"expected exactly one warning, proving the entry was read and processed rather \
than rejected outright: {:?}",
warnings
);
assert!(
warnings[0].message.contains("huge.bin"),
"warning must name the file: {}",
warnings[0].message
);
assert!(
warnings[0].message.contains("MIME type could not be determined"),
"the tiny real payload must reach the normal MIME-detection path, not be rejected \
for its (forged) declared size: {}",
warnings[0].message
);
}
#[tokio::test]
async fn test_forged_declared_size_still_extracts_real_small_payload() {
let payload = b"Hello, world!";
let zip_bytes = make_forged_zip64_entry("word/embeddings/note.txt", payload, u64::MAX);
let config = ExtractionConfig::default();
let (children, warnings) = extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;
assert_eq!(
children.len(),
1,
"the real (small) payload behind the forged declaration must still be extracted: {:?}",
warnings
);
assert_eq!(
children[0].result.content.trim(),
"Hello, world!",
"extracted content must match the real payload bytes, not be corrupted by the \
forged declared size"
);
}
#[tokio::test]
async fn test_legitimate_small_embedded_object_extracts_unchanged_bytes() {
let payload = b"Hello, world!";
let zip_bytes = make_zip_with_file("word/embeddings/note.txt", payload);
let config = ExtractionConfig::default();
let (children, warnings) = extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;
assert_eq!(
children.len(),
1,
"a legitimate small embedded file must still be extracted: {:?}",
warnings
);
assert!(warnings.is_empty(), "no warnings expected: {:?}", warnings);
assert_eq!(
children[0].result.content.trim(),
"Hello, world!",
"extracted content must be exactly the original bytes"
);
}
#[tokio::test]
async fn test_embedded_file_exactly_at_cap_is_extracted() {
let payload = b"Hello, world!"; let zip_bytes = make_zip_with_file("word/embeddings/note.txt", payload);
let config = ExtractionConfig {
max_embedded_file_bytes: Some(payload.len() as u64),
..Default::default()
};
let (children, warnings) = extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;
assert!(
!warnings.iter().any(|w| w.message.contains("exceeds cap")),
"a file exactly at the cap must not be treated as oversized: {:?}",
warnings
);
assert_eq!(
children.len(),
1,
"a file exactly at the cap must still be extracted: {:?}",
warnings
);
}
#[tokio::test]
async fn test_embedded_file_one_byte_over_cap_is_rejected() {
let payload = b"Hello, world!!"; let zip_bytes = make_zip_with_file("word/embeddings/note.txt", payload);
let config = ExtractionConfig {
max_embedded_file_bytes: Some((payload.len() - 1) as u64),
..Default::default()
};
let (children, warnings) = extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;
assert!(
children.is_empty(),
"a file one byte over the cap must not produce a child"
);
assert_eq!(warnings.len(), 1, "expected exactly one warning: {:?}", warnings);
assert!(
warnings[0].message.contains("exceeds cap"),
"warning must mention the cap: {}",
warnings[0].message
);
}
#[tokio::test]
async fn test_embedded_objects_fall_back_to_default_max_files_in_archive_when_unset() {
let default_limit = crate::extractors::security::SecurityLimits::default().max_files_in_archive;
let entries: Vec<String> = (0..=default_limit)
.map(|i| format!("word/embeddings/blob{i}"))
.collect();
let entry_refs: Vec<(&str, &[u8])> = entries.iter().map(|p| (p.as_str(), &[][..])).collect();
let zip_bytes = make_zip_with_files(&entry_refs);
let config = ExtractionConfig::default();
assert!(
config.security_limits.is_none(),
"this test must exercise the unset fallback, not an explicit limit"
);
let (children, warnings) = extract_ooxml_embedded_objects(&zip_bytes, "word/embeddings/", "test", &config).await;
assert!(children.is_empty(), "empty entries never produce children");
assert_eq!(
warnings.len(),
1,
"exactly one cap warning expected, nothing else: {:?}",
warnings
);
assert!(
warnings[0].message.contains("Skipped 1 "),
"exactly one entry past the default ceiling must be skipped: {}",
warnings[0].message
);
assert!(
warnings[0]
.message
.contains(&format!("max_files_in_archive ({default_limit})")),
"warning must name the default limit that was hit: {}",
warnings[0].message
);
}