use std::fs::File;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use zip::ZipArchive;
pub(crate) const MAX_ZIP_DEPTH: u32 = 10;
const EXTRACTION_RATIO_LIMIT: u64 = 100;
const COPY_CHUNK_BYTES: usize = 64 * 1024;
pub(crate) struct ExtractedMember {
pub name: String,
pub path: PathBuf,
pub size: u64,
}
fn is_apple_metadata_entry(entry_name: &str) -> bool {
if entry_name.starts_with("__MACOSX/") {
return true;
}
entry_name
.rsplit('/')
.next()
.unwrap_or(entry_name)
.starts_with("._")
}
fn is_skippable_zip_entry(entry_name: &str) -> bool {
if is_apple_metadata_entry(entry_name) {
return true;
}
let basename = entry_name.rsplit('/').next().unwrap_or(entry_name);
if basename == ".DS_Store" {
return true;
}
entry_name
.split('/')
.any(|segment| segment == ".git" || segment == "node_modules")
}
pub(crate) fn expand_to_dir(
zip_path: &Path,
raw_dir: &Path,
counter: &AtomicU64,
) -> Result<(Vec<ExtractedMember>, usize), String> {
std::fs::create_dir_all(raw_dir)
.map_err(|err| format!("failed to create {}: {err}", raw_dir.display()))?;
let zip_file = File::open(zip_path)
.map_err(|err| format!("failed to open {}: {err}", zip_path.display()))?;
let compressed_size = zip_file
.metadata()
.map(|metadata| metadata.len())
.unwrap_or(0);
let budget = compressed_size.saturating_mul(EXTRACTION_RATIO_LIMIT);
let mut archive =
ZipArchive::new(zip_file).map_err(|err| format!("invalid zip archive: {err}"))?;
let mut members = Vec::with_capacity(archive.len());
let mut dropped = 0usize;
let mut running_total = 0u64;
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|err| format!("failed to read zip entry {index}: {err}"))?;
if entry.is_dir() {
continue;
}
let name = entry.name().to_string();
if is_skippable_zip_entry(&name) {
tracing::debug!(
member = %name,
archive = %zip_path.display(),
"skipping macOS AppleDouble/__MACOSX/.DS_Store/.git/node_modules entry"
);
continue;
}
let member_index = counter.fetch_add(1, Ordering::SeqCst);
let member_path = raw_dir.join(format!("{member_index:012}.member"));
let mut out_file = File::create(&member_path)
.map_err(|err| format!("failed to create {}: {err}", member_path.display()))?;
match copy_capped(&mut entry, &mut out_file, &mut running_total, budget) {
Ok(size) => members.push(ExtractedMember {
name,
path: member_path,
size,
}),
Err(err) => {
let _ = std::fs::remove_file(&member_path);
tracing::warn!(
member = %name,
archive = %zip_path.display(),
step = "archive",
error = %err,
"dropped zip member during extraction"
);
dropped += 1;
}
}
}
Ok((members, dropped))
}
fn copy_capped(
reader: &mut impl Read,
writer: &mut impl Write,
running_total: &mut u64,
budget: u64,
) -> Result<u64, String> {
let mut buf = [0u8; COPY_CHUNK_BYTES];
let mut written = 0u64;
loop {
let read = reader
.read(&mut buf)
.map_err(|err| format!("failed to read zip entry: {err}"))?;
if read == 0 {
return Ok(written);
}
writer
.write_all(&buf[..read])
.map_err(|err| format!("failed to write extracted member: {err}"))?;
written += read as u64;
*running_total += read as u64;
if *running_total > budget {
return Err(format!(
"extraction cap ({budget} bytes, {EXTRACTION_RATIO_LIMIT}x this archive's compressed size) exceeded"
));
}
}
}
#[cfg(test)]
mod tests {
use std::io::Cursor;
use zip::ZipWriter;
use zip::write::SimpleFileOptions;
use super::*;
fn build_test_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut buf = Vec::new();
{
let mut writer = ZipWriter::new(Cursor::new(&mut buf));
let options =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, contents) in entries {
writer.start_file(*name, options).unwrap();
writer.write_all(contents).unwrap();
}
writer.finish().unwrap();
}
buf
}
fn write_zip_file(dir: &Path, bytes: &[u8]) -> PathBuf {
let path = dir.join("archive.zip");
std::fs::write(&path, bytes).unwrap();
path
}
#[test]
fn expand_to_dir_extracts_every_member_to_disk_in_order() {
let dir = tempfile::tempdir().unwrap();
let raw_dir = dir.path().join("raw");
let zip_path = write_zip_file(
dir.path(),
&build_test_zip(&[("a.txt", b"hello"), ("b.txt", b"world")]),
);
let counter = AtomicU64::new(0);
let (members, dropped) = expand_to_dir(&zip_path, &raw_dir, &counter).unwrap();
assert_eq!(dropped, 0);
assert_eq!(members.len(), 2);
assert_eq!(members[0].name, "a.txt");
assert_eq!(std::fs::read(&members[0].path).unwrap(), b"hello");
assert_eq!(members[0].size, 5);
assert_eq!(members[1].name, "b.txt");
assert_eq!(std::fs::read(&members[1].path).unwrap(), b"world");
}
#[test]
fn expand_to_dir_rejects_non_zip_bytes() {
let dir = tempfile::tempdir().unwrap();
let raw_dir = dir.path().join("raw");
let zip_path = write_zip_file(dir.path(), b"not a zip file");
let counter = AtomicU64::new(0);
assert!(expand_to_dir(&zip_path, &raw_dir, &counter).is_err());
}
#[test]
fn expand_to_dir_skips_directory_entries() {
let dir = tempfile::tempdir().unwrap();
let raw_dir = dir.path().join("raw");
let mut buf = Vec::new();
{
let mut writer = ZipWriter::new(Cursor::new(&mut buf));
writer
.add_directory("photos/", SimpleFileOptions::default())
.unwrap();
writer
.start_file("photos/img.jpg", SimpleFileOptions::default())
.unwrap();
writer.write_all(b"fake-jpeg-bytes").unwrap();
writer.finish().unwrap();
}
let zip_path = write_zip_file(dir.path(), &buf);
let counter = AtomicU64::new(0);
let (members, _dropped) = expand_to_dir(&zip_path, &raw_dir, &counter).unwrap();
assert_eq!(members.len(), 1);
assert_eq!(members[0].name, "photos/img.jpg");
}
#[test]
fn expand_to_dir_skips_apple_double_and_macosx_entries() {
let dir = tempfile::tempdir().unwrap();
let raw_dir = dir.path().join("raw");
let zip_path = write_zip_file(
dir.path(),
&build_test_zip(&[
("real.txt", b"hello"),
("__MACOSX/._real.txt", b"resource-fork-junk"),
("__MACOSX/nested/._archive.zip", b"resource-fork-junk"),
("._real.txt", b"resource-fork-junk"),
]),
);
let counter = AtomicU64::new(0);
let (members, dropped) = expand_to_dir(&zip_path, &raw_dir, &counter).unwrap();
assert_eq!(dropped, 0);
assert_eq!(members.len(), 1);
assert_eq!(members[0].name, "real.txt");
}
#[test]
fn expand_to_dir_skips_ds_store_git_and_node_modules_entries() {
let dir = tempfile::tempdir().unwrap();
let raw_dir = dir.path().join("raw");
let zip_path = write_zip_file(
dir.path(),
&build_test_zip(&[
("real.txt", b"hello"),
(".DS_Store", b"finder-junk"),
("project/.DS_Store", b"finder-junk"),
("project/.git/config", b"git-internal"),
("project/node_modules/pkg/index.js", b"vendor-code"),
]),
);
let counter = AtomicU64::new(0);
let (members, dropped) = expand_to_dir(&zip_path, &raw_dir, &counter).unwrap();
assert_eq!(dropped, 0);
assert_eq!(members.len(), 1);
assert_eq!(members[0].name, "real.txt");
}
#[test]
fn copy_capped_streams_and_reports_actual_bytes_written() {
let source = b"hello world".to_vec();
let mut reader = Cursor::new(source.clone());
let mut sink = Vec::new();
let mut total = 0u64;
let written = copy_capped(&mut reader, &mut sink, &mut total, u64::MAX).unwrap();
assert_eq!(written, source.len() as u64);
assert_eq!(sink, source);
assert_eq!(total, source.len() as u64);
}
#[test]
fn copy_capped_enforces_the_cap_against_real_bytes_not_a_declared_size() {
let source = vec![0u8; 100];
let mut reader = Cursor::new(source);
let mut sink = Vec::new();
let mut total = 0u64;
let result = copy_capped(&mut reader, &mut sink, &mut total, 50);
assert!(result.is_err());
}
#[test]
fn expand_to_dir_drops_a_member_that_exceeds_this_archive_s_ratio_cap_but_keeps_others() {
let dir = tempfile::tempdir().unwrap();
let raw_dir = dir.path().join("raw");
let mut buf = Vec::new();
{
let mut writer = ZipWriter::new(Cursor::new(&mut buf));
let stored =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let deflated =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
writer.start_file("small.txt", stored).unwrap();
writer.write_all(b"ok").unwrap();
writer.start_file("bomb.bin", deflated).unwrap();
writer.write_all(&vec![0u8; 1_000_000]).unwrap();
writer.finish().unwrap();
}
let zip_path = write_zip_file(dir.path(), &buf);
let counter = AtomicU64::new(0);
let (members, dropped) = expand_to_dir(&zip_path, &raw_dir, &counter).unwrap();
assert_eq!(dropped, 1);
assert_eq!(members.len(), 1);
assert_eq!(members[0].name, "small.txt");
}
}