use std::io::{self, Cursor, Read};
use std::path::{Component, Path, PathBuf};
use crate::error::{Error, Result};
use crate::format::ArchiveFormat;
#[derive(Debug, Clone, serde::Serialize)]
pub struct ExtractedFile {
pub path: String,
#[serde(skip)]
pub data: Vec<u8>,
pub size: u64,
pub is_directory: bool,
}
#[derive(Debug, Clone)]
#[allow(clippy::struct_field_names)]
pub struct Extractor {
max_file_size: u64,
max_total_size: u64,
max_entries: usize,
max_compression_ratio: f64,
}
impl Default for Extractor {
fn default() -> Self {
Self::new()
}
}
impl Extractor {
#[must_use]
pub const fn new() -> Self {
Self {
max_file_size: 100 * 1024 * 1024,
max_total_size: 1024 * 1024 * 1024,
max_entries: 100_000,
max_compression_ratio: 1000.0,
}
}
#[must_use]
pub const fn with_max_file_size(mut self, limit: u64) -> Self {
self.max_file_size = limit;
self
}
#[must_use]
pub const fn with_max_total_size(mut self, limit: u64) -> Self {
self.max_total_size = limit;
self
}
#[must_use]
pub const fn with_max_entries(mut self, limit: usize) -> Self {
self.max_entries = limit;
self
}
#[must_use]
pub const fn with_max_compression_ratio(mut self, ratio: f64) -> Self {
self.max_compression_ratio = ratio;
self
}
pub fn extract(&self, data: &[u8], format: ArchiveFormat) -> Result<Vec<ExtractedFile>> {
if let Some(result) = self.try_extract_archive(data, format) {
return result;
}
if let Some(result) = self.try_extract_compressed_tar(data, format) {
return result;
}
if let Some(result) = Self::try_decompress_single(data, format) {
return self.extract_single(result?);
}
Err(Error::UnsupportedFormat(format.to_string()))
}
fn try_extract_archive(
&self,
data: &[u8],
format: ArchiveFormat,
) -> Option<Result<Vec<ExtractedFile>>> {
match format {
ArchiveFormat::Zip => Some(self.extract_zip(data)),
ArchiveFormat::Tar => Some(self.extract_tar(data)),
ArchiveFormat::SevenZip => Some(self.extract_7z(data)),
ArchiveFormat::Ar => Some(self.extract_ar(data)),
ArchiveFormat::Deb => Some(self.extract_deb(data)),
ArchiveFormat::Cab => Some(self.extract_cab(data)),
_ => None,
}
}
fn try_extract_compressed_tar(
&self,
data: &[u8],
format: ArchiveFormat,
) -> Option<Result<Vec<ExtractedFile>>> {
let decompressed = match format {
ArchiveFormat::TarGz => decompress_gz(data),
ArchiveFormat::TarBz2 => decompress_bz2(data),
ArchiveFormat::TarXz => decompress_xz(data),
ArchiveFormat::TarZst => decompress_zstd(data),
ArchiveFormat::TarLz4 => decompress_lz4(data),
_ => return None,
};
Some(decompressed.and_then(|d| self.extract_tar(&d)))
}
fn try_decompress_single(data: &[u8], format: ArchiveFormat) -> Option<Result<Vec<u8>>> {
match format {
ArchiveFormat::Gz => Some(decompress_gz(data)),
ArchiveFormat::Bz2 => Some(decompress_bz2(data)),
ArchiveFormat::Xz => Some(decompress_xz(data)),
ArchiveFormat::Lz4 => Some(decompress_lz4(data)),
ArchiveFormat::Zstd => Some(decompress_zstd(data)),
ArchiveFormat::Brotli => Some(decompress_brotli(data)),
ArchiveFormat::Snappy => Some(decompress_snappy(data)),
ArchiveFormat::Lzma => Some(decompress_lzma(data)),
ArchiveFormat::Bz3 => Some(decompress_bz3(data)),
_ => None,
}
}
pub fn extract_path(&self, path: &Path, format: ArchiveFormat) -> Result<Vec<ExtractedFile>> {
#[cfg(feature = "rar")]
if format == ArchiveFormat::Rar {
return self.extract_rar(path);
}
let data = std::fs::read(path)?;
self.extract(&data, format)
}
pub fn list_path(&self, path: &Path, format: ArchiveFormat) -> Result<Vec<ArchiveEntry>> {
#[cfg(feature = "rar")]
if format == ArchiveFormat::Rar {
return self.list_rar(path);
}
let data = std::fs::read(path)?;
self.list(&data, format)
}
pub fn list(&self, data: &[u8], format: ArchiveFormat) -> Result<Vec<ArchiveEntry>> {
match format {
ArchiveFormat::Zip => self.list_zip(data),
ArchiveFormat::Tar
| ArchiveFormat::TarGz
| ArchiveFormat::TarBz2
| ArchiveFormat::TarXz
| ArchiveFormat::TarZst
| ArchiveFormat::TarLz4 => {
let tar_data = match format {
ArchiveFormat::TarGz => decompress_gz(data)?,
ArchiveFormat::TarBz2 => decompress_bz2(data)?,
ArchiveFormat::TarXz => decompress_xz(data)?,
ArchiveFormat::TarZst => decompress_zstd(data)?,
ArchiveFormat::TarLz4 => decompress_lz4(data)?,
_ => data.to_vec(),
};
self.list_tar(&tar_data)
},
ArchiveFormat::Ar => self.list_ar(data),
ArchiveFormat::Cab => self.list_cab(data),
other => Err(Error::UnsupportedFormat(other.to_string())),
}
}
pub fn validate(&self, data: &[u8], format: ArchiveFormat) -> Result<ValidationResult> {
match self.extract(data, format) {
Ok(files) => {
let total: u64 = files.iter().map(|f| f.size).sum();
#[allow(clippy::cast_precision_loss)]
let compressed_size = data.len() as f64;
if compressed_size > 0.0 {
#[allow(clippy::cast_precision_loss)]
let ratio = total as f64 / compressed_size;
if ratio > self.max_compression_ratio {
return Err(Error::CompressionBomb {
ratio,
limit: self.max_compression_ratio,
});
}
}
Ok(ValidationResult {
format,
entry_count: files.len(),
total_uncompressed_size: total,
is_valid: true,
})
},
Err(e) => Err(e),
}
}
fn extract_zip(&self, data: &[u8]) -> Result<Vec<ExtractedFile>> {
let cursor = Cursor::new(data);
let mut archive =
zip::ZipArchive::new(cursor).map_err(|e| Error::InvalidArchive(e.to_string()))?;
let mut files = Vec::new();
let mut total_size: u64 = 0;
if archive.len() > self.max_entries {
return Err(Error::TooManyEntries {
count: archive.len(),
limit: self.max_entries,
});
}
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
if entry.encrypted() {
return Err(Error::EncryptedNotSupported);
}
let path = sanitize_path(entry.name())?;
let is_directory = entry.is_dir();
if is_directory {
files.push(ExtractedFile {
path,
data: Vec::new(),
size: 0,
is_directory: true,
});
continue;
}
let size = entry.size();
if size > self.max_file_size {
return Err(Error::FileTooLarge {
size,
limit: self.max_file_size,
});
}
total_size = total_size.saturating_add(size);
if total_size > self.max_total_size {
return Err(Error::TotalSizeLimitExceeded {
limit: self.max_total_size,
});
}
let mut buf = Vec::with_capacity(size.try_into().unwrap_or(0));
entry.read_to_end(&mut buf)?;
files.push(ExtractedFile {
path,
data: buf,
size,
is_directory: false,
});
}
Ok(files)
}
fn extract_tar(&self, data: &[u8]) -> Result<Vec<ExtractedFile>> {
let cursor = Cursor::new(data);
let mut archive = tar::Archive::new(cursor);
let mut files = Vec::new();
let mut total_size: u64 = 0;
for entry in archive.entries()? {
let mut entry = entry?;
let entry_type = entry.header().entry_type();
if matches!(entry_type, tar::EntryType::Symlink | tar::EntryType::Link) {
let link_name = entry
.path()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
return Err(Error::SymlinkRejected(link_name));
}
let path = entry.path()?.to_str().map(String::from).unwrap_or_default();
let safe_path = sanitize_path(&path)?;
let is_directory = entry_type.is_dir();
let size = entry.header().size()?;
if files.len() >= self.max_entries {
return Err(Error::TooManyEntries {
count: files.len(),
limit: self.max_entries,
});
}
if is_directory {
files.push(ExtractedFile {
path: safe_path,
data: Vec::new(),
size: 0,
is_directory: true,
});
continue;
}
if size > self.max_file_size {
return Err(Error::FileTooLarge {
size,
limit: self.max_file_size,
});
}
total_size = total_size.saturating_add(size);
if total_size > self.max_total_size {
return Err(Error::TotalSizeLimitExceeded {
limit: self.max_total_size,
});
}
let mut buf = Vec::with_capacity(size.try_into().unwrap_or(0));
entry.read_to_end(&mut buf)?;
files.push(ExtractedFile {
path: safe_path,
data: buf,
size,
is_directory: false,
});
}
Ok(files)
}
fn extract_7z(&self, data: &[u8]) -> Result<Vec<ExtractedFile>> {
let tmp = tempfile::tempdir().map_err(Error::Io)?;
let cursor = Cursor::new(data);
sevenz_rust::decompress(cursor, tmp.path()).map_err(|e| Error::SevenZip(e.to_string()))?;
let mut files = Vec::new();
let mut total_size: u64 = 0;
collect_7z_files(tmp.path(), tmp.path(), self, &mut files, &mut total_size)?;
Ok(files)
}
fn extract_single(&self, decompressed: Vec<u8>) -> Result<Vec<ExtractedFile>> {
let size = decompressed.len() as u64;
if size > self.max_file_size {
return Err(Error::FileTooLarge {
size,
limit: self.max_file_size,
});
}
Ok(vec![ExtractedFile {
path: "decompressed".into(),
data: decompressed,
size,
is_directory: false,
}])
}
fn list_zip(&self, data: &[u8]) -> Result<Vec<ArchiveEntry>> {
let cursor = Cursor::new(data);
let mut archive =
zip::ZipArchive::new(cursor).map_err(|e| Error::InvalidArchive(e.to_string()))?;
let mut entries = Vec::new();
for i in 0..archive.len() {
let entry = archive.by_index_raw(i)?;
entries.push(ArchiveEntry {
path: entry.name().to_string(),
compressed_size: entry.compressed_size(),
uncompressed_size: entry.size(),
is_directory: entry.is_dir(),
compression_method: format!("{}", entry.compression()),
});
}
Ok(entries)
}
fn list_tar(&self, data: &[u8]) -> Result<Vec<ArchiveEntry>> {
let cursor = Cursor::new(data);
let mut archive = tar::Archive::new(cursor);
let mut entries = Vec::new();
for entry in archive.entries()? {
let entry = entry?;
let path = entry.path()?.to_str().map(String::from).unwrap_or_default();
let size = entry.header().size()?;
entries.push(ArchiveEntry {
path,
compressed_size: size,
uncompressed_size: size,
is_directory: entry.header().entry_type().is_dir(),
compression_method: "stored".into(),
});
}
Ok(entries)
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ValidationResult {
pub format: ArchiveFormat,
pub entry_count: usize,
pub total_uncompressed_size: u64,
pub is_valid: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ArchiveEntry {
pub path: String,
pub compressed_size: u64,
pub uncompressed_size: u64,
pub is_directory: bool,
pub compression_method: String,
}
pub fn sanitize_path(raw: &str) -> Result<String> {
let path = Path::new(raw);
let mut safe = PathBuf::new();
for component in path.components() {
match component {
Component::Normal(c) => safe.push(c),
Component::RootDir | Component::CurDir => {},
Component::ParentDir => {
return Err(Error::PathTraversal(raw.to_string()));
},
Component::Prefix(_) => {
return Err(Error::PathTraversal(raw.to_string()));
},
}
}
Ok(safe.to_string_lossy().into_owned())
}
fn collect_7z_files(
base: &Path,
dir: &Path,
extractor: &Extractor,
files: &mut Vec<ExtractedFile>,
total_size: &mut u64,
) -> Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let rel = path
.strip_prefix(base)
.map_err(|_| Error::PathTraversal("7z extraction escaped temp dir".into()))?
.to_string_lossy()
.into_owned();
if path.is_dir() {
files.push(ExtractedFile {
path: rel,
data: Vec::new(),
size: 0,
is_directory: true,
});
collect_7z_files(base, &path, extractor, files, total_size)?;
} else {
let data = std::fs::read(&path)?;
let size = data.len() as u64;
if size > extractor.max_file_size {
return Err(Error::FileTooLarge {
size,
limit: extractor.max_file_size,
});
}
*total_size = total_size.saturating_add(size);
if *total_size > extractor.max_total_size {
return Err(Error::TotalSizeLimitExceeded {
limit: extractor.max_total_size,
});
}
files.push(ExtractedFile {
path: rel,
data,
size,
is_directory: false,
});
}
}
Ok(())
}
fn decompress_gz(data: &[u8]) -> Result<Vec<u8>> {
let mut decoder = flate2::read::GzDecoder::new(data);
let mut out = Vec::new();
decoder.read_to_end(&mut out)?;
Ok(out)
}
fn decompress_bz2(data: &[u8]) -> Result<Vec<u8>> {
let mut decoder = bzip2::read::BzDecoder::new(data);
let mut out = Vec::new();
decoder.read_to_end(&mut out)?;
Ok(out)
}
fn decompress_xz(data: &[u8]) -> Result<Vec<u8>> {
let mut out = Vec::new();
lzma_rs::xz_decompress(&mut io::Cursor::new(data), &mut out)?;
Ok(out)
}
fn decompress_zstd(data: &[u8]) -> Result<Vec<u8>> {
let decoder = zstd::Decoder::new(data)?;
let mut out = Vec::new();
io::BufReader::new(decoder).read_to_end(&mut out)?;
Ok(out)
}
fn decompress_lz4(data: &[u8]) -> Result<Vec<u8>> {
let mut decoder = lz4_flex::frame::FrameDecoder::new(data);
let mut out = Vec::new();
decoder
.read_to_end(&mut out)
.map_err(|e| Error::Lz4(e.to_string()))?;
Ok(out)
}
fn decompress_brotli(data: &[u8]) -> Result<Vec<u8>> {
let mut decoder = brotli::Decompressor::new(data, 4096);
let mut out = Vec::new();
decoder
.read_to_end(&mut out)
.map_err(|e| Error::Brotli(e.to_string()))?;
Ok(out)
}
fn decompress_snappy(data: &[u8]) -> Result<Vec<u8>> {
let mut decoder = snap::read::FrameDecoder::new(data);
let mut out = Vec::new();
decoder
.read_to_end(&mut out)
.map_err(|e| Error::Snappy(e.to_string()))?;
Ok(out)
}
fn decompress_lzma(data: &[u8]) -> Result<Vec<u8>> {
let mut out = Vec::new();
lzma_rs::lzma_decompress(&mut io::Cursor::new(data), &mut out)?;
Ok(out)
}
fn decompress_bz3(data: &[u8]) -> Result<Vec<u8>> {
let mut decoder = bzip3::read::Bz3Decoder::new(Cursor::new(data))
.map_err(|e| Error::InvalidArchive(format!("bzip3: {e}")))?;
let mut out = Vec::new();
decoder
.read_to_end(&mut out)
.map_err(|e| Error::InvalidArchive(format!("bzip3 decompression: {e}")))?;
Ok(out)
}
pub fn compress_zopfli(data: &[u8]) -> Result<Vec<u8>> {
use std::io::Write;
let options = zopfli::Options::default();
let mut encoder = zopfli::GzipEncoder::new(options, zopfli::BlockType::Dynamic, Vec::new())
.map_err(|e| Error::Io(std::io::Error::other(format!("zopfli init: {e}"))))?;
encoder
.write_all(data)
.map_err(|e| Error::Io(std::io::Error::other(format!("zopfli write: {e}"))))?;
encoder
.finish()
.map_err(|e| Error::Io(std::io::Error::other(format!("zopfli finish: {e}"))))
}
impl Extractor {
fn extract_ar(&self, data: &[u8]) -> Result<Vec<ExtractedFile>> {
let cursor = Cursor::new(data);
let mut archive = ar::Archive::new(cursor);
let mut files = Vec::new();
let mut total_size: u64 = 0;
while let Some(entry) = archive.next_entry() {
let mut entry = entry.map_err(|e| Error::Ar(e.to_string()))?;
let name = String::from_utf8_lossy(entry.header().identifier()).into_owned();
let size = entry.header().size();
if files.len() >= self.max_entries {
return Err(Error::TooManyEntries {
count: files.len(),
limit: self.max_entries,
});
}
if size > self.max_file_size {
return Err(Error::FileTooLarge {
size,
limit: self.max_file_size,
});
}
total_size = total_size.saturating_add(size);
if total_size > self.max_total_size {
return Err(Error::TotalSizeLimitExceeded {
limit: self.max_total_size,
});
}
let mut buf = Vec::with_capacity(size.try_into().unwrap_or(0));
entry
.read_to_end(&mut buf)
.map_err(|e| Error::Ar(e.to_string()))?;
files.push(ExtractedFile {
path: name,
data: buf,
size,
is_directory: false,
});
}
Ok(files)
}
fn list_ar(&self, data: &[u8]) -> Result<Vec<ArchiveEntry>> {
let cursor = Cursor::new(data);
let mut archive = ar::Archive::new(cursor);
let mut entries = Vec::new();
while let Some(entry) = archive.next_entry() {
let entry = entry.map_err(|e| Error::Ar(e.to_string()))?;
let name = String::from_utf8_lossy(entry.header().identifier()).into_owned();
let size = entry.header().size();
entries.push(ArchiveEntry {
path: name,
compressed_size: size,
uncompressed_size: size,
is_directory: false,
compression_method: "stored".into(),
});
}
Ok(entries)
}
fn extract_deb(&self, data: &[u8]) -> Result<Vec<ExtractedFile>> {
let cursor = Cursor::new(data);
let mut archive = ar::Archive::new(cursor);
while let Some(entry) = archive.next_entry() {
let mut entry = entry.map_err(|e| Error::Ar(e.to_string()))?;
let name = String::from_utf8_lossy(entry.header().identifier()).into_owned();
if name.starts_with("data.tar") {
let mut member_data = Vec::new();
entry
.read_to_end(&mut member_data)
.map_err(|e| Error::Ar(e.to_string()))?;
let inner_fmt = crate::format::detect_format(&member_data);
let tar_data = match inner_fmt {
ArchiveFormat::Gz => decompress_gz(&member_data)?,
ArchiveFormat::Xz => decompress_xz(&member_data)?,
ArchiveFormat::Bz2 => decompress_bz2(&member_data)?,
ArchiveFormat::Zstd => decompress_zstd(&member_data)?,
ArchiveFormat::Lzma => decompress_lzma(&member_data)?,
_ => member_data,
};
return self.extract_tar(&tar_data);
}
}
Err(Error::InvalidArchive(
"DEB package missing data.tar.* member".into(),
))
}
fn extract_cab(&self, data: &[u8]) -> Result<Vec<ExtractedFile>> {
let cursor = Cursor::new(data);
let mut cabinet = cab::Cabinet::new(cursor).map_err(|e| Error::Cab(e.to_string()))?;
let file_names: Vec<String> = cabinet
.folder_entries()
.flat_map(|folder| folder.file_entries().map(|fe| fe.name().to_string()))
.collect();
let mut files = Vec::new();
let mut total_size: u64 = 0;
for name in file_names {
if files.len() >= self.max_entries {
return Err(Error::TooManyEntries {
count: files.len(),
limit: self.max_entries,
});
}
let mut reader = cabinet
.read_file(&name)
.map_err(|e| Error::Cab(e.to_string()))?;
let mut buf = Vec::new();
reader
.read_to_end(&mut buf)
.map_err(|e| Error::Cab(e.to_string()))?;
let size = buf.len() as u64;
if size > self.max_file_size {
return Err(Error::FileTooLarge {
size,
limit: self.max_file_size,
});
}
total_size = total_size.saturating_add(size);
if total_size > self.max_total_size {
return Err(Error::TotalSizeLimitExceeded {
limit: self.max_total_size,
});
}
files.push(ExtractedFile {
path: name,
data: buf,
size,
is_directory: false,
});
}
Ok(files)
}
fn list_cab(&self, data: &[u8]) -> Result<Vec<ArchiveEntry>> {
let cursor = Cursor::new(data);
let cabinet = cab::Cabinet::new(cursor).map_err(|e| Error::Cab(e.to_string()))?;
let mut entries = Vec::new();
for folder in cabinet.folder_entries() {
let method = format!("{:?}", folder.compression_type());
for file_entry in folder.file_entries() {
entries.push(ArchiveEntry {
path: file_entry.name().to_string(),
compressed_size: u64::from(file_entry.uncompressed_size()),
uncompressed_size: u64::from(file_entry.uncompressed_size()),
is_directory: false,
compression_method: method.clone(),
});
}
}
Ok(entries)
}
#[cfg(feature = "rar")]
fn extract_rar(&self, path: &Path) -> Result<Vec<ExtractedFile>> {
let archive = unrar::Archive::new(path)
.open_for_processing()
.map_err(|e| Error::Rar(format!("{e}")))?;
let mut files = Vec::new();
let mut total_size: u64 = 0;
let mut cursor = archive;
loop {
let header = match cursor.read_header() {
Ok(Some(h)) => h,
Ok(None) => break,
Err(e) => return Err(Error::Rar(format!("{e}"))),
};
let entry = header.entry();
let name = entry.filename.to_string_lossy().into_owned();
let is_directory = entry.is_directory();
let size = entry.unpacked_size;
if files.len() >= self.max_entries {
return Err(Error::TooManyEntries {
count: files.len(),
limit: self.max_entries,
});
}
if is_directory {
files.push(ExtractedFile {
path: name,
data: Vec::new(),
size: 0,
is_directory: true,
});
cursor = match header.skip() {
Ok(a) => a,
Err(e) => return Err(Error::Rar(format!("{e}"))),
};
continue;
}
if size > self.max_file_size {
return Err(Error::FileTooLarge {
size,
limit: self.max_file_size,
});
}
total_size = total_size.saturating_add(size);
if total_size > self.max_total_size {
return Err(Error::TotalSizeLimitExceeded {
limit: self.max_total_size,
});
}
let (data, next) = match header.read() {
Ok(result) => result,
Err(e) => return Err(Error::Rar(format!("{e}"))),
};
files.push(ExtractedFile {
path: name,
data,
size,
is_directory: false,
});
cursor = next;
}
Ok(files)
}
#[cfg(feature = "rar")]
fn list_rar(&self, path: &Path) -> Result<Vec<ArchiveEntry>> {
let archive = unrar::Archive::new(path)
.open_for_listing()
.map_err(|e| Error::Rar(format!("{e}")))?;
let mut entries = Vec::new();
for result in archive {
let entry = result.map_err(|e| Error::Rar(format!("{e}")))?;
entries.push(ArchiveEntry {
path: entry.filename.to_string_lossy().into_owned(),
compressed_size: entry.unpacked_size,
uncompressed_size: entry.unpacked_size,
is_directory: entry.is_directory(),
compression_method: "rar".into(),
});
}
Ok(entries)
}
}
#[cfg(test)]
#[allow(clippy::missing_panics_doc)]
mod tests {
use super::*;
#[test]
fn test_sanitize_path_normal() {
assert_eq!(
sanitize_path("foo/bar.txt").ok(),
Some("foo/bar.txt".into())
);
}
#[test]
fn test_sanitize_path_traversal() {
assert!(sanitize_path("../../../etc/passwd").is_err());
}
#[test]
fn test_sanitize_path_absolute() {
let result = sanitize_path("/etc/passwd");
assert!(result.is_ok());
assert_eq!(result.ok(), Some("etc/passwd".into()));
}
#[test]
fn test_extractor_defaults() {
let ext = Extractor::new();
assert_eq!(ext.max_file_size, 100 * 1024 * 1024);
assert_eq!(ext.max_total_size, 1024 * 1024 * 1024);
}
#[test]
fn test_extractor_custom_limits() {
let ext = Extractor::new()
.with_max_file_size(1024)
.with_max_total_size(4096);
assert_eq!(ext.max_file_size, 1024);
assert_eq!(ext.max_total_size, 4096);
}
#[test]
fn test_sanitize_path_curdir() {
let result = sanitize_path("./foo/bar.txt");
assert!(result.is_ok());
assert_eq!(result.ok(), Some("foo/bar.txt".into()));
}
#[test]
fn test_sanitize_path_empty() {
let result = sanitize_path("");
assert!(result.is_ok());
assert_eq!(result.ok(), Some(String::new()));
}
#[test]
fn test_sanitize_path_windows_prefix() {
let result = sanitize_path("C:\\windows\\system32");
assert!(result.is_ok());
}
#[test]
fn test_validate_unsupported_format() {
let ext = Extractor::new();
let result = ext.validate(&[], ArchiveFormat::Unknown);
assert!(result.is_err());
}
#[test]
fn test_validate_empty_data() {
let ext = Extractor::new();
let result = ext.validate(&[], ArchiveFormat::Zip);
assert!(result.is_err());
}
#[test]
fn test_extract_single_respects_limit() {
let ext = Extractor::new().with_max_file_size(10);
let big_data = vec![0u8; 100];
let result = ext.extract_single(big_data);
assert!(result.is_err());
}
fn make_zip_in_memory(files: &[(&str, &[u8])]) -> Vec<u8> {
use std::io::Write;
let buf = Cursor::new(Vec::new());
let mut writer = zip::ZipWriter::new(buf);
for (name, data) in files {
writer
.start_file::<&str, ()>(*name, zip::write::SimpleFileOptions::default())
.unwrap();
writer.write_all(data).unwrap();
}
writer.finish().unwrap().into_inner()
}
#[test]
fn test_zip_roundtrip_single_file() {
let content = b"Hello from ZIP roundtrip test!";
let zip_data = make_zip_in_memory(&[("test.txt", content)]);
let ext = Extractor::new();
let files = ext.extract(&zip_data, ArchiveFormat::Zip).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "test.txt");
assert_eq!(files[0].data, content);
}
#[test]
fn test_zip_roundtrip_multiple_files() {
let zip_data = make_zip_in_memory(&[
("a.txt", b"alpha"),
("b.txt", b"bravo"),
("c.txt", b"charlie"),
]);
let ext = Extractor::new();
let files = ext.extract(&zip_data, ArchiveFormat::Zip).unwrap();
assert_eq!(files.len(), 3);
assert_eq!(files[0].data, b"alpha");
assert_eq!(files[1].data, b"bravo");
assert_eq!(files[2].data, b"charlie");
}
#[test]
fn test_zip_magic_bytes() {
let zip_data = make_zip_in_memory(&[("x.txt", b"x")]);
assert_eq!(&zip_data[0..4], &[0x50, 0x4B, 0x03, 0x04]);
}
#[test]
fn test_zip_empty_file() {
let zip_data = make_zip_in_memory(&[("empty.txt", b"")]);
let ext = Extractor::new();
let files = ext.extract(&zip_data, ArchiveFormat::Zip).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].data.is_empty());
assert_eq!(files[0].size, 0);
}
#[test]
fn test_zip_list_roundtrip() {
let zip_data = make_zip_in_memory(&[("file1.txt", b"data1"), ("file2.txt", b"data2data2")]);
let ext = Extractor::new();
let entries = ext.list(&zip_data, ArchiveFormat::Zip).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].path, "file1.txt");
assert_eq!(entries[1].path, "file2.txt");
assert_eq!(entries[1].uncompressed_size, 10);
}
#[test]
fn test_zip_validate_roundtrip() {
let zip_data = make_zip_in_memory(&[("v.txt", b"validate me")]);
let ext = Extractor::new();
let result = ext.validate(&zip_data, ArchiveFormat::Zip).unwrap();
assert!(result.is_valid);
assert_eq!(result.entry_count, 1);
assert_eq!(result.total_uncompressed_size, 11);
}
#[test]
fn test_zip_entry_count_limit() {
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..10 {
files.push((format!("file{i}.txt"), vec![0u8; 1]));
}
let refs: Vec<(&str, &[u8])> = files
.iter()
.map(|(n, d)| (n.as_str(), d.as_slice()))
.collect();
let zip_data = make_zip_in_memory(&refs);
let ext = Extractor::new().with_max_entries(5);
let result = ext.extract(&zip_data, ArchiveFormat::Zip);
assert!(result.is_err());
}
fn make_tar_in_memory(files: &[(&str, &[u8])]) -> Vec<u8> {
let mut builder = tar::Builder::new(Vec::new());
for (name, data) in files {
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append_data(&mut header, *name, *data).unwrap();
}
builder.into_inner().unwrap()
}
#[test]
fn test_tar_roundtrip_single_file() {
let content = b"TAR roundtrip content";
let tar_data = make_tar_in_memory(&[("hello.txt", content)]);
let ext = Extractor::new();
let files = ext.extract(&tar_data, ArchiveFormat::Tar).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "hello.txt");
assert_eq!(files[0].data, content);
}
#[test]
fn test_tar_roundtrip_multiple() {
let tar_data =
make_tar_in_memory(&[("one.txt", b"1"), ("two.txt", b"22"), ("three.txt", b"333")]);
let ext = Extractor::new();
let files = ext.extract(&tar_data, ArchiveFormat::Tar).unwrap();
assert_eq!(files.len(), 3);
assert_eq!(files[2].data, b"333");
}
#[test]
fn test_tar_list_roundtrip() {
let tar_data = make_tar_in_memory(&[("a.bin", b"abc"), ("b.bin", b"defgh")]);
let ext = Extractor::new();
let entries = ext.list(&tar_data, ArchiveFormat::Tar).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].uncompressed_size, 3);
assert_eq!(entries[1].uncompressed_size, 5);
}
#[test]
fn test_tar_gz_roundtrip() {
let tar_data = make_tar_in_memory(&[("gz.txt", b"gzip me")]);
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
io::copy(&mut Cursor::new(&tar_data), &mut encoder).unwrap();
let gz_data = encoder.finish().unwrap();
let ext = Extractor::new();
let files = ext.extract(&gz_data, ArchiveFormat::TarGz).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].data, b"gzip me");
}
#[test]
fn test_tar_bz2_roundtrip() {
let tar_data = make_tar_in_memory(&[("bz2.txt", b"bzip2 me")]);
let mut encoder = bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::default());
io::copy(&mut Cursor::new(&tar_data), &mut encoder).unwrap();
let bz2_data = encoder.finish().unwrap();
let ext = Extractor::new();
let files = ext.extract(&bz2_data, ArchiveFormat::TarBz2).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].data, b"bzip2 me");
}
#[test]
fn test_tar_zstd_roundtrip() {
let tar_data = make_tar_in_memory(&[("zst.txt", b"zstd me")]);
let zst_data = zstd::encode_all(Cursor::new(&tar_data), 3).unwrap();
let ext = Extractor::new();
let files = ext.extract(&zst_data, ArchiveFormat::TarZst).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].data, b"zstd me");
}
#[test]
fn test_tar_lz4_roundtrip() {
let tar_data = make_tar_in_memory(&[("lz4.txt", b"lz4 me")]);
let lz4_data = crate::lz4::compress_frame(&tar_data).unwrap();
let ext = Extractor::new();
let files = ext.extract(&lz4_data, ArchiveFormat::TarLz4).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].data, b"lz4 me");
}
fn make_ar_in_memory(files: &[(&str, &[u8])]) -> Vec<u8> {
let mut builder = ar::Builder::new(Vec::new());
for (name, data) in files {
let mut header = ar::Header::new(name.as_bytes().to_vec(), data.len() as u64);
header.set_mtime(1_700_000_000);
header.set_uid(1000);
header.set_gid(1000);
header.set_mode(0o10_0644);
builder.append(&header, *data).unwrap();
}
builder.into_inner().unwrap()
}
#[test]
fn test_ar_roundtrip_single() {
let ar_data = make_ar_in_memory(&[("test.o", b"object file data")]);
let ext = Extractor::new();
let files = ext.extract(&ar_data, ArchiveFormat::Ar).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "test.o");
assert_eq!(files[0].data, b"object file data");
}
#[test]
fn test_ar_roundtrip_multiple() {
let ar_data = make_ar_in_memory(&[("a.o", b"aaa"), ("b.o", b"bbb"), ("c.o", b"ccc")]);
let ext = Extractor::new();
let files = ext.extract(&ar_data, ArchiveFormat::Ar).unwrap();
assert_eq!(files.len(), 3);
}
#[test]
fn test_ar_list_roundtrip() {
let ar_data = make_ar_in_memory(&[("lib.a", b"12345")]);
let ext = Extractor::new();
let entries = ext.list(&ar_data, ArchiveFormat::Ar).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path, "lib.a");
assert_eq!(entries[0].uncompressed_size, 5);
}
#[test]
fn test_ar_magic_bytes() {
let ar_data = make_ar_in_memory(&[("x", b"y")]);
assert_eq!(&ar_data[..7], b"!<arch>");
}
fn roundtrip_compression(data: &[u8], format: ArchiveFormat, compress: fn(&[u8]) -> Vec<u8>) {
let compressed = compress(data);
let ext = Extractor::new();
let files = ext.extract(&compressed, format).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].data, data);
}
fn compress_gz(data: &[u8]) -> Vec<u8> {
let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut enc, data).unwrap();
enc.finish().unwrap()
}
fn compress_bz2(data: &[u8]) -> Vec<u8> {
let mut enc = bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::default());
std::io::Write::write_all(&mut enc, data).unwrap();
enc.finish().unwrap()
}
fn compress_zstd(data: &[u8]) -> Vec<u8> {
zstd::encode_all(Cursor::new(data), 3).unwrap()
}
fn compress_lz4(data: &[u8]) -> Vec<u8> {
crate::lz4::compress_frame(data).unwrap()
}
fn compress_snappy(data: &[u8]) -> Vec<u8> {
let mut enc = snap::write::FrameEncoder::new(Vec::new());
std::io::Write::write_all(&mut enc, data).unwrap();
enc.into_inner().unwrap()
}
#[test]
fn test_gz_roundtrip() {
roundtrip_compression(b"gzip roundtrip data!", ArchiveFormat::Gz, compress_gz);
}
#[test]
fn test_bz2_roundtrip() {
roundtrip_compression(b"bzip2 roundtrip data!", ArchiveFormat::Bz2, compress_bz2);
}
#[test]
fn test_zstd_roundtrip() {
roundtrip_compression(b"zstd roundtrip data!", ArchiveFormat::Zstd, compress_zstd);
}
#[test]
fn test_lz4_roundtrip() {
roundtrip_compression(b"lz4 roundtrip data!", ArchiveFormat::Lz4, compress_lz4);
}
#[test]
fn test_snappy_roundtrip() {
roundtrip_compression(
b"snappy roundtrip data!",
ArchiveFormat::Snappy,
compress_snappy,
);
}
#[test]
fn test_gz_roundtrip_large() {
let data: Vec<u8> = (0..100_000).map(|i: i32| i.to_le_bytes()[0]).collect();
roundtrip_compression(&data, ArchiveFormat::Gz, compress_gz);
}
#[test]
fn test_bz2_roundtrip_large() {
let data: Vec<u8> = (0..50_000).map(|i: i32| i.to_le_bytes()[0]).collect();
roundtrip_compression(&data, ArchiveFormat::Bz2, compress_bz2);
}
#[test]
fn test_zstd_roundtrip_large() {
let data: Vec<u8> = (0..100_000).map(|i: i32| i.to_le_bytes()[0]).collect();
roundtrip_compression(&data, ArchiveFormat::Zstd, compress_zstd);
}
#[test]
fn test_lz4_roundtrip_large() {
let data: Vec<u8> = (0..100_000).map(|i: i32| i.to_le_bytes()[0]).collect();
roundtrip_compression(&data, ArchiveFormat::Lz4, compress_lz4);
}
#[test]
fn test_snappy_roundtrip_large() {
let data: Vec<u8> = (0..100_000).map(|i: i32| i.to_le_bytes()[0]).collect();
roundtrip_compression(&data, ArchiveFormat::Snappy, compress_snappy);
}
#[test]
fn test_gz_roundtrip_empty() {
roundtrip_compression(b"", ArchiveFormat::Gz, compress_gz);
}
#[test]
fn test_zstd_roundtrip_empty() {
roundtrip_compression(b"", ArchiveFormat::Zstd, compress_zstd);
}
#[test]
fn test_lz4_roundtrip_empty() {
roundtrip_compression(b"", ArchiveFormat::Lz4, compress_lz4);
}
#[test]
fn test_corrupted_zip_data() {
let ext = Extractor::new();
let result = ext.extract(b"PK\x03\x04CORRUPT", ArchiveFormat::Zip);
assert!(result.is_err());
}
#[test]
fn test_corrupted_gz_data() {
let ext = Extractor::new();
let result = ext.extract(b"\x1f\x8bCORRUPT", ArchiveFormat::Gz);
assert!(result.is_err());
}
#[test]
fn test_corrupted_bz2_data() {
let ext = Extractor::new();
let result = ext.extract(b"BZh9CORRUPT", ArchiveFormat::Bz2);
assert!(result.is_err());
}
#[test]
fn test_corrupted_zstd_data() {
let ext = Extractor::new();
let result = ext.extract(b"\x28\xb5\x2f\xfdCORRUPT", ArchiveFormat::Zstd);
assert!(result.is_err());
}
#[test]
fn test_corrupted_lz4_data() {
let ext = Extractor::new();
let result = ext.extract(b"\x04\x22\x4d\x18CORRUPT", ArchiveFormat::Lz4);
assert!(result.is_err());
}
#[test]
fn test_corrupted_ar_data() {
let ext = Extractor::new();
let result = ext.extract(b"!<arch>\nCORRUPT", ArchiveFormat::Ar);
assert!(result.is_err());
}
#[test]
fn test_truncated_zip() {
let zip_data = make_zip_in_memory(&[("test.txt", b"data")]);
let truncated = &zip_data[..zip_data.len() / 2];
let ext = Extractor::new();
let result = ext.extract(truncated, ArchiveFormat::Zip);
assert!(result.is_err());
}
#[test]
fn test_zip_file_size_limit() {
let big_content = vec![0u8; 1024];
let zip_data = make_zip_in_memory(&[("big.bin", &big_content)]);
let ext = Extractor::new().with_max_file_size(500);
let result = ext.extract(&zip_data, ArchiveFormat::Zip);
assert!(result.is_err());
}
#[test]
fn test_zip_total_size_limit() {
let zip_data = make_zip_in_memory(&[
("a.bin", &vec![0u8; 300]),
("b.bin", &vec![0u8; 300]),
("c.bin", &vec![0u8; 300]),
]);
let ext = Extractor::new().with_max_total_size(500);
let result = ext.extract(&zip_data, ArchiveFormat::Zip);
assert!(result.is_err());
}
#[test]
fn test_tar_file_size_limit() {
let big_content = vec![0u8; 1024];
let tar_data = make_tar_in_memory(&[("big.bin", &big_content)]);
let ext = Extractor::new().with_max_file_size(500);
let result = ext.extract(&tar_data, ArchiveFormat::Tar);
assert!(result.is_err());
}
#[test]
fn test_tar_total_size_limit() {
let tar_data = make_tar_in_memory(&[
("a.bin", &vec![0u8; 300]),
("b.bin", &vec![0u8; 300]),
("c.bin", &vec![0u8; 300]),
]);
let ext = Extractor::new().with_max_total_size(500);
let result = ext.extract(&tar_data, ArchiveFormat::Tar);
assert!(result.is_err());
}
#[test]
fn test_tar_entry_count_limit() {
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..10 {
files.push((format!("f{i}.txt"), vec![0u8; 1]));
}
let refs: Vec<(&str, &[u8])> = files
.iter()
.map(|(n, d)| (n.as_str(), d.as_slice()))
.collect();
let tar_data = make_tar_in_memory(&refs);
let ext = Extractor::new().with_max_entries(5);
let result = ext.extract(&tar_data, ArchiveFormat::Tar);
assert!(result.is_err());
}
#[test]
fn test_sanitize_path_double_dot_middle() {
assert!(sanitize_path("foo/../bar").is_err());
}
#[test]
fn test_sanitize_path_dot_dot_only() {
assert!(sanitize_path("..").is_err());
}
#[test]
fn test_sanitize_path_complex_traversal() {
assert!(sanitize_path("a/b/../../../../../../etc/shadow").is_err());
}
#[test]
fn test_validate_tar_gz() {
let tar_data = make_tar_in_memory(&[("v.txt", b"validate tar gz")]);
let gz_data = compress_gz(&tar_data);
let ext = Extractor::new();
let result = ext.validate(&gz_data, ArchiveFormat::TarGz).unwrap();
assert!(result.is_valid);
assert_eq!(result.entry_count, 1);
}
#[test]
fn test_validate_ar() {
let ar_data = make_ar_in_memory(&[("v.o", b"validate ar")]);
let ext = Extractor::new();
let result = ext.validate(&ar_data, ArchiveFormat::Ar).unwrap();
assert!(result.is_valid);
assert_eq!(result.entry_count, 1);
}
#[test]
fn test_validate_corrupted_returns_error() {
let ext = Extractor::new();
let result = ext.validate(b"NOT_AN_ARCHIVE", ArchiveFormat::Zip);
assert!(result.is_err());
}
#[test]
fn test_zip_blocks_zip_slip_traversal() {
use std::io::Write;
let buf = Cursor::new(Vec::new());
let mut writer = zip::ZipWriter::new(buf);
writer
.start_file::<&str, ()>("../../evil.txt", zip::write::SimpleFileOptions::default())
.unwrap();
writer.write_all(b"malicious content").unwrap();
let zip_data = writer.finish().unwrap().into_inner();
let ext = Extractor::new();
let result = ext.extract(&zip_data, ArchiveFormat::Zip);
assert!(result.is_err());
}
#[test]
fn test_zip_blocks_double_dot_in_path() {
use std::io::Write;
let buf = Cursor::new(Vec::new());
let mut writer = zip::ZipWriter::new(buf);
writer
.start_file::<&str, ()>(
"foo/../../bar.txt",
zip::write::SimpleFileOptions::default(),
)
.unwrap();
writer.write_all(b"escape attempt").unwrap();
let zip_data = writer.finish().unwrap().into_inner();
let ext = Extractor::new();
let result = ext.extract(&zip_data, ArchiveFormat::Zip);
assert!(result.is_err());
}
#[test]
fn test_zip_path_depth_limit() {
use std::io::Write;
let deep_path = (0..51)
.map(|i| format!("d{i}"))
.collect::<Vec<_>>()
.join("/")
+ "/file.txt";
let buf = Cursor::new(Vec::new());
let mut writer = zip::ZipWriter::new(buf);
writer
.start_file::<&str, ()>(&deep_path, zip::write::SimpleFileOptions::default())
.unwrap();
writer.write_all(b"deep").unwrap();
let zip_data = writer.finish().unwrap().into_inner();
let ext = Extractor::new();
let result = ext.extract(&zip_data, ArchiveFormat::Zip);
assert!(result.is_ok());
}
#[test]
fn test_zopfli_roundtrip() {
let original = b"Hello, Zopfli compression test! ".repeat(100);
let compressed = compress_zopfli(&original).unwrap();
let mut decoder = flate2::read::GzDecoder::new(&compressed[..]);
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed).unwrap();
assert_eq!(original, decompressed);
}
#[test]
fn test_zopfli_produces_smaller_output() {
let data = b"AAAAAAAAAA".repeat(1000);
let zopfli_out = compress_zopfli(&data).unwrap();
let flate2_out = compress_gz(&data);
assert!(
zopfli_out.len() <= flate2_out.len() + 20,
"zopfli={} flate2={}",
zopfli_out.len(),
flate2_out.len()
);
}
#[test]
fn test_safe_unzip_verify_valid() {
let zip_data = make_zip_in_memory(&[("test.txt", b"hello safe_unzip")]);
let result = safe_unzip::verify_bytes(&zip_data);
assert!(result.is_ok());
}
#[test]
fn test_safe_unzip_verify_corrupted() {
let result = safe_unzip::verify_bytes(b"NOT_A_ZIP");
assert!(result.is_err());
}
#[test]
fn test_safe_unzip_list_entries() {
let zip_data = make_zip_in_memory(&[("a.txt", b"alpha"), ("b.txt", b"bravo")]);
let entries = safe_unzip::list_zip(Cursor::new(&zip_data));
assert!(entries.is_ok());
let entries = entries.unwrap();
assert_eq!(entries.len(), 2);
}
#[test]
fn test_safe_unzip_extract_to_disk() {
let zip_data = make_zip_in_memory(&[("safe.txt", b"safe extraction test")]);
let tmp = tempfile::tempdir().unwrap();
let extractor = safe_unzip::Extractor::new(tmp.path()).unwrap();
let result = extractor.extract(Cursor::new(&zip_data));
assert!(result.is_ok());
let report = result.unwrap();
assert_eq!(report.files_extracted, 1);
assert!(tmp.path().join("safe.txt").exists());
}
#[test]
fn test_safe_unzip_blocks_zip_slip() {
use std::io::Write;
let buf = Cursor::new(Vec::new());
let mut writer = zip::ZipWriter::new(buf);
writer
.start_file::<&str, ()>("../../escape.txt", zip::write::SimpleFileOptions::default())
.unwrap();
writer.write_all(b"evil").unwrap();
let zip_data = writer.finish().unwrap().into_inner();
let tmp = tempfile::tempdir().unwrap();
let extractor = safe_unzip::Extractor::new(tmp.path()).unwrap();
let result = extractor.extract(Cursor::new(&zip_data));
assert!(result.is_err());
}
#[test]
fn test_safe_unzip_enforces_limits() {
let zip_data = make_zip_in_memory(&[("big.bin", &vec![0u8; 1024])]);
let tmp = tempfile::tempdir().unwrap();
let limits = safe_unzip::Limits {
max_total_bytes: 100,
max_file_count: 10,
max_single_file: 100,
max_path_depth: 50,
};
let extractor = safe_unzip::Extractor::new(tmp.path())
.unwrap()
.limits(limits);
let result = extractor.extract(Cursor::new(&zip_data));
assert!(result.is_err());
}
#[test]
fn test_safe_unzip_validate_first_mode() {
let zip_data = make_zip_in_memory(&[("valid.txt", b"validate first")]);
let tmp = tempfile::tempdir().unwrap();
let extractor = safe_unzip::Extractor::new(tmp.path())
.unwrap()
.mode(safe_unzip::ExtractionMode::ValidateFirst);
let result = extractor.extract(Cursor::new(&zip_data));
assert!(result.is_ok());
}
}