#![allow(clippy::indexing_slicing)]
#![allow(clippy::arithmetic_side_effects)]
#![allow(clippy::as_conversions)]
#![allow(clippy::cast_possible_truncation)]
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_files: usize,
max_compression_ratio: u64,
}
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_files: 10_000,
max_compression_ratio: 100,
}
}
#[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_files(mut self, limit: usize) -> Self {
self.max_files = limit;
self
}
#[must_use]
pub const fn with_max_compression_ratio(mut self, limit: u64) -> Self {
self.max_compression_ratio = limit;
self
}
pub fn extract(&self, data: &[u8], format: ArchiveFormat) -> Result<Vec<ExtractedFile>> {
match format {
ArchiveFormat::TarLz4 => {
let decompressed = decompress_lz4(data)?;
self.extract_tar(&decompressed)
},
ArchiveFormat::Gz => self.extract_single(decompress_gz(data)?),
ArchiveFormat::Bz2 => self.extract_single(decompress_bz2(data)?),
ArchiveFormat::Xz => self.extract_single(decompress_xz(data)?),
ArchiveFormat::Lz4 => self.extract_single(decompress_lz4(data)?),
ArchiveFormat::Zstd => self.extract_single(decompress_zstd(data)?),
ArchiveFormat::Lzma => self.extract_single(decompress_lzma(data)?),
ArchiveFormat::Lha => self.extract_lha(data),
ArchiveFormat::Rar => Err(Error::UnsupportedFormat(
"RAR extraction not available \
(no pure-Rust library compatible \
with forbid(unsafe_code))"
.into(),
)),
ArchiveFormat::Arc => Err(Error::UnsupportedFormat(
"ARC extraction not available \
(no pure-Rust library available)"
.into(),
)),
ArchiveFormat::Zoo => Err(Error::UnsupportedFormat(
"ZOO extraction not available \
(no pure-Rust library available)"
.into(),
)),
other @ (ArchiveFormat::Zip
| ArchiveFormat::Tar
| ArchiveFormat::TarGz
| ArchiveFormat::TarBz2
| ArchiveFormat::TarXz
| ArchiveFormat::TarZst
| ArchiveFormat::SevenZip
| ArchiveFormat::Xar
| ArchiveFormat::Dds
| ArchiveFormat::StuffIt
| ArchiveFormat::CompactPro
| ArchiveFormat::Unknown) => Err(Error::UnsupportedFormat(format!(
"{other}: use exarch-core path \
for this format"
))),
}
}
pub fn list(&self, data: &[u8], format: ArchiveFormat) -> Result<Vec<ArchiveEntry>> {
match format {
ArchiveFormat::TarLz4 => {
let decompressed = decompress_lz4(data)?;
self.list_tar(&decompressed)
},
ArchiveFormat::Lha => self.list_lha(data),
other @ (ArchiveFormat::Zip
| ArchiveFormat::Tar
| ArchiveFormat::TarGz
| ArchiveFormat::TarBz2
| ArchiveFormat::TarXz
| ArchiveFormat::TarZst
| ArchiveFormat::SevenZip
| ArchiveFormat::Gz
| ArchiveFormat::Bz2
| ArchiveFormat::Xz
| ArchiveFormat::Lz4
| ArchiveFormat::Zstd
| ArchiveFormat::Lzma
| ArchiveFormat::Rar
| ArchiveFormat::Arc
| ArchiveFormat::Zoo
| ArchiveFormat::Xar
| ArchiveFormat::Dds
| ArchiveFormat::StuffIt
| ArchiveFormat::CompactPro
| ArchiveFormat::Unknown) => Err(Error::UnsupportedFormat(format!(
"{other}: use exarch-core path \
for listing"
))),
}
}
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()? {
if files.len() >= self.max_files {
return Err(Error::MaxFilesExceeded {
count: files.len(),
limit: self.max_files,
});
}
let mut entry = entry?;
let path = entry.path()?.to_str().map(String::from).unwrap_or_default();
let safe_path = sanitize_path(&path)?;
let is_directory = entry.header().entry_type().is_dir();
let size = entry.header().size()?;
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_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_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 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_owned()));
},
Component::Prefix(_) => {
return Err(Error::PathTraversal(raw.to_owned()));
},
}
}
Ok(safe.to_string_lossy().into_owned())
}
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>> {
if data.len() < 12 {
return Err(Error::Xz("XZ stream too short".into()));
}
if data[6] != 0x00 {
return Err(Error::Xz(
"invalid XZ stream flags (byte 6 \
must be 0x00)"
.into(),
));
}
let check_type = data[7] & 0x0F;
let reserved_hi = data[7] & 0xF0;
if reserved_hi != 0 {
return Err(Error::Xz(
"invalid XZ stream flags (reserved \
bits set in byte 7)"
.into(),
));
}
if !matches!(check_type, 0 | 1 | 4 | 10) {
return Err(Error::Xz(format!(
"unsupported XZ check type: {check_type}"
)));
}
let stored_crc = u32::from_le_bytes([data[8], data[9], data[10], data[11]]);
let computed_crc = crc32fast::hash(&data[6..8]);
if stored_crc != computed_crc {
return Err(Error::Xz("XZ header CRC32 mismatch".into()));
}
let data_vec = data.to_vec();
std::panic::catch_unwind(|| {
let mut out = Vec::new();
lzma_rs::xz_decompress(&mut io::Cursor::new(&data_vec), &mut out).map(|()| out)
})
.map_err(|_| Error::Xz("XZ decoder panicked on malformed input".into()))?
.map_err(|e| Error::Xz(e.to_string()))
}
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_lzma(data: &[u8]) -> Result<Vec<u8>> {
let mut out = Vec::new();
lzma_rs::lzma_decompress(&mut io::Cursor::new(data), &mut out)?;
Ok(out)
}
const LHA_PREALLOC_CAP: usize = 16 * 1024 * 1024;
impl Extractor {
fn extract_lha(&self, data: &[u8]) -> Result<Vec<ExtractedFile>> {
let cursor = Cursor::new(data);
let mut lha_reader =
delharc::LhaDecodeReader::new(cursor).map_err(|e| Error::Lha(e.to_string()))?;
let mut files = Vec::new();
let mut total_size: u64 = 0;
loop {
if files.len() >= self.max_files {
return Err(Error::MaxFilesExceeded {
count: files.len(),
limit: self.max_files,
});
}
let header = lha_reader.header();
let path = header.parse_pathname();
let safe_path = sanitize_path(&path.to_string_lossy())?;
let is_directory = header.is_directory();
let original_size = header.original_size;
if is_directory {
files.push(ExtractedFile {
path: safe_path,
data: Vec::new(),
size: 0,
is_directory: true,
});
} else if lha_reader.is_decoder_supported() {
if original_size > self.max_file_size {
return Err(Error::FileTooLarge {
size: original_size,
limit: self.max_file_size,
});
}
total_size = total_size.saturating_add(original_size);
if total_size > self.max_total_size {
return Err(Error::TotalSizeLimitExceeded {
limit: self.max_total_size,
});
}
let cap = usize::try_from(original_size)
.unwrap_or(0)
.min(LHA_PREALLOC_CAP);
let mut buf = Vec::with_capacity(cap);
lha_reader
.read_to_end(&mut buf)
.map_err(|e| Error::Lha(e.to_string()))?;
files.push(ExtractedFile {
path: safe_path,
data: buf,
size: original_size,
is_directory: false,
});
} else {
eprintln!(
"Warning: skipping '{}' \
(unsupported LHA compression)",
safe_path
);
}
match lha_reader.next_file() {
Ok(true) => {},
Ok(false) => break,
Err(e) => {
return Err(Error::Lha(e.to_string()));
},
}
}
Ok(files)
}
fn list_lha(&self, data: &[u8]) -> Result<Vec<ArchiveEntry>> {
let cursor = Cursor::new(data);
let mut lha_reader =
delharc::LhaDecodeReader::new(cursor).map_err(|e| Error::Lha(e.to_string()))?;
let mut entries = Vec::new();
loop {
let header = lha_reader.header();
let path = header.parse_pathname();
let method = header
.compression_method()
.map_or_else(|_| "unknown".into(), |m| m.to_string());
entries.push(ArchiveEntry {
path: path.to_string_lossy().into_owned(),
compressed_size: header.compressed_size,
uncompressed_size: header.original_size,
is_directory: header.is_directory(),
compression_method: method,
});
match lha_reader.next_file() {
Ok(true) => {},
Ok(false) => break,
Err(e) => {
return Err(Error::Lha(e.to_string()));
},
}
}
Ok(entries)
}
}
#[must_use]
pub const fn is_exarch_format(fmt: ArchiveFormat) -> bool {
matches!(
fmt,
ArchiveFormat::Zip
| ArchiveFormat::Tar
| ArchiveFormat::TarGz
| ArchiveFormat::TarBz2
| ArchiveFormat::TarXz
| ArchiveFormat::TarZst
| ArchiveFormat::SevenZip
)
}
#[cfg(test)]
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);
assert_eq!(ext.max_files, 10_000);
assert_eq!(ext.max_compression_ratio, 100);
}
#[test]
fn test_extractor_custom_limits() {
let ext = Extractor::new()
.with_max_file_size(1024)
.with_max_total_size(4096)
.with_max_files(500)
.with_max_compression_ratio(50);
assert_eq!(ext.max_file_size, 1024);
assert_eq!(ext.max_total_size, 4096);
assert_eq!(ext.max_files, 500);
assert_eq!(ext.max_compression_ratio, 50);
}
#[test]
fn test_is_exarch_format() {
assert!(is_exarch_format(ArchiveFormat::Zip));
assert!(is_exarch_format(ArchiveFormat::Tar));
assert!(is_exarch_format(ArchiveFormat::TarGz));
assert!(is_exarch_format(ArchiveFormat::SevenZip));
assert!(!is_exarch_format(ArchiveFormat::TarLz4));
assert!(!is_exarch_format(ArchiveFormat::Lha));
assert!(!is_exarch_format(ArchiveFormat::Lzma));
assert!(!is_exarch_format(ArchiveFormat::Gz));
}
fn tar_with_entries(count: usize, size: usize) -> Vec<u8> {
let mut builder = tar::Builder::new(Vec::new());
for i in 0..count {
let data = vec![b'a'; size];
let mut header = tar::Header::new_gnu();
header.set_size(size as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, format!("f{i}.bin"), &data[..])
.expect("append entry to in-memory tar");
}
builder.into_inner().expect("finish in-memory tar")
}
fn tar_lz4(count: usize, size: usize) -> Vec<u8> {
let raw = tar_with_entries(count, size);
let mut enc = lz4_flex::frame::FrameEncoder::new(Vec::new());
std::io::Write::write_all(&mut enc, &raw).expect("lz4 write");
enc.finish().expect("lz4 finish")
}
#[test]
fn tar_entry_exactly_at_max_file_size_is_accepted() {
let data = tar_lz4(1, 1024);
let out = Extractor::new()
.with_max_file_size(1024)
.with_max_total_size(1024 * 1024)
.extract(&data, ArchiveFormat::TarLz4);
assert!(
out.is_ok(),
"an entry of exactly max_file_size must extract, got {out:?}"
);
}
#[test]
fn tar_entry_one_byte_over_max_file_size_is_refused() {
let data = tar_lz4(1, 1025);
let err = Extractor::new()
.with_max_file_size(1024)
.with_max_total_size(1024 * 1024)
.extract(&data, ArchiveFormat::TarLz4)
.expect_err("an entry over max_file_size must be refused");
assert!(
matches!(
err,
Error::FileTooLarge {
size: 1025,
limit: 1024
}
),
"expected FileTooLarge{{size:1025,limit:1024}}, got {err:?}"
);
}
#[test]
fn tar_total_exactly_at_max_total_size_is_accepted() {
let data = tar_lz4(2, 512);
let out = Extractor::new()
.with_max_file_size(1024)
.with_max_total_size(1024)
.extract(&data, ArchiveFormat::TarLz4);
assert!(
out.is_ok(),
"a running total of exactly max_total_size must extract, got {out:?}"
);
}
#[test]
fn tar_total_one_byte_over_max_total_size_is_refused() {
let data = tar_lz4(2, 512);
let err = Extractor::new()
.with_max_file_size(1024)
.with_max_total_size(1023)
.extract(&data, ArchiveFormat::TarLz4)
.expect_err("a running total over max_total_size must be refused");
assert!(
matches!(err, Error::TotalSizeLimitExceeded { limit: 1023 }),
"expected TotalSizeLimitExceeded{{limit:1023}}, got {err:?}"
);
}
#[test]
fn tar_exactly_max_files_entries_are_accepted() {
let data = tar_lz4(3, 16);
let out = Extractor::new()
.with_max_files(3)
.with_max_file_size(1024)
.with_max_total_size(1024)
.extract(&data, ArchiveFormat::TarLz4);
assert!(
out.is_ok(),
"exactly max_files entries must extract, got {out:?}"
);
}
#[test]
fn tar_one_entry_over_max_files_is_refused() {
let data = tar_lz4(4, 16);
let err = Extractor::new()
.with_max_files(3)
.with_max_file_size(1024)
.with_max_total_size(1024)
.extract(&data, ArchiveFormat::TarLz4)
.expect_err("more than max_files entries must be refused");
assert!(
matches!(err, Error::MaxFilesExceeded { count: 3, limit: 3 }),
"expected MaxFilesExceeded{{count:3,limit:3}}, got {err:?}"
);
}
#[test]
fn single_file_exactly_at_max_file_size_is_accepted() {
let payload = vec![b'z'; 256];
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
std::io::Write::write_all(&mut gz, &payload).expect("gzip write");
let data = gz.finish().expect("gzip finish");
let out = Extractor::new()
.with_max_file_size(256)
.with_max_total_size(1024)
.extract(&data, ArchiveFormat::Gz);
assert!(
out.is_ok(),
"a single file of exactly max_file_size must extract, got {out:?}"
);
}
#[test]
fn lha_prealloc_cap_is_16_mib() {
assert_eq!(
LHA_PREALLOC_CAP, 16_777_216,
"the LHA pre-allocation cap is a documented OOM bound; \
changing it is a deliberate decision, not a refactor"
);
}
#[test]
fn xz_stream_below_minimum_length_is_refused_as_too_short() {
let err = Extractor::new()
.extract(&[0u8; 11], ArchiveFormat::Xz)
.expect_err("an 11-byte xz stream must be refused");
let Error::Xz(ref msg) = err else {
panic!("expected Error::Xz(too short), got {err:?}")
};
assert!(
msg.contains("too short"),
"expected the short-stream guard, got Xz({msg:?})"
);
}
#[test]
fn xz_stream_at_minimum_length_passes_the_short_guard() {
let err = Extractor::new()
.extract(&[0u8; 12], ArchiveFormat::Xz)
.expect_err("12 zero bytes are still not a valid xz stream");
if let Error::Xz(ref msg) = err {
assert!(
!msg.contains("too short"),
"12 bytes must clear the length guard, got Xz({msg:?})"
);
}
}
fn lha_fixture() -> Vec<u8> {
std::fs::read("test-fixtures/sample.lzh").expect("read sample.lzh fixture")
}
fn lha_largest_entry_size() -> u64 {
Extractor::new()
.extract(&lha_fixture(), ArchiveFormat::Lha)
.expect("fixture extracts with default limits")
.iter()
.map(|f| f.size)
.max()
.expect("fixture has at least one entry")
}
#[test]
fn lha_entry_exactly_at_max_file_size_is_accepted() {
let n = lha_largest_entry_size();
let out = Extractor::new()
.with_max_file_size(n)
.with_max_total_size(u64::MAX)
.extract(&lha_fixture(), ArchiveFormat::Lha);
assert!(
out.is_ok(),
"an entry of exactly max_file_size must extract, got {out:?}"
);
}
#[test]
fn lha_entry_one_byte_over_max_file_size_is_refused() {
let n = lha_largest_entry_size();
let err = Extractor::new()
.with_max_file_size(n - 1)
.with_max_total_size(u64::MAX)
.extract(&lha_fixture(), ArchiveFormat::Lha)
.expect_err("an entry over max_file_size must be refused");
assert!(
matches!(err, Error::FileTooLarge { limit, .. } if limit == n - 1),
"expected FileTooLarge with limit {}, got {err:?}",
n - 1
);
}
#[test]
fn lha_total_exactly_at_max_total_size_is_accepted() {
let total: u64 = Extractor::new()
.extract(&lha_fixture(), ArchiveFormat::Lha)
.expect("fixture extracts")
.iter()
.map(|f| f.size)
.sum();
let out = Extractor::new()
.with_max_file_size(u64::MAX)
.with_max_total_size(total)
.extract(&lha_fixture(), ArchiveFormat::Lha);
assert!(
out.is_ok(),
"a total of exactly max_total_size must extract, got {out:?}"
);
}
#[test]
fn lha_total_one_byte_over_max_total_size_is_refused() {
let total: u64 = Extractor::new()
.extract(&lha_fixture(), ArchiveFormat::Lha)
.expect("fixture extracts")
.iter()
.map(|f| f.size)
.sum();
let err = Extractor::new()
.with_max_file_size(u64::MAX)
.with_max_total_size(total - 1)
.extract(&lha_fixture(), ArchiveFormat::Lha)
.expect_err("a total over max_total_size must be refused");
assert!(
matches!(err, Error::TotalSizeLimitExceeded { limit } if limit == total - 1),
"expected TotalSizeLimitExceeded with limit {}, got {err:?}",
total - 1
);
}
#[test]
fn single_file_one_byte_over_max_file_size_is_refused() {
let payload = vec![b'z'; 257];
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
std::io::Write::write_all(&mut gz, &payload).expect("gzip write");
let data = gz.finish().expect("gzip finish");
let err = Extractor::new()
.with_max_file_size(256)
.with_max_total_size(1024)
.extract(&data, ArchiveFormat::Gz)
.expect_err("a single file over max_file_size must be refused");
assert!(
matches!(
err,
Error::FileTooLarge {
size: 257,
limit: 256
}
),
"expected FileTooLarge{{size:257,limit:256}}, got {err:?}"
);
}
}