use std::{
fs,
io::{BufReader, Read, Write},
path::{Component, Path, PathBuf},
};
use anyhow::Result;
use asar::AsarReader;
use bzip2_rs::DecoderReader;
use cfb::CompoundFile;
use flate2::read::{DeflateDecoder, GzDecoder, ZlibDecoder};
use lzma_rs::xz_decompress;
use memmap2::Mmap;
use tar::Archive;
use tempfile::{TempDir, tempdir};
use uuid::Uuid;
use zip::ZipArchive;
pub const ZIP_BASED_FORMATS: &[&str] = &[
"zip", "zipx", "jar", "war", "ear", "aar", "apk", "aab", "ipa", "jmod", "jhm", "jnlp", "nupkg",
"vsix", "xap", "docx", "xlsx", "pptx", "odt", "ods", "odp", "odg", "odf", "epub", "gadget",
"kmz", "widget", "xpi", "sketch", "pages", "key", "numbers", "hwpx",
];
fn is_tar_wrapped_compression(path: &Path) -> bool {
let filename = match path.file_name().and_then(|s| s.to_str()) {
Some(name) => name.to_ascii_lowercase(),
None => return false,
};
filename.ends_with(".tgz")
|| filename.ends_with(".tar.gz")
|| filename.ends_with(".tar.gzip")
|| filename.ends_with(".tar.bz2")
|| filename.ends_with(".tar.bzip2")
|| filename.ends_with(".tar.xz")
}
#[derive(Debug)]
pub enum CompressedContent {
Raw(Vec<u8>),
RawFile(PathBuf),
Archive(Vec<(String, Vec<u8>)>),
ArchiveFiles(Vec<(String, PathBuf)>),
}
pub fn is_safe_extract_path(path: &Path) -> bool {
if path.is_absolute() {
return false;
}
for comp in path.components() {
match comp {
Component::ParentDir => return false,
Component::Prefix(_) | Component::RootDir => return false,
_ => {}
}
}
true
}
fn has_parent_or_embedded_prefix(path: &Path) -> bool {
for (idx, comp) in path.components().enumerate() {
match comp {
Component::ParentDir => return true,
Component::Prefix(_) if idx > 0 => return true,
_ => {}
}
}
false
}
fn is_zip_format(ext: &str) -> bool {
ZIP_BASED_FORMATS.iter().any(|z| z == &ext)
}
pub const MAX_TAR_ARCHIVE_ENTRIES: usize = 10_000;
pub const MAX_TAR_ENTRY_DECOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
pub const MAX_TAR_DECOMPRESSED_BYTES: u64 = 256 * 1024 * 1024;
#[derive(Clone, Copy)]
struct TarExtractionLimits {
max_entries: usize,
max_entry_bytes: u64,
max_total_bytes: u64,
}
const TAR_EXTRACTION_LIMITS: TarExtractionLimits = TarExtractionLimits {
max_entries: MAX_TAR_ARCHIVE_ENTRIES,
max_entry_bytes: MAX_TAR_ENTRY_DECOMPRESSED_BYTES,
max_total_bytes: MAX_TAR_DECOMPRESSED_BYTES,
};
fn handle_tar_archive_streaming(
file: &mut fs::File,
archive_path: &Path,
base_dir: &Path,
) -> Result<CompressedContent> {
handle_tar_archive_streaming_with_limits(file, archive_path, base_dir, TAR_EXTRACTION_LIMITS)
}
fn handle_tar_archive_streaming_with_limits(
file: &mut fs::File,
archive_path: &Path,
base_dir: &Path,
limits: TarExtractionLimits,
) -> Result<CompressedContent> {
let mut archive = Archive::new(file);
let mut entries_on_disk = Vec::new();
let mut total_decompressed = 0;
let mut truncated = false;
let entries = match archive.entries() {
Ok(entries) => entries,
Err(e) => {
tracing::debug!("failed to open tar archive {}: {}", archive_path.display(), e);
return Ok(CompressedContent::RawFile(archive_path.to_owned()));
}
};
for (index, entry) in entries.enumerate() {
if index >= limits.max_entries {
tracing::debug!(
"tar archive {} exceeded {} entry cap; truncating",
archive_path.display(),
limits.max_entries
);
truncated = true;
break;
}
let mut entry = match entry {
Ok(entry) => entry,
Err(e) => {
tracing::debug!("tar archive {} ended early: {}", archive_path.display(), e);
truncated = true;
break;
}
};
if entry.header().entry_type().is_file() {
let path_in_tar = match entry.path() {
Ok(path) => path.to_string_lossy().to_string(),
Err(e) => {
tracing::debug!(
"failed to read tar entry path in {}: {}",
archive_path.display(),
e
);
truncated = true;
break;
}
};
if !is_safe_extract_path(Path::new(&path_in_tar)) {
tracing::debug!("unsafe tar path: {path_in_tar}");
continue;
}
let expected_size = entry.size();
if expected_size > limits.max_entry_bytes {
tracing::debug!(
"tar entry {} in {} exceeds {} byte per-entry cap; skipping",
path_in_tar,
archive_path.display(),
limits.max_entry_bytes
);
truncated = true;
continue;
}
let remaining = limits.max_total_bytes.saturating_sub(total_decompressed);
if expected_size > remaining {
tracing::debug!(
"tar archive {} exceeded {} byte aggregate cap; truncating",
archive_path.display(),
limits.max_total_bytes
);
truncated = true;
break;
}
let logical_path = format!("{}!{}", archive_path.display(), path_in_tar);
let out_path = base_dir.join(&path_in_tar);
if let Some(parent) = out_path.parent()
&& let Err(e) = fs::create_dir_all(parent)
{
tracing::debug!("failed to create directory {}: {}", parent.display(), e);
continue;
}
match fs::File::create(&out_path) {
Ok(mut out_file) => {
let mut limited = (&mut entry).take(expected_size);
let copied = match std::io::copy(&mut limited, &mut out_file) {
Ok(n) => n,
Err(e) => {
tracing::debug!("failed to extract {}: {}", out_path.display(), e);
drop(out_file);
let _ = fs::remove_file(&out_path);
truncated = true;
break;
}
};
if copied != expected_size {
tracing::debug!(
"tar entry {} in {} was truncated after {copied} of {expected_size} bytes",
path_in_tar,
archive_path.display()
);
drop(out_file);
let _ = fs::remove_file(&out_path);
truncated = true;
break;
}
total_decompressed += copied;
entries_on_disk.push((logical_path, out_path));
}
Err(e) => {
tracing::debug!("failed to create file {}: {}", out_path.display(), e);
continue;
}
}
}
}
if truncated && entries_on_disk.is_empty() {
tracing::debug!(
"tar archive {} was truncated before any entry completed; falling back to raw scan",
archive_path.display()
);
return Ok(CompressedContent::RawFile(archive_path.to_owned()));
}
Ok(CompressedContent::ArchiveFiles(entries_on_disk))
}
pub const MAX_INMEM_ZIP_ARCHIVE_BYTES: usize = 64 * 1024 * 1024;
pub const MAX_INMEM_ZIP_DECOMPRESSED_BYTES: u64 = 256 * 1024 * 1024;
pub fn extract_zip_archive_in_memory(
data: &[u8],
archive_label: &str,
) -> Result<Vec<(String, Vec<u8>)>> {
if data.len() > MAX_INMEM_ZIP_ARCHIVE_BYTES {
anyhow::bail!(
"zip archive {archive_label} is {} bytes, exceeding {} byte in-memory cap",
data.len(),
MAX_INMEM_ZIP_ARCHIVE_BYTES
);
}
Ok(zip_entries(std::io::Cursor::new(data), archive_label.to_owned())?.collect())
}
pub(crate) fn zip_entries<R: std::io::Read + std::io::Seek>(
reader: R,
archive_label: String,
) -> Result<impl Iterator<Item = (String, Vec<u8>)>> {
let mut zip = ZipArchive::new(reader)?;
let mut index = 0;
let mut total = 0u64;
Ok(std::iter::from_fn(move || {
while index < zip.len() && total < MAX_INMEM_ZIP_DECOMPRESSED_BYTES {
let i = index;
index += 1;
let mut file = match zip.by_index(i) {
Ok(file) => file,
Err(e) => {
tracing::debug!("zip entry {i} read failed: {e}");
continue;
}
};
if !file.is_file() || !is_safe_extract_path(Path::new(file.name())) {
continue;
}
let logical = format!("{archive_label}!{}", file.name());
let remaining = MAX_INMEM_ZIP_DECOMPRESSED_BYTES - total;
let mut bytes = Vec::new();
if let Err(e) = (&mut file).take(remaining).read_to_end(&mut bytes) {
tracing::debug!("failed to decompress zip entry {logical}: {e}");
continue;
}
total += bytes.len() as u64;
return Some((logical, bytes));
}
None
}))
}
pub fn looks_like_zip(data: &[u8]) -> bool {
data.starts_with(b"PK\x03\x04")
|| data.starts_with(b"PK\x05\x06")
|| data.starts_with(b"PK\x07\x08")
}
fn handle_zip_archive_streaming(
file: &mut fs::File,
archive_path: &Path,
base_dir: &Path,
) -> Result<CompressedContent> {
const MAX_ZIP_ENTRY_DECOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
let mut zip = ZipArchive::new(file)?;
let mut entries_on_disk = Vec::new();
let mut total_decompressed: u64 = 0;
for i in 0..zip.len() {
if total_decompressed >= MAX_INMEM_ZIP_DECOMPRESSED_BYTES {
tracing::debug!(
"zip archive {} exceeded {} byte aggregate cap at entry {i}/{}; truncating",
archive_path.display(),
MAX_INMEM_ZIP_DECOMPRESSED_BYTES,
zip.len()
);
break;
}
let mut zipped_file = zip.by_index(i)?;
if zipped_file.is_file() {
let name_in_zip = zipped_file.name().to_string();
if !is_safe_extract_path(Path::new(&name_in_zip)) {
tracing::debug!("unsafe zip path: {name_in_zip}");
continue;
}
let logical_path = format!("{}!{}", archive_path.display(), name_in_zip);
let out_path = base_dir.join(&name_in_zip);
if let Some(parent) = out_path.parent()
&& let Err(e) = fs::create_dir_all(parent)
{
tracing::debug!("failed to create directory {}: {}", parent.display(), e);
continue;
}
match fs::File::create(&out_path) {
Ok(mut out_file) => {
let remaining =
MAX_INMEM_ZIP_DECOMPRESSED_BYTES.saturating_sub(total_decompressed);
let entry_cap = remaining.min(MAX_ZIP_ENTRY_DECOMPRESSED_BYTES);
let mut limited = (&mut zipped_file).take(entry_cap);
let copied = match std::io::copy(&mut limited, &mut out_file) {
Ok(n) => n,
Err(e) => {
tracing::debug!("failed to extract {}: {}", out_path.display(), e);
continue;
}
};
total_decompressed += copied;
if copied == entry_cap && entry_cap == MAX_ZIP_ENTRY_DECOMPRESSED_BYTES {
tracing::debug!(
"zip entry {} exceeded {} byte cap; truncating",
out_path.display(),
MAX_ZIP_ENTRY_DECOMPRESSED_BYTES
);
}
entries_on_disk.push((logical_path, out_path));
if total_decompressed >= MAX_INMEM_ZIP_DECOMPRESSED_BYTES {
tracing::debug!(
"zip archive {} reached {} byte aggregate cap; truncating remaining entries",
archive_path.display(),
MAX_INMEM_ZIP_DECOMPRESSED_BYTES
);
break;
}
}
Err(e) => {
tracing::debug!("failed to create file {}: {}", out_path.display(), e);
continue;
}
}
}
}
Ok(CompressedContent::ArchiveFiles(entries_on_disk))
}
fn extract_zip_bytes_via_streaming(
buffer: &[u8],
label_path: &Path,
base_dir: &Path,
) -> Result<CompressedContent> {
let staged = base_dir.join(format!("{}.zip", Uuid::new_v4()));
let mut out = safe_create_for_write(&staged)?;
out.write_all(buffer)?;
drop(out);
let mut file = safe_open_for_read(&staged)?;
handle_zip_archive_streaming(&mut file, label_path, base_dir)
}
fn handle_hwp_archive_in_memory(path: &Path, archive_path: &Path) -> Result<CompressedContent> {
const MAX_HWP_RAW_BYTES: u64 = 64 * 1024 * 1024;
const MAX_HWP_DECODED_BYTES: u64 = 512 * 1024 * 1024;
let file = safe_open_for_read(path)?;
let mut cf = CompoundFile::open(file)?;
let stream_paths: Vec<PathBuf> =
cf.walk().filter(|e| e.is_stream()).map(|e| e.path().to_path_buf()).collect();
let mut out = Vec::with_capacity(stream_paths.len());
for sp in stream_paths {
let mut raw = Vec::new();
match cf.open_stream(&sp) {
Ok(s) => {
let mut limited = s.take(MAX_HWP_RAW_BYTES);
if let Err(e) = limited.read_to_end(&mut raw) {
tracing::debug!("failed to read hwp stream {}: {}", sp.display(), e);
continue;
}
}
Err(e) => {
tracing::debug!("failed to open hwp stream {}: {}", sp.display(), e);
continue;
}
}
let try_decode = |mut decoder: Box<dyn Read>| -> Option<Vec<u8>> {
let mut buf = Vec::new();
match decoder.read_to_end(&mut buf) {
Ok(_) if !buf.is_empty() => Some(buf),
_ => None,
}
};
let decoded = if raw.is_empty() {
raw
} else {
let deflate =
try_decode(Box::new(DeflateDecoder::new(&raw[..]).take(MAX_HWP_DECODED_BYTES)));
if let Some(buf) = deflate {
buf
} else {
let zlib =
try_decode(Box::new(ZlibDecoder::new(&raw[..]).take(MAX_HWP_DECODED_BYTES)));
zlib.unwrap_or(raw)
}
};
let logical = format!("{}!{}", archive_path.display(), sp.display());
out.push((logical, decoded));
}
Ok(CompressedContent::Archive(out))
}
fn handle_asar_archive_in_memory(buffer: &[u8], archive_path: &Path) -> Result<CompressedContent> {
const MAX_ASAR_ENTRY_BYTES: usize = 512 * 1024 * 1024;
match AsarReader::new(buffer, None) {
Ok(reader) => {
let mut contents = Vec::new();
for (path_in_asar, file) in reader.files() {
let inner_path = path_in_asar.to_string_lossy().replace('\\', "/");
if !is_safe_extract_path(Path::new(&inner_path)) {
tracing::debug!("unsafe asar path: {inner_path}");
continue;
}
let logical_path = format!("{}!{}", archive_path.display(), inner_path);
let data = file.data();
let take = data.len().min(MAX_ASAR_ENTRY_BYTES);
if take < data.len() {
tracing::debug!(
"asar entry {} exceeded {} byte cap; truncating",
inner_path,
MAX_ASAR_ENTRY_BYTES
);
}
contents.push((logical_path, data[..take].to_vec()));
}
Ok(CompressedContent::Archive(contents))
}
Err(e) => Err(e.into()),
}
}
fn materialize_in_memory_archive_entries(
files: &[(String, Vec<u8>)],
base_dir: &Path,
) -> Result<()> {
for (name, data) in files {
let rel = name.split_once('!').map(|(_, sub)| sub).unwrap_or(name.as_str());
let normalized_rel = rel.replace('\\', "/");
let rel_path = Path::new(&normalized_rel);
if !is_safe_extract_path(rel_path) {
tracing::debug!("unsafe archive path: {normalized_rel}");
continue;
}
let p = base_dir.join(rel_path);
if let Some(parent) = p.parent() {
fs::create_dir_all(parent)?;
}
fs::write(p, data)?;
}
Ok(())
}
fn safe_open_for_read(path: &Path) -> Result<fs::File> {
if has_parent_or_embedded_prefix(path) {
anyhow::bail!("unsafe input path during decompression: {}", path.display());
}
Ok(fs::File::open(path)?)
}
fn safe_create_for_write(path: &Path) -> Result<fs::File> {
if has_parent_or_embedded_prefix(path) {
anyhow::bail!("unsafe output path during decompression: {}", path.display());
}
Ok(fs::File::create(path)?)
}
pub const MAX_SINGLE_STREAM_DECOMPRESSED_BYTES: u64 = 4 * 1024 * 1024 * 1024;
struct CappedWriter<W: Write> {
inner: W,
remaining: u64,
truncated: bool,
}
impl<W: Write> CappedWriter<W> {
fn new(inner: W, cap: u64) -> Self {
Self { inner, remaining: cap, truncated: false }
}
fn truncated(&self) -> bool {
self.truncated
}
}
impl<W: Write> Write for CappedWriter<W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let allowed = (buf.len() as u64).min(self.remaining) as usize;
if allowed > 0 {
self.inner.write_all(&buf[..allowed])?;
self.remaining -= allowed as u64;
}
if allowed < buf.len() {
self.truncated = true;
}
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
fn stream_to_file_capped<R: Read>(
mut decoder: R,
out_path: &Path,
cap: u64,
) -> Result<CompressedContent> {
let out_file = safe_create_for_write(out_path)?;
let mut capped = CappedWriter::new(out_file, cap);
std::io::copy(&mut decoder, &mut capped)?;
if capped.truncated() {
tracing::debug!(
"decompressed stream written to {} exceeded {cap} byte cap; truncating",
out_path.display()
);
}
Ok(CompressedContent::RawFile(out_path.to_owned()))
}
fn stream_xz_to_file_capped(path: &Path, out_path: &Path, cap: u64) -> Result<CompressedContent> {
let input = safe_open_for_read(path)?;
let mut reader = BufReader::new(input);
let out_file = safe_create_for_write(out_path)?;
let mut capped = CappedWriter::new(out_file, cap);
xz_decompress(&mut reader, &mut capped)?;
if capped.truncated() {
tracing::debug!(
"decompressed xz stream written to {} exceeded {cap} byte cap; truncating",
out_path.display()
);
}
Ok(CompressedContent::RawFile(out_path.to_owned()))
}
fn decompress_once_with_single_stream_cap(
path: &Path,
base_dir: Option<&Path>,
single_stream_cap: u64,
) -> Result<CompressedContent> {
let extension = path.extension().and_then(|ext| ext.to_str()).map(|s| s.to_ascii_lowercase());
let mut file = safe_open_for_read(path)?;
if let Some(ext) = extension.as_deref() {
match ext {
"asar" => {
let mmap = unsafe { Mmap::map(&file)? };
return handle_asar_archive_in_memory(&mmap, path);
}
"hwp" => {
return handle_hwp_archive_in_memory(path, path);
}
"egg" => {
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)?;
return Ok(CompressedContent::Raw(buffer));
}
"tar" => {
if let Some(base) = base_dir {
return handle_tar_archive_streaming(&mut file, path, base);
} else {
let temp = tempdir()?;
return handle_tar_archive_streaming(&mut file, path, temp.path());
}
}
_ if is_zip_format(ext) => {
if let Some(base) = base_dir {
return handle_zip_archive_streaming(&mut file, path, base);
} else {
let temp = tempdir()?;
return handle_zip_archive_streaming(&mut file, path, temp.path());
}
}
"gz" | "gzip" | "tgz" => {
let out_path = make_output_path(path, base_dir, "decomp.tar");
let decoder = GzDecoder::new(BufReader::new(safe_open_for_read(path)?));
return stream_to_file_capped(decoder, &out_path, single_stream_cap);
}
"bz2" | "bzip2" => {
let out_path = make_output_path(path, base_dir, "decomp.tar");
let decoder = DecoderReader::new(BufReader::new(safe_open_for_read(path)?));
return stream_to_file_capped(decoder, &out_path, single_stream_cap);
}
"xz" => {
let out_path = make_output_path(path, base_dir, "decomp.tar");
return stream_xz_to_file_capped(path, &out_path, single_stream_cap);
}
"zlib" => {
let out_path = make_output_path(path, base_dir, "decomp.tar");
let decoder = ZlibDecoder::new(BufReader::new(safe_open_for_read(path)?));
return stream_to_file_capped(decoder, &out_path, single_stream_cap);
}
_ => {}
}
}
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)?;
if looks_like_zip(&buffer) {
let archive_label = path.display().to_string();
if buffer.len() <= MAX_INMEM_ZIP_ARCHIVE_BYTES {
match extract_zip_archive_in_memory(&buffer, &archive_label) {
Ok(entries) if !entries.is_empty() => {
return Ok(CompressedContent::Archive(entries));
}
Ok(_) => {}
Err(e) => {
tracing::debug!(
"content-sniffed zip extract failed for {archive_label}: {e:#}; scanning raw bytes"
);
}
}
} else if let Some(base) = base_dir {
match extract_zip_bytes_via_streaming(&buffer, path, base) {
Ok(content) => return Ok(content),
Err(e) => {
tracing::debug!(
"content-sniffed streaming zip extract failed for {archive_label}: {e:#}; scanning raw bytes"
);
}
}
}
}
Ok(CompressedContent::Raw(buffer))
}
pub fn decompress_file(path: &Path, base_dir: Option<&Path>) -> Result<CompressedContent> {
decompress_file_with_single_stream_cap(path, base_dir, MAX_SINGLE_STREAM_DECOMPRESSED_BYTES)
}
pub fn decompress_file_with_single_stream_cap(
path: &Path,
base_dir: Option<&Path>,
single_stream_cap: u64,
) -> Result<CompressedContent> {
let mut current_path: &Path = path;
let mut owned_buf: Option<PathBuf>;
loop {
let should_extract_tar = is_tar_wrapped_compression(current_path);
let content =
decompress_once_with_single_stream_cap(current_path, base_dir, single_stream_cap)?;
if let CompressedContent::RawFile(ref p) = content
&& should_extract_tar
{
owned_buf = Some(p.clone()); current_path = owned_buf.as_ref().unwrap();
continue;
}
return Ok(content);
}
}
fn make_output_path(path: &Path, base: Option<&Path>, extension: &str) -> PathBuf {
if let Some(b) = base {
let stem = path.file_stem().unwrap_or_default();
b.join(stem).with_extension(extension)
} else {
std::env::temp_dir().join(format!(
"kingfisher-{}-{}-{}",
std::process::id(),
Uuid::new_v4(),
extension
))
}
}
pub fn decompress_file_to_temp(path: &Path) -> Result<(CompressedContent, TempDir)> {
let temp_dir = tempdir()?;
let mut content = decompress_file(path, Some(temp_dir.path()))?;
let mut prefix_for_replace = None;
if let Some(stem) = path.file_stem() {
let candidate = temp_dir.path().join(stem).with_extension("decomp.tar");
prefix_for_replace = Some(candidate);
}
if let CompressedContent::Archive(ref mut files) = content {
if let Some(prefix) = &prefix_for_replace {
let prefix_str = prefix.display().to_string();
for (name, _) in files.iter_mut() {
if let Some(rest) = name.strip_prefix(&prefix_str)
&& let Some((_, suffix)) = rest.split_once('!')
{
*name = format!("{}!{}", path.display(), suffix);
}
}
}
materialize_in_memory_archive_entries(files, temp_dir.path())?;
} else if let CompressedContent::ArchiveFiles(ref mut entries) = content
&& let Some(prefix) = &prefix_for_replace
{
let prefix_str = prefix.display().to_string();
for (name, _) in entries.iter_mut() {
if let Some(rest) = name.strip_prefix(&prefix_str)
&& let Some((_, suffix)) = rest.split_once('!')
{
*name = format!("{}!{}", path.display(), suffix);
}
}
}
Ok((content, temp_dir))
}
#[cfg(test)]
mod tests {
use std::{fs::File, io::Write, path::Path};
use flate2::{Compression, write::GzEncoder};
use tar::Builder;
use tempfile::tempdir;
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
use super::{
CompressedContent, decompress_file_to_temp, materialize_in_memory_archive_entries,
};
fn decompress_once(path: &Path, base_dir: Option<&Path>) -> anyhow::Result<CompressedContent> {
super::decompress_once_with_single_stream_cap(
path,
base_dir,
super::MAX_SINGLE_STREAM_DECOMPRESSED_BYTES,
)
}
fn write_tar(path: &Path, entries: &[(&str, &[u8])]) -> anyhow::Result<()> {
let file = File::create(path)?;
let mut tar = Builder::new(file);
for (name, contents) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(contents.len() as u64);
header.set_mode(0o644);
header.set_cksum();
tar.append_data(&mut header, name, *contents)?;
}
tar.finish()?;
Ok(())
}
#[test]
fn tar_streaming_skips_entries_over_the_per_entry_cap() -> anyhow::Result<()> {
let dir = tempdir()?;
let archive_path = dir.path().join("payload.tar");
write_tar(
&archive_path,
&[("small.txt", b"tiny"), ("too-large.txt", b"oversized"), ("after.txt", b"kept")],
)?;
let extraction_dir = tempdir()?;
let mut archive_file = File::open(&archive_path)?;
let content = super::handle_tar_archive_streaming_with_limits(
&mut archive_file,
&archive_path,
extraction_dir.path(),
super::TarExtractionLimits { max_entries: 10, max_entry_bytes: 8, max_total_bytes: 16 },
)?;
let CompressedContent::ArchiveFiles(entries) = content else {
panic!("expected TAR entries to be extracted");
};
assert_eq!(entries.len(), 2);
assert!(entries.iter().any(|(logical, _)| logical.ends_with("!small.txt")));
assert!(entries.iter().any(|(logical, _)| logical.ends_with("!after.txt")));
assert!(!entries.iter().any(|(logical, _)| logical.ends_with("!too-large.txt")));
assert!(!extraction_dir.path().join("too-large.txt").exists());
Ok(())
}
#[test]
fn tar_streaming_stops_at_aggregate_and_entry_count_caps() -> anyhow::Result<()> {
let dir = tempdir()?;
let archive_path = dir.path().join("payload.tar");
write_tar(
&archive_path,
&[("first.txt", b"1234"), ("second.txt", b"5678"), ("third.txt", b"9012")],
)?;
let aggregate_dir = tempdir()?;
let mut aggregate_file = File::open(&archive_path)?;
let aggregate_content = super::handle_tar_archive_streaming_with_limits(
&mut aggregate_file,
&archive_path,
aggregate_dir.path(),
super::TarExtractionLimits { max_entries: 10, max_entry_bytes: 8, max_total_bytes: 6 },
)?;
let CompressedContent::ArchiveFiles(aggregate_entries) = aggregate_content else {
panic!("expected TAR entries to be extracted");
};
assert_eq!(aggregate_entries.len(), 1);
assert!(aggregate_entries[0].0.ends_with("!first.txt"));
let count_dir = tempdir()?;
let mut count_file = File::open(&archive_path)?;
let count_content = super::handle_tar_archive_streaming_with_limits(
&mut count_file,
&archive_path,
count_dir.path(),
super::TarExtractionLimits { max_entries: 1, max_entry_bytes: 8, max_total_bytes: 16 },
)?;
let CompressedContent::ArchiveFiles(count_entries) = count_content else {
panic!("expected TAR entries to be extracted");
};
assert_eq!(count_entries.len(), 1);
assert!(count_entries[0].0.ends_with("!first.txt"));
Ok(())
}
#[test]
fn decompress_sniffs_zip_without_archive_extension() -> anyhow::Result<()> {
let dir = tempdir()?;
let plan = dir.path().join("tf.plan");
let github_pat = "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";
{
let f = File::create(&plan)?;
let mut zip = ZipWriter::new(f);
let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
zip.start_file("tfstate", opts)?;
zip.write_all(format!("token={github_pat}\n").as_bytes())?;
zip.finish()?;
}
match decompress_once(&plan, Some(dir.path()))? {
CompressedContent::Archive(entries) => {
let found = entries.iter().any(|(logical, bytes)| {
logical.ends_with("!tfstate")
&& std::str::from_utf8(bytes).is_ok_and(|s| s.contains(github_pat))
});
assert!(found, "expected tfstate entry with secret, got {entries:?}");
}
other => panic!("expected Archive from content-sniffed zip, got {other:?}"),
}
Ok(())
}
#[test]
fn decompress_leaves_non_zip_unknown_extension_as_raw() -> anyhow::Result<()> {
let dir = tempdir()?;
let plan = dir.path().join("notes.plan");
let body = b"plain text plan, definitely not a zip\n";
std::fs::write(&plan, body)?;
match decompress_once(&plan, Some(dir.path()))? {
CompressedContent::Raw(bytes) => assert_eq!(bytes, body),
other => panic!("expected Raw for non-zip file, got {other:?}"),
}
Ok(())
}
#[test]
fn decompress_empty_zip_falls_back_to_raw() -> anyhow::Result<()> {
let dir = tempdir()?;
let plan = dir.path().join("empty.plan");
let mut bytes = Vec::new();
{
let w = ZipWriter::new(std::io::Cursor::new(&mut bytes));
w.finish()?;
}
std::fs::write(&plan, &bytes)?;
match decompress_once(&plan, Some(dir.path()))? {
CompressedContent::Raw(raw) => assert_eq!(raw, bytes),
other => panic!("expected Raw for empty zip, got {other:?}"),
}
Ok(())
}
#[test]
fn streaming_zip_bytes_extracts_and_labels_by_path() -> anyhow::Result<()> {
let dir = tempdir()?;
let github_pat = "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";
let mut bytes = Vec::new();
{
let mut zip = ZipWriter::new(std::io::Cursor::new(&mut bytes));
let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
zip.start_file("tfstate", opts)?;
zip.write_all(format!("token={github_pat}\n").as_bytes())?;
zip.finish()?;
}
let label = Path::new("tf.plan");
match super::extract_zip_bytes_via_streaming(&bytes, label, dir.path())? {
CompressedContent::ArchiveFiles(entries) => {
let (_, on_disk) = entries
.iter()
.find(|(logical, _)| logical == "tf.plan!tfstate")
.expect("expected tf.plan!tfstate entry");
let txt = std::fs::read_to_string(on_disk)?;
assert!(txt.contains(github_pat));
}
other => panic!("expected ArchiveFiles from streaming extract, got {other:?}"),
}
Ok(())
}
#[test]
fn smoke_decompress_tar_gz_archive() -> anyhow::Result<()> {
let dir = tempdir()?;
let tar_gz = dir.path().join("payload.tar.gz");
let github_pat = "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";
{
let f = File::create(&tar_gz)?;
let gz = GzEncoder::new(f, Compression::default());
let mut tar = Builder::new(gz);
let data = format!("token={github_pat}\n");
let mut hdr = tar::Header::new_gnu();
hdr.set_size(data.len() as u64);
hdr.set_mode(0o644);
hdr.set_cksum();
tar.append_data(&mut hdr, "secret.txt", data.as_bytes())?;
tar.into_inner()?.finish()?;
}
let tmp = tempdir()?;
let layer1 = decompress_once(&tar_gz, Some(tmp.path()))?;
let tar_path = match layer1 {
CompressedContent::RawFile(p) => p,
other => panic!("expected RawFile on first pass, got {:?}", other),
};
let content = decompress_once(&tar_path, Some(tmp.path()))?;
if let CompressedContent::ArchiveFiles(files) = content {
let mut found = false;
for (logical, path) in files {
if logical.ends_with("!secret.txt") {
let txt = std::fs::read_to_string(&path)?;
assert!(txt.contains(github_pat));
found = true;
}
}
assert!(found, "did not find secret.txt in ArchiveFiles");
} else {
panic!("expected ArchiveFiles on second pass, got {:?}", content);
}
Ok(())
}
#[test]
fn smoke_decompress_tgz_archive() -> anyhow::Result<()> {
let dir = tempdir()?;
let tgz = dir.path().join("payload.tgz");
let github_pat = "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";
{
let f = File::create(&tgz)?;
let gz = GzEncoder::new(f, Compression::default());
let mut tar = Builder::new(gz);
let data = format!("token={github_pat}\n");
let mut hdr = tar::Header::new_gnu();
hdr.set_size(data.len() as u64);
hdr.set_mode(0o644);
hdr.set_cksum();
tar.append_data(&mut hdr, "secret.txt", data.as_bytes())?;
tar.into_inner()?.finish()?;
}
let (content, _tmp) = decompress_file_to_temp(&tgz)?;
if let CompressedContent::ArchiveFiles(files) = content {
let mut found = false;
for (logical, path) in files {
if logical.ends_with("payload.tgz!secret.txt") {
let txt = std::fs::read_to_string(&path)?;
assert!(txt.contains(github_pat));
found = true;
}
}
assert!(found, "did not find secret.txt in tgz ArchiveFiles");
} else {
panic!("expected ArchiveFiles for tgz archive, got {:?}", content);
}
Ok(())
}
#[test]
fn smoke_decompress_truncated_tgz_archive_keeps_partial_entries() -> anyhow::Result<()> {
let dir = tempdir()?;
let tgz = dir.path().join("payload.tgz");
let github_pat = "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";
{
let f = File::create(&tgz)?;
let gz = GzEncoder::new(f, Compression::default());
let mut tar = Builder::new(gz);
let first = format!("token={github_pat}\n");
let mut hdr = tar::Header::new_gnu();
hdr.set_size(first.len() as u64);
hdr.set_mode(0o644);
hdr.set_cksum();
tar.append_data(&mut hdr, "first.txt", first.as_bytes())?;
let second = vec![b'B'; 4096];
let mut hdr = tar::Header::new_gnu();
hdr.set_size(second.len() as u64);
hdr.set_mode(0o644);
hdr.set_cksum();
tar.append_data(&mut hdr, "second.txt", second.as_slice())?;
tar.into_inner()?.finish()?;
}
let tmp = tempdir()?;
let content = super::decompress_file_with_single_stream_cap(&tgz, Some(tmp.path()), 1536)?;
if let CompressedContent::ArchiveFiles(files) = content {
let mut found = false;
for (logical, path) in files {
if logical.ends_with("!first.txt") {
let txt = std::fs::read_to_string(&path)?;
assert!(txt.contains(github_pat));
found = true;
}
}
assert!(found, "did not recover first.txt from truncated archive");
} else {
panic!("expected ArchiveFiles for truncated tgz, got {:?}", content);
}
Ok(())
}
#[test]
fn smoke_decompress_truncated_tgz_archive_falls_back_to_raw_when_no_entry_completes()
-> anyhow::Result<()> {
let dir = tempdir()?;
let tgz = dir.path().join("payload.tgz");
{
let f = File::create(&tgz)?;
let gz = GzEncoder::new(f, Compression::default());
let mut tar = Builder::new(gz);
let first = vec![b'A'; 2048];
let mut hdr = tar::Header::new_gnu();
hdr.set_size(first.len() as u64);
hdr.set_mode(0o644);
hdr.set_cksum();
tar.append_data(&mut hdr, "secret.txt", first.as_slice())?;
tar.into_inner()?.finish()?;
}
let tmp = tempdir()?;
let content = super::decompress_file_with_single_stream_cap(&tgz, Some(tmp.path()), 600)?;
match content {
CompressedContent::RawFile(path) => {
let data = std::fs::read(&path)?;
let as_str = String::from_utf8_lossy(&data);
assert!(
as_str.contains("secret.txt") || data.windows(5).any(|w| w == b"ustar"),
"raw fallback should preserve tar bytes"
);
}
other => panic!("expected RawFile for heavily truncated tgz, got {:?}", other),
}
Ok(())
}
#[test]
fn smoke_decompress_without_extract_archives() -> anyhow::Result<()> {
let dir = tempdir()?;
let tar_gz = dir.path().join("payload.tar.gz");
let github_pat = "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";
{
let f = File::create(&tar_gz)?;
let gz = GzEncoder::new(f, Compression::default());
let mut tar = Builder::new(gz);
let data = format!("token={github_pat}\n");
let mut hdr = tar::Header::new_gnu();
hdr.set_size(data.len() as u64);
hdr.set_mode(0o644);
hdr.set_cksum();
tar.append_data(&mut hdr, "secret.txt", data.as_bytes())?;
tar.into_inner()?.finish()?;
}
let content = decompress_once(&tar_gz, None)?;
match content {
CompressedContent::RawFile(path) => {
let data = std::fs::read(&path)?;
let as_str = String::from_utf8_lossy(&data);
assert!(
as_str.contains("secret.txt") || data.windows(5).any(|w| w == b"ustar"),
"raw file isn’t a tar archive"
);
}
other => panic!("expected RawFile, got {:?}", other),
}
Ok(())
}
#[test]
fn smoke_decompress_zip_archive() -> anyhow::Result<()> {
let dir = tempdir()?;
let zip_path = dir.path().join("payload.zip");
let github_pat = "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";
{
let file = File::create(&zip_path)?;
let mut zip = ZipWriter::new(file);
let options = SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.unix_permissions(0o644);
zip.start_file("nested/secret.txt", options)?;
zip.write_all(format!("token={github_pat}\n").as_bytes())?;
zip.finish()?;
}
let tmp = tempdir()?;
let content = decompress_once(&zip_path, Some(tmp.path()))?;
if let CompressedContent::ArchiveFiles(files) = content {
let mut found = false;
for (logical, path) in files {
if logical.ends_with("!nested/secret.txt") {
let txt = std::fs::read_to_string(&path)?;
assert!(txt.contains(github_pat));
found = true;
}
}
assert!(found, "did not find nested/secret.txt in ArchiveFiles");
} else {
panic!("expected ArchiveFiles for zip archive, got {:?}", content);
}
Ok(())
}
#[test]
fn materialize_in_memory_archive_entries_skips_unsafe_paths() -> anyhow::Result<()> {
let sandbox = tempdir()?;
let extract_root = sandbox.path().join("extract");
std::fs::create_dir(&extract_root)?;
let outside_parent = sandbox.path().join("outside-parent.txt");
let outside_absolute = sandbox.path().join("outside-absolute.txt");
let entries = vec![
("archive.asar!nested/safe.txt".to_string(), b"safe".to_vec()),
("archive.asar!../outside-parent.txt".to_string(), b"bad".to_vec()),
(format!("archive.asar!{}", outside_absolute.display()), b"bad".to_vec()),
];
materialize_in_memory_archive_entries(&entries, &extract_root)?;
assert_eq!(std::fs::read(extract_root.join("nested/safe.txt"))?, b"safe");
assert!(!outside_parent.exists());
assert!(!outside_absolute.exists());
Ok(())
}
#[test]
fn decompress_asar_skips_parent_dir_entries() -> anyhow::Result<()> {
use asar::AsarWriter;
let mut writer = AsarWriter::new();
writer.write_file("safe.txt", b"safe", false)?;
writer.write_file("aa/bb/escape.txt", b"bad", false)?;
let mut archive = Vec::new();
writer.finalize(&mut archive)?;
let json_size = u32::from_le_bytes(archive[12..16].try_into().unwrap()) as usize;
let header = &mut archive[16..16 + json_size];
let header_str = std::str::from_utf8(header)?;
assert!(header_str.contains("\"aa\""));
assert!(header_str.contains("\"bb\""));
let patched = header_str.replace("\"aa\"", "\"..\"").replace("\"bb\"", "\"..\"");
assert_eq!(patched.len(), header_str.len());
header.copy_from_slice(patched.as_bytes());
let dir = tempdir()?;
let asar_path = dir.path().join("malicious.asar");
std::fs::write(&asar_path, archive)?;
let (content, _tmp) = decompress_file_to_temp(&asar_path)?;
let CompressedContent::Archive(entries) = content else {
panic!("expected Archive for asar");
};
assert_eq!(entries.len(), 1);
assert!(
entries
.iter()
.any(|(name, data)| name.ends_with("!safe.txt") && data.as_slice() == b"safe")
);
assert!(!entries.iter().any(|(name, _)| name.contains("..")));
Ok(())
}
#[test]
fn smoke_decompress_nested_tar_gz_archives() -> anyhow::Result<()> {
use std::{fs::File, io::Read, path::PathBuf};
use flate2::{Compression, write::GzEncoder};
use tar::Builder;
use tempfile::tempdir;
use super::CompressedContent;
let tmp = tempdir()?;
let inner_tgz = tmp.path().join("inner.tar.gz");
{
let f = File::create(&inner_tgz)?;
let gz = GzEncoder::new(f, Compression::default());
let mut tar = Builder::new(gz);
let data = b"nested_secret=shh\n";
let mut hdr = tar::Header::new_gnu();
hdr.set_size(data.len() as u64);
hdr.set_mode(0o644);
hdr.set_cksum();
tar.append_data(&mut hdr, "secret.txt", &data[..])?;
tar.into_inner()?.finish()?;
}
let mut inner_bytes = Vec::new();
File::open(&inner_tgz)?.read_to_end(&mut inner_bytes)?;
let outer_tgz = tmp.path().join("outer.tar.gz");
{
let f = File::create(&outer_tgz)?;
let gz = GzEncoder::new(f, Compression::default());
let mut tar = Builder::new(gz);
let mut hdr = tar::Header::new_gnu();
hdr.set_size(inner_bytes.len() as u64);
hdr.set_mode(0o644);
hdr.set_cksum();
tar.append_data(&mut hdr, "inner.tar.gz", inner_bytes.as_slice())?;
tar.into_inner()?.finish()?;
}
let scratch = tempdir()?; let tar_path = match decompress_once(&outer_tgz, Some(scratch.path()))? {
CompressedContent::RawFile(p) => p,
other => panic!("expected RawFile after gunzip, got {:?}", other),
};
let inner_on_disk: PathBuf = match decompress_once(&tar_path, Some(scratch.path()))? {
CompressedContent::ArchiveFiles(files) => files
.into_iter()
.find(|(logical, _)| logical.ends_with("!inner.tar.gz"))
.map(|(_, p)| p)
.expect("inner.tar.gz not found in outer archive"),
other => panic!("expected ArchiveFiles after untar, got {:?}", other),
};
let inner_tar = match decompress_once(&inner_on_disk, Some(scratch.path()))? {
CompressedContent::RawFile(p) => p,
other => panic!("expected RawFile after gunzip inner, got {:?}", other),
};
match decompress_once(&inner_tar, Some(scratch.path()))? {
CompressedContent::ArchiveFiles(files) => {
let mut found = false;
for (logical, path) in files {
if logical.ends_with("!secret.txt") {
let txt = std::fs::read_to_string(&path)?;
assert!(txt.contains("nested_secret=shh"), "secret.txt content corrupted");
found = true;
}
}
assert!(found, "secret.txt not extracted from nested archive");
}
other => panic!("expected ArchiveFiles after untar inner, got {:?}", other),
}
Ok(())
}
#[test]
fn smoke_decompress_apk_archive() -> anyhow::Result<()> {
let dir = tempdir()?;
let apk_path = dir.path().join("aws_leak.apk");
let aws_key = "AKIAIOSFODNN7EXAMPLE";
{
let file = File::create(&apk_path)?;
let mut zip = ZipWriter::new(file);
let options = SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.unix_permissions(0o644);
zip.start_file("res/values/strings.xml", options)?;
zip.write_all(
format!(
"<?xml version=\"1.0\"?><resources><string name=\"aws\">{aws_key}</string></resources>"
)
.as_bytes(),
)?;
zip.finish()?;
}
let tmp = tempdir()?;
let content = decompress_once(&apk_path, Some(tmp.path()))?;
if let CompressedContent::ArchiveFiles(files) = content {
let mut found = false;
for (logical, path) in files {
if logical.ends_with("!res/values/strings.xml") {
let txt = std::fs::read_to_string(&path)?;
assert!(txt.contains(aws_key));
found = true;
}
}
assert!(found, "did not find res/values/strings.xml in apk ArchiveFiles");
} else {
panic!("expected ArchiveFiles for apk archive, got {:?}", content);
}
Ok(())
}
#[test]
fn smoke_decompress_hwpx_archive() -> anyhow::Result<()> {
let dir = tempdir()?;
let hwpx_path = dir.path().join("document.hwpx");
let github_pat = "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";
{
let file = File::create(&hwpx_path)?;
let mut zip = ZipWriter::new(file);
let options = SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.unix_permissions(0o644);
zip.start_file("Contents/section0.xml", options)?;
zip.write_all(
format!("<?xml version=\"1.0\"?><doc>token={github_pat}</doc>").as_bytes(),
)?;
zip.finish()?;
}
let tmp = tempdir()?;
let content = decompress_once(&hwpx_path, Some(tmp.path()))?;
if let CompressedContent::ArchiveFiles(files) = content {
let mut found = false;
for (logical, path) in files {
if logical.ends_with("!Contents/section0.xml") {
let txt = std::fs::read_to_string(&path)?;
assert!(txt.contains(github_pat));
found = true;
}
}
assert!(found, "did not find Contents/section0.xml in hwpx ArchiveFiles");
} else {
panic!("expected ArchiveFiles for hwpx archive, got {:?}", content);
}
Ok(())
}
#[test]
fn smoke_decompress_hwp_archive() -> anyhow::Result<()> {
use cfb::CompoundFile;
use flate2::{Compression, write::ZlibEncoder};
let dir = tempdir()?;
let hwp_path = dir.path().join("document.hwp");
let github_pat = "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";
{
let file = File::create(&hwp_path)?;
let mut cf = CompoundFile::create(file)?;
cf.create_storage("/BodyText")?;
let mut s_plain = cf.create_stream("/DocInfo")?;
s_plain.write_all(format!("metadata token={github_pat}").as_bytes())?;
drop(s_plain);
let mut zencoder = ZlibEncoder::new(Vec::new(), Compression::default());
zencoder.write_all(format!("body token={github_pat}").as_bytes())?;
let zbytes = zencoder.finish()?;
let mut s_body = cf.create_stream("/BodyText/Section0")?;
s_body.write_all(&zbytes)?;
drop(s_body);
cf.flush()?;
}
let content = decompress_once(&hwp_path, None)?;
if let CompressedContent::Archive(entries) = content {
let mut saw_plain = false;
let mut saw_body = false;
for (logical, bytes) in &entries {
let as_str = String::from_utf8_lossy(bytes);
if logical.contains("DocInfo") && as_str.contains(github_pat) {
saw_plain = true;
}
if logical.contains("Section0") && as_str.contains(github_pat) {
saw_body = true;
}
}
assert!(saw_plain, "plaintext DocInfo stream missing or not decoded");
assert!(saw_body, "zlib-framed BodyText/Section0 stream missing or not decoded");
} else {
panic!("expected Archive for hwp, got {:?}", content);
}
Ok(())
}
#[test]
fn smoke_decompress_egg_raw() -> anyhow::Result<()> {
let dir = tempdir()?;
let egg_path = dir.path().join("archive.egg");
let github_pat = "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";
{
let mut f = File::create(&egg_path)?;
f.write_all(format!("EGG-pretend-header\ntoken={github_pat}\n").as_bytes())?;
}
let content = decompress_once(&egg_path, None)?;
match content {
CompressedContent::Raw(bytes) => {
let as_str = String::from_utf8_lossy(&bytes);
assert!(
as_str.contains(github_pat),
"raw egg bytes did not contain the embedded pat"
);
}
other => panic!("expected Raw for egg, got {:?}", other),
}
Ok(())
}
#[test]
fn capped_writer_drops_bytes_past_cap() {
use std::io::Write;
use super::CappedWriter;
let mut sink = Vec::new();
let mut capped = CappedWriter::new(&mut sink, 40);
assert_eq!(capped.write(&[0u8; 100]).unwrap(), 100);
assert!(capped.truncated());
capped.flush().unwrap();
assert_eq!(sink.len(), 40);
let mut sink = Vec::new();
let mut capped = CappedWriter::new(&mut sink, 40);
assert_eq!(capped.write(&[0u8; 10]).unwrap(), 10);
assert!(!capped.truncated());
assert_eq!(sink.len(), 10);
}
#[test]
fn stream_to_file_capped_truncates_oversized_stream() -> anyhow::Result<()> {
use std::io::Cursor;
use super::{CompressedContent, stream_to_file_capped};
let dir = tempdir()?;
let out_path = dir.path().join("out.bin");
let payload = vec![b'A'; 8192];
let content = stream_to_file_capped(Cursor::new(payload), &out_path, 128)?;
match content {
CompressedContent::RawFile(p) => {
let written = std::fs::metadata(&p)?.len();
assert_eq!(written, 128, "output must be capped at the byte budget");
}
other => panic!("expected RawFile, got {other:?}"),
}
Ok(())
}
}
#[cfg(test)]
mod streaming_tests {
use super::*;
use std::{
io::{Cursor, Read, Seek, SeekFrom, Write},
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
};
struct CountReads {
inner: Cursor<Vec<u8>>,
read: Arc<AtomicUsize>,
}
impl Read for CountReads {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
let size = self.inner.read(buffer)?;
self.read.fetch_add(size, Ordering::Relaxed);
Ok(size)
}
}
impl Seek for CountReads {
fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
self.inner.seek(position)
}
}
#[test]
fn zip_entries_read_only_the_consumed_payload() -> Result<()> {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let payload = vec![b'x'; 1024 * 1024];
for index in 0..8 {
zip.start_file(
format!("entry-{index}.txt"),
zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored),
)?;
zip.write_all(&payload)?;
}
let data = zip.finish()?.into_inner();
let read = Arc::new(AtomicUsize::new(0));
let mut entries = zip_entries(
CountReads { inner: Cursor::new(data), read: Arc::clone(&read) },
"archive.zip".into(),
)?;
let (logical, first) = entries.next().expect("first entry");
assert_eq!(logical, "archive.zip!entry-0.txt");
assert_eq!(first, payload);
assert!(
read.load(Ordering::Relaxed) < 2 * payload.len(),
"later payloads must remain unread"
);
assert_eq!(entries.count(), 7);
Ok(())
}
}