use std::io::Read;
use std::io::Seek;
use std::path::Path;
use std::path::PathBuf;
use std::time::Instant;
use zip::ZipArchive as ZipReader;
use crate::ExtractionError;
use crate::ExtractionOptions;
use crate::ExtractionReport;
use crate::Result;
use crate::SecurityConfig;
use crate::copy::CopyBuffer;
use crate::security::EntryValidator;
use crate::security::validator::ValidatedEntryType;
use crate::types::DestDir;
use crate::types::EntryType;
use super::common;
use super::traits::ArchiveFormat;
pub struct ZipArchive<R: Read + Seek> {
inner: ZipReader<R>,
}
impl<R: Read + Seek> ZipArchive<R> {
pub fn new(reader: R) -> Result<Self> {
let mut inner = ZipReader::new(reader).map_err(|e| {
ExtractionError::InvalidArchive(format!("failed to open ZIP archive: {e}"))
})?;
if Self::is_password_protected(&mut inner)? {
return Err(ExtractionError::SecurityViolation {
reason: "password-protected ZIP archives are not supported".into(),
});
}
Ok(Self { inner })
}
fn is_password_protected(archive: &mut ZipReader<R>) -> Result<bool> {
const SAMPLE_SIZE: usize = 100;
let total_entries = archive.len();
if total_entries <= SAMPLE_SIZE * 3 {
for i in 0..total_entries {
if Self::check_entry_encrypted(archive, i)? {
return Ok(true);
}
}
return Ok(false);
}
for i in 0..SAMPLE_SIZE {
if Self::check_entry_encrypted(archive, i)? {
return Ok(true);
}
}
let middle_start = (total_entries / 2).saturating_sub(SAMPLE_SIZE / 2);
let middle_end = middle_start + SAMPLE_SIZE;
for i in middle_start..middle_end.min(total_entries) {
if Self::check_entry_encrypted(archive, i)? {
return Ok(true);
}
}
let tail_start = total_entries.saturating_sub(SAMPLE_SIZE);
if tail_start > middle_end {
for i in tail_start..total_entries {
if Self::check_entry_encrypted(archive, i)? {
return Ok(true);
}
}
}
Ok(false)
}
#[inline]
fn check_entry_encrypted(archive: &mut ZipReader<R>, index: usize) -> Result<bool> {
match archive.by_index(index) {
Ok(file) => Ok(file.encrypted()),
Err(e) if e.to_string().contains("Password required to decrypt file") => Ok(true),
Err(e) => Err(ExtractionError::InvalidArchive(format!(
"failed to check entry {index} for encryption: {e}"
))),
}
}
#[allow(clippy::too_many_arguments)]
fn process_entry(
&mut self,
index: usize,
validator: &mut EntryValidator,
dest: &DestDir,
report: &mut ExtractionReport,
copy_buffer: &mut CopyBuffer,
dir_cache: &mut common::DirCache,
skip_duplicates: bool,
) -> Result<()> {
let mut zip_file = self.inner.by_index(index).map_err(|e| {
if e.to_string().contains("Password required to decrypt file") {
return ExtractionError::SecurityViolation {
reason: "archive is password-protected.\n Password-protected ZIP archives are not supported. Decrypt the archive externally and try again.".into(),
};
}
ExtractionError::InvalidArchive(format!("failed to read entry {index}: {e}"))
})?;
if zip_file.encrypted() {
return Err(ExtractionError::SecurityViolation {
reason: format!("encrypted entry detected: {}", zip_file.name()),
});
}
let path = PathBuf::from(zip_file.name());
let (uncompressed_size, compressed_size) = ZipEntryAdapter::get_sizes(&zip_file);
let mode = zip_file.unix_mode();
let compression = ZipEntryAdapter::get_compression_method(&zip_file);
if matches!(compression, CompressionMethod::Unsupported) {
return Err(ExtractionError::SecurityViolation {
reason: format!(
"unsupported compression method: {:?}",
zip_file.compression()
),
});
}
if zip_file.is_dir() {
drop(zip_file);
let validated = validator.validate_entry(
&path,
&EntryType::Directory,
uncompressed_size,
Some(compressed_size),
mode,
Some(dir_cache),
)?;
common::create_directory(&validated, dest, report, dir_cache)?;
} else if ZipEntryAdapter::is_symlink_from_mode(mode) {
let target = ZipEntryAdapter::read_symlink_target(&mut zip_file)?;
drop(zip_file);
let entry_type = EntryType::Symlink { target };
let validated = validator.validate_entry(
&path,
&entry_type,
uncompressed_size,
Some(compressed_size),
mode,
Some(dir_cache),
)?;
if let ValidatedEntryType::Symlink(safe_symlink) = validated.entry_type {
common::create_symlink(&safe_symlink, dest, report, dir_cache, skip_duplicates)?;
}
} else {
let validated = validator.validate_entry(
&path,
&EntryType::File,
uncompressed_size,
Some(compressed_size),
mode,
Some(dir_cache),
)?;
Self::extract_file(
&mut zip_file,
&validated,
dest,
report,
uncompressed_size,
copy_buffer,
dir_cache,
skip_duplicates,
)?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn extract_file(
zip_file: &mut zip::read::ZipFile<'_, R>,
validated: &crate::security::validator::ValidatedEntry,
dest: &DestDir,
report: &mut ExtractionReport,
file_size: u64,
copy_buffer: &mut CopyBuffer,
dir_cache: &mut common::DirCache,
skip_duplicates: bool,
) -> Result<()> {
common::extract_file_generic(
zip_file,
validated,
dest,
report,
Some(file_size),
copy_buffer,
dir_cache,
skip_duplicates,
)
}
}
impl<R: Read + Seek> ArchiveFormat for ZipArchive<R> {
fn extract(
&mut self,
output_dir: &Path,
config: &SecurityConfig,
options: &ExtractionOptions,
) -> Result<ExtractionReport> {
let start = Instant::now();
let skip_duplicates = options.skip_duplicates;
let dest = DestDir::new_or_create(output_dir.to_path_buf())?;
let mut validator = EntryValidator::new(config, &dest);
let mut report = ExtractionReport::new();
let mut copy_buffer = CopyBuffer::new();
let mut dir_cache = common::DirCache::new();
let entry_count = self.inner.len();
for i in 0..entry_count {
if let Err(e) = self.process_entry(
i,
&mut validator,
&dest,
&mut report,
&mut copy_buffer,
&mut dir_cache,
skip_duplicates,
) {
return Err(if report.total_items() > 0 {
ExtractionError::PartialExtraction {
source: Box::new(e),
report: std::mem::take(&mut report),
}
} else {
e
});
}
}
report.duration = start.elapsed();
Ok(report)
}
fn format_name(&self) -> &'static str {
"zip"
}
}
struct ZipEntryAdapter;
impl ZipEntryAdapter {
fn is_symlink_from_mode(mode: Option<u32>) -> bool {
mode.is_some_and(|m| {
const S_IFMT: u32 = 0o170_000;
const S_IFLNK: u32 = 0o120_000;
(m & S_IFMT) == S_IFLNK
})
}
fn read_symlink_target<R: Read>(zip_file: &mut zip::read::ZipFile<'_, R>) -> Result<PathBuf> {
const MAX_SYMLINK_TARGET_SIZE: u64 = 4096;
let size = zip_file.size();
if size > MAX_SYMLINK_TARGET_SIZE {
return Err(ExtractionError::SecurityViolation {
reason: format!(
"symlink target too large: {size} bytes (max {MAX_SYMLINK_TARGET_SIZE})"
),
});
}
#[allow(clippy::cast_possible_truncation)]
let mut target_bytes = Vec::with_capacity(size as usize);
zip_file
.take(MAX_SYMLINK_TARGET_SIZE)
.read_to_end(&mut target_bytes)
.map_err(|e| {
ExtractionError::InvalidArchive(format!("failed to read symlink target: {e}"))
})?;
let target_str = std::str::from_utf8(&target_bytes).map_err(|_| {
ExtractionError::InvalidArchive("symlink target is not valid UTF-8".into())
})?;
Ok(PathBuf::from(target_str))
}
fn get_compression_method<R: Read>(zip_file: &zip::read::ZipFile<'_, R>) -> CompressionMethod {
match zip_file.compression() {
zip::CompressionMethod::Stored => CompressionMethod::Stored,
zip::CompressionMethod::Deflated => CompressionMethod::Deflate,
zip::CompressionMethod::Bzip2 => CompressionMethod::Bzip2,
zip::CompressionMethod::Zstd => CompressionMethod::Zstd,
_ => CompressionMethod::Unsupported,
}
}
fn get_sizes<R: Read>(zip_file: &zip::read::ZipFile<'_, R>) -> (u64, u64) {
(zip_file.size(), zip_file.compressed_size())
}
}
#[derive(Debug, Clone, Copy)]
enum CompressionMethod {
Stored,
Deflate,
Bzip2,
Zstd,
Unsupported,
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::items_after_statements,
clippy::uninlined_format_args,
clippy::field_reassign_with_default
)]
mod tests {
use super::*;
use crate::test_utils::create_test_zip;
use std::io::Cursor;
use std::io::Write;
use tempfile::TempDir;
use zip::write::SimpleFileOptions;
use zip::write::ZipWriter;
#[test]
fn test_zip_archive_new() {
let zip_data = create_test_zip(vec![]);
let cursor = Cursor::new(zip_data);
let archive = ZipArchive::new(cursor).unwrap();
assert_eq!(archive.format_name(), "zip");
}
#[test]
fn test_extract_empty_archive() {
let zip_data = create_test_zip(vec![]);
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
assert_eq!(report.files_extracted, 0);
assert_eq!(report.directories_created, 0);
}
#[test]
fn test_extract_simple_file() {
let zip_data = create_test_zip(vec![("file.txt", b"hello world")]);
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
assert_eq!(report.files_extracted, 1);
assert!(temp.path().join("file.txt").exists());
let content = std::fs::read_to_string(temp.path().join("file.txt")).unwrap();
assert_eq!(content, "hello world");
}
#[test]
fn test_extract_multiple_files() {
let zip_data = create_test_zip(vec![
("file1.txt", b"content1"),
("file2.txt", b"content2"),
("file3.txt", b"content3"),
]);
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
assert_eq!(report.files_extracted, 3);
}
#[test]
fn test_extract_nested_structure() {
let zip_data = create_test_zip(vec![("dir1/dir2/file.txt", b"nested")]);
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
assert_eq!(report.files_extracted, 1);
assert!(temp.path().join("dir1/dir2/file.txt").exists());
}
#[test]
fn test_extract_with_deflate_compression() {
let buffer = Vec::new();
let mut zip = ZipWriter::new(Cursor::new(buffer));
let options =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
zip.start_file("compressed.txt", options).unwrap();
zip.write_all(b"This text will be compressed with DEFLATE")
.unwrap();
let zip_data = zip.finish().unwrap().into_inner();
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
assert_eq!(report.files_extracted, 1);
let content = std::fs::read_to_string(temp.path().join("compressed.txt")).unwrap();
assert_eq!(content, "This text will be compressed with DEFLATE");
}
#[test]
fn test_extract_with_bzip2_compression() {
let buffer = Vec::new();
let mut zip = ZipWriter::new(Cursor::new(buffer));
let options =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Bzip2);
zip.start_file("bzip2.txt", options).unwrap();
zip.write_all(b"This text will be compressed with BZIP2")
.unwrap();
let zip_data = zip.finish().unwrap().into_inner();
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
assert_eq!(report.files_extracted, 1);
}
#[test]
fn test_extract_with_zstd_compression() {
let buffer = Vec::new();
let mut zip = ZipWriter::new(Cursor::new(buffer));
let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Zstd);
zip.start_file("zstd.txt", options).unwrap();
zip.write_all(b"This text will be compressed with ZSTD")
.unwrap();
let zip_data = zip.finish().unwrap().into_inner();
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
assert_eq!(report.files_extracted, 1);
}
#[test]
fn test_extract_directory_entry() {
let buffer = Vec::new();
let mut zip = ZipWriter::new(Cursor::new(buffer));
let options = SimpleFileOptions::default();
zip.add_directory("mydir/", options).unwrap();
let zip_data = zip.finish().unwrap().into_inner();
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
assert_eq!(report.directories_created, 1);
assert!(temp.path().join("mydir").is_dir());
}
#[test]
fn test_extract_empty_file() {
let zip_data = create_test_zip(vec![("empty.txt", b"")]);
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
assert_eq!(report.files_extracted, 1);
assert!(temp.path().join("empty.txt").exists());
let metadata = std::fs::metadata(temp.path().join("empty.txt")).unwrap();
assert_eq!(metadata.len(), 0);
}
#[test]
fn test_quota_file_size_exceeded() {
let zip_data = create_test_zip(vec![("large.bin", &vec![0u8; 1000])]);
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let mut config = SecurityConfig::default();
config.max_file_size = 100;
let result = archive.extract(temp.path(), &config, &ExtractionOptions::default());
assert!(result.is_err());
}
#[test]
fn test_quota_file_count_exceeded() {
let zip_data = create_test_zip(vec![
("file1.txt", b"data"),
("file2.txt", b"data"),
("file3.txt", b"data"),
]);
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let mut config = SecurityConfig::default();
config.max_file_count = 2;
let result = archive.extract(temp.path(), &config, &ExtractionOptions::default());
assert!(result.is_err());
}
#[test]
fn test_path_traversal_rejected() {
let zip_data = create_test_zip(vec![("../etc/passwd", b"malicious")]);
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let result = archive.extract(temp.path(), &config, &ExtractionOptions::default());
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ExtractionError::PathTraversal { .. }
));
}
#[test]
fn test_absolute_path_rejected() {
let zip_data = create_test_zip(vec![("/etc/shadow", b"malicious")]);
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let result = archive.extract(temp.path(), &config, &ExtractionOptions::default());
assert!(result.is_err());
}
#[test]
fn test_zip_bomb_detection() {
let buffer = Vec::new();
let mut zip = ZipWriter::new(Cursor::new(buffer));
let options =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
zip.start_file("bomb.txt", options).unwrap();
zip.write_all(&vec![0u8; 100_000]).unwrap();
let zip_data = zip.finish().unwrap().into_inner();
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let mut config = SecurityConfig::default();
config.max_compression_ratio = 10.0;
let result = archive.extract(temp.path(), &config, &ExtractionOptions::default());
assert!(result.is_err());
}
#[test]
#[cfg(unix)]
fn test_file_permissions_preserved() {
use std::os::unix::fs::PermissionsExt;
let buffer = Vec::new();
let mut zip = ZipWriter::new(Cursor::new(buffer));
let options = SimpleFileOptions::default().unix_permissions(0o755);
zip.start_file("script.sh", options).unwrap();
zip.write_all(b"#!/bin/sh\n").unwrap();
let zip_data = zip.finish().unwrap().into_inner();
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
assert_eq!(report.files_extracted, 1);
let metadata = std::fs::metadata(temp.path().join("script.sh")).unwrap();
let permissions = metadata.permissions();
assert_eq!(permissions.mode() & 0o777, 0o755);
}
#[test]
#[cfg(unix)]
fn test_permissions_sanitized_setuid_removed() {
use std::os::unix::fs::PermissionsExt;
let buffer = Vec::new();
let mut zip = ZipWriter::new(Cursor::new(buffer));
let options = SimpleFileOptions::default().unix_permissions(0o4755); zip.start_file("binary", options).unwrap();
zip.write_all(b"data").unwrap();
let zip_data = zip.finish().unwrap().into_inner();
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let _report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
let metadata = std::fs::metadata(temp.path().join("binary")).unwrap();
let permissions = metadata.permissions();
assert_eq!(permissions.mode() & 0o7777, 0o755);
}
#[test]
#[cfg(unix)]
fn test_permissions_sanitized_setgid_removed() {
use std::os::unix::fs::PermissionsExt;
let buffer = Vec::new();
let mut zip = ZipWriter::new(Cursor::new(buffer));
let options = SimpleFileOptions::default().unix_permissions(0o2755); zip.start_file("binary", options).unwrap();
zip.write_all(b"data").unwrap();
let zip_data = zip.finish().unwrap().into_inner();
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let _report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
let metadata = std::fs::metadata(temp.path().join("binary")).unwrap();
let permissions = metadata.permissions();
assert_eq!(permissions.mode() & 0o7777, 0o755);
}
#[test]
#[cfg(unix)]
fn test_permissions_sanitized_setuid_setgid_removed() {
use std::os::unix::fs::PermissionsExt;
let buffer = Vec::new();
let mut zip = ZipWriter::new(Cursor::new(buffer));
let options = SimpleFileOptions::default().unix_permissions(0o6755); zip.start_file("binary", options).unwrap();
zip.write_all(b"data").unwrap();
let zip_data = zip.finish().unwrap().into_inner();
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let _report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
let metadata = std::fs::metadata(temp.path().join("binary")).unwrap();
let permissions = metadata.permissions();
assert_eq!(permissions.mode() & 0o7777, 0o755);
}
#[test]
#[cfg(unix)]
#[ignore = "zip crate does not preserve file type bits in unix_permissions()"]
fn test_extract_symlink_via_unix_attributes() {
let buffer = Vec::new();
let mut zip = ZipWriter::new(Cursor::new(buffer));
let options = SimpleFileOptions::default().unix_permissions(0o644);
zip.start_file("target.txt", options).unwrap();
zip.write_all(b"data").unwrap();
const S_IFLNK: u32 = 0o120_000; let symlink_mode = S_IFLNK | 0o777;
let options = SimpleFileOptions::default().unix_permissions(symlink_mode);
zip.start_file("link.txt", options).unwrap();
zip.write_all(b"target.txt").unwrap();
let zip_data = zip.finish().unwrap().into_inner();
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let mut config = SecurityConfig::default();
config.allowed.symlinks = true;
let report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
assert_eq!(report.files_extracted, 1, "should have 1 regular file");
assert_eq!(report.symlinks_created, 1, "should have 1 symlink");
let link_path = temp.path().join("link.txt");
assert!(link_path.exists(), "symlink should exist");
let metadata = std::fs::symlink_metadata(&link_path).unwrap();
assert!(metadata.is_symlink(), "link.txt should be a symlink");
}
#[test]
#[cfg(unix)]
#[ignore = "zip crate does not preserve file type bits in unix_permissions()"]
fn test_symlink_disabled_by_default() {
let buffer = Vec::new();
let mut zip = ZipWriter::new(Cursor::new(buffer));
const S_IFLNK: u32 = 0o120_000;
let symlink_mode = S_IFLNK | 0o777;
let options = SimpleFileOptions::default().unix_permissions(symlink_mode);
zip.start_file("link.txt", options).unwrap();
zip.write_all(b"target.txt").unwrap();
let zip_data = zip.finish().unwrap().into_inner();
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let result = archive.extract(temp.path(), &config, &ExtractionOptions::default());
assert!(
result.is_err(),
"extraction should fail when symlinks are disabled"
);
match result {
Err(ExtractionError::SecurityViolation { reason }) => {
assert!(
reason.contains("symlinks not allowed") || reason.contains("symlink"),
"error should mention symlinks: {reason}"
);
}
Err(other) => panic!("expected SecurityViolation, got: {other:?}"),
Ok(_) => panic!("expected error, got success"),
}
}
#[test]
#[cfg(unix)]
#[ignore = "debug test showing zip crate limitation"]
fn test_debug_zip_unix_mode() {
let buffer = Vec::new();
let mut zip = ZipWriter::new(Cursor::new(buffer));
const S_IFLNK: u32 = 0o120_000;
let symlink_mode = S_IFLNK | 0o777;
let options = SimpleFileOptions::default().unix_permissions(symlink_mode);
zip.start_file("link.txt", options).unwrap();
zip.write_all(b"target.txt").unwrap();
let zip_data = zip.finish().unwrap().into_inner();
let mut reader = zip::ZipArchive::new(Cursor::new(zip_data)).unwrap();
let file = reader.by_index(0).unwrap();
if let Some(mode) = file.unix_mode() {
eprintln!("Mode retrieved: {:o} (decimal: {})", mode, mode);
eprintln!("Expected symlink mode: {:o}", symlink_mode);
const S_IFMT: u32 = 0o170_000;
const S_IFLNK_CHECK: u32 = 0o120_000;
eprintln!("File type bits: {:o}", mode & S_IFMT);
eprintln!("Is symlink: {}", (mode & S_IFMT) == S_IFLNK_CHECK);
} else {
panic!("No Unix mode set!");
}
}
#[test]
fn test_hardlink_rejected() {
let zip_data = create_test_zip(vec![("file.txt", b"content")]);
let cursor = Cursor::new(zip_data);
let archive = ZipArchive::new(cursor).unwrap();
assert_eq!(archive.format_name(), "zip");
}
#[test]
fn test_compression_method_detection() {
let buffer = Vec::new();
let mut zip = ZipWriter::new(Cursor::new(buffer));
let stored =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
zip.start_file("stored.txt", stored).unwrap();
zip.write_all(b"stored").unwrap();
let deflated =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
zip.start_file("deflated.txt", deflated).unwrap();
zip.write_all(b"deflated").unwrap();
let zip_data = zip.finish().unwrap().into_inner();
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
assert_eq!(report.files_extracted, 2);
}
#[test]
fn test_bytes_written_tracking() {
let zip_data = create_test_zip(vec![
("file1.txt", b"hello"), ("file2.txt", b"world!!!"), ]);
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
assert_eq!(report.bytes_written, 13);
}
#[test]
fn test_duration_tracking() {
let zip_data = create_test_zip(vec![("file.txt", b"data")]);
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
assert!(report.duration.as_nanos() > 0);
}
#[test]
fn test_invalid_zip_archive() {
let invalid_data = b"not a zip file";
let cursor = Cursor::new(invalid_data);
let result = ZipArchive::new(cursor);
assert!(result.is_err());
}
#[test]
fn test_entry_type_detection_file() {
let zip_data = create_test_zip(vec![("regular.txt", b"content")]);
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
assert_eq!(report.files_extracted, 1);
assert_eq!(report.directories_created, 0);
assert_eq!(report.symlinks_created, 0);
}
#[test]
fn test_entry_type_detection_directory() {
let buffer = Vec::new();
let mut zip = ZipWriter::new(Cursor::new(buffer));
let options = SimpleFileOptions::default();
zip.add_directory("testdir/", options).unwrap();
let zip_data = zip.finish().unwrap().into_inner();
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
assert_eq!(report.files_extracted, 0);
assert_eq!(report.directories_created, 1);
}
#[test]
fn test_nested_directories_created_automatically() {
let zip_data = create_test_zip(vec![("a/b/c/file.txt", b"nested")]);
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let _report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
assert!(temp.path().join("a/b/c/file.txt").exists());
assert!(temp.path().join("a").is_dir());
assert!(temp.path().join("a/b").is_dir());
assert!(temp.path().join("a/b/c").is_dir());
}
#[test]
fn test_large_file_extraction() {
let large_data = vec![0xAB; 1024 * 1024];
let zip_data = create_test_zip(vec![("large.bin", &large_data)]);
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
assert_eq!(report.files_extracted, 1);
let extracted = std::fs::read(temp.path().join("large.bin")).unwrap();
assert_eq!(extracted.len(), 1024 * 1024);
}
#[test]
fn test_many_files_extraction() {
let entries: Vec<_> = (0..100)
.map(|i| (format!("file{i}.txt"), format!("content{i}").into_bytes()))
.collect();
let buffer = Vec::new();
let mut zip = ZipWriter::new(Cursor::new(buffer));
for (name, data) in &entries {
let options = SimpleFileOptions::default();
zip.start_file(name, options).unwrap();
zip.write_all(data).unwrap();
}
let zip_data = zip.finish().unwrap().into_inner();
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
assert_eq!(report.files_extracted, 100);
}
#[test]
fn test_quota_total_size_exceeded() {
let zip_data = create_test_zip(vec![
("file1.txt", &vec![0u8; 600]),
("file2.txt", &vec![0u8; 600]),
]);
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let mut config = SecurityConfig::default();
config.max_total_size = 1000;
let result = archive.extract(temp.path(), &config, &ExtractionOptions::default());
assert!(result.is_err());
}
#[test]
fn test_special_characters_in_filename() {
let zip_data = create_test_zip(vec![
("file with spaces.txt", b"content"),
("file-with-dashes.txt", b"content"),
("file_with_underscores.txt", b"content"),
]);
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let report = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap();
assert_eq!(report.files_extracted, 3);
assert!(temp.path().join("file with spaces.txt").exists());
}
#[test]
fn test_is_symlink_from_mode() {
assert!(ZipEntryAdapter::is_symlink_from_mode(Some(0o120_777)));
assert!(ZipEntryAdapter::is_symlink_from_mode(Some(0o120_755)));
assert!(!ZipEntryAdapter::is_symlink_from_mode(Some(0o100_644)));
assert!(!ZipEntryAdapter::is_symlink_from_mode(Some(0o040_755)));
assert!(!ZipEntryAdapter::is_symlink_from_mode(Some(0o755)));
assert!(!ZipEntryAdapter::is_symlink_from_mode(None));
}
#[allow(clippy::cast_possible_truncation)]
fn raw_zip_with_custom_entry(
filename: &str,
content: &[u8],
compression_method: u16,
flags: u16,
unix_mode: u32,
) -> Vec<u8> {
let crc = crc32_ieee(content);
let external_attributes = unix_mode << 16;
let name_bytes = filename.as_bytes();
let name_len = name_bytes.len() as u16;
let content_len = content.len() as u32;
let mut buf: Vec<u8> = Vec::new();
let local_offset = buf.len() as u32;
buf.extend_from_slice(b"PK\x03\x04");
buf.extend_from_slice(&20u16.to_le_bytes()); buf.extend_from_slice(&flags.to_le_bytes());
buf.extend_from_slice(&compression_method.to_le_bytes());
buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&crc.to_le_bytes());
buf.extend_from_slice(&content_len.to_le_bytes()); buf.extend_from_slice(&content_len.to_le_bytes()); buf.extend_from_slice(&name_len.to_le_bytes());
buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(name_bytes);
buf.extend_from_slice(content);
let central_offset = buf.len() as u32;
buf.extend_from_slice(b"PK\x01\x02");
buf.extend_from_slice(&0x031eu16.to_le_bytes()); buf.extend_from_slice(&20u16.to_le_bytes()); buf.extend_from_slice(&flags.to_le_bytes());
buf.extend_from_slice(&compression_method.to_le_bytes());
buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&crc.to_le_bytes());
buf.extend_from_slice(&content_len.to_le_bytes()); buf.extend_from_slice(&content_len.to_le_bytes()); buf.extend_from_slice(&name_len.to_le_bytes());
buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&external_attributes.to_le_bytes());
buf.extend_from_slice(&local_offset.to_le_bytes());
buf.extend_from_slice(name_bytes);
let central_size = (buf.len() as u32) - central_offset;
buf.extend_from_slice(b"PK\x05\x06");
buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&1u16.to_le_bytes()); buf.extend_from_slice(&1u16.to_le_bytes()); buf.extend_from_slice(¢ral_size.to_le_bytes());
buf.extend_from_slice(¢ral_offset.to_le_bytes());
buf.extend_from_slice(&0u16.to_le_bytes()); buf
}
fn crc32_ieee(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &byte in data {
crc ^= u32::from(byte);
for _ in 0..8 {
if crc & 1 != 0 {
crc = (crc >> 1) ^ 0xEDB8_8320;
} else {
crc >>= 1;
}
}
}
!crc
}
#[test]
fn test_unsupported_compression_method_rejected() {
let zip_bytes = raw_zip_with_custom_entry("file.txt", b"", 99, 0, 0o100_644);
let cursor = Cursor::new(zip_bytes);
let result = ZipArchive::new(cursor);
if let Ok(mut archive) = result {
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let err = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap_err();
assert!(
matches!(err, ExtractionError::SecurityViolation { .. }),
"expected SecurityViolation for unsupported compression, got: {err:?}"
);
}
}
#[test]
fn test_symlink_target_too_large() {
let target = vec![b'a'; 4097];
let zip_bytes = raw_zip_with_custom_entry("link", &target, 0, 0, 0o120_777);
let cursor = Cursor::new(zip_bytes);
let result = ZipArchive::new(cursor);
if let Ok(mut archive) = result {
let temp = TempDir::new().unwrap();
let mut config = SecurityConfig::default();
config.allowed.symlinks = true;
let err = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap_err();
assert!(
matches!(err, ExtractionError::SecurityViolation { ref reason } if reason.contains("symlink target too large")),
"expected SecurityViolation(symlink target too large), got: {err:?}"
);
}
}
#[test]
fn test_symlink_target_invalid_utf8() {
let invalid_utf8 = vec![0xFF, 0xFE, 0x00];
let zip_bytes = raw_zip_with_custom_entry("link", &invalid_utf8, 0, 0, 0o120_777);
let cursor = Cursor::new(zip_bytes);
let result = ZipArchive::new(cursor);
if let Ok(mut archive) = result {
let temp = TempDir::new().unwrap();
let mut config = SecurityConfig::default();
config.allowed.symlinks = true;
let err = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap_err();
assert!(
matches!(err, ExtractionError::InvalidArchive(ref msg) if msg.contains("UTF-8")),
"expected InvalidArchive(UTF-8), got: {err:?}"
);
}
}
fn create_large_archive_with_encrypted_entry(encrypted_index: usize) -> Vec<u8> {
use zip::unstable::write::FileOptionsExt;
let total = 400usize;
let buffer = Vec::new();
let mut zip = ZipWriter::new(Cursor::new(buffer));
for i in 0..total {
let options = if i == encrypted_index {
SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored)
.with_deprecated_encryption(b"pass")
.unwrap()
} else {
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored)
};
zip.start_file(format!("file{i}.txt"), options).unwrap();
zip.write_all(b"x").unwrap();
}
zip.finish().unwrap().into_inner()
}
#[test]
fn test_password_protected_large_archive_first_entry() {
let zip_data = create_large_archive_with_encrypted_entry(0);
let cursor = Cursor::new(zip_data);
let result = ZipArchive::new(cursor);
assert!(
matches!(result, Err(ExtractionError::SecurityViolation { .. })),
"expected SecurityViolation for encrypted entry in first batch"
);
}
#[test]
fn test_password_protected_large_archive_middle_entry() {
let zip_data = create_large_archive_with_encrypted_entry(200);
let cursor = Cursor::new(zip_data);
let result = ZipArchive::new(cursor);
assert!(
matches!(result, Err(ExtractionError::SecurityViolation { .. })),
"expected SecurityViolation for encrypted entry in middle batch"
);
}
#[test]
fn test_password_protected_large_archive_last_entry() {
let zip_data = create_large_archive_with_encrypted_entry(399);
let cursor = Cursor::new(zip_data);
let result = ZipArchive::new(cursor);
assert!(
matches!(result, Err(ExtractionError::SecurityViolation { .. })),
"expected SecurityViolation for encrypted entry in last batch"
);
}
#[test]
fn test_large_archive_no_encryption_passes_constructor() {
let buffer = Vec::new();
let mut zip = ZipWriter::new(Cursor::new(buffer));
let options =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for i in 0..400usize {
zip.start_file(format!("file{i}.txt"), options).unwrap();
zip.write_all(b"x").unwrap();
}
let zip_data = zip.finish().unwrap().into_inner();
let cursor = Cursor::new(zip_data);
let archive = ZipArchive::new(cursor);
assert!(
archive.is_ok(),
"unencrypted 400-entry archive should open fine"
);
}
#[test]
fn test_per_entry_encrypted_check_catches_missed_by_sampling() {
let zip_data = create_large_archive_with_encrypted_entry(125);
let cursor = Cursor::new(zip_data);
let result = ZipArchive::new(cursor);
match result {
Err(ExtractionError::SecurityViolation { .. }) => {
}
Ok(mut archive) => {
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let err = archive
.extract(temp.path(), &config, &ExtractionOptions::default())
.unwrap_err();
let source = match err {
ExtractionError::PartialExtraction { source, .. } => *source,
other => other,
};
assert!(
matches!(source, ExtractionError::SecurityViolation { .. }),
"per-entry check must catch encrypted entry missed by sampling, got: {source:?}"
);
}
Err(other) => panic!("unexpected error: {other:?}"),
}
}
#[allow(clippy::cast_possible_truncation)]
fn create_raw_duplicate_zip(path: &str, content1: &[u8], content2: &[u8]) -> Vec<u8> {
let name_bytes = path.as_bytes();
let name_len = name_bytes.len() as u16;
let mut buf: Vec<u8> = Vec::new();
let write_local = |buf: &mut Vec<u8>, content: &[u8]| {
let crc = crc32_ieee(content);
let size = content.len() as u32;
buf.extend_from_slice(b"PK\x03\x04");
buf.extend_from_slice(&20u16.to_le_bytes());
buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&crc.to_le_bytes());
buf.extend_from_slice(&size.to_le_bytes());
buf.extend_from_slice(&size.to_le_bytes());
buf.extend_from_slice(&name_len.to_le_bytes());
buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(name_bytes);
buf.extend_from_slice(content);
};
let offset1 = buf.len() as u32;
write_local(&mut buf, content1);
let offset2 = buf.len() as u32;
write_local(&mut buf, content2);
let write_central = |buf: &mut Vec<u8>, content: &[u8], offset: u32| {
let crc = crc32_ieee(content);
let size = content.len() as u32;
buf.extend_from_slice(b"PK\x01\x02");
buf.extend_from_slice(&0x031eu16.to_le_bytes()); buf.extend_from_slice(&20u16.to_le_bytes());
buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&crc.to_le_bytes());
buf.extend_from_slice(&size.to_le_bytes());
buf.extend_from_slice(&size.to_le_bytes());
buf.extend_from_slice(&name_len.to_le_bytes());
buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&(0o100_644u32 << 16).to_le_bytes()); buf.extend_from_slice(&offset.to_le_bytes());
buf.extend_from_slice(name_bytes);
};
let central_start = buf.len() as u32;
write_central(&mut buf, content1, offset1);
write_central(&mut buf, content2, offset2);
let central_size = (buf.len() as u32) - central_start;
buf.extend_from_slice(b"PK\x05\x06");
buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&2u16.to_le_bytes()); buf.extend_from_slice(&2u16.to_le_bytes()); buf.extend_from_slice(¢ral_size.to_le_bytes());
buf.extend_from_slice(¢ral_start.to_le_bytes());
buf.extend_from_slice(&0u16.to_le_bytes()); buf
}
#[test]
fn test_duplicate_entry_skip_default() {
let zip_data = create_raw_duplicate_zip("legit.txt", b"first", b"second");
let cursor = Cursor::new(zip_data);
let mut archive = ZipArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default();
let options = ExtractionOptions::default();
let report = archive.extract(temp.path(), &config, &options).unwrap();
assert_eq!(report.files_extracted, 1);
assert!(temp.path().join("legit.txt").exists());
}
#[test]
fn test_encrypted_zip_rejected_with_security_violation() {
use zip::unstable::write::FileOptionsExt;
let buffer = Vec::new();
let mut writer = ZipWriter::new(Cursor::new(buffer));
let options = SimpleFileOptions::default()
.with_deprecated_encryption(b"password123")
.unwrap();
writer.start_file("secret.txt", options).unwrap();
writer.write_all(b"secret data").unwrap();
let zip_data = writer.finish().unwrap().into_inner();
let cursor = Cursor::new(zip_data);
let result = ZipArchive::new(cursor);
let Err(err) = result else {
panic!("expected error for encrypted ZIP, got Ok");
};
match err {
ExtractionError::SecurityViolation { reason } => {
assert!(
reason.contains("password") || reason.contains("encrypted"),
"expected password/encryption mention in reason: {reason}"
);
}
other => panic!("expected SecurityViolation, got: {other}"),
}
}
}