Files
rustfs/crates/zip/src/lib.rs
T
houseme 9354b939ca refactor(zip): simplify archive extraction path (#3290)
* refactor(zip): simplify archive extraction path

* build(deps): align datafusion and http updates

* fix(test): restore pre-commit compatibility
2026-06-08 20:03:02 +00:00

1667 lines
58 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use async_compression::tokio::bufread::{BzDecoder, GzipDecoder, XzDecoder, ZlibDecoder, ZstdDecoder};
use async_compression::tokio::write::{BzEncoder, GzipEncoder, XzEncoder, ZlibEncoder, ZstdEncoder};
use std::collections::HashSet;
use std::future::Future;
use std::io::{Read, Write};
use std::path::{Component, Path, PathBuf};
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use thiserror::Error;
use tokio::fs::File;
use tokio::io::{self, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader, BufWriter};
use tokio::task::spawn_blocking;
use tokio_stream::StreamExt;
use tokio_tar::Archive;
use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions};
pub type Result<T> = std::result::Result<T, ZipError>;
#[derive(Debug, Error)]
pub enum ZipError {
#[error("unsupported {operation} for format {format:?}")]
UnsupportedFormat {
format: CompressionFormat,
operation: &'static str,
},
#[error("invalid compression level {0}: value exceeds i32::MAX")]
InvalidCompressionLevel(u32),
#[error("unsafe archive entry path: {0}")]
UnsafeEntryPath(String),
#[error("archive entry path length {length} exceeds limit {limit}: {path}")]
EntryPathTooLong { path: String, length: usize, limit: usize },
#[error("archive entry count {count} exceeds limit {limit}")]
EntryCountLimitExceeded { count: usize, limit: usize },
#[error("archive entry '{path}' size {size} exceeds limit {limit}")]
EntrySizeLimitExceeded { path: String, size: u64, limit: u64 },
#[error("archive total unpacked size {size} exceeds limit {limit}")]
TotalUnpackedSizeLimitExceeded { size: u64, limit: u64 },
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Zip(#[from] zip::result::ZipError),
#[error(transparent)]
Join(#[from] tokio::task::JoinError),
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum CompressionCodec {
Gzip,
Bzip2,
Xz,
Zlib,
Zstd,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum ArchiveKind {
Tar,
Zip,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum ArchiveFormat {
Tar,
TarGzip,
TarBzip2,
TarXz,
TarZlib,
TarZstd,
Zip,
Unknown,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum CompressionFormat {
Gzip,
Bzip2,
Zip,
Xz,
Zlib,
Zstd,
Tar,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CompressionLevel {
Fastest,
Best,
#[default]
Default,
Level(u32),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ZipEntry {
pub name: String,
pub size: u64,
pub compressed_size: u64,
pub is_dir: bool,
pub compression_method: String,
pub archive_kind: ArchiveKind,
pub format: ArchiveFormat,
pub unix_mode: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ZipExtractSummary {
pub entry_count: usize,
pub directory_count: usize,
pub file_count: usize,
pub total_unpacked_size: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ArchiveLimits {
pub max_entries: usize,
pub max_entry_size: u64,
pub max_total_unpacked_size: u64,
pub max_path_length: usize,
pub validate_entry_paths: bool,
}
impl Default for ArchiveLimits {
fn default() -> Self {
Self {
max_entries: 100_000,
max_entry_size: 1_073_741_824,
max_total_unpacked_size: 10_737_418_240,
max_path_length: 1024,
validate_entry_paths: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ZipWriteOptions {
pub compression_level: CompressionLevel,
pub create_directory_entries: bool,
}
impl Default for ZipWriteOptions {
fn default() -> Self {
Self {
compression_level: CompressionLevel::Default,
create_directory_entries: false,
}
}
}
const SMALL_ZIP_EXTRACT_FAST_PATH_LIMIT: u64 = 8 * 1024;
#[derive(Clone, Default)]
struct SharedBuffer {
inner: Arc<Mutex<Vec<u8>>>,
}
impl SharedBuffer {
fn into_vec(self) -> Vec<u8> {
self.inner.lock().expect("shared in-memory writer lock poisoned").clone()
}
}
impl AsyncWrite for SharedBuffer {
fn poll_write(self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
let mut inner = self
.inner
.lock()
.map_err(|_| io::Error::other("shared in-memory writer lock poisoned"))?;
inner.extend_from_slice(buf);
Poll::Ready(Ok(buf.len()))
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
}
impl CompressionFormat {
pub fn from_extension(ext: &str) -> Self {
Self::from_archive_format(ArchiveFormat::from_extension(ext))
}
pub fn from_archive_format(format: ArchiveFormat) -> Self {
match format {
ArchiveFormat::TarGzip => CompressionFormat::Gzip,
ArchiveFormat::TarBzip2 => CompressionFormat::Bzip2,
ArchiveFormat::TarXz => CompressionFormat::Xz,
ArchiveFormat::TarZlib => CompressionFormat::Zlib,
ArchiveFormat::TarZstd => CompressionFormat::Zstd,
ArchiveFormat::Tar => CompressionFormat::Tar,
ArchiveFormat::Zip => CompressionFormat::Zip,
ArchiveFormat::Unknown => CompressionFormat::Unknown,
}
}
pub fn archive_format_from_path<P: AsRef<Path>>(path: P) -> ArchiveFormat {
ArchiveFormat::from_path(path)
}
pub fn archive_kind(&self) -> Option<ArchiveKind> {
match self {
CompressionFormat::Tar => Some(ArchiveKind::Tar),
CompressionFormat::Zip => Some(ArchiveKind::Zip),
CompressionFormat::Gzip
| CompressionFormat::Bzip2
| CompressionFormat::Xz
| CompressionFormat::Zlib
| CompressionFormat::Zstd
| CompressionFormat::Unknown => None,
}
}
pub fn compression_codec(&self) -> Option<CompressionCodec> {
match self {
CompressionFormat::Gzip => Some(CompressionCodec::Gzip),
CompressionFormat::Bzip2 => Some(CompressionCodec::Bzip2),
CompressionFormat::Xz => Some(CompressionCodec::Xz),
CompressionFormat::Zlib => Some(CompressionCodec::Zlib),
CompressionFormat::Zstd => Some(CompressionCodec::Zstd),
CompressionFormat::Tar | CompressionFormat::Zip | CompressionFormat::Unknown => None,
}
}
pub fn from_path<P: AsRef<Path>>(path: P) -> Self {
Self::from_archive_format(ArchiveFormat::from_path(path))
}
pub fn extension(&self) -> &'static str {
match self {
CompressionFormat::Gzip => "gz",
CompressionFormat::Bzip2 => "bz2",
CompressionFormat::Zip => "zip",
CompressionFormat::Xz => "xz",
CompressionFormat::Zlib => "zlib",
CompressionFormat::Zstd => "zst",
CompressionFormat::Tar => "tar",
CompressionFormat::Unknown => "",
}
}
pub fn is_supported(&self) -> bool {
!matches!(self, CompressionFormat::Unknown)
}
pub fn get_decoder<R>(&self, input: R) -> Result<Box<dyn AsyncRead + Send + Unpin>>
where
R: AsyncRead + Send + Unpin + 'static,
{
let reader = BufReader::new(input);
let decoder: Box<dyn AsyncRead + Send + Unpin + 'static> = match self {
CompressionFormat::Gzip => Box::new(GzipDecoder::new(reader)),
CompressionFormat::Bzip2 => Box::new(BzDecoder::new(reader)),
CompressionFormat::Zlib => Box::new(ZlibDecoder::new(reader)),
CompressionFormat::Xz => Box::new(XzDecoder::new(reader)),
CompressionFormat::Zstd => Box::new(ZstdDecoder::new(reader)),
CompressionFormat::Tar => Box::new(reader),
CompressionFormat::Zip => {
return Err(ZipError::UnsupportedFormat {
format: *self,
operation: "stream decoding",
});
}
CompressionFormat::Unknown => {
return Err(ZipError::UnsupportedFormat {
format: *self,
operation: "decoding",
});
}
};
Ok(decoder)
}
fn convert_level(level: CompressionLevel) -> Result<async_compression::Level> {
match level {
CompressionLevel::Fastest => Ok(async_compression::Level::Fastest),
CompressionLevel::Best => Ok(async_compression::Level::Best),
CompressionLevel::Default => Ok(async_compression::Level::Default),
CompressionLevel::Level(n) => {
let level = i32::try_from(n).map_err(|_| ZipError::InvalidCompressionLevel(n))?;
Ok(async_compression::Level::Precise(level))
}
}
}
pub fn get_encoder<W>(&self, output: W, level: CompressionLevel) -> Result<Box<dyn AsyncWrite + Send + Unpin>>
where
W: AsyncWrite + Send + Unpin + 'static,
{
let writer = BufWriter::new(output);
let encoder: Box<dyn AsyncWrite + Send + Unpin + 'static> = match self {
CompressionFormat::Gzip => Box::new(GzipEncoder::with_quality(writer, Self::convert_level(level)?)),
CompressionFormat::Bzip2 => Box::new(BzEncoder::with_quality(writer, Self::convert_level(level)?)),
CompressionFormat::Zlib => Box::new(ZlibEncoder::with_quality(writer, Self::convert_level(level)?)),
CompressionFormat::Xz => Box::new(XzEncoder::with_quality(writer, Self::convert_level(level)?)),
CompressionFormat::Zstd => Box::new(ZstdEncoder::with_quality(writer, Self::convert_level(level)?)),
CompressionFormat::Tar => Box::new(writer),
CompressionFormat::Zip => {
return Err(ZipError::UnsupportedFormat {
format: *self,
operation: "stream encoding",
});
}
CompressionFormat::Unknown => {
return Err(ZipError::UnsupportedFormat {
format: *self,
operation: "encoding",
});
}
};
Ok(encoder)
}
}
impl ArchiveFormat {
pub fn from_extension(ext: &str) -> Self {
match ext.to_ascii_lowercase().as_str() {
"gz" | "gzip" | "tgz" => ArchiveFormat::TarGzip,
"bz2" | "bzip2" | "tbz" | "tbz2" => ArchiveFormat::TarBzip2,
"xz" | "txz" => ArchiveFormat::TarXz,
"zlib" | "zz" => ArchiveFormat::TarZlib,
"zst" | "zstd" | "tzst" => ArchiveFormat::TarZstd,
"tar" => ArchiveFormat::Tar,
"zip" => ArchiveFormat::Zip,
_ => ArchiveFormat::Unknown,
}
}
pub fn from_path<P: AsRef<Path>>(path: P) -> Self {
let path = path.as_ref();
let lower_name = path.file_name().and_then(|name| name.to_str()).map(str::to_ascii_lowercase);
if let Some(name) = lower_name {
if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
return ArchiveFormat::TarGzip;
}
if name.ends_with(".tar.bz2") || name.ends_with(".tbz") || name.ends_with(".tbz2") {
return ArchiveFormat::TarBzip2;
}
if name.ends_with(".tar.xz") || name.ends_with(".txz") {
return ArchiveFormat::TarXz;
}
if name.ends_with(".tar.zst") || name.ends_with(".tzst") {
return ArchiveFormat::TarZstd;
}
if name.ends_with(".tar.zlib") {
return ArchiveFormat::TarZlib;
}
}
path.extension()
.and_then(|s| s.to_str())
.map(Self::from_extension)
.unwrap_or(ArchiveFormat::Unknown)
}
pub fn archive_kind(&self) -> Option<ArchiveKind> {
match self {
ArchiveFormat::Tar
| ArchiveFormat::TarGzip
| ArchiveFormat::TarBzip2
| ArchiveFormat::TarXz
| ArchiveFormat::TarZlib
| ArchiveFormat::TarZstd => Some(ArchiveKind::Tar),
ArchiveFormat::Zip => Some(ArchiveKind::Zip),
ArchiveFormat::Unknown => None,
}
}
pub fn compression_codec(&self) -> Option<CompressionCodec> {
match self {
ArchiveFormat::TarGzip => Some(CompressionCodec::Gzip),
ArchiveFormat::TarBzip2 => Some(CompressionCodec::Bzip2),
ArchiveFormat::TarXz => Some(CompressionCodec::Xz),
ArchiveFormat::TarZlib => Some(CompressionCodec::Zlib),
ArchiveFormat::TarZstd => Some(CompressionCodec::Zstd),
ArchiveFormat::Tar | ArchiveFormat::Zip | ArchiveFormat::Unknown => None,
}
}
pub fn extension(&self) -> &'static str {
match self {
ArchiveFormat::Tar => "tar",
ArchiveFormat::TarGzip => "tar.gz",
ArchiveFormat::TarBzip2 => "tar.bz2",
ArchiveFormat::TarXz => "tar.xz",
ArchiveFormat::TarZlib => "tar.zlib",
ArchiveFormat::TarZstd => "tar.zst",
ArchiveFormat::Zip => "zip",
ArchiveFormat::Unknown => "",
}
}
}
/// Read entries from a tar-family archive stream.
///
/// Supported formats are:
/// - `CompressionFormat::Tar`
/// - `CompressionFormat::Gzip`
/// - `CompressionFormat::Bzip2`
/// - `CompressionFormat::Xz`
/// - `CompressionFormat::Zlib`
/// - `CompressionFormat::Zstd`
///
/// `CompressionFormat::Zip` is intentionally not supported here because ZIP
/// requires central-directory semantics and is handled through file-based
/// helper APIs.
pub async fn read_archive_entries<R, F, Fut>(input: R, format: CompressionFormat, callback: F) -> Result<()>
where
R: AsyncRead + Send + Unpin + 'static,
F: FnMut(tokio_tar::Entry<Archive<Box<dyn AsyncRead + Send + Unpin + 'static>>>) -> Fut + Send + 'static,
Fut: Future<Output = Result<()>> + Send,
{
read_archive_entries_with_limits(input, format, ArchiveLimits::default(), callback).await
}
pub async fn read_archive_entries_with_limits<R, F, Fut>(
input: R,
format: CompressionFormat,
limits: ArchiveLimits,
mut callback: F,
) -> Result<()>
where
R: AsyncRead + Send + Unpin + 'static,
F: FnMut(tokio_tar::Entry<Archive<Box<dyn AsyncRead + Send + Unpin + 'static>>>) -> Fut + Send + 'static,
Fut: Future<Output = Result<()>> + Send,
{
let decoder = format.get_decoder(input)?;
let mut ar = Archive::new(decoder);
let mut entries = ar.entries()?;
let mut entry_count = 0_usize;
let mut total_unpacked_size = 0_u64;
while let Some(entry) = entries.next().await {
let entry = entry?;
entry_count += 1;
validate_archive_entry_count(entry_count, limits)?;
let entry_path = entry.path()?.to_string_lossy().into_owned();
validate_archive_entry_name(&entry_path, limits)?;
let entry_size = entry.header().size()?;
validate_archive_entry_size(&entry_path, entry_size, limits)?;
total_unpacked_size = total_unpacked_size.saturating_add(entry_size);
validate_archive_total_size(total_unpacked_size, limits)?;
callback(entry).await?;
}
Ok(())
}
/// Backward-compatible wrapper for archive entry iteration.
pub async fn decompress<R, F, Fut>(input: R, format: CompressionFormat, callback: F) -> Result<()>
where
R: AsyncRead + Send + Unpin + 'static,
F: FnMut(tokio_tar::Entry<Archive<Box<dyn AsyncRead + Send + Unpin + 'static>>>) -> Fut + Send + 'static,
Fut: Future<Output = Result<()>> + Send,
{
read_archive_entries(input, format, callback).await
}
/// Explicit tar-family alias for callers that want a clearer name than
/// `decompress()`.
pub async fn extract_tar_entries<R, F, Fut>(input: R, format: CompressionFormat, callback: F) -> Result<()>
where
R: AsyncRead + Send + Unpin + 'static,
F: FnMut(tokio_tar::Entry<Archive<Box<dyn AsyncRead + Send + Unpin + 'static>>>) -> Fut + Send + 'static,
Fut: Future<Output = Result<()>> + Send,
{
read_archive_entries(input, format, callback).await
}
fn normalize_zip_entry_name(name: &str) -> Result<String> {
let path = Path::new(name);
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::Normal(part) => normalized.push(part),
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(ZipError::UnsafeEntryPath(name.to_string()));
}
}
}
let normalized = normalized.to_string_lossy().replace('\\', "/");
if normalized.is_empty() {
return Err(ZipError::UnsafeEntryPath(name.to_string()));
}
Ok(normalized)
}
fn validate_archive_entry_name(name: &str, limits: ArchiveLimits) -> Result<()> {
if !limits.validate_entry_paths {
return Ok(());
}
let normalized = normalize_zip_entry_name(name)?;
let length = normalized.len();
if length > limits.max_path_length {
return Err(ZipError::EntryPathTooLong {
path: normalized,
length,
limit: limits.max_path_length,
});
}
Ok(())
}
fn validate_archive_entry_size(path: &str, size: u64, limits: ArchiveLimits) -> Result<()> {
if size > limits.max_entry_size {
return Err(ZipError::EntrySizeLimitExceeded {
path: path.to_string(),
size,
limit: limits.max_entry_size,
});
}
Ok(())
}
fn validate_archive_entry_count(count: usize, limits: ArchiveLimits) -> Result<()> {
if count > limits.max_entries {
return Err(ZipError::EntryCountLimitExceeded {
count,
limit: limits.max_entries,
});
}
Ok(())
}
fn validate_archive_total_size(total_size: u64, limits: ArchiveLimits) -> Result<()> {
if total_size > limits.max_total_unpacked_size {
return Err(ZipError::TotalUnpackedSizeLimitExceeded {
size: total_size,
limit: limits.max_total_unpacked_size,
});
}
Ok(())
}
fn zip_method_for_level(level: CompressionLevel) -> CompressionMethod {
match level {
CompressionLevel::Fastest => CompressionMethod::Stored,
CompressionLevel::Best | CompressionLevel::Default | CompressionLevel::Level(_) => CompressionMethod::Deflated,
}
}
fn parent_directories_for(path: &str) -> Vec<String> {
let path = Path::new(path);
let mut current = PathBuf::new();
let mut directories = Vec::new();
if let Some(parent) = path.parent() {
for component in parent.components() {
if let Component::Normal(part) = component {
current.push(part);
directories.push(format!("{}/", current.to_string_lossy().replace('\\', "/")));
}
}
}
directories
}
fn ensure_directory(path: &Path, created_directories: &mut HashSet<PathBuf>) -> Result<()> {
let path = path.to_path_buf();
if created_directories.insert(path.clone()) {
std::fs::create_dir_all(&path)?;
}
Ok(())
}
fn write_small_zip_entry<R: Read>(reader: &mut R, output_path: &Path, size: u64) -> Result<()> {
let size = usize::try_from(size).map_err(|_| io::Error::other("small zip entry size overflow"))?;
let mut buffer = [0_u8; SMALL_ZIP_EXTRACT_FAST_PATH_LIMIT as usize];
reader.read_exact(&mut buffer[..size])?;
std::fs::write(output_path, &buffer[..size])?;
Ok(())
}
pub async fn extract_zip_simple<P: AsRef<Path>, Q: AsRef<Path>>(zip_path: P, extract_to: Q) -> Result<Vec<ZipEntry>> {
extract_zip_with_limits(zip_path, extract_to, ArchiveLimits::default()).await
}
pub async fn extract_zip_to_path_with_limits<P: AsRef<Path>, Q: AsRef<Path>>(
zip_path: P,
extract_to: Q,
limits: ArchiveLimits,
) -> Result<ZipExtractSummary> {
let zip_path = zip_path.as_ref().to_path_buf();
let extract_to = extract_to.as_ref().to_path_buf();
spawn_blocking(move || extract_zip_impl(zip_path, extract_to, limits, false).map(|(_, summary)| summary)).await?
}
pub async fn extract_zip_with_limits<P: AsRef<Path>, Q: AsRef<Path>>(
zip_path: P,
extract_to: Q,
limits: ArchiveLimits,
) -> Result<Vec<ZipEntry>> {
let zip_path = zip_path.as_ref().to_path_buf();
let extract_to = extract_to.as_ref().to_path_buf();
spawn_blocking(move || extract_zip_impl(zip_path, extract_to, limits, true).map(|(entries, _)| entries.unwrap_or_default()))
.await?
}
fn extract_zip_impl(
zip_path: PathBuf,
extract_to: PathBuf,
limits: ArchiveLimits,
collect_entries: bool,
) -> Result<(Option<Vec<ZipEntry>>, ZipExtractSummary)> {
let file = std::fs::File::open(&zip_path)?;
let mut archive = ZipArchive::new(file)?;
std::fs::create_dir_all(&extract_to)?;
let mut created_directories = HashSet::from([extract_to.clone()]);
let mut entries = collect_entries.then(|| Vec::with_capacity(archive.len()));
let mut summary = ZipExtractSummary::default();
for index in 0..archive.len() {
validate_archive_entry_count(index + 1, limits)?;
let mut zip_file = archive.by_index(index)?;
let enclosed_name = zip_file
.enclosed_name()
.ok_or_else(|| ZipError::UnsafeEntryPath(zip_file.name().to_string()))?;
let entry_name = enclosed_name.to_string_lossy().replace('\\', "/");
let is_dir = zip_file.is_dir();
let size = zip_file.size();
validate_archive_entry_name(&entry_name, limits)?;
validate_archive_entry_size(&entry_name, size, limits)?;
summary.total_unpacked_size = summary.total_unpacked_size.saturating_add(size);
validate_archive_total_size(summary.total_unpacked_size, limits)?;
let output_path = extract_to.join(&enclosed_name);
if is_dir {
ensure_directory(&output_path, &mut created_directories)?;
summary.directory_count += 1;
} else {
if let Some(parent) = output_path.parent() {
ensure_directory(parent, &mut created_directories)?;
}
if size <= SMALL_ZIP_EXTRACT_FAST_PATH_LIMIT {
write_small_zip_entry(&mut zip_file, &output_path, size)?;
} else {
let mut output = std::fs::File::create(&output_path)?;
std::io::copy(&mut zip_file, &mut output)?;
}
summary.file_count += 1;
}
summary.entry_count += 1;
if let Some(ref mut entries) = entries {
entries.push(ZipEntry {
name: entry_name,
size,
compressed_size: zip_file.compressed_size(),
is_dir,
compression_method: format!("{:?}", zip_file.compression()),
archive_kind: ArchiveKind::Zip,
format: ArchiveFormat::Zip,
unix_mode: zip_file.unix_mode(),
});
}
}
Ok((entries, summary))
}
pub async fn create_zip_simple<P: AsRef<Path>>(
zip_path: P,
files: Vec<(String, Vec<u8>)>,
compression_level: CompressionLevel,
) -> Result<()> {
create_zip_with_options(
zip_path,
files,
ZipWriteOptions {
compression_level,
..ZipWriteOptions::default()
},
)
.await
}
pub async fn create_zip_with_options<P: AsRef<Path>>(
zip_path: P,
files: Vec<(String, Vec<u8>)>,
options: ZipWriteOptions,
) -> Result<()> {
let zip_path = zip_path.as_ref().to_path_buf();
spawn_blocking(move || -> Result<()> {
if let Some(parent) = zip_path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = std::fs::File::create(&zip_path)?;
let mut writer = ZipWriter::new(file);
let file_options = SimpleFileOptions::default().compression_method(zip_method_for_level(options.compression_level));
let explicit_directories = files
.iter()
.filter(|(name, _)| name.ends_with('/'))
.map(|(name, _)| normalize_zip_entry_name(name))
.collect::<Result<HashSet<_>>>()?;
let mut written_directories = HashSet::new();
for (name, contents) in files {
let entry_name = normalize_zip_entry_name(&name)?;
if name.ends_with('/') {
if written_directories.insert(entry_name.clone()) {
writer.add_directory(entry_name, file_options)?;
}
} else {
if options.create_directory_entries {
for directory in parent_directories_for(&entry_name) {
if !explicit_directories.contains(&directory) && written_directories.insert(directory.clone()) {
writer.add_directory(directory, file_options)?;
}
}
}
writer.start_file(entry_name, file_options)?;
writer.write_all(&contents)?;
}
}
writer.finish()?;
Ok(())
})
.await?
}
pub struct Compressor {
format: CompressionFormat,
level: CompressionLevel,
}
impl Compressor {
pub fn new(format: CompressionFormat) -> Self {
Self {
format,
level: CompressionLevel::Default,
}
}
pub fn with_level(mut self, level: CompressionLevel) -> Self {
self.level = level;
self
}
pub async fn compress(&self, input: &[u8]) -> Result<Vec<u8>> {
let sink = SharedBuffer::default();
let mut encoder = self.format.get_encoder(sink.clone(), self.level)?;
let mut reader = input;
io::copy(&mut reader, &mut encoder).await?;
encoder.shutdown().await?;
drop(encoder);
Ok(sink.into_vec())
}
pub async fn decompress(&self, input: Vec<u8>) -> Result<Vec<u8>> {
let mut output = Vec::new();
let cursor = std::io::Cursor::new(input);
let mut decoder = self.format.get_decoder(cursor)?;
decoder.read_to_end(&mut output).await?;
Ok(output)
}
}
pub struct Decompressor {
format: CompressionFormat,
}
impl Decompressor {
pub fn new(format: CompressionFormat) -> Self {
Self { format }
}
pub fn auto_detect<P: AsRef<Path>>(path: P) -> Self {
Self {
format: CompressionFormat::from_path(path),
}
}
pub async fn decompress_file<P: AsRef<Path>>(&self, input_path: P, output_path: P) -> Result<()> {
let input_file = File::open(&input_path).await?;
let output_file = File::create(&output_path).await?;
let mut decoder = self.format.get_decoder(input_file)?;
let mut writer = BufWriter::new(output_file);
io::copy(&mut decoder, &mut writer).await?;
writer.shutdown().await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::mem::size_of;
use tempfile::tempdir;
use tokio::fs;
use tokio::io::AsyncReadExt;
use tokio_tar::{Builder, Header};
use zip::write::FileOptions;
async fn build_tar_bytes(files: &[(&str, &[u8])]) -> io::Result<Vec<u8>> {
let sink = SharedBuffer::default();
let handle = sink.clone();
let mut builder = Builder::new(sink);
for (path, content) in files {
let mut header = Header::new_gnu();
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append_data(&mut header, *path, &content[..]).await?;
}
builder.finish().await?;
Ok(handle.into_vec())
}
async fn build_compressed_tar_bytes(format: CompressionFormat, files: &[(&str, &[u8])]) -> Result<Vec<u8>> {
let tar_bytes = build_tar_bytes(files).await?;
Compressor::new(format).compress(&tar_bytes).await
}
async fn build_zip_file_with_entries(path: &Path, files: &[(&str, &[u8])]) -> Result<()> {
let path = path.to_path_buf();
let files = files
.iter()
.map(|(name, content)| ((*name).to_string(), content.to_vec()))
.collect::<Vec<_>>();
spawn_blocking(move || -> Result<()> {
let file = std::fs::File::create(path)?;
let mut writer = ZipWriter::new(file);
let options: FileOptions<'_, ()> = FileOptions::default().compression_method(CompressionMethod::Stored);
for (name, content) in files {
writer.start_file(name, options)?;
writer.write_all(&content)?;
}
writer.finish()?;
Ok(())
})
.await??;
Ok(())
}
async fn collect_archive_entries(payload: Vec<u8>, format: CompressionFormat) -> Result<Vec<(String, Vec<u8>)>> {
let seen = Arc::new(Mutex::new(Vec::<(String, Vec<u8>)>::new()));
let seen_ref = Arc::clone(&seen);
let cursor = std::io::Cursor::new(payload);
read_archive_entries(cursor, format, move |mut entry| {
let seen_ref = Arc::clone(&seen_ref);
async move {
let path = entry.path()?.to_string_lossy().into_owned();
let mut content = Vec::new();
entry.read_to_end(&mut content).await?;
seen_ref.lock().expect("seen collection lock poisoned").push((path, content));
Ok(())
}
})
.await?;
Ok(seen.lock().expect("seen collection lock poisoned").clone())
}
#[test]
fn test_compression_format_from_extension() {
assert_eq!(CompressionFormat::from_extension("gz"), CompressionFormat::Gzip);
assert_eq!(CompressionFormat::from_extension("ZIP"), CompressionFormat::Zip);
assert_eq!(CompressionFormat::from_extension("tzst"), CompressionFormat::Zstd);
assert_eq!(CompressionFormat::from_extension("txt"), CompressionFormat::Unknown);
}
#[test]
fn test_archive_format_from_extension() {
assert_eq!(ArchiveFormat::from_extension("gz"), ArchiveFormat::TarGzip);
assert_eq!(ArchiveFormat::from_extension("tbz2"), ArchiveFormat::TarBzip2);
assert_eq!(ArchiveFormat::from_extension("txz"), ArchiveFormat::TarXz);
assert_eq!(ArchiveFormat::from_extension("zip"), ArchiveFormat::Zip);
assert_eq!(ArchiveFormat::from_extension("txt"), ArchiveFormat::Unknown);
}
#[test]
fn test_compression_format_from_path_handles_compound_suffixes() {
assert_eq!(CompressionFormat::from_path("archive.tar.gz"), CompressionFormat::Gzip);
assert_eq!(CompressionFormat::from_path("archive.tgz"), CompressionFormat::Gzip);
assert_eq!(CompressionFormat::from_path("archive.tar.bz2"), CompressionFormat::Bzip2);
assert_eq!(CompressionFormat::from_path("archive.zip"), CompressionFormat::Zip);
assert_eq!(CompressionFormat::from_path("archive"), CompressionFormat::Unknown);
}
#[test]
fn test_archive_format_from_path_handles_compound_suffixes() {
assert_eq!(ArchiveFormat::from_path("archive.tar.gz"), ArchiveFormat::TarGzip);
assert_eq!(ArchiveFormat::from_path("archive.tar.bz2"), ArchiveFormat::TarBzip2);
assert_eq!(ArchiveFormat::from_path("archive.tar.xz"), ArchiveFormat::TarXz);
assert_eq!(ArchiveFormat::from_path("archive.tar.zst"), ArchiveFormat::TarZstd);
assert_eq!(ArchiveFormat::from_path("archive.zip"), ArchiveFormat::Zip);
assert_eq!(ArchiveFormat::from_path("archive"), ArchiveFormat::Unknown);
}
#[test]
fn test_archive_format_and_legacy_compression_format_are_compatible() {
assert_eq!(CompressionFormat::from_archive_format(ArchiveFormat::TarGzip), CompressionFormat::Gzip);
assert_eq!(CompressionFormat::from_archive_format(ArchiveFormat::Tar), CompressionFormat::Tar);
assert_eq!(CompressionFormat::from_archive_format(ArchiveFormat::Zip), CompressionFormat::Zip);
}
#[test]
fn test_archive_format_exposes_archive_kind_and_codec() {
assert_eq!(ArchiveFormat::TarGzip.archive_kind(), Some(ArchiveKind::Tar));
assert_eq!(ArchiveFormat::TarGzip.compression_codec(), Some(CompressionCodec::Gzip));
assert_eq!(ArchiveFormat::Tar.archive_kind(), Some(ArchiveKind::Tar));
assert_eq!(ArchiveFormat::Tar.compression_codec(), None);
assert_eq!(ArchiveFormat::Zip.archive_kind(), Some(ArchiveKind::Zip));
assert_eq!(ArchiveFormat::Zip.compression_codec(), None);
}
#[test]
fn test_legacy_compression_format_exposes_kind_and_codec() {
assert_eq!(CompressionFormat::Gzip.archive_kind(), None);
assert_eq!(CompressionFormat::Gzip.compression_codec(), Some(CompressionCodec::Gzip));
assert_eq!(CompressionFormat::Tar.archive_kind(), Some(ArchiveKind::Tar));
assert_eq!(CompressionFormat::Tar.compression_codec(), None);
assert_eq!(CompressionFormat::Zip.archive_kind(), Some(ArchiveKind::Zip));
}
#[test]
fn test_compression_format_size_is_small() {
assert!(size_of::<CompressionFormat>() <= 8);
assert!(size_of::<Option<CompressionFormat>>() <= 16);
}
#[test]
fn test_convert_level_rejects_overflow() {
let err = match CompressionFormat::Gzip.get_encoder(SharedBuffer::default(), CompressionLevel::Level(u32::MAX)) {
Ok(_) => panic!("overflow level should return an error"),
Err(err) => err,
};
assert!(matches!(err, ZipError::InvalidCompressionLevel(u32::MAX)));
}
#[test]
fn test_validate_archive_entry_name_rejects_absolute_path() {
let err = validate_archive_entry_name("/absolute.txt", ArchiveLimits::default())
.expect_err("absolute path should fail validation");
assert!(matches!(err, ZipError::UnsafeEntryPath(path) if path == "/absolute.txt"));
}
#[tokio::test]
async fn test_compressor_round_trip_gzip() {
let input = b"hello rustfs zip ".repeat(64);
let compressor = Compressor::new(CompressionFormat::Gzip);
let compressed = compressor.compress(&input).await.expect("gzip compress should succeed");
assert!(!compressed.is_empty());
assert_ne!(compressed, input);
let decompressed = compressor
.decompress(compressed)
.await
.expect("gzip decompress should succeed");
assert_eq!(decompressed, input);
}
#[tokio::test]
async fn test_compressor_round_trip_zstd() {
let input = b"zstd payload ".repeat(128);
let compressor = Compressor::new(CompressionFormat::Zstd).with_level(CompressionLevel::Best);
let compressed = compressor.compress(&input).await.expect("zstd compress should succeed");
let decompressed = compressor
.decompress(compressed)
.await
.expect("zstd decompress should succeed");
assert_eq!(decompressed, input);
}
#[tokio::test]
async fn test_zip_stream_encoder_is_rejected() {
let err = CompressionFormat::Zip
.get_encoder(SharedBuffer::default(), CompressionLevel::Default)
.err()
.expect("zip stream encoder should be rejected");
assert!(matches!(
err,
ZipError::UnsupportedFormat {
format: CompressionFormat::Zip,
operation: "stream encoding",
}
));
}
#[tokio::test]
async fn test_read_archive_entries_iterates_tar_gzip_entries() {
let gzip_bytes =
build_compressed_tar_bytes(CompressionFormat::Gzip, &[("nested/hello.txt", b"hello"), ("world.txt", b"world")])
.await
.expect("tar.gz build should succeed");
let seen = collect_archive_entries(gzip_bytes, CompressionFormat::Gzip)
.await
.expect("tar.gz archive iteration should succeed");
assert_eq!(seen.len(), 2);
assert_eq!(seen[0].0, "nested/hello.txt");
assert_eq!(seen[0].1, b"hello");
assert_eq!(seen[1].0, "world.txt");
assert_eq!(seen[1].1, b"world");
}
#[tokio::test]
async fn test_read_archive_entries_iterates_tar_bzip2_entries() {
let payload =
build_compressed_tar_bytes(CompressionFormat::Bzip2, &[("nested/hello.txt", b"hello"), ("world.txt", b"world")])
.await
.expect("tar.bz2 build should succeed");
let seen = collect_archive_entries(payload, CompressionFormat::Bzip2)
.await
.expect("tar.bz2 archive iteration should succeed");
assert_eq!(seen.len(), 2);
assert_eq!(seen[0].0, "nested/hello.txt");
assert_eq!(seen[1].0, "world.txt");
}
#[tokio::test]
async fn test_read_archive_entries_iterates_tar_xz_entries() {
let payload =
build_compressed_tar_bytes(CompressionFormat::Xz, &[("nested/hello.txt", b"hello"), ("world.txt", b"world")])
.await
.expect("tar.xz build should succeed");
let seen = collect_archive_entries(payload, CompressionFormat::Xz)
.await
.expect("tar.xz archive iteration should succeed");
assert_eq!(seen.len(), 2);
assert_eq!(seen[0].0, "nested/hello.txt");
assert_eq!(seen[1].0, "world.txt");
}
#[tokio::test]
async fn test_read_archive_entries_iterates_tar_zstd_entries() {
let payload =
build_compressed_tar_bytes(CompressionFormat::Zstd, &[("nested/hello.txt", b"hello"), ("world.txt", b"world")])
.await
.expect("tar.zst build should succeed");
let seen = collect_archive_entries(payload, CompressionFormat::Zstd)
.await
.expect("tar.zst archive iteration should succeed");
assert_eq!(seen.len(), 2);
assert_eq!(seen[0].0, "nested/hello.txt");
assert_eq!(seen[1].0, "world.txt");
}
#[tokio::test]
async fn test_extract_tar_entries_alias_matches_stream_behavior() {
let payload = build_compressed_tar_bytes(CompressionFormat::Gzip, &[("hello.txt", b"hello")])
.await
.expect("tar.gz build should succeed");
let seen = Arc::new(Mutex::new(Vec::<String>::new()));
let seen_ref = Arc::clone(&seen);
extract_tar_entries(std::io::Cursor::new(payload), CompressionFormat::Gzip, move |entry| {
let seen_ref = Arc::clone(&seen_ref);
async move {
seen_ref
.lock()
.expect("seen collection lock poisoned")
.push(entry.path()?.to_string_lossy().into_owned());
Ok(())
}
})
.await
.expect("extract_tar_entries alias should succeed");
assert_eq!(seen.lock().expect("seen collection lock poisoned").as_slice(), ["hello.txt"]);
}
#[tokio::test]
async fn test_read_archive_entries_rejects_zip_streams() {
let err = read_archive_entries(std::io::Cursor::new(Vec::<u8>::new()), CompressionFormat::Zip, |_entry| async { Ok(()) })
.await
.expect_err("zip stream should be rejected");
assert!(matches!(
err,
ZipError::UnsupportedFormat {
format: CompressionFormat::Zip,
operation: "stream decoding",
}
));
}
#[tokio::test]
async fn test_read_archive_entries_rejects_corrupt_tar_gzip_stream() {
let err = read_archive_entries(
std::io::Cursor::new(b"not-a-valid-gzip-stream".to_vec()),
CompressionFormat::Gzip,
|_entry| async { Ok(()) },
)
.await
.expect_err("corrupt tar.gz stream should fail");
assert!(matches!(err, ZipError::Io(_)));
}
#[tokio::test]
async fn test_read_archive_entries_rejects_truncated_tar_gzip_stream() {
let payload = build_compressed_tar_bytes(CompressionFormat::Gzip, &[("hello.txt", b"hello world")])
.await
.expect("tar.gz build should succeed");
let truncated = payload[..payload.len() / 2].to_vec();
let err = read_archive_entries(std::io::Cursor::new(truncated), CompressionFormat::Gzip, |_entry| async { Ok(()) })
.await
.expect_err("truncated tar.gz stream should fail");
assert!(matches!(err, ZipError::Io(_)));
}
#[tokio::test]
async fn test_read_archive_entries_rejects_too_many_entries() {
let payload = build_compressed_tar_bytes(CompressionFormat::Gzip, &[("one.txt", b"1"), ("two.txt", b"2")])
.await
.expect("tar.gz build should succeed");
let err = read_archive_entries_with_limits(
std::io::Cursor::new(payload),
CompressionFormat::Gzip,
ArchiveLimits {
max_entries: 1,
..ArchiveLimits::default()
},
|_entry| async { Ok(()) },
)
.await
.expect_err("entry count limit should fail");
assert!(matches!(err, ZipError::EntryCountLimitExceeded { count: 2, limit: 1 }));
}
#[tokio::test]
async fn test_read_archive_entries_rejects_oversized_entry() {
let payload = build_compressed_tar_bytes(CompressionFormat::Gzip, &[("big.txt", b"hello world")])
.await
.expect("tar.gz build should succeed");
let err = read_archive_entries_with_limits(
std::io::Cursor::new(payload),
CompressionFormat::Gzip,
ArchiveLimits {
max_entry_size: 4,
..ArchiveLimits::default()
},
|_entry| async { Ok(()) },
)
.await
.expect_err("entry size limit should fail");
assert!(matches!(
err,
ZipError::EntrySizeLimitExceeded {
path,
size: 11,
limit: 4,
} if path == "big.txt"
));
}
#[tokio::test]
async fn test_read_archive_entries_rejects_total_unpacked_size_limit() {
let payload = build_compressed_tar_bytes(CompressionFormat::Gzip, &[("one.txt", b"12345"), ("two.txt", b"67890")])
.await
.expect("tar.gz build should succeed");
let err = read_archive_entries_with_limits(
std::io::Cursor::new(payload),
CompressionFormat::Gzip,
ArchiveLimits {
max_total_unpacked_size: 9,
..ArchiveLimits::default()
},
|_entry| async { Ok(()) },
)
.await
.expect_err("total unpacked size limit should fail");
assert!(matches!(err, ZipError::TotalUnpackedSizeLimitExceeded { size: 10, limit: 9 }));
}
#[tokio::test]
async fn test_read_archive_entries_rejects_entry_path_length_limit() {
let payload = build_compressed_tar_bytes(CompressionFormat::Gzip, &[("nested/hello.txt", b"hello")])
.await
.expect("tar.gz build should succeed");
let err = read_archive_entries_with_limits(
std::io::Cursor::new(payload),
CompressionFormat::Gzip,
ArchiveLimits {
max_path_length: 5,
..ArchiveLimits::default()
},
|_entry| async { Ok(()) },
)
.await
.expect_err("path length limit should fail");
assert!(matches!(
err,
ZipError::EntryPathTooLong { path, limit: 5, .. } if path == "nested/hello.txt"
));
}
#[tokio::test]
async fn test_create_and_extract_zip_round_trip() {
let temp = tempdir().expect("tempdir should be created");
let zip_path = temp.path().join("archive.zip");
let extract_path = temp.path().join("extract");
create_zip_simple(
&zip_path,
vec![
("nested/hello.txt".to_string(), b"hello".to_vec()),
("world.txt".to_string(), b"world".to_vec()),
],
CompressionLevel::Default,
)
.await
.expect("zip creation should succeed");
let entries = extract_zip_simple(&zip_path, &extract_path)
.await
.expect("zip extraction should succeed");
assert_eq!(entries.len(), 2);
assert_eq!(
fs::read(extract_path.join("nested/hello.txt"))
.await
.expect("nested zip entry should be extracted"),
b"hello"
);
assert_eq!(
fs::read(extract_path.join("world.txt"))
.await
.expect("root zip entry should be extracted"),
b"world"
);
assert!(entries.iter().all(|entry| entry.archive_kind == ArchiveKind::Zip));
assert!(entries.iter().all(|entry| entry.format == ArchiveFormat::Zip));
}
#[tokio::test]
async fn test_create_zip_with_directory_entries_and_extract_directory_scenarios() {
let temp = tempdir().expect("tempdir should be created");
let explicit_zip_path = temp.path().join("explicit-directories.zip");
let explicit_extract_path = temp.path().join("explicit-extract");
let auto_zip_path = temp.path().join("auto-directories.zip");
let auto_extract_path = temp.path().join("auto-extract");
create_zip_with_options(
&explicit_zip_path,
vec![
("nested/".to_string(), Vec::new()),
("nested/deeper/".to_string(), Vec::new()),
],
ZipWriteOptions {
compression_level: CompressionLevel::Default,
create_directory_entries: false,
},
)
.await
.expect("zip creation with explicit directory entries should succeed");
let explicit_entries = extract_zip_with_limits(&explicit_zip_path, &explicit_extract_path, ArchiveLimits::default())
.await
.expect("zip extraction with explicit directory entries should succeed");
assert!(
explicit_entries
.iter()
.any(|entry| entry.name.trim_end_matches('/') == "nested" && entry.is_dir)
);
assert!(
explicit_entries
.iter()
.any(|entry| entry.name.trim_end_matches('/') == "nested/deeper" && entry.is_dir)
);
assert!(
fs::metadata(explicit_extract_path.join("nested"))
.await
.expect("nested directory should exist")
.is_dir()
);
assert!(
fs::metadata(explicit_extract_path.join("nested/deeper"))
.await
.expect("nested deeper directory should exist")
.is_dir()
);
create_zip_with_options(
&auto_zip_path,
vec![("nested/deeper/file.txt".to_string(), b"hello".to_vec())],
ZipWriteOptions {
compression_level: CompressionLevel::Default,
create_directory_entries: true,
},
)
.await
.expect("zip creation with automatic directory entries should succeed");
let entries = extract_zip_with_limits(&auto_zip_path, &auto_extract_path, ArchiveLimits::default())
.await
.expect("zip extraction with automatic directory entries should succeed");
assert!(
entries
.iter()
.any(|entry| entry.name.trim_end_matches('/') == "nested" && entry.is_dir)
);
assert!(
entries
.iter()
.any(|entry| entry.name.trim_end_matches('/') == "nested/deeper" && entry.is_dir)
);
assert!(
fs::metadata(auto_extract_path.join("nested"))
.await
.expect("nested directory should exist")
.is_dir()
);
assert!(
fs::metadata(auto_extract_path.join("nested/deeper"))
.await
.expect("nested deeper directory should exist")
.is_dir()
);
assert_eq!(
fs::read(auto_extract_path.join("nested/deeper/file.txt"))
.await
.expect("nested file should be extracted"),
b"hello"
);
}
#[tokio::test]
async fn test_create_zip_with_lots_of_small_files_round_trip() {
let temp = tempdir().expect("tempdir should be created");
let zip_path = temp.path().join("many-small-files.zip");
let extract_path = temp.path().join("extract");
let files = (0..32)
.map(|index| (format!("batch/file-{index}.txt"), format!("payload-{index}").into_bytes()))
.collect::<Vec<_>>();
create_zip_with_options(
&zip_path,
files.clone(),
ZipWriteOptions {
compression_level: CompressionLevel::Default,
create_directory_entries: true,
},
)
.await
.expect("zip creation for many small files should succeed");
let entries = extract_zip_with_limits(&zip_path, &extract_path, ArchiveLimits::default())
.await
.expect("zip extraction for many small files should succeed");
assert!(entries.len() >= files.len());
for (path, expected) in files {
assert_eq!(
fs::read(extract_path.join(path))
.await
.expect("small file should be extracted"),
expected
);
}
}
#[tokio::test]
async fn test_extract_zip_to_path_with_limits_returns_summary() {
let temp = tempdir().expect("tempdir should be created");
let zip_path = temp.path().join("summary.zip");
let extract_path = temp.path().join("extract");
create_zip_with_options(
&zip_path,
vec![
("nested/".to_string(), Vec::new()),
("nested/hello.txt".to_string(), b"hello".to_vec()),
("world.txt".to_string(), b"world".to_vec()),
],
ZipWriteOptions {
compression_level: CompressionLevel::Default,
create_directory_entries: false,
},
)
.await
.expect("zip creation for summary should succeed");
let summary = extract_zip_to_path_with_limits(&zip_path, &extract_path, ArchiveLimits::default())
.await
.expect("zip extract summary should succeed");
assert_eq!(summary.entry_count, 3);
assert_eq!(summary.directory_count, 1);
assert_eq!(summary.file_count, 2);
assert_eq!(summary.total_unpacked_size, 10);
assert_eq!(
fs::read(extract_path.join("nested/hello.txt"))
.await
.expect("nested file should be extracted"),
b"hello"
);
assert_eq!(
fs::read(extract_path.join("world.txt"))
.await
.expect("world file should be extracted"),
b"world"
);
}
#[tokio::test]
async fn test_zip_helper_exposes_stored_vs_deflated_metadata() {
let temp = tempdir().expect("tempdir should be created");
let stored_zip_path = temp.path().join("stored.zip");
let deflated_zip_path = temp.path().join("deflated.zip");
let stored_extract_path = temp.path().join("stored-extract");
let deflated_extract_path = temp.path().join("deflated-extract");
let payload = b"compressible-content-".repeat(64);
create_zip_with_options(
&stored_zip_path,
vec![("payload.txt".to_string(), payload.clone())],
ZipWriteOptions {
compression_level: CompressionLevel::Fastest,
create_directory_entries: false,
},
)
.await
.expect("stored zip creation should succeed");
create_zip_with_options(
&deflated_zip_path,
vec![("payload.txt".to_string(), payload.clone())],
ZipWriteOptions {
compression_level: CompressionLevel::Best,
create_directory_entries: false,
},
)
.await
.expect("deflated zip creation should succeed");
let stored_entries = extract_zip_with_limits(&stored_zip_path, &stored_extract_path, ArchiveLimits::default())
.await
.expect("stored zip extraction should succeed");
let deflated_entries = extract_zip_with_limits(&deflated_zip_path, &deflated_extract_path, ArchiveLimits::default())
.await
.expect("deflated zip extraction should succeed");
assert_eq!(stored_entries[0].compression_method, "Stored");
assert_eq!(deflated_entries[0].compression_method, "Deflated");
assert_eq!(
fs::read(stored_extract_path.join("payload.txt"))
.await
.expect("stored payload should be extracted"),
payload
);
assert_eq!(
fs::read(deflated_extract_path.join("payload.txt"))
.await
.expect("deflated payload should be extracted"),
payload
);
assert!(deflated_entries[0].compressed_size <= stored_entries[0].compressed_size);
}
#[tokio::test]
async fn test_create_zip_rejects_unsafe_entry_name() {
let temp = tempdir().expect("tempdir should be created");
let zip_path = temp.path().join("archive.zip");
let err = create_zip_simple(
&zip_path,
vec![("../escape.txt".to_string(), b"escape".to_vec())],
CompressionLevel::Default,
)
.await
.unwrap_err();
assert!(matches!(err, ZipError::UnsafeEntryPath(path) if path == "../escape.txt"));
}
#[tokio::test]
async fn test_extract_zip_rejects_too_many_entries() {
let temp = tempdir().expect("tempdir should be created");
let zip_path = temp.path().join("too-many.zip");
let extract_path = temp.path().join("extract");
build_zip_file_with_entries(&zip_path, &[("one.txt", b"1"), ("two.txt", b"2")])
.await
.expect("zip fixture should be created");
let err = extract_zip_with_limits(
&zip_path,
&extract_path,
ArchiveLimits {
max_entries: 1,
..ArchiveLimits::default()
},
)
.await
.expect_err("zip entry count limit should fail");
assert!(matches!(err, ZipError::EntryCountLimitExceeded { count: 2, limit: 1 }));
}
#[tokio::test]
async fn test_extract_zip_rejects_oversized_entry() {
let temp = tempdir().expect("tempdir should be created");
let zip_path = temp.path().join("oversized.zip");
let extract_path = temp.path().join("extract");
build_zip_file_with_entries(&zip_path, &[("big.txt", b"hello world")])
.await
.expect("zip fixture should be created");
let err = extract_zip_with_limits(
&zip_path,
&extract_path,
ArchiveLimits {
max_entry_size: 4,
..ArchiveLimits::default()
},
)
.await
.expect_err("zip entry size limit should fail");
assert!(matches!(
err,
ZipError::EntrySizeLimitExceeded {
path,
size: 11,
limit: 4,
} if path == "big.txt"
));
}
#[tokio::test]
async fn test_extract_zip_rejects_total_unpacked_size_limit() {
let temp = tempdir().expect("tempdir should be created");
let zip_path = temp.path().join("too-large-total.zip");
let extract_path = temp.path().join("extract");
build_zip_file_with_entries(&zip_path, &[("one.txt", b"12345"), ("two.txt", b"67890")])
.await
.expect("zip fixture should be created");
let err = extract_zip_with_limits(
&zip_path,
&extract_path,
ArchiveLimits {
max_total_unpacked_size: 9,
..ArchiveLimits::default()
},
)
.await
.expect_err("zip total size limit should fail");
assert!(matches!(err, ZipError::TotalUnpackedSizeLimitExceeded { size: 10, limit: 9 }));
}
#[tokio::test]
async fn test_extract_zip_rejects_entry_path_length_limit() {
let temp = tempdir().expect("tempdir should be created");
let zip_path = temp.path().join("long-path.zip");
let extract_path = temp.path().join("extract");
build_zip_file_with_entries(&zip_path, &[("nested/hello.txt", b"hello")])
.await
.expect("zip fixture should be created");
let err = extract_zip_with_limits(
&zip_path,
&extract_path,
ArchiveLimits {
max_path_length: 5,
..ArchiveLimits::default()
},
)
.await
.expect_err("zip path length limit should fail");
assert!(matches!(
err,
ZipError::EntryPathTooLong { path, limit: 5, .. } if path == "nested/hello.txt"
));
}
#[tokio::test]
async fn test_decompress_file_round_trip() {
let temp = tempdir().expect("tempdir should be created");
let input_path = temp.path().join("payload.txt.gz");
let output_path = temp.path().join("payload.txt");
let compressed = Compressor::new(CompressionFormat::Gzip)
.compress(b"payload")
.await
.expect("gzip compress should succeed");
fs::write(&input_path, compressed)
.await
.expect("compressed input file should be written");
Decompressor::auto_detect(&input_path)
.decompress_file(&input_path, &output_path)
.await
.expect("gzip file decompress should succeed");
assert_eq!(
fs::read(&output_path)
.await
.expect("decompressed output file should be readable"),
b"payload"
);
}
}