use safe_unzip::{Driver, Error, ExtractionMode, Extractor, Limits, OverwritePolicy, ZipAdapter};
use std::io::{Seek, Write};
use tempfile::{tempdir, NamedTempFile};
use zip::write::FileOptions;
fn create_simple_zip(filename: &str, content: &[u8]) -> std::fs::File {
let file = tempfile::tempfile().unwrap();
let mut zip = zip::ZipWriter::new(file);
let options: FileOptions<()> = FileOptions::default();
zip.start_file(filename, options).unwrap();
zip.write_all(content).unwrap();
zip.finish().unwrap()
}
fn create_multi_file_zip(files: &[(&str, &[u8])]) -> std::fs::File {
let file = tempfile::tempfile().unwrap();
let mut zip = zip::ZipWriter::new(file);
let options: FileOptions<()> = FileOptions::default();
for (name, content) in files {
zip.start_file(*name, options).unwrap();
zip.write_all(content).unwrap();
}
zip.finish().unwrap()
}
fn create_malicious_zip() -> std::io::Result<std::fs::File> {
let file = tempfile::tempfile()?;
let mut zip = zip::ZipWriter::new(file);
let options: FileOptions<()> =
FileOptions::default().compression_method(zip::CompressionMethod::Stored);
zip.start_file("safe.txt", options)?;
zip.write_all(b"safe content")?;
zip.start_file("../../evil.txt", options)?;
zip.write_all(b"evil content")?;
Ok(zip.finish()?)
}
#[test]
fn test_blocks_zip_slip() {
let root = tempdir().unwrap();
let zip_file = create_malicious_zip().expect("failed to create fixture");
let result = Extractor::new(root.path())
.expect("jail init failed")
.extract(zip_file);
match result {
Err(Error::PathEscape { entry, .. }) => {
println!("✅ Successfully blocked traversal: {}", entry);
assert_eq!(entry, "../../evil.txt");
}
Ok(_) => panic!("❌ SECURITY FAIL: Malicious file was extracted!"),
Err(e) => panic!("❌ Unexpected error type: {:?}", e),
}
let evil_path = root.path().join("../../evil.txt");
if evil_path.exists() {
let _ = std::fs::remove_file(evil_path);
panic!("❌ SECURITY FAIL: File found on disk outside jail!");
}
}
#[test]
fn test_limits_quota() {
let root = tempdir().unwrap();
let file = tempfile::tempfile().unwrap();
let mut zip = zip::ZipWriter::new(file);
let options: FileOptions<()> = FileOptions::default();
zip.start_file("big.txt", options).unwrap();
zip.write_all(&[0u8; 200]).unwrap();
let zip_file = zip.finish().unwrap();
let result = Extractor::new(root.path())
.unwrap()
.limits(safe_unzip::Limits {
max_total_bytes: 100,
..Default::default()
})
.extract(zip_file);
match result {
Err(Error::TotalSizeExceeded { limit, would_be }) => {
println!("✅ Successfully enforced quota: {} > {}", would_be, limit);
}
_ => panic!("❌ Failed to enforce quota"),
}
}
#[test]
fn test_extract_file_method() {
let mut zip_file = NamedTempFile::new().unwrap();
{
let mut zip = zip::ZipWriter::new(&mut zip_file);
let options: FileOptions<()> = FileOptions::default();
zip.start_file("hello.txt", options).unwrap();
zip.write_all(b"Hello, World!").unwrap();
zip.finish().unwrap();
}
zip_file.seek(std::io::SeekFrom::Start(0)).unwrap();
let dest = tempdir().unwrap();
let report = Extractor::new(dest.path())
.unwrap()
.extract_file(zip_file.path())
.unwrap();
assert_eq!(report.files_extracted, 1);
assert_eq!(report.bytes_written, 13);
let content = std::fs::read_to_string(dest.path().join("hello.txt")).unwrap();
assert_eq!(content, "Hello, World!");
println!("✅ extract_file() works correctly");
}
#[test]
fn test_validate_first_no_partial_state() {
let file = tempfile::tempfile().unwrap();
let mut zip = zip::ZipWriter::new(file);
let options: FileOptions<()> = FileOptions::default();
zip.start_file("good.txt", options).unwrap();
zip.write_all(b"This is fine").unwrap();
zip.start_file("../../evil.txt", options).unwrap();
zip.write_all(b"pwned").unwrap();
let zip_file = zip.finish().unwrap();
let dest = tempdir().unwrap();
let result = Extractor::new(dest.path())
.unwrap()
.mode(ExtractionMode::ValidateFirst)
.extract(zip_file);
assert!(matches!(result, Err(Error::PathEscape { .. })));
let good_path = dest.path().join("good.txt");
assert!(
!good_path.exists(),
"❌ ValidateFirst FAIL: good.txt was written before validation completed!"
);
println!("✅ ValidateFirst prevented partial extraction");
}
#[test]
fn test_overwrite_policy_error() {
let dest = tempdir().unwrap();
let zip1 = create_simple_zip("test.txt", b"original");
Extractor::new(dest.path()).unwrap().extract(zip1).unwrap();
let zip2 = create_simple_zip("test.txt", b"modified");
let result = Extractor::new(dest.path()).unwrap().extract(zip2);
assert!(matches!(result, Err(Error::AlreadyExists { .. })));
let content = std::fs::read_to_string(dest.path().join("test.txt")).unwrap();
assert_eq!(content, "original");
println!("✅ OverwritePolicy::Error works");
}
#[test]
fn test_overwrite_policy_skip() {
let dest = tempdir().unwrap();
let zip1 = create_simple_zip("test.txt", b"original");
Extractor::new(dest.path()).unwrap().extract(zip1).unwrap();
let zip2 = create_simple_zip("test.txt", b"modified");
let report = Extractor::new(dest.path())
.unwrap()
.overwrite(OverwritePolicy::Skip)
.extract(zip2)
.unwrap();
assert_eq!(report.entries_skipped, 1);
assert_eq!(report.files_extracted, 0);
let content = std::fs::read_to_string(dest.path().join("test.txt")).unwrap();
assert_eq!(content, "original");
println!("✅ OverwritePolicy::Skip works");
}
#[test]
fn test_overwrite_policy_overwrite() {
let dest = tempdir().unwrap();
let zip1 = create_simple_zip("test.txt", b"original");
Extractor::new(dest.path()).unwrap().extract(zip1).unwrap();
let zip2 = create_simple_zip("test.txt", b"modified");
let report = Extractor::new(dest.path())
.unwrap()
.overwrite(OverwritePolicy::Overwrite)
.extract(zip2)
.unwrap();
assert_eq!(report.files_extracted, 1);
let content = std::fs::read_to_string(dest.path().join("test.txt")).unwrap();
assert_eq!(content, "modified");
println!("✅ OverwritePolicy::Overwrite works");
}
#[test]
fn test_filter_by_extension() {
let dest = tempdir().unwrap();
let zip = create_multi_file_zip(&[
("image.png", b"fake png data"),
("document.txt", b"text content"),
("photo.jpg", b"fake jpg data"),
("script.sh", b"#!/bin/bash"),
]);
let report = Extractor::new(dest.path())
.unwrap()
.filter(|e| e.name.ends_with(".txt"))
.extract(zip)
.unwrap();
assert_eq!(report.files_extracted, 1);
assert_eq!(report.entries_skipped, 3);
assert!(dest.path().join("document.txt").exists());
assert!(!dest.path().join("image.png").exists());
assert!(!dest.path().join("photo.jpg").exists());
assert!(!dest.path().join("script.sh").exists());
println!("✅ Filter by extension works");
}
#[test]
fn test_filter_by_size() {
let dest = tempdir().unwrap();
let zip = create_multi_file_zip(&[
("small.txt", b"tiny"),
("large.txt", b"this is a much larger file with more content"),
]);
let report = Extractor::new(dest.path())
.unwrap()
.filter(|e| e.size < 10)
.extract(zip)
.unwrap();
assert_eq!(report.files_extracted, 1);
assert!(dest.path().join("small.txt").exists());
assert!(!dest.path().join("large.txt").exists());
println!("✅ Filter by size works");
}
#[test]
fn test_single_file_size_limit() {
let dest = tempdir().unwrap();
let zip = create_simple_zip("big.txt", &[0u8; 500]);
let result = Extractor::new(dest.path())
.unwrap()
.limits(Limits {
max_single_file: 100,
..Default::default()
})
.extract(zip);
match result {
Err(Error::FileTooLarge { entry, limit, size }) => {
assert_eq!(entry, "big.txt");
assert_eq!(limit, 100);
assert_eq!(size, 500);
println!("✅ Single file size limit works");
}
_ => panic!("Expected FileTooLarge error"),
}
}
#[test]
fn test_file_count_limit() {
let dest = tempdir().unwrap();
let zip = create_multi_file_zip(&[
("file1.txt", b"1"),
("file2.txt", b"2"),
("file3.txt", b"3"),
("file4.txt", b"4"),
("file5.txt", b"5"),
]);
let result = Extractor::new(dest.path())
.unwrap()
.limits(Limits {
max_file_count: 3,
..Default::default()
})
.extract(zip);
assert!(matches!(
result,
Err(Error::FileCountExceeded { limit: 3, .. })
));
println!("✅ File count limit works");
}
#[test]
fn test_path_depth_limit() {
let dest = tempdir().unwrap();
let file = tempfile::tempfile().unwrap();
let mut zip = zip::ZipWriter::new(file);
let options: FileOptions<()> = FileOptions::default();
zip.start_file("a/b/c/d/e/f/g/deep.txt", options).unwrap();
zip.write_all(b"deep").unwrap();
let zip_file = zip.finish().unwrap();
let result = Extractor::new(dest.path())
.unwrap()
.limits(Limits {
max_path_depth: 3,
..Default::default()
})
.extract(zip_file);
match result {
Err(Error::PathTooDeep { depth, limit, .. }) => {
assert_eq!(limit, 3);
assert!(depth > 3);
println!(
"✅ Path depth limit works (depth={}, limit={})",
depth, limit
);
}
_ => panic!("Expected PathTooDeep error"),
}
}
#[test]
fn test_creates_directories() {
let dest = tempdir().unwrap();
let file = tempfile::tempfile().unwrap();
let mut zip = zip::ZipWriter::new(file);
let options: FileOptions<()> = FileOptions::default();
zip.add_directory("mydir/", options).unwrap();
zip.start_file("mydir/subdir/file.txt", options).unwrap();
zip.write_all(b"nested content").unwrap();
let zip_file = zip.finish().unwrap();
let report = Extractor::new(dest.path())
.unwrap()
.extract(zip_file)
.unwrap();
assert_eq!(report.dirs_created, 1);
assert_eq!(report.files_extracted, 1);
assert!(dest.path().join("mydir").is_dir());
assert!(dest.path().join("mydir/subdir/file.txt").exists());
println!("✅ Directory creation works");
}
#[test]
fn test_sanitize_filenames() {
let dest = tempdir().unwrap();
let zip = create_simple_zip("CON.txt", b"safe");
let result = Extractor::new(dest.path()).unwrap().extract(zip);
match result {
Err(Error::InvalidFilename { entry, reason }) => {
assert_eq!(entry, "CON.txt");
assert!(
reason.contains("reserved"),
"reason should mention reserved: {}",
reason
);
println!("✅ Successfully rejected '{}': {}", entry, reason);
}
_ => panic!("❌ Failed to reject reserved filename"),
}
}
#[test]
fn test_symlink_overwrite_protection() {
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
let dest = tempdir().unwrap();
let target_path = dest.path().join("target.txt");
let link_path = dest.path().join("link");
std::fs::write(&target_path, "sensitive").unwrap();
symlink(&target_path, &link_path).unwrap();
let zip = create_simple_zip("link", b"pwned");
let report = Extractor::new(dest.path())
.unwrap()
.overwrite(OverwritePolicy::Overwrite)
.extract(zip)
.unwrap();
assert_eq!(report.files_extracted, 1);
let link_content = std::fs::read_to_string(&link_path).unwrap();
assert_eq!(link_content, "pwned");
assert!(!link_path.is_symlink());
let target_content = std::fs::read_to_string(&target_path).unwrap();
assert_eq!(target_content, "sensitive");
println!("✅ Symlink overwrite protection works");
}
}
fn create_fake_size_zip(name: &str, content: &[u8], declared_size: u32) -> std::fs::File {
let file = tempfile::tempfile().unwrap();
let mut zip = zip::ZipWriter::new(file);
let options: FileOptions<()> = FileOptions::default()
.compression_method(zip::CompressionMethod::Stored)
.unix_permissions(0o644);
zip.start_file(name, options).unwrap();
zip.write_all(content).unwrap();
let mut finalized_file = zip.finish().unwrap();
finalized_file.seek(std::io::SeekFrom::Start(0)).unwrap();
let mut buffer = Vec::new();
use std::io::Read;
finalized_file.read_to_end(&mut buffer).unwrap();
let lfh_sig = &[0x50, 0x4b, 0x03, 0x04];
if &buffer[0..4] == lfh_sig {
let size_bytes = declared_size.to_le_bytes();
buffer[22] = size_bytes[0];
buffer[23] = size_bytes[1];
buffer[24] = size_bytes[2];
buffer[25] = size_bytes[3];
buffer[18] = size_bytes[0];
buffer[19] = size_bytes[1];
buffer[20] = size_bytes[2];
buffer[21] = size_bytes[3];
let cd_sig = &[0x50, 0x4b, 0x01, 0x02];
if let Some(pos) = buffer.windows(4).position(|w| w == cd_sig) {
buffer[pos + 20] = size_bytes[0];
buffer[pos + 21] = size_bytes[1];
buffer[pos + 22] = size_bytes[2];
buffer[pos + 23] = size_bytes[3];
buffer[pos + 24] = size_bytes[0];
buffer[pos + 25] = size_bytes[1];
buffer[pos + 26] = size_bytes[2];
buffer[pos + 27] = size_bytes[3];
} else {
println!("⚠️ Could not find Central Directory");
}
} else {
println!("⚠️ Could not find LFH");
}
let mut hacked_file = tempfile::tempfile().unwrap();
hacked_file.write_all(&buffer).unwrap();
hacked_file.seek(std::io::SeekFrom::Start(0)).unwrap();
hacked_file
}
#[test]
fn test_strict_size_enforcement() {
let dest = tempdir().unwrap();
let zip_file = create_fake_size_zip("lie.txt", b"0123456789", 5);
let result = Extractor::new(dest.path()).unwrap().extract(zip_file);
match result {
Err(Error::FileTooLarge { limit, size, .. }) => {
assert_eq!(limit, 5);
assert_eq!(size, 6);
println!("✅ Successfully caught zip bomb verification failure");
}
Err(Error::Io(e)) if e.to_string().contains("Invalid checksum") => {
println!("✅ Successfully rejected zip bomb (checksum)");
}
_ => panic!("❌ Failed to enforce declared size: {:?}", result),
}
}
#[test]
fn test_absolute_path_rejection() {
let dest = tempdir().unwrap();
#[cfg(unix)]
let zip = create_simple_zip("/tmp/evil.txt", b"evil");
#[cfg(windows)]
let zip = create_simple_zip("C:\\evil.txt", b"evil");
let result = Extractor::new(dest.path()).unwrap().extract(zip);
match result {
Err(Error::PathEscape { .. }) => {
println!("✅ Blocked absolute path via PathEscape");
}
Err(Error::InvalidFilename { .. }) => {
println!("✅ Blocked absolute path via InvalidFilename");
}
Ok(_) => {
#[cfg(unix)]
assert!(
!std::path::Path::new("/tmp/evil.txt").exists(),
"❌ Wrote to absolute path outside jail!"
);
let inside =
dest.path().join("tmp/evil.txt").exists() || dest.path().join("evil.txt").exists();
assert!(inside, "File should be inside jail");
println!("✅ Absolute path stripped and contained in jail");
}
Err(e) => panic!("❌ Unexpected error: {:?}", e),
}
}
#[test]
fn test_backslash_rejection() {
let dest = tempdir().unwrap();
let zip = create_simple_zip("folder\\file.txt", b"data");
let result = Extractor::new(dest.path()).unwrap().extract(zip);
match result {
Err(Error::InvalidFilename { entry, reason }) => {
assert!(
reason.contains("backslash"),
"Should mention backslash: {}",
reason
);
println!("✅ Rejected backslash in filename '{}': {}", entry, reason);
}
_ => panic!("❌ Should reject backslash in filename: {:?}", result),
}
}
#[test]
fn test_null_byte_rejection() {
let dest = tempdir().unwrap();
let zip = create_simple_zip("harmless.txt\0.exe", b"malware");
let result = Extractor::new(dest.path()).unwrap().extract(zip);
match result {
Err(Error::InvalidFilename { entry, reason }) => {
assert!(
reason.contains("control"),
"Should mention control chars: {}",
reason
);
println!("✅ Rejected null byte in filename '{}': {}", entry, reason);
}
_ => panic!("❌ Should reject null byte in filename: {:?}", result),
}
}
#[test]
fn test_empty_filename_rejection() {
let dest = tempdir().unwrap();
let zip = create_simple_zip("", b"data");
let result = Extractor::new(dest.path()).unwrap().extract(zip);
match result {
Err(Error::InvalidFilename { reason, .. }) => {
assert!(reason.contains("empty"), "Should mention empty: {}", reason);
println!("✅ Rejected empty filename: {}", reason);
}
_ => panic!("❌ Should reject empty filename: {:?}", result),
}
}
#[test]
#[cfg(unix)]
fn test_symlink_then_file_in_same_archive() {
use std::os::unix::fs::symlink;
let dest = tempdir().unwrap();
let link_path = dest.path().join("link");
let target_file = dest.path().join("target.txt");
std::fs::write(&target_file, "original").unwrap();
symlink("target.txt", &link_path).unwrap();
assert!(link_path.is_symlink());
assert_eq!(std::fs::read_to_string(&link_path).unwrap(), "original");
let zip = create_simple_zip("link", b"overwritten");
let result = Extractor::new(dest.path())
.unwrap()
.overwrite(OverwritePolicy::Overwrite)
.extract(zip);
assert!(result.is_ok(), "Should succeed: {:?}", result);
assert!(!link_path.is_symlink(), "Should no longer be a symlink");
assert!(link_path.is_file(), "Should be a regular file");
let content = std::fs::read_to_string(&link_path).unwrap();
assert_eq!(content, "overwritten", "File should have new content");
let target_content = std::fs::read_to_string(&target_file).unwrap();
assert_eq!(
target_content, "original",
"Original target should be unchanged"
);
println!("✅ Symlink replaced with file safely (didn't follow symlink)");
}
#[test]
fn test_mixed_slash_traversal() {
let dest = tempdir().unwrap();
let zip = create_simple_zip("foo\\..\\bar.txt", b"data");
let result = Extractor::new(dest.path()).unwrap().extract(zip);
assert!(
matches!(result, Err(Error::InvalidFilename { .. })),
"Should reject mixed slashes: {:?}",
result
);
println!("✅ Rejected mixed slash traversal attempt");
}
#[test]
fn test_unicode_lookalike_characters() {
let dest = tempdir().unwrap();
let zip = create_multi_file_zip(&[
("file.txt", b"normal dot"),
("file\u{2024}txt", b"unicode lookalike"), ]);
let result = Extractor::new(dest.path()).unwrap().extract(zip);
match result {
Ok(report) => {
assert_eq!(report.files_extracted, 2, "Both files should extract");
println!("✅ Unicode lookalike characters handled correctly (both extracted)");
}
Err(e) => {
println!("✅ Unicode lookalike rejected: {:?}", e);
}
}
}
#[test]
fn test_url_encoded_traversal() {
let dest = tempdir().unwrap();
let zip = create_simple_zip("..%2Fevil.txt", b"evil");
let result = Extractor::new(dest.path()).unwrap().extract(zip);
match result {
Ok(report) => {
assert_eq!(report.files_extracted, 1);
let parent_evil = dest.path().parent().unwrap().join("evil.txt");
assert!(
!parent_evil.exists(),
"❌ URL-encoded traversal escaped jail!"
);
println!("✅ URL-encoded traversal treated as literal filename");
}
Err(Error::PathEscape { .. }) | Err(Error::InvalidFilename { .. }) => {
println!("✅ URL-encoded traversal rejected");
}
Err(e) => panic!("❌ Unexpected error: {:?}", e),
}
}
#[test]
fn test_double_encoded_traversal() {
let dest = tempdir().unwrap();
let zip = create_simple_zip("%252e%252e%252fevil.txt", b"evil");
let result = Extractor::new(dest.path()).unwrap().extract(zip);
match result {
Ok(_) => {
let parent_evil = dest.path().parent().unwrap().join("evil.txt");
assert!(
!parent_evil.exists(),
"❌ Double-encoded traversal escaped!"
);
println!("✅ Double-encoded traversal treated as literal");
}
Err(_) => {
println!("✅ Double-encoded traversal rejected");
}
}
}
#[test]
fn test_windows_drive_letter_colon() {
let dest = tempdir().unwrap();
let zip = create_simple_zip("C:/Windows/evil.txt", b"evil");
let result = Extractor::new(dest.path()).unwrap().extract(zip);
match result {
Ok(_) => {
assert!(
!std::path::Path::new("/C:/Windows/evil.txt").exists(),
"❌ Created file at absolute Windows path!"
);
println!("✅ Windows drive letter path contained in jail");
}
Err(Error::InvalidFilename { reason, .. }) => {
println!("✅ Windows drive letter rejected: {}", reason);
}
Err(Error::PathEscape { .. }) => {
println!("✅ Windows drive letter blocked by path jail");
}
Err(e) => panic!("❌ Unexpected error: {:?}", e),
}
}
#[test]
fn test_trailing_space_in_filename() {
let dest = tempdir().unwrap();
let zip = create_simple_zip("file.txt ", b"data");
let result = Extractor::new(dest.path()).unwrap().extract(zip);
match result {
Ok(_) => {
let exists_with_space = dest.path().join("file.txt ").exists();
let exists_without = dest.path().join("file.txt").exists();
assert!(
exists_with_space || exists_without,
"File should exist somewhere"
);
println!("✅ Trailing space handled (file exists)");
}
Err(Error::InvalidFilename { reason, .. }) => {
assert!(
reason.contains("trailing") || reason.contains("space"),
"Reason should mention trailing/space: {}",
reason
);
println!("✅ Trailing space rejected: {}", reason);
}
Err(e) => panic!("❌ Unexpected error: {:?}", e),
}
}
#[test]
fn test_trailing_dot_in_filename() {
let dest = tempdir().unwrap();
let zip = create_simple_zip("file.txt.", b"data");
let result = Extractor::new(dest.path()).unwrap().extract(zip);
match result {
Ok(_) => {
let exists_with_dot = dest.path().join("file.txt.").exists();
let exists_without = dest.path().join("file.txt").exists();
assert!(
exists_with_dot || exists_without,
"File should exist somewhere"
);
println!("✅ Trailing dot handled (file exists)");
}
Err(Error::InvalidFilename { reason, .. }) => {
println!("✅ Trailing dot rejected: {}", reason);
}
Err(e) => panic!("❌ Unexpected error: {:?}", e),
}
}
#[test]
fn test_ntfs_alternate_data_stream() {
let dest = tempdir().unwrap();
let zip = create_simple_zip("file.txt:hidden", b"secret data");
let result = Extractor::new(dest.path()).unwrap().extract(zip);
match result {
Err(Error::InvalidFilename { reason, .. }) => {
println!("✅ NTFS ADS syntax rejected: {}", reason);
}
Ok(_) => {
let file_path = dest.path().join("file.txt:hidden");
if file_path.exists() {
println!("✅ NTFS ADS treated as literal filename on Unix");
} else {
println!("⚠️ File created with unexpected name");
}
}
Err(e) => panic!("❌ Unexpected error: {:?}", e),
}
}
#[test]
fn test_zone_identifier_ads() {
let dest = tempdir().unwrap();
let zip = create_simple_zip("download.exe:Zone.Identifier", b"[ZoneTransfer]\nZoneId=3");
let result = Extractor::new(dest.path()).unwrap().extract(zip);
match result {
Err(_) => println!("✅ Zone.Identifier ADS rejected"),
Ok(_) => {
let exists = dest.path().join("download.exe:Zone.Identifier").exists();
assert!(exists, "If allowed, file should exist with literal name");
println!("✅ Zone.Identifier ADS treated literally");
}
}
}
#[test]
fn test_very_long_filename() {
let dest = tempdir().unwrap();
let long_name = "a".repeat(300) + ".txt";
let zip = create_simple_zip(&long_name, b"data");
let result = Extractor::new(dest.path()).unwrap().extract(zip);
match result {
Err(Error::InvalidFilename { reason, .. }) => {
assert!(
reason.contains("long") || reason.contains("length"),
"Reason should mention length: {}",
reason
);
println!("✅ Very long filename rejected: {}", reason);
}
Err(Error::Io(e)) => {
println!("✅ Very long filename rejected by filesystem: {}", e);
}
Ok(_) => {
println!("⚠️ Very long filename allowed (filesystem accepted)");
}
Err(e) => {
panic!("❌ Unexpected error: {}", e);
}
}
}
#[test]
fn test_unicode_normalization_collision() {
let dest = tempdir().unwrap();
let composed = "caf\u{00E9}.txt"; let decomposed = "cafe\u{0301}.txt";
let zip = create_multi_file_zip(&[(composed, b"composed"), (decomposed, b"decomposed")]);
let result = Extractor::new(dest.path()).unwrap().extract(zip);
match result {
Ok(report) => {
if report.files_extracted == 1 {
println!("✅ Unicode normalization caused collision (filesystem normalized)");
} else if report.files_extracted == 2 {
println!("✅ Unicode NFC/NFD treated as separate files");
}
}
Err(Error::AlreadyExists { .. }) => {
println!("✅ Unicode normalization collision detected");
}
Err(e) => panic!("❌ Unexpected error: {:?}", e),
}
}
#[test]
fn test_empty_archive() {
let dest = tempdir().unwrap();
let mut buffer = std::io::Cursor::new(Vec::new());
{
let zip = zip::ZipWriter::new(&mut buffer);
zip.finish().unwrap();
}
let result = Extractor::new(dest.path()).unwrap().extract(buffer);
match result {
Ok(report) => {
assert_eq!(report.files_extracted, 0);
assert_eq!(report.dirs_created, 0);
assert_eq!(report.bytes_written, 0);
println!("✅ Empty archive handled correctly");
}
Err(e) => panic!("❌ Empty archive should succeed: {:?}", e),
}
}
#[test]
fn test_directory_only_archive() {
let dest = tempdir().unwrap();
let mut buffer = std::io::Cursor::new(Vec::new());
{
let mut zip = zip::ZipWriter::new(&mut buffer);
let options: zip::write::FileOptions<()> = zip::write::FileOptions::default();
zip.add_directory("dir1/", options).unwrap();
zip.add_directory("dir1/subdir/", options).unwrap();
zip.add_directory("dir2/", options).unwrap();
zip.finish().unwrap();
}
let result = Extractor::new(dest.path()).unwrap().extract(buffer);
match result {
Ok(report) => {
assert_eq!(report.files_extracted, 0, "No files should be extracted");
let _ = report.dirs_created;
assert!(dest.path().join("dir1").is_dir());
assert!(dest.path().join("dir1/subdir").is_dir());
assert!(dest.path().join("dir2").is_dir());
println!(
"✅ Directory-only archive: {} dirs created",
report.dirs_created
);
}
Err(e) => panic!("❌ Directory-only archive should succeed: {:?}", e),
}
}
#[test]
fn test_encrypted_entry_rejected() {
let dest = tempdir().unwrap();
let mut buffer = std::io::Cursor::new(Vec::new());
{
let mut zip = zip::ZipWriter::new(&mut buffer);
let options: zip::write::FileOptions<()> =
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored);
zip.start_file("normal.txt", options).unwrap();
zip.write_all(b"not encrypted").unwrap();
zip.finish().unwrap();
}
let encrypted_zip = create_encrypted_zip();
let adapter = match ZipAdapter::new(std::io::Cursor::new(encrypted_zip)) {
Ok(a) => a,
Err(e) => {
println!("✅ Malformed encrypted zip rejected at parse: {:?}", e);
return;
}
};
let result = Driver::new(dest.path()).unwrap().extract_zip(adapter);
match result {
Err(Error::EncryptedEntry { entry }) => {
println!("✅ Encrypted entry rejected: {}", entry);
}
Err(Error::Zip(_)) => {
println!("✅ Encrypted/malformed zip rejected");
}
Ok(_) => {
println!("⚠️ Zip crate handled encrypted entry (check contents)");
}
Err(e) => panic!("❌ Unexpected error: {:?}", e),
}
}
fn create_encrypted_zip() -> Vec<u8> {
let mut zip = Vec::new();
let filename = b"secret.txt";
let data = b"encrypted content";
zip.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04]); zip.extend_from_slice(&[0x14, 0x00]);
zip.extend_from_slice(&[0x01, 0x00]); zip.extend_from_slice(&[0x00, 0x00]);
zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
let size = data.len() as u32;
zip.extend_from_slice(&size.to_le_bytes());
zip.extend_from_slice(&size.to_le_bytes());
let name_len = filename.len() as u16;
zip.extend_from_slice(&name_len.to_le_bytes());
zip.extend_from_slice(&[0x00, 0x00]);
zip.extend_from_slice(filename);
zip.extend_from_slice(data);
let local_header_offset = 0u32;
zip.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02]); zip.extend_from_slice(&[0x14, 0x00]);
zip.extend_from_slice(&[0x14, 0x00]);
zip.extend_from_slice(&[0x01, 0x00]);
zip.extend_from_slice(&[0x00, 0x00]);
zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
zip.extend_from_slice(&size.to_le_bytes());
zip.extend_from_slice(&size.to_le_bytes());
zip.extend_from_slice(&name_len.to_le_bytes());
zip.extend_from_slice(&[0x00, 0x00]);
zip.extend_from_slice(&[0x00, 0x00]);
zip.extend_from_slice(&[0x00, 0x00]);
zip.extend_from_slice(&[0x00, 0x00]);
zip.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
zip.extend_from_slice(&local_header_offset.to_le_bytes());
zip.extend_from_slice(filename);
let cd_offset = (30 + filename.len() + data.len()) as u32;
let cd_size = (46 + filename.len()) as u32;
zip.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06]); zip.extend_from_slice(&[0x00, 0x00]);
zip.extend_from_slice(&[0x00, 0x00]);
zip.extend_from_slice(&[0x01, 0x00]);
zip.extend_from_slice(&[0x01, 0x00]);
zip.extend_from_slice(&cd_size.to_le_bytes());
zip.extend_from_slice(&cd_offset.to_le_bytes());
zip.extend_from_slice(&[0x00, 0x00]);
zip
}
#[test]
fn test_zero_limits() {
let dest = tempdir().unwrap();
fn make_zip() -> std::io::Cursor<Vec<u8>> {
let mut buffer = std::io::Cursor::new(Vec::new());
{
let mut zip = zip::ZipWriter::new(&mut buffer);
let options: FileOptions<()> = FileOptions::default();
zip.start_file("tiny.txt", options).unwrap();
zip.write_all(b"x").unwrap();
zip.finish().unwrap();
}
buffer.set_position(0);
buffer
}
let result = Extractor::new(dest.path())
.unwrap()
.limits(Limits {
max_total_bytes: 0,
..Limits::default()
})
.extract(make_zip());
assert!(
matches!(result, Err(Error::TotalSizeExceeded { .. })),
"Zero max_total_bytes should reject any content: {:?}",
result
);
let result = Extractor::new(dest.path())
.unwrap()
.limits(Limits {
max_file_count: 0,
..Limits::default()
})
.extract(make_zip());
assert!(
matches!(result, Err(Error::FileCountExceeded { .. })),
"Zero max_file_count should reject any file: {:?}",
result
);
let result = Extractor::new(dest.path())
.unwrap()
.limits(Limits {
max_single_file: 0,
..Limits::default()
})
.extract(make_zip());
assert!(
matches!(result, Err(Error::FileTooLarge { .. })),
"Zero max_single_file should reject any file: {:?}",
result
);
println!("✅ Zero limits handled correctly");
}
#[test]
fn test_duplicate_entry_names() {
let dest = tempdir().unwrap();
let file = tempfile::tempfile().unwrap();
let mut zip = zip::ZipWriter::new(file);
let options: FileOptions<()> = FileOptions::default();
zip.start_file("same.txt", options).unwrap();
zip.write_all(b"first content").unwrap();
let dup_result = zip.start_file("same.txt", options);
match dup_result {
Err(e) => {
println!("✅ Zip crate rejects duplicate at creation: {}", e);
}
Ok(_) => {
zip.write_all(b"second content").unwrap();
let mut zip_file = zip.finish().unwrap();
zip_file.seek(std::io::SeekFrom::Start(0)).unwrap();
let result = Extractor::new(dest.path()).unwrap().extract(zip_file);
match result {
Err(Error::AlreadyExists { entry, .. }) => {
println!("✅ Extractor rejected duplicate: {}", entry);
}
Ok(report) if report.files_extracted == 1 => {
println!("✅ Only first entry extracted (1 file)");
}
r => panic!("❌ Unexpected result: {:?}", r),
}
}
}
}
#[test]
fn test_duplicate_entry_with_overwrite_policy() {
let dest = tempdir().unwrap();
let zip1 = create_simple_zip("same.txt", b"first");
Extractor::new(dest.path()).unwrap().extract(zip1).unwrap();
let zip2 = create_simple_zip("same.txt", b"second");
let result = Extractor::new(dest.path())
.unwrap()
.overwrite(OverwritePolicy::Overwrite)
.extract(zip2);
match result {
Ok(report) => {
let content = std::fs::read_to_string(dest.path().join("same.txt")).unwrap();
assert_eq!(content, "second", "With Overwrite, second should win");
assert_eq!(report.files_extracted, 1);
println!("✅ With Overwrite policy, second archive wins");
}
Err(e) => panic!("❌ Overwrite policy should allow: {:?}", e),
}
}
#[test]
fn test_case_sensitivity_collision() {
let dest = tempdir().unwrap();
let zip = create_multi_file_zip(&[("File.TXT", b"uppercase"), ("file.txt", b"lowercase")]);
let result = Extractor::new(dest.path()).unwrap().extract(zip);
match result {
Ok(report) => {
if report.files_extracted == 2 {
println!("✅ Case-sensitive FS: both files extracted");
assert!(dest.path().join("File.TXT").exists());
assert!(dest.path().join("file.txt").exists());
} else if report.files_extracted == 1 {
println!("✅ Case-insensitive FS: first file preserved");
}
}
Err(Error::AlreadyExists { entry, .. }) => {
println!("✅ Case collision detected: {}", entry);
}
Err(e) => panic!("❌ Unexpected error: {:?}", e),
}
}
#[test]
fn test_path_canonicalization_current_dir() {
let dest = tempdir().unwrap();
let zip = create_simple_zip("./foo/../bar.txt", b"data");
let result = Extractor::new(dest.path()).unwrap().extract(zip);
match result {
Ok(_) => {
let exists_canonical = dest.path().join("bar.txt").exists();
let exists_literal = dest.path().join("./foo/../bar.txt").exists();
let exists_in_foo = dest.path().join("foo").join("..").join("bar.txt").exists();
assert!(
exists_canonical || exists_literal || exists_in_foo,
"File should exist somewhere in jail"
);
let parent_bar = dest.path().parent().unwrap().join("bar.txt");
assert!(
!parent_bar.exists(),
"❌ Escaped jail via canonicalization!"
);
println!("✅ Path canonicalization handled safely");
}
Err(Error::PathEscape { .. }) => {
println!("✅ Path with '..' rejected by jail");
}
Err(Error::InvalidFilename { .. }) => {
println!("✅ Path with '..' rejected as invalid");
}
Err(e) => panic!("❌ Unexpected error: {:?}", e),
}
}
#[test]
fn test_path_multiple_parent_segments() {
let dest = tempdir().unwrap();
let zip = create_simple_zip("a/b/../../c.txt", b"data");
let result = Extractor::new(dest.path()).unwrap().extract(zip);
match result {
Ok(_) => {
let in_dest =
dest.path().join("c.txt").exists() || dest.path().join("a/b/../../c.txt").exists();
assert!(in_dest, "File should be in jail");
let parent_c = dest.path().parent().unwrap().join("c.txt");
assert!(!parent_c.exists(), "❌ Escaped via multiple '..'!");
println!("✅ Multiple parent segments handled safely");
}
Err(Error::PathEscape { .. }) | Err(Error::InvalidFilename { .. }) => {
println!("✅ Multiple parent segments rejected");
}
Err(e) => panic!("❌ Unexpected error: {:?}", e),
}
}
#[test]
fn test_dot_and_dotdot_entries() {
let dest = tempdir().unwrap();
let zip1 = create_simple_zip(".", b"data");
let result1 = Extractor::new(dest.path()).unwrap().extract(zip1);
match result1 {
Err(_) => println!("✅ Single '.' entry rejected"),
Ok(_) => println!("⚠️ Single '.' entry accepted (check semantics)"),
}
let zip2 = create_simple_zip("..", b"data");
let result2 = Extractor::new(dest.path()).unwrap().extract(zip2);
match result2 {
Err(Error::PathEscape { .. }) | Err(Error::InvalidFilename { .. }) => {
println!("✅ Single '..' entry rejected");
}
Ok(_) => {
let _parent_exists = dest.path().parent().unwrap().join("..").exists();
println!("⚠️ Single '..' accepted - verify no escape");
}
Err(e) => panic!("❌ Unexpected error for '..': {:?}", e),
}
}
#[test]
fn test_trailing_slash_directory() {
let dest = tempdir().unwrap();
let mut buffer = std::io::Cursor::new(Vec::new());
{
let mut zip = zip::ZipWriter::new(&mut buffer);
let options: FileOptions<()> = FileOptions::default();
zip.add_directory("mydir/", options).unwrap();
zip.finish().unwrap();
}
buffer.set_position(0);
let result = Extractor::new(dest.path()).unwrap().extract(buffer);
match result {
Ok(report) => {
assert!(
dest.path().join("mydir").is_dir(),
"Should create directory"
);
assert_eq!(report.files_extracted, 0, "No files, just directory");
println!("✅ Trailing slash creates directory correctly");
}
Err(e) => panic!("❌ Directory entry should work: {:?}", e),
}
}
#[test]
fn test_extreme_path_depth() {
let dest = tempdir().unwrap();
let deep_path: String = (0..100).map(|i| format!("d{}/", i)).collect::<String>() + "file.txt";
let zip = create_simple_zip(&deep_path, b"deep");
let result = Extractor::new(dest.path())
.unwrap()
.limits(Limits {
max_path_depth: 50, ..Limits::default()
})
.extract(zip);
match result {
Err(Error::PathTooDeep { depth, limit, .. }) => {
assert_eq!(limit, 50);
assert!(depth > 50);
println!("✅ Extreme depth rejected: {} > {}", depth, limit);
}
Err(e) => panic!("Expected PathTooDeep, got: {:?}", e),
Ok(_) => panic!("❌ Should reject 100-level deep path with limit 50"),
}
}