use std::fs::File;
use std::io::Read;
use std::io::Seek;
use std::io::SeekFrom;
use std::path::Path;
use std::path::PathBuf;
use std::time::SystemTime;
use crate::ArchiveError;
use crate::ListOptions;
use crate::Result;
use crate::SecurityConfig;
use crate::StoredTargets;
use crate::config::Validated;
#[cfg(test)]
use crate::error::QuotaResource;
use crate::formats::ZipArchive;
use crate::formats::common::encrypted_archive_error;
use crate::formats::common::normalize_entry_name;
use crate::formats::common::symlink_target_too_large;
use crate::formats::compression::CompressionCodec;
use crate::formats::detect::ArchiveType;
use crate::formats::detect::FormatFamily;
use crate::formats::detect::detect_format;
use crate::formats::sevenz::LinkCapture;
use crate::formats::sevenz::LinkOutcome;
use crate::formats::sevenz::LinkTable;
use crate::formats::sevenz::SevenZClass;
use crate::formats::sevenz::SevenZHeader;
use crate::formats::sevenz::SevenZKind;
use crate::formats::sevenz::StoredLinkTarget;
use crate::formats::sevenz::deliver_undecoded_links;
use crate::formats::sevenz::read_sevenz_header;
use crate::formats::sevenz::sevenz_entry_compression;
use crate::formats::sevenz::verify_sevenz_blocks;
use crate::formats::sevenz::walk_sevenz_links;
use crate::formats::tar::TarNode;
use crate::formats::tar::effective_entry_type;
use crate::formats::tar::is_metadata_type;
use crate::formats::tar::open_tar_stream;
use crate::formats::tar::tar_node;
use crate::formats::tar_metadata_limit::TarEntryGuard;
use crate::formats::tar_metadata_limit::TarReadBudget;
use crate::formats::tar_metadata_limit::budget_violation;
use crate::formats::tar_metadata_limit::budgeted_reader;
use crate::formats::tar_metadata_limit::budgeted_tar_entries;
use crate::formats::zip::ZipEntryKind;
use crate::formats::zip::is_password_required;
use crate::formats::zip::parse_zip_entry_name;
use crate::formats::zip::read_zip_symlink_target;
use crate::formats::zip::read_zip_symlink_target_checked;
use crate::formats::zip::resolve_zip_entry_path;
use crate::formats::zip::zip_entry_kind;
use crate::formats::zip::zip_node_kind;
use crate::formats::zip_tail::DescriptorTail;
use crate::inspection::integrity::Charge;
use crate::inspection::integrity::DecodeOutcome;
use crate::inspection::integrity::IntegrityPass;
use crate::inspection::integrity::Slot;
use crate::inspection::integrity::TarTrailer;
use crate::inspection::manifest::ArchiveEntry;
use crate::inspection::manifest::ArchiveManifest;
use crate::inspection::manifest::EntryCompression;
use crate::inspection::manifest::EntryPosition;
use crate::inspection::manifest::LinkTarget;
use crate::inspection::manifest::ManifestEntryKind;
use crate::inspection::manifest::ManifestEntryType;
use crate::inspection::report::UnverifiedReason;
use crate::security::fs_view::LinkKind;
use crate::security::fs_view::MAX_SYMLINK_TARGET_BYTES;
use crate::security::fs_view::check_name_lengths;
use crate::security::fs_view::name_too_long;
use crate::security::quota::QuotaPermit;
use crate::security::quota::QuotaTracker;
use crate::security::validator::EntryPath;
use crate::security::validator::NodeKind;
use crate::security::validator::require_non_empty_name;
use crate::types::safe_path::has_null_bytes;
pub fn list_archive<P: AsRef<Path>>(
archive_path: P,
config: &SecurityConfig,
) -> Result<ArchiveManifest> {
list_archive_with_options(archive_path, config, &ListOptions::default())
}
pub fn list_archive_with_options<P: AsRef<Path>>(
archive_path: P,
config: &SecurityConfig,
options: &ListOptions,
) -> Result<ArchiveManifest> {
let config = config.clone().validate()?;
list_validated(
archive_path.as_ref(),
&config,
&mut ListPass::Strict(options.stored_targets),
)
}
pub(crate) enum ListPass<'a, 'c> {
Strict(StoredTargets),
VerifyPreflight(Option<&'a mut IntegrityPass<'c>>),
}
impl<'c> ListPass<'_, 'c> {
pub(crate) const fn strict() -> Self {
Self::Strict(StoredTargets::Skip)
}
pub(crate) fn integrity(&mut self) -> Option<&mut IntegrityPass<'c>> {
match self {
Self::Strict(_) => None,
Self::VerifyPreflight(pass) => pass.as_deref_mut(),
}
}
const fn mode(&self) -> ListMode {
match self {
Self::Strict(_) => ListMode::Strict,
Self::VerifyPreflight(_) => ListMode::VerifyPreflight,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ListMode {
Strict,
VerifyPreflight,
}
fn reserve_entry(
quota: &mut QuotaTracker,
size: u64,
config: &SecurityConfig<Validated>,
mode: ListMode,
) -> Result<QuotaPermit> {
match mode {
ListMode::Strict => quota.reserve(size, config),
ListMode::VerifyPreflight => quota.reserve_without_file_limit(size, config),
}
}
pub(crate) fn list_validated(
archive_path: &Path,
config: &SecurityConfig<Validated>,
listing: &mut ListPass<'_, '_>,
) -> Result<ArchiveManifest> {
list_detected(archive_path, detect_format(archive_path)?, config, listing)
}
pub(crate) fn list_detected(
archive_path: &Path,
format: ArchiveType,
config: &SecurityConfig<Validated>,
listing: &mut ListPass<'_, '_>,
) -> Result<ArchiveManifest> {
match format.family() {
FormatFamily::Tar(codec) => list_tar(archive_path, codec, format, config, listing),
FormatFamily::Zip => list_zip(archive_path, config, listing),
FormatFamily::SevenZ => list_sevenz(archive_path, config, listing),
}
}
fn list_tar(
archive_path: &Path,
codec: Option<CompressionCodec>,
format: ArchiveType,
config: &SecurityConfig<Validated>,
listing: &mut ListPass<'_, '_>,
) -> Result<ArchiveManifest> {
let stream = open_tar_stream(archive_path, codec, config.max_decoder_memory)?;
let (budgeted, budget) = budgeted_reader(stream, config.max_tar_metadata_bytes);
let archive = tar::Archive::new(budgeted);
let trailer = if codec.is_some() {
TarTrailer::DrainToDecoderEof
} else {
TarTrailer::Probe
};
list_tar_entries(archive, budget, format, config, listing, trailer)
}
fn list_tar_entries<R: std::io::Read>(
mut archive: tar::Archive<crate::formats::tar_metadata_limit::BudgetedReader<R>>,
budget: TarReadBudget,
format: ArchiveType,
config: &SecurityConfig<Validated>,
listing: &mut ListPass<'_, '_>,
trailer: TarTrailer,
) -> Result<ArchiveManifest> {
let list_mode = listing.mode();
let mut manifest = ArchiveManifest::new(format);
let mut quota = QuotaTracker::new();
let mut entries = budgeted_tar_entries(&mut archive, budget)
.map_err(|e| ArchiveError::InvalidArchive(format!("failed to read TAR entries: {e}")))?;
while let Some(entry_result) = entries.next_entry() {
let mut entry = entry_result.map_err(|e| {
budget_violation(&e).unwrap_or_else(|| {
ArchiveError::InvalidArchive(format!("failed to read TAR entry: {e}"))
})
})?;
if is_metadata_type(entry.header().entry_type()) {
continue;
}
let size = entry.size();
let _permit = reserve_entry(&mut quota, size, config, list_mode)?;
let path = entry
.path()
.map_err(|e| ArchiveError::InvalidArchive(format!("invalid path: {e}")))?
.into_owned();
if list_mode == ListMode::Strict && has_null_bytes(&path) {
return Err(ArchiveError::violation(format!(
"path contains null bytes: {}",
path.display()
)));
}
if contains_traversal(&path, config) {
return Err(ArchiveError::PathTraversal { path });
}
require_non_empty_name(&path)?;
let tar_type = effective_entry_type(&entry)?;
let kind = NodeKind::directory_if(tar_type == tar::EntryType::Directory);
if !admit_path(&path, kind, &mut manifest, listing)? {
continue;
}
let entry_type = convert_tar_entry_type(tar_type)?;
let mode = crate::formats::tar::tar_permission_bits(entry.header());
let modified =
entry.header().mtime().ok().and_then(|t| {
SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(t))
});
let target = match entry.header().entry_type() {
tar::EntryType::Symlink | tar::EntryType::Link => {
let target = entry
.link_name()
.map_err(|e| ArchiveError::InvalidArchive(format!("invalid link target: {e}")))?
.map(std::borrow::Cow::into_owned);
let is_symlink = entry.header().entry_type() == tar::EntryType::Symlink;
let kind = if is_symlink {
LinkKind::Symlink
} else {
LinkKind::Hardlink
};
match target {
Some(t) => classify_link_target(t, kind, list_mode)?,
None if list_mode == ListMode::Strict => {
return Err(ArchiveError::InvalidArchive(format!(
"{kind} missing target"
)));
}
None => LinkTarget::Missing,
}
}
_ => LinkTarget::Missing,
};
let archive_entry = ArchiveEntry {
path,
kind: manifest_entry_kind(entry_type, target),
size,
compression: EntryCompression::Unknown,
mode,
modified,
};
if let Some(pass) = listing.integrity() {
let slot = pass.begin_entry(size, &archive_entry.compression);
if entry_type == ManifestEntryType::File && size > 0 {
verify_tar_payload(pass, slot, &mut entry, &archive_entry.path)?;
}
}
manifest.add_entry(archive_entry)?;
}
drop(entries);
if let Some(pass) = listing.integrity() {
pass.check_tar_trailer(&mut archive.into_inner(), trailer)?;
}
Ok(manifest)
}
fn verify_tar_payload<R: Read>(
pass: &mut IntegrityPass<'_>,
slot: Slot,
entry: &mut TarEntryGuard<'_, '_, R>,
path: &Path,
) -> Result<()> {
let charge = if entry.header().entry_type() == tar::EntryType::GNUSparse {
Charge::Declared
} else {
Charge::Free
};
let declared = entry.size();
match pass.decode(slot, &mut **entry, declared, charge) {
DecodeOutcome::Failed(failure) => {
entry.abandon();
Err(failure.into_tar_error(path))
}
outcome => pass.record(slot, path, outcome),
}
}
fn list_zip(
archive_path: &Path,
config: &SecurityConfig<Validated>,
listing: &mut ListPass<'_, '_>,
) -> Result<ArchiveManifest> {
let file = File::open(archive_path)?;
ZipArchive::new(file)?.list_with(config, listing)
}
pub(crate) fn list_tar_reader<R: Read>(
reader: crate::formats::tar_metadata_limit::BudgetedReader<R>,
budget: TarReadBudget,
format: ArchiveType,
config: &SecurityConfig<Validated>,
listing: &mut ListPass<'_, '_>,
trailer: TarTrailer,
) -> Result<ArchiveManifest> {
let archive = tar::Archive::new(reader);
list_tar_entries(archive, budget, format, config, listing, trailer)
}
pub(crate) fn list_zip_reader<R: Read + Seek>(
archive: &mut ZipArchive<R>,
config: &SecurityConfig<Validated>,
listing: &mut ListPass<'_, '_>,
) -> Result<ArchiveManifest> {
let list_mode = listing.mode();
let mut manifest = ArchiveManifest::new(ArchiveType::Zip);
let mut quota = QuotaTracker::new();
let archive = archive.reader_mut();
for i in 0..archive.len() {
let mut entry = archive.by_index(i).map_err(|e| {
if is_password_required(&e) {
return encrypted_archive_error();
}
ArchiveError::InvalidArchive(format!("failed to read ZIP entry: {e}"))
})?;
let _permit = reserve_entry(&mut quota, entry.size(), config, list_mode)?;
let raw_name = entry
.name()
.map_err(|e| ArchiveError::InvalidArchive(format!("invalid entry name: {e}")))?
.into_owned();
let path = match list_mode {
ListMode::Strict => resolve_zip_entry_path(&raw_name, config)?,
ListMode::VerifyPreflight => {
parse_zip_entry_name(&raw_name, config)?.into_preflight_path()
}
};
let kind = zip_node_kind(&path, &entry)?;
if !admit_path(&path, kind, &mut manifest, listing)? {
continue;
}
let entry_type = convert_zip_entry_type(&entry, kind);
let size = entry.size();
let compression = EntryCompression::Entry(entry.compressed_size());
let mode = entry.unix_mode().map(|m| m & 0o7777);
#[expect(
clippy::cast_sign_loss,
reason = "the value is non-negative at this point"
)]
let modified = entry.last_modified().and_then(|dt| {
time::PrimitiveDateTime::try_from(dt).ok().and_then(|t| {
let timestamp = t.assume_utc().unix_timestamp().max(0) as u64;
SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(timestamp))
})
});
let mut tracked = listing.integrity().map(|pass| {
let slot = pass.begin_entry(size, &compression);
(pass, slot)
});
let has_payload = entry_type != ManifestEntryType::Symlink
&& (entry_type != ManifestEntryType::Directory || size > 0);
let mut descriptor_tail = None;
if has_payload && let Some((pass, slot)) = tracked.as_mut() {
let outcome = pass.decode(*slot, &mut entry, size, Charge::Declared);
if matches!(outcome, DecodeOutcome::Verified) {
descriptor_tail = DescriptorTail::of(&entry);
}
pass.record(*slot, &path, outcome)?;
}
drop(entry);
if let (Some(tail), Some((pass, slot))) = (descriptor_tail, tracked.as_mut()) {
tail.check(archive, i, pass, *slot, &path)?;
}
let target = if entry_type == ManifestEntryType::Symlink {
let mut sym_entry = archive.by_index(i).map_err(|e| {
ArchiveError::InvalidArchive(format!("failed to re-read ZIP entry {i}: {e}"))
})?;
let declared = sym_entry.size();
if list_mode == ListMode::VerifyPreflight && declared > MAX_SYMLINK_TARGET_BYTES as u64
{
LinkTarget::Oversized {
bytes: usize::try_from(declared).unwrap_or(usize::MAX),
}
} else {
let target = match tracked.as_mut() {
Some((pass, slot)) => {
read_zip_symlink_target_checked(&mut sym_entry, pass, *slot, &path)?
}
None => read_zip_symlink_target(&mut sym_entry)?,
};
classify_link_target(target, LinkKind::Symlink, list_mode)?
}
} else {
LinkTarget::Missing
};
let archive_entry = ArchiveEntry {
path,
kind: manifest_entry_kind(entry_type, target),
size,
compression,
mode,
modified,
};
manifest.add_entry(archive_entry)?;
}
Ok(manifest)
}
pub(crate) fn list_sevenz_reader<R: Read + Seek>(
mut source: R,
config: &SecurityConfig<Validated>,
listing: &mut ListPass<'_, '_>,
) -> Result<ArchiveManifest> {
let header = read_sevenz_header(&mut source)?;
list_sevenz_parsed(&header, source, config, listing)
}
pub(crate) fn list_sevenz_parsed<R: Read + Seek>(
header: &SevenZHeader,
mut source: R,
config: &SecurityConfig<Validated>,
listing: &mut ListPass<'_, '_>,
) -> Result<ArchiveManifest> {
let source_len = source.seek(SeekFrom::End(0))?;
let ListedSevenZ {
mut manifest,
slots,
compression,
links,
} = list_sevenz_archive(header, source_len, config, listing)?;
let list_mode = listing.mode();
let targets = SevenZTargets::of(listing, config.allowed.symlinks);
let settle_header_targets = |manifest: &mut ArchiveManifest| {
deliver_undecoded_links(header, links.indices(), &mut |index, outcome| {
record_link_target(manifest, &links, list_mode, index, outcome)
})
.map(drop)
};
match targets {
SevenZTargets::Leave(pass) => {
if let Some(pass) = pass {
verify_sevenz_blocks(header, &mut source, pass, &slots, None)?;
}
}
SevenZTargets::Free => settle_header_targets(&mut manifest)?,
SevenZTargets::Walk => walk_sevenz_links(
header,
&mut source,
&compression,
config,
&links,
&mut |index, outcome| {
record_link_target(&mut manifest, &links, list_mode, index, outcome)
},
)?,
SevenZTargets::Capture(pass) => {
settle_header_targets(&mut manifest)?;
let capture = LinkCapture {
links: &links,
set: &mut |position, target| manifest.set_link_target(position, target),
};
verify_sevenz_blocks(header, &mut source, pass, &slots, Some(capture))?;
}
}
Ok(manifest)
}
enum SevenZTargets<'p, 'c> {
Leave(Option<&'p mut IntegrityPass<'c>>),
Free,
Walk,
Capture(&'p mut IntegrityPass<'c>),
}
impl<'p, 'c> SevenZTargets<'p, 'c> {
fn of(listing: &'p mut ListPass<'_, 'c>, symlinks_allowed: bool) -> Self {
match listing {
ListPass::Strict(StoredTargets::Read) => Self::Walk,
ListPass::Strict(StoredTargets::Skip) => Self::Free,
ListPass::VerifyPreflight(pass) => match (pass.as_deref_mut(), symlinks_allowed) {
(pass, false) => Self::Leave(pass),
(None, true) => Self::Free,
(Some(pass), true) => Self::Capture(pass),
},
}
}
}
fn record_link_target(
manifest: &mut ArchiveManifest,
links: &LinkTable<EntryPosition>,
mode: ListMode,
index: usize,
outcome: LinkOutcome,
) -> Result<()> {
if let Some(&position) = links.get(index) {
manifest.set_link_target(position, link_target_of(outcome, mode)?);
}
Ok(())
}
fn link_target_of(outcome: LinkOutcome, mode: ListMode) -> Result<LinkTarget> {
match outcome {
Ok(StoredLinkTarget::Read(target)) => classify_link_target(target, LinkKind::Symlink, mode),
Ok(StoredLinkTarget::Oversized { bytes }) => match mode {
ListMode::Strict => Err(symlink_target_too_large(bytes)),
ListMode::VerifyPreflight => Ok(LinkTarget::Oversized {
bytes: usize::try_from(bytes).unwrap_or(usize::MAX),
}),
},
Err(_) => Ok(LinkTarget::Unreadable),
}
}
fn list_sevenz_archive(
header: &SevenZHeader,
source_len: u64,
config: &SecurityConfig<Validated>,
listing: &mut ListPass<'_, '_>,
) -> Result<ListedSevenZ> {
let list_mode = listing.mode();
let mut manifest = ArchiveManifest::new(ArchiveType::SevenZ);
let mut quota = QuotaTracker::new();
let compression = sevenz_entry_compression(header, source_len)?;
let mut slots = Vec::with_capacity(header.files().len());
let mut links = LinkTable::default();
for (index, (entry, compression)) in header
.files()
.iter()
.zip(compression.iter().copied())
.enumerate()
{
let Some(kind) = SevenZClass::of(entry).present(&entry.name)? else {
slots.push(None);
continue;
};
let _permit = reserve_entry(&mut quota, entry.size, config, list_mode)?;
let path = PathBuf::from(normalize_entry_name(&entry.name));
if contains_traversal(&path, config) {
return Err(ArchiveError::PathTraversal { path });
}
require_non_empty_name(&path)?;
let entry_type = kind.manifest_type();
if !admit_path(&path, kind.node_kind(), &mut manifest, listing)? {
slots.push(None);
continue;
}
let size = entry.size;
let modified = entry
.has_last_modified_date
.then(|| SystemTime::from(entry.last_modified_date));
slots.push(
listing
.integrity()
.map(|pass| pass.begin_entry(size, &compression)),
);
let target = if kind == SevenZKind::ReparsePoint {
LinkTarget::Unreadable
} else {
LinkTarget::Unread
};
let archive_entry = ArchiveEntry {
path,
kind: manifest_entry_kind(entry_type, target),
size,
compression,
mode: None,
modified,
};
if kind == SevenZKind::UnixSymlink {
links.push(index, manifest.next_position())?;
}
manifest.add_entry(archive_entry)?;
}
Ok(ListedSevenZ {
manifest,
slots,
compression,
links,
})
}
struct ListedSevenZ {
manifest: ArchiveManifest,
slots: Vec<Option<Slot>>,
compression: Vec<EntryCompression>,
links: LinkTable<EntryPosition>,
}
fn list_sevenz(
archive_path: &Path,
config: &SecurityConfig<Validated>,
listing: &mut ListPass<'_, '_>,
) -> Result<ArchiveManifest> {
let mut file = File::open(archive_path)?;
list_sevenz_reader(&mut file, config, listing)
}
fn admit_path(
path: &Path,
kind: NodeKind,
manifest: &mut ArchiveManifest,
listing: &mut ListPass<'_, '_>,
) -> Result<bool> {
EntryPath::of(path).require_directory_root(kind)?;
if check_name_lengths(path).is_ok() {
return Ok(true);
}
if listing.mode() == ListMode::Strict {
return Err(ArchiveError::Io(name_too_long()));
}
manifest.add_oversized(path.as_os_str().len());
if let Some(pass) = listing.integrity() {
pass.count_unverified(UnverifiedReason::PathTooLong, 1);
}
Ok(false)
}
fn manifest_entry_kind(entry_type: ManifestEntryType, target: LinkTarget) -> ManifestEntryKind {
match entry_type {
ManifestEntryType::File => ManifestEntryKind::File,
ManifestEntryType::Directory => ManifestEntryKind::Directory,
ManifestEntryType::Symlink => ManifestEntryKind::Symlink { target },
ManifestEntryType::Hardlink => ManifestEntryKind::Hardlink { target },
}
}
fn validate_link_target(target: &Path, kind: LinkKind, mode: ListMode) -> Result<()> {
if mode == ListMode::VerifyPreflight {
return Ok(());
}
if target.is_empty() {
return Err(ArchiveError::violation(format!("{kind} target is empty")));
}
if has_null_bytes(target) {
return Err(ArchiveError::violation(format!(
"{kind} target contains null bytes"
)));
}
Ok(())
}
fn classify_link_target(target: PathBuf, kind: LinkKind, mode: ListMode) -> Result<LinkTarget> {
validate_link_target(&target, kind, mode)?;
match LinkTarget::classify(target, kind) {
LinkTarget::Oversized { bytes } if mode == ListMode::Strict => Err(
ArchiveError::InvalidArchive(format!("{kind} target too long: {bytes} bytes")),
),
classified => Ok(classified),
}
}
fn contains_traversal(path: &Path, config: &SecurityConfig<Validated>) -> bool {
use std::path::Component;
path.components().any(|c| match c {
Component::ParentDir => true,
Component::Prefix(_) | Component::RootDir => !config.allowed.absolute_paths,
_ => false,
})
}
fn convert_tar_entry_type(tar_type: tar::EntryType) -> Result<ManifestEntryType> {
Ok(match tar_node(tar_type)? {
TarNode::File => ManifestEntryType::File,
TarNode::Directory => ManifestEntryType::Directory,
TarNode::Symlink => ManifestEntryType::Symlink,
TarNode::Hardlink => ManifestEntryType::Hardlink,
})
}
fn convert_zip_entry_type<R: std::io::Read + std::io::Seek>(
entry: &zip::read::ZipFile<'_, R>,
kind: NodeKind,
) -> ManifestEntryType {
match zip_entry_kind(kind == NodeKind::Directory, entry.unix_mode()) {
ZipEntryKind::Directory => ManifestEntryType::Directory,
ZipEntryKind::Symlink => ManifestEntryType::Symlink,
ZipEntryKind::Regular => ManifestEntryType::File,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::assert_matches;
use crate::OversizedEntry;
use crate::raw_zip::RawEntry;
use crate::raw_zip::RawZip;
use crate::raw_zip::extra_field;
use crate::test_utils::HOST_PATH_LIMIT;
use crate::test_utils::create_raw_zip_entry;
use crate::test_utils::path_of_len;
use crate::test_utils::tar_with_pax_linkpath;
use crate::test_utils::tar_with_pax_nul_path;
use flate2::write::GzEncoder;
use std::io::Write;
use tempfile::NamedTempFile;
fn tar_with_raw_path(path: &str) -> Vec<u8> {
let mut block = [0u8; 512];
let name_bytes = path.as_bytes();
debug_assert!(
name_bytes.len() <= 99,
"tar_with_raw_path: path exceeds POSIX TAR name field limit (99 bytes)"
);
let name_len = name_bytes.len().min(99);
block[..name_len].copy_from_slice(&name_bytes[..name_len]);
block[100..107].copy_from_slice(b"0000644");
block[108..115].copy_from_slice(b"0000000");
block[116..123].copy_from_slice(b"0000000");
block[124..135].copy_from_slice(b"00000000000");
block[136..147].copy_from_slice(b"00000000000");
block[156] = b'0';
block[148..156].copy_from_slice(b" ");
let checksum: u32 = block.iter().map(|&b| u32::from(b)).sum();
let checksum_str = format!("{checksum:06o}\0 ");
block[148..156].copy_from_slice(checksum_str.as_bytes());
let mut tar = block.to_vec();
tar.extend_from_slice(&[0u8; 1024]);
tar
}
fn zip_with_raw_unix_mode(filename: &str, unix_mode: u32) -> Vec<u8> {
let entry = RawEntry {
date: 0,
external_attributes: unix_mode << 16,
..RawEntry::stored(filename, b"")
};
RawZip::new(vec![entry]).build().bytes
}
#[test]
fn test_list_archive_empty_tar() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
let builder = tar::Builder::new(Vec::new());
let data = builder.into_inner().unwrap();
temp_file.write_all(&data).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let manifest = list_archive(temp_file.path(), &config).unwrap();
assert_eq!(manifest.total_entries(), 0);
assert_eq!(manifest.total_size(), 0);
assert_eq!(manifest.entries().len(), 0);
assert_eq!(manifest.format(), ArchiveType::Tar);
}
#[test]
fn test_list_archive_tar_gz() {
let mut temp_file = NamedTempFile::with_suffix(".tar.gz").unwrap();
let mut builder = tar::Builder::new(Vec::new());
let data = b"test content";
let mut header = tar::Header::new_gnu();
header.set_path("file.txt").unwrap();
header.set_size(data.len() as u64);
header.set_cksum();
builder.append(&header, &data[..]).unwrap();
let tar_data = builder.into_inner().unwrap();
let mut encoder = GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(&tar_data).unwrap();
let compressed = encoder.finish().unwrap();
temp_file.write_all(&compressed).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let manifest = list_archive(temp_file.path(), &config).unwrap();
assert_eq!(manifest.total_entries(), 1);
assert_eq!(manifest.format(), ArchiveType::TarGz);
assert_eq!(manifest.entries()[0].path, PathBuf::from("file.txt"));
assert_eq!(
manifest.entries()[0].kind.entry_type(),
ManifestEntryType::File
);
}
#[test]
fn test_list_archive_zip() {
let temp_file = NamedTempFile::with_suffix(".zip").unwrap();
let file = std::fs::File::create(temp_file.path()).unwrap();
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
zip.start_file("test.txt", options).unwrap();
zip.write_all(b"test content").unwrap();
zip.finish().unwrap();
let config = SecurityConfig::default();
let manifest = list_archive(temp_file.path(), &config).unwrap();
assert_eq!(manifest.total_entries(), 1);
assert_eq!(manifest.format(), ArchiveType::Zip);
assert_eq!(manifest.entries()[0].path, PathBuf::from("test.txt"));
assert_eq!(
manifest.entries()[0].kind.entry_type(),
ManifestEntryType::File
);
}
#[test]
fn test_list_archive_with_entries() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
let mut builder = tar::Builder::new(Vec::new());
let file1_data = b"content1";
let mut header1 = tar::Header::new_gnu();
header1.set_path("dir/file1.txt").unwrap();
header1.set_size(file1_data.len() as u64);
header1.set_cksum();
builder.append(&header1, &file1_data[..]).unwrap();
let file2_data = b"content2";
let mut header2 = tar::Header::new_gnu();
header2.set_path("dir/file2.txt").unwrap();
header2.set_size(file2_data.len() as u64);
header2.set_cksum();
builder.append(&header2, &file2_data[..]).unwrap();
let data = builder.into_inner().unwrap();
temp_file.write_all(&data).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let manifest = list_archive(temp_file.path(), &config).unwrap();
assert_eq!(manifest.total_entries(), 2);
assert_eq!(manifest.total_size(), 16);
assert_eq!(manifest.entries().len(), 2);
assert_eq!(manifest.entries()[0].path, PathBuf::from("dir/file1.txt"));
assert_eq!(manifest.entries()[1].path, PathBuf::from("dir/file2.txt"));
}
#[test]
fn test_list_archive_with_symlink() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
let mut builder = tar::Builder::new(Vec::new());
let mut header = tar::Header::new_gnu();
header.set_path("link").unwrap();
header.set_size(0);
header.set_entry_type(tar::EntryType::Symlink);
header.set_link_name("target.txt").unwrap();
header.set_cksum();
builder.append(&header, &[][..]).unwrap();
let data = builder.into_inner().unwrap();
temp_file.write_all(&data).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let manifest = list_archive(temp_file.path(), &config).unwrap();
assert_eq!(manifest.total_entries(), 1);
assert_eq!(
manifest.entries()[0].kind.entry_type(),
ManifestEntryType::Symlink
);
assert_eq!(
manifest.entries()[0].kind.symlink_target(),
Some(Path::new("target.txt"))
);
}
#[test]
#[cfg(unix)]
fn test_list_zip_symlink_target_is_actual_target() {
use crate::test_utils::create_zip_with_symlink;
use std::io::Cursor;
let zip_bytes = create_zip_with_symlink("link", "target.txt");
let mut archive = ZipArchive::new(Cursor::new(zip_bytes)).unwrap();
let config = SecurityConfig::default().validate().expect("valid config");
let manifest = list_zip_reader(&mut archive, &config, &mut ListPass::strict()).unwrap();
assert_eq!(manifest.total_entries(), 1);
assert_eq!(
manifest.entries()[0].kind.entry_type(),
ManifestEntryType::Symlink
);
assert_eq!(
manifest.entries()[0].kind.symlink_target(),
Some(Path::new("target.txt")),
"symlink_target must be the actual link target, not the entry path"
);
assert_eq!(
manifest.entries()[0].kind.hardlink_target(),
None,
"ZIP has no hardlink concept — hardlink_target must always be None"
);
}
#[test]
fn test_list_archive_with_hardlink() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
let mut builder = tar::Builder::new(Vec::new());
let mut header = tar::Header::new_gnu();
header.set_path("link").unwrap();
header.set_size(0);
header.set_entry_type(tar::EntryType::Link);
header.set_link_name("original.txt").unwrap();
header.set_cksum();
builder.append(&header, &[][..]).unwrap();
let data = builder.into_inner().unwrap();
temp_file.write_all(&data).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let manifest = list_archive(temp_file.path(), &config).unwrap();
assert_eq!(manifest.total_entries(), 1);
assert_eq!(
manifest.entries()[0].kind.entry_type(),
ManifestEntryType::Hardlink
);
assert_eq!(
manifest.entries()[0].kind.hardlink_target(),
Some(Path::new("original.txt"))
);
}
#[test]
fn test_list_archive_quota_exceeded() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
let mut builder = tar::Builder::new(Vec::new());
let data = b"test data";
let mut header = tar::Header::new_gnu();
header.set_path("file1.txt").unwrap();
header.set_size(data.len() as u64);
header.set_cksum();
builder.append(&header, &data[..]).unwrap();
let mut header2 = tar::Header::new_gnu();
header2.set_path("file2.txt").unwrap();
header2.set_size(data.len() as u64);
header2.set_cksum();
builder.append(&header2, &data[..]).unwrap();
let archive_data = builder.into_inner().unwrap();
temp_file.write_all(&archive_data).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default().with_max_file_count(1);
let result = list_archive(temp_file.path(), &config);
match result {
Err(ArchiveError::QuotaExceeded {
resource: QuotaResource::FileCount { current, max },
}) => {
assert_eq!(max, 1);
assert_eq!(current, 2, "current must be max+1 (off-by-one fix)");
}
other => panic!("expected FileCount quota error, got {other:?}"),
}
}
#[test]
fn test_list_zip_quota_exceeded_file_count() {
let temp_file = NamedTempFile::with_suffix(".zip").unwrap();
let file = std::fs::File::create(temp_file.path()).unwrap();
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
zip.start_file("a.txt", options).unwrap();
zip.start_file("b.txt", options).unwrap();
zip.finish().unwrap();
let config = SecurityConfig::default().with_max_file_count(1);
let result = list_archive(temp_file.path(), &config);
match result {
Err(ArchiveError::QuotaExceeded {
resource: QuotaResource::FileCount { current, max },
}) => {
assert_eq!(max, 1);
assert_eq!(current, 2, "current must be max+1 (off-by-one fix)");
}
other => panic!("expected FileCount quota error, got {other:?}"),
}
}
#[test]
fn test_zip_mode_strips_s_ifreg_bits() {
let zip_bytes = zip_with_raw_unix_mode("file.txt", 0o100_644);
let mut temp_file = NamedTempFile::with_suffix(".zip").unwrap();
temp_file.write_all(&zip_bytes).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let manifest = list_archive(temp_file.path(), &config).unwrap();
assert_eq!(manifest.total_entries(), 1);
assert_eq!(manifest.entries()[0].mode, Some(0o644));
}
#[test]
fn test_zip_mode_strips_s_ifdir_bits() {
let zip_bytes = zip_with_raw_unix_mode("mydir/", 0o040_755);
let mut temp_file = NamedTempFile::with_suffix(".zip").unwrap();
temp_file.write_all(&zip_bytes).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let manifest = list_archive(temp_file.path(), &config).unwrap();
assert_eq!(manifest.total_entries(), 1);
assert_eq!(manifest.entries()[0].mode, Some(0o755));
}
#[test]
fn test_zip_mode_permission_bits_unchanged() {
let zip_bytes = zip_with_raw_unix_mode("file.txt", 0o644);
let mut temp_file = NamedTempFile::with_suffix(".zip").unwrap();
temp_file.write_all(&zip_bytes).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let manifest = list_archive(temp_file.path(), &config).unwrap();
assert_eq!(manifest.total_entries(), 1);
assert_eq!(manifest.entries()[0].mode, Some(0o644));
}
#[test]
fn test_list_zip_encrypted_returns_security_violation() {
use std::io::Cursor;
use zip::ZipWriter;
use zip::unstable::write::FileOptionsExt;
use zip::write::SimpleFileOptions;
let mut writer = ZipWriter::new(Cursor::new(Vec::new()));
let options = SimpleFileOptions::default()
.with_deprecated_encryption(b"password")
.unwrap();
writer.start_file("secret.txt", options).unwrap();
writer.write_all(b"secret data").unwrap();
let zip_data = writer.finish().unwrap().into_inner();
let mut temp_file = NamedTempFile::with_suffix(".zip").unwrap();
temp_file.write_all(&zip_data).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
match result {
Err(ArchiveError::SecurityViolation { reason, .. }) => {
assert!(
reason.contains("password-protected"),
"expected 'password-protected' in reason: {reason}"
);
}
other => panic!("expected SecurityViolation, got: {other:?}"),
}
}
#[test]
fn test_list_archive_tar_bz2() {
use bzip2::Compression as BzCompression;
use bzip2::write::BzEncoder;
use std::io::Write;
let mut temp_file = NamedTempFile::with_suffix(".tar.bz2").unwrap();
let mut builder = tar::Builder::new(Vec::new());
let data = b"bzip2 content";
let mut header = tar::Header::new_gnu();
header.set_path("file.txt").unwrap();
header.set_size(data.len() as u64);
header.set_cksum();
builder.append(&header, &data[..]).unwrap();
let tar_data = builder.into_inner().unwrap();
let mut encoder = BzEncoder::new(Vec::new(), BzCompression::default());
encoder.write_all(&tar_data).unwrap();
let compressed = encoder.finish().unwrap();
temp_file.write_all(&compressed).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let manifest = list_archive(temp_file.path(), &config).unwrap();
assert_eq!(manifest.total_entries(), 1);
assert_eq!(manifest.format(), ArchiveType::TarBz2);
assert_eq!(manifest.entries()[0].path, PathBuf::from("file.txt"));
}
#[test]
fn test_list_archive_tar_xz() {
use std::io::Write;
use xz2::write::XzEncoder;
let mut temp_file = NamedTempFile::with_suffix(".tar.xz").unwrap();
let mut builder = tar::Builder::new(Vec::new());
let data = b"xz content";
let mut header = tar::Header::new_gnu();
header.set_path("file.txt").unwrap();
header.set_size(data.len() as u64);
header.set_cksum();
builder.append(&header, &data[..]).unwrap();
let tar_data = builder.into_inner().unwrap();
let mut encoder = XzEncoder::new(Vec::new(), 1);
encoder.write_all(&tar_data).unwrap();
let compressed = encoder.finish().unwrap();
temp_file.write_all(&compressed).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let manifest = list_archive(temp_file.path(), &config).unwrap();
assert_eq!(manifest.total_entries(), 1);
assert_eq!(manifest.format(), ArchiveType::TarXz);
}
#[test]
fn test_list_archive_tar_zst() {
use std::io::Write;
use zstd::stream::write::Encoder as ZstdEncoder;
let mut temp_file = NamedTempFile::with_suffix(".tar.zst").unwrap();
let mut builder = tar::Builder::new(Vec::new());
let data = b"zstd content";
let mut header = tar::Header::new_gnu();
header.set_path("file.txt").unwrap();
header.set_size(data.len() as u64);
header.set_cksum();
builder.append(&header, &data[..]).unwrap();
let tar_data = builder.into_inner().unwrap();
let mut encoder = ZstdEncoder::new(Vec::new(), 1).unwrap();
encoder.write_all(&tar_data).unwrap();
let compressed = encoder.finish().unwrap();
temp_file.write_all(&compressed).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let manifest = list_archive(temp_file.path(), &config).unwrap();
assert_eq!(manifest.total_entries(), 1);
assert_eq!(manifest.format(), ArchiveType::TarZst);
}
#[test]
fn test_list_sevenz_corrupt() {
let mut temp_file = NamedTempFile::with_suffix(".7z").unwrap();
temp_file.write_all(b"7z\xbc\xaf\x27\x1c\x00\x04").unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
assert_matches!(
result,
Err(ArchiveError::InvalidArchive(_)),
"expected InvalidArchive for truncated 7z, got: {result:?}"
);
}
#[test]
fn test_list_tar_special_file_rejected() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
let mut builder = tar::Builder::new(Vec::new());
let mut header = tar::Header::new_gnu();
header.set_path("chardev").unwrap();
header.set_size(0);
header.set_entry_type(tar::EntryType::Char);
header.set_cksum();
builder.append(&header, &[][..]).unwrap();
let data = builder.into_inner().unwrap();
temp_file.write_all(&data).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
assert_matches!(
result,
Err(ArchiveError::SecurityViolation { .. }),
"expected SecurityViolation for char device entry, got: {result:?}"
);
}
#[test]
fn test_list_zip_quota_exceeded_total_size() {
let temp_file = NamedTempFile::with_suffix(".zip").unwrap();
let file = std::fs::File::create(temp_file.path()).unwrap();
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
zip.start_file("big.bin", options).unwrap();
zip.write_all(&vec![0u8; 600]).unwrap();
zip.finish().unwrap();
let config = SecurityConfig::default().with_max_total_size(100);
let result = list_archive(temp_file.path(), &config);
assert_matches!(
result,
Err(ArchiveError::QuotaExceeded {
resource: crate::error::QuotaResource::TotalSize { .. }
}),
"expected TotalSize quota error, got: {result:?}"
);
}
#[test]
fn test_list_tar_quota_exceeded_total_size() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
let mut builder = tar::Builder::new(Vec::new());
let data = vec![0u8; 600];
let mut header = tar::Header::new_gnu();
header.set_path("big.bin").unwrap();
header.set_size(data.len() as u64);
header.set_cksum();
builder.append(&header, &data[..]).unwrap();
let archive_data = builder.into_inner().unwrap();
temp_file.write_all(&archive_data).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default().with_max_total_size(100);
let result = list_archive(temp_file.path(), &config);
assert_matches!(
result,
Err(ArchiveError::QuotaExceeded {
resource: crate::error::QuotaResource::TotalSize { .. }
}),
"expected TotalSize quota error, got: {result:?}"
);
}
#[test]
fn test_list_zip_path_traversal() {
let temp_file = NamedTempFile::with_suffix(".zip").unwrap();
let file = std::fs::File::create(temp_file.path()).unwrap();
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
zip.start_file("../etc/passwd", options).unwrap();
zip.write_all(b"malicious").unwrap();
zip.finish().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
assert_matches!(
result,
Err(ArchiveError::PathTraversal { .. }),
"expected PathTraversal error, got: {result:?}"
);
}
#[test]
fn test_list_tar_path_traversal_dotdot() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
temp_file
.write_all(&tar_with_raw_path("../escape.txt"))
.unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
assert_matches!(
&result,
Err(ArchiveError::PathTraversal { path }) if path == &PathBuf::from("../escape.txt"),
"expected PathTraversal {{ path: ../escape.txt }}, got: {result:?}"
);
}
#[test]
fn test_list_tar_path_traversal_nested() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
temp_file
.write_all(&tar_with_raw_path("foo/../../escape.txt"))
.unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
assert_matches!(
result,
Err(ArchiveError::PathTraversal { .. }),
"expected PathTraversal error for nested ../ in TAR, got: {result:?}"
);
}
#[test]
fn test_list_tar_path_traversal_absolute() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
temp_file
.write_all(&tar_with_raw_path("/etc/passwd"))
.unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
assert_matches!(
result,
Err(ArchiveError::PathTraversal { .. }),
"expected PathTraversal error for absolute path in TAR, got: {result:?}"
);
}
#[test]
fn test_list_tar_absolute_path_allowed_when_flag_set() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
temp_file
.write_all(&tar_with_raw_path("/etc/passwd"))
.unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default().with_allow_absolute_paths(true);
let result = list_archive(temp_file.path(), &config);
assert!(
result.is_ok(),
"list_archive must accept absolute paths when allow_absolute_paths=true, got: {result:?}"
);
let manifest = result.unwrap();
assert_eq!(manifest.total_entries(), 1);
}
#[test]
fn test_list_sevenz_absolute_path_allowed_when_flag_set() {
let archive_bytes = make_sevenz_archive(&[("/absolute/path.txt", b"data")]);
let mut temp_file = NamedTempFile::with_suffix(".7z").unwrap();
temp_file.write_all(&archive_bytes).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default().with_allow_absolute_paths(true);
let result = list_archive(temp_file.path(), &config);
assert!(
result.is_ok(),
"list_archive must accept absolute paths in 7z when allow_absolute_paths=true, got: {result:?}"
);
}
#[test]
fn test_contains_traversal_prefix_gated_by_flag() {
let absolute = PathBuf::from("/absolute/path.txt");
let config_deny = SecurityConfig::default().validate().expect("valid config"); assert!(
contains_traversal(&absolute, &config_deny),
"absolute path must be rejected when allow_absolute_paths=false"
);
let config_allow = SecurityConfig::default()
.with_allow_absolute_paths(true)
.validate()
.expect("valid config");
assert!(
!contains_traversal(&absolute, &config_allow),
"absolute path must be accepted when allow_absolute_paths=true"
);
let dotdot = PathBuf::from("../escape.txt");
assert!(
contains_traversal(&dotdot, &config_allow),
"ParentDir must be rejected even when allow_absolute_paths=true"
);
}
#[test]
fn test_list_tar_gz_path_traversal() {
let mut temp_file = NamedTempFile::with_suffix(".tar.gz").unwrap();
let tar_data = tar_with_raw_path("../escape.txt");
let mut encoder = GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(&tar_data).unwrap();
let compressed = encoder.finish().unwrap();
temp_file.write_all(&compressed).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
assert_matches!(
result,
Err(ArchiveError::PathTraversal { .. }),
"expected PathTraversal error for tar.gz with ../ entry, got: {result:?}"
);
}
#[test]
fn test_list_tar_path_traversal_mixed_entries_fail_fast() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
let mut builder = tar::Builder::new(Vec::new());
let safe_data = b"safe content";
let mut header = tar::Header::new_gnu();
header.set_path("hello.txt").unwrap();
header.set_size(safe_data.len() as u64);
header.set_cksum();
builder.append(&header, &safe_data[..]).unwrap();
let safe_tar = builder.into_inner().unwrap();
let eof_marker_len = 1024;
let tar_body = &safe_tar[..safe_tar.len() - eof_marker_len];
let traversal_entry = tar_with_raw_path("../escape.txt");
let mut combined = tar_body.to_vec();
combined.extend_from_slice(&traversal_entry);
temp_file.write_all(&combined).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
assert_matches!(
result,
Err(ArchiveError::PathTraversal { .. }),
"expected PathTraversal error for mixed-entry TAR, got: {result:?}"
);
}
#[test]
fn test_list_zip_by_index_encrypted_error_path() {
use std::io::Write;
use zip::ZipWriter;
use zip::unstable::write::FileOptionsExt;
use zip::write::SimpleFileOptions;
let mut writer = ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = SimpleFileOptions::default()
.with_deprecated_encryption(b"password")
.unwrap();
writer.start_file("secret.txt", options).unwrap();
writer.write_all(b"secret data").unwrap();
let zip_data = writer.finish().unwrap().into_inner();
let mut temp_file = NamedTempFile::with_suffix(".zip").unwrap();
temp_file.write_all(&zip_data).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
assert_matches!(
result,
Err(ArchiveError::SecurityViolation { .. }),
"expected SecurityViolation for encrypted ZIP in list, got: {result:?}"
);
}
#[test]
fn test_tar_and_zip_mode_consistent() {
let tar_file = NamedTempFile::with_suffix(".tar").unwrap();
{
let mut builder = tar::Builder::new(std::fs::File::create(tar_file.path()).unwrap());
let data = b"content";
let mut header = tar::Header::new_gnu();
header.set_path("file.txt").unwrap();
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append(&header, &data[..]).unwrap();
builder.into_inner().unwrap();
}
let zip_bytes = zip_with_raw_unix_mode("file.txt", 0o100_644);
let zip_file = NamedTempFile::with_suffix(".zip").unwrap();
{
let mut f = std::fs::File::create(zip_file.path()).unwrap();
f.write_all(&zip_bytes).unwrap();
}
let config = SecurityConfig::default();
let tar_manifest = list_archive(tar_file.path(), &config).unwrap();
let zip_manifest = list_archive(zip_file.path(), &config).unwrap();
assert_eq!(
tar_manifest.entries()[0].mode,
zip_manifest.entries()[0].mode
);
assert_eq!(tar_manifest.entries()[0].mode, Some(0o644));
}
#[test]
fn test_list_sevenz_simple() {
let path = std::path::Path::new("../../tests/fixtures/simple.7z");
let config = SecurityConfig::default();
let manifest = list_archive(path, &config).unwrap();
assert!(
manifest.total_entries() > 0,
"simple.7z should have entries"
);
for entry in manifest.entries() {
assert!(!entry.path.is_empty(), "entry path should not be empty");
}
}
#[test]
fn test_list_sevenz_empty() {
let path = std::path::Path::new("../../tests/fixtures/empty.7z");
let config = SecurityConfig::default();
let manifest = list_archive(path, &config).unwrap();
assert_eq!(
manifest.total_entries(),
0,
"empty.7z should have no entries"
);
assert_eq!(
manifest.total_size(),
0,
"empty.7z should have zero total size"
);
}
#[test]
fn test_list_sevenz_nested_dirs() {
let path = std::path::Path::new("../../tests/fixtures/nested-dirs.7z");
let config = SecurityConfig::default();
let manifest = list_archive(path, &config).unwrap();
assert!(manifest.total_entries() > 0);
let has_dir = manifest
.entries()
.iter()
.any(|e| e.kind.entry_type() == ManifestEntryType::Directory);
assert!(has_dir, "nested-dirs.7z should contain directory entries");
}
#[test]
fn test_list_sevenz_encrypted_rejected() {
let path = std::path::Path::new("../../tests/fixtures/encrypted.7z");
let config = SecurityConfig::default();
let result = list_archive(path, &config);
assert!(result.is_err(), "encrypted archive should return an error");
let err_str = result.unwrap_err().to_string().to_lowercase();
assert!(
err_str.contains("encrypt") || err_str.contains("password"),
"error should mention encryption, got: {err_str}"
);
}
#[test]
fn test_list_sevenz_solid() {
let path = std::path::Path::new("../../tests/fixtures/solid.7z");
let config = SecurityConfig::default();
let manifest = list_archive(path, &config).unwrap();
assert!(manifest.total_entries() > 0, "solid.7z should have entries");
}
#[test]
fn test_verify_sevenz_simple() {
let path = std::path::Path::new("../../tests/fixtures/simple.7z");
let config = SecurityConfig::default();
let report = crate::verify_archive(path, &config).unwrap();
assert_ne!(
report.status(),
crate::inspection::report::VerificationStatus::Fail,
"simple.7z should not fail verification"
);
}
#[test]
fn test_list_sevenz_format_field() {
let path = std::path::Path::new("../../tests/fixtures/simple.7z");
let config = SecurityConfig::default();
let manifest = list_archive(path, &config).unwrap();
assert_eq!(
manifest.format(),
crate::formats::detect::ArchiveType::SevenZ,
"manifest format should be SevenZ"
);
}
fn make_sevenz_archive(entries: &[(&str, &[u8])]) -> Vec<u8> {
use sevenz_rust2::ArchiveEntry;
use sevenz_rust2::ArchiveWriter;
use sevenz_rust2::EncoderConfiguration;
use sevenz_rust2::EncoderMethod;
use std::io::Cursor;
let buf = Cursor::new(Vec::new());
let mut writer = ArchiveWriter::new(buf).unwrap();
writer.set_content_methods(vec![EncoderConfiguration::new(EncoderMethod::COPY)]);
for (name, data) in entries {
let mut entry = ArchiveEntry::new_file(name);
entry.has_stream = true;
entry.size = data.len() as u64;
writer
.push_archive_entry(entry, Some(std::io::Cursor::new(*data)))
.unwrap();
}
writer.finish().unwrap().into_inner()
}
#[test]
fn test_list_sevenz_path_traversal_dotdot() {
let archive_bytes = make_sevenz_archive(&[("../escape.txt", b"evil")]);
let mut temp_file = NamedTempFile::with_suffix(".7z").unwrap();
temp_file.write_all(&archive_bytes).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
assert_matches!(
result,
Err(ArchiveError::PathTraversal { .. }),
"expected PathTraversal error for ../ entry, got: {result:?}"
);
}
#[test]
fn test_list_sevenz_quota_file_count() {
let archive_bytes =
make_sevenz_archive(&[("a.txt", b"a"), ("b.txt", b"b"), ("c.txt", b"c")]);
let mut temp_file = NamedTempFile::with_suffix(".7z").unwrap();
temp_file.write_all(&archive_bytes).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default().with_max_file_count(2);
let result = list_archive(temp_file.path(), &config);
assert_matches!(
result,
Err(ArchiveError::QuotaExceeded {
resource: crate::error::QuotaResource::FileCount { .. }
}),
"expected FileCount QuotaExceeded, got: {result:?}"
);
}
#[test]
fn test_list_sevenz_quota_total_size() {
let data = vec![0u8; 100];
let archive_bytes = make_sevenz_archive(&[("big.bin", &data)]);
let mut temp_file = NamedTempFile::with_suffix(".7z").unwrap();
temp_file.write_all(&archive_bytes).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default().with_max_total_size(50);
let result = list_archive(temp_file.path(), &config);
assert_matches!(
result,
Err(ArchiveError::QuotaExceeded {
resource: crate::error::QuotaResource::TotalSize { .. }
}),
"expected TotalSize QuotaExceeded, got: {result:?}"
);
}
#[test]
fn test_list_sevenz_hardlink_reported_as_file() {
let path = std::path::Path::new("../../tests/fixtures/hardlink.7z");
let config = SecurityConfig::default();
let manifest = list_archive(path, &config).unwrap();
assert!(
manifest.total_entries() > 0,
"hardlink.7z should have entries"
);
for entry in manifest.entries() {
assert_ne!(
entry.kind.entry_type(),
ManifestEntryType::Hardlink,
"7z format never produces Hardlink entries (no API support)"
);
}
}
#[test]
fn test_list_sevenz_solid_compressed_size() {
let path = std::path::Path::new("../../tests/fixtures/solid.7z");
let config = SecurityConfig::default();
let manifest = list_archive(path, &config).unwrap();
for entry in manifest.entries() {
if entry.size > 0 {
assert_ne!(
entry.compressed_size(),
Some(0),
"entry {:?} has compressed_size=Some(0) with size={}, which is a false positive trigger",
entry.path,
entry.size
);
}
}
}
#[test]
fn test_list_archive_custom_max_total_size_honoured_tar() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
let mut builder = tar::Builder::new(Vec::new());
let data = vec![0u8; 600];
let mut header = tar::Header::new_gnu();
header.set_path("file.bin").unwrap();
header.set_size(data.len() as u64);
header.set_cksum();
builder.append(&header, &data[..]).unwrap();
temp_file.write_all(&builder.into_inner().unwrap()).unwrap();
temp_file.flush().unwrap();
let small = SecurityConfig::default().with_max_total_size(100);
assert_matches!(
list_archive(temp_file.path(), &small),
Err(ArchiveError::QuotaExceeded {
resource: QuotaResource::TotalSize { .. }
}),
"expected TotalSize quota error with limit=100"
);
let large = SecurityConfig::default().with_max_total_size(1024 * 1024 * 1024);
assert!(
list_archive(temp_file.path(), &large).is_ok(),
"list_archive must succeed when caller supplies a large max_total_size (regression #166)"
);
}
#[test]
fn test_list_archive_custom_max_total_size_honoured_zip() {
let temp_file = NamedTempFile::with_suffix(".zip").unwrap();
{
let file = std::fs::File::create(temp_file.path()).unwrap();
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
zip.start_file("file.bin", options).unwrap();
zip.write_all(&vec![0u8; 600]).unwrap();
zip.finish().unwrap();
}
let small = SecurityConfig::default().with_max_total_size(100);
assert_matches!(
list_archive(temp_file.path(), &small),
Err(ArchiveError::QuotaExceeded {
resource: QuotaResource::TotalSize { .. }
}),
"expected TotalSize quota error with limit=100"
);
let large = SecurityConfig::default().with_max_total_size(1024 * 1024 * 1024);
assert!(
list_archive(temp_file.path(), &large).is_ok(),
"list_archive must succeed when caller supplies a large max_total_size (regression #166)"
);
}
#[test]
fn test_list_archive_custom_max_total_size_honoured_sevenz() {
let data = vec![0u8; 600];
let archive_bytes = make_sevenz_archive(&[("file.bin", &data)]);
let mut temp_file = NamedTempFile::with_suffix(".7z").unwrap();
temp_file.write_all(&archive_bytes).unwrap();
temp_file.flush().unwrap();
let small = SecurityConfig::default().with_max_total_size(100);
assert_matches!(
list_archive(temp_file.path(), &small),
Err(ArchiveError::QuotaExceeded {
resource: QuotaResource::TotalSize { .. }
}),
"expected TotalSize quota error with limit=100"
);
let large = SecurityConfig::default().with_max_total_size(1024 * 1024 * 1024);
assert!(
list_archive(temp_file.path(), &large).is_ok(),
"list_archive must succeed when caller supplies a large max_total_size (regression #166)"
);
}
#[test]
fn test_list_sevenz_regular_mode_entry_reported_as_file() {
let path = std::path::Path::new("../../tests/fixtures/symlink-unix.7z");
let config = SecurityConfig::default();
let manifest = list_archive(path, &config).unwrap();
assert!(
manifest.total_entries() > 0,
"symlink-unix.7z should have entries"
);
for entry in manifest.entries() {
assert_ne!(
entry.kind.entry_type(),
ManifestEntryType::Symlink,
"regular-mode entries are reported as File: {}",
entry.path.display()
);
}
}
#[test]
fn test_verify_sevenz_solid_no_false_positive() {
use crate::inspection::report::IssueSeverity;
let path = std::path::Path::new("../../tests/fixtures/solid.7z");
let config = SecurityConfig::default().with_allow_solid_archives(true);
let report = crate::verify_archive(path, &config).unwrap();
assert_ne!(
report.status(),
crate::inspection::report::VerificationStatus::Fail,
"solid.7z should not fail verification when solid archives are allowed"
);
let has_false_positive = report.issues().iter().any(|issue| {
issue.severity == IssueSeverity::High
&& issue.message.to_lowercase().contains("compressed_size")
});
assert!(
!has_false_positive,
"solid.7z verification should not produce High-severity compressed_size issue"
);
}
#[test]
fn list_archive_rejects_invalid_security_config() {
let config = SecurityConfig::default().with_max_file_size(0);
let result = crate::list_archive("any.tar.gz", &config);
assert_matches!(
result,
Err(crate::ArchiveError::InvalidConfiguration { .. }),
"list_archive must return InvalidConfiguration for zero max_file_size"
);
}
#[test]
fn test_list_zip_absolute_path_rejected_by_default() {
let zip_bytes = create_raw_zip_entry("/etc/passwd", b"");
let mut temp_file = NamedTempFile::with_suffix(".zip").unwrap();
temp_file.write_all(&zip_bytes).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
assert_matches!(
result,
Err(ArchiveError::PathTraversal { .. }),
"absolute ZIP name must be rejected by default like TAR, got: {result:?}"
);
}
#[test]
fn test_list_zip_absolute_path_allowed_when_configured() {
let zip_bytes = create_raw_zip_entry("/etc/passwd", b"");
let mut temp_file = NamedTempFile::with_suffix(".zip").unwrap();
temp_file.write_all(&zip_bytes).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default().with_allow_absolute_paths(true);
let manifest = list_archive(temp_file.path(), &config).unwrap();
assert_eq!(manifest.total_entries(), 1);
assert_eq!(manifest.entries()[0].path, PathBuf::from("etc/passwd"));
}
#[test]
fn test_list_zip_traversal_absolute_path_rejected_by_default() {
let zip_bytes = create_raw_zip_entry("/../etc/passwd", b"");
let mut temp_file = NamedTempFile::with_suffix(".zip").unwrap();
temp_file.write_all(&zip_bytes).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
assert_matches!(
result,
Err(ArchiveError::PathTraversal { .. }),
"absolute path with traversal must be rejected by default, got: {result:?}"
);
}
#[test]
fn test_list_zip_traversal_absolute_path_still_rejected_when_allowed() {
let zip_bytes = create_raw_zip_entry("/../etc/passwd", b"");
let mut temp_file = NamedTempFile::with_suffix(".zip").unwrap();
temp_file.write_all(&zip_bytes).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default().with_allow_absolute_paths(true);
let result = list_archive(temp_file.path(), &config);
assert_matches!(
result,
Err(ArchiveError::PathTraversal { .. }),
"traversal inside absolute path must still be rejected even when allowed, got: {result:?}"
);
}
#[test]
fn test_list_zip_absolute_path_with_traversal_still_rejected() {
let zip_bytes = create_raw_zip_entry("/foo/../../../etc/passwd", b"");
let mut temp_file = NamedTempFile::with_suffix(".zip").unwrap();
temp_file.write_all(&zip_bytes).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default().with_allow_absolute_paths(true);
let result = list_archive(temp_file.path(), &config);
assert_matches!(
result,
Err(ArchiveError::PathTraversal { .. }),
"traversal inside absolute path must still be rejected, got: {result:?}"
);
}
#[test]
fn test_list_zip_parent_glued_to_prefix_rejected_with_allow_absolute_paths() {
for name in ["\\\\?\\C:..\\x", "/\\?\\C:.."] {
let zip_bytes = create_raw_zip_entry(name, b"");
let mut temp_file = NamedTempFile::with_suffix(".zip").unwrap();
temp_file.write_all(&zip_bytes).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default().with_allow_absolute_paths(true);
let result = list_archive(temp_file.path(), &config);
assert_matches!(result, Err(ArchiveError::PathTraversal { .. }), "{name:?}");
}
}
#[test]
fn test_list_tar_pax_path_nul_byte_rejected() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
temp_file
.write_all(&tar_with_pax_nul_path(b"foo\0bar.txt", b"hello"))
.unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
match result {
Err(ArchiveError::SecurityViolation { reason, .. }) => {
assert!(reason.contains("null bytes"), "reason: {reason}");
}
other => {
panic!("expected SecurityViolation for NUL byte in PAX path, got: {other:?}")
}
}
}
#[test]
fn test_list_zip_nul_byte_rejected_as_security_violation_by_default() {
let zip_bytes = create_raw_zip_entry("foo\0bar.txt", b"");
let mut temp_file = NamedTempFile::with_suffix(".zip").unwrap();
temp_file.write_all(&zip_bytes).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
match result {
Err(ArchiveError::SecurityViolation { reason, .. }) => {
assert!(reason.contains("null bytes"), "reason: {reason}");
}
other => {
panic!("expected SecurityViolation for NUL byte in ZIP filename, got: {other:?}")
}
}
}
#[test]
fn test_list_zip_nul_byte_rejected_with_allow_absolute_paths() {
let zip_bytes = create_raw_zip_entry("foo\0bar.txt", b"");
let mut temp_file = NamedTempFile::with_suffix(".zip").unwrap();
temp_file.write_all(&zip_bytes).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default().with_allow_absolute_paths(true);
let result = list_archive(temp_file.path(), &config);
match result {
Err(ArchiveError::SecurityViolation { reason, .. }) => {
assert!(reason.contains("null bytes"), "reason: {reason}");
}
other => panic!(
"expected SecurityViolation for NUL byte in ZIP filename with allow_absolute_paths, got: {other:?}"
),
}
}
#[test]
fn test_list_tar_symlink_target_nul_byte_rejected() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
temp_file
.write_all(&tar_with_pax_linkpath(b"foo\0bar", b'2'))
.unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
match result {
Err(ArchiveError::SecurityViolation { reason, .. }) => {
assert_eq!(reason, "symlink target contains null bytes");
}
other => {
panic!("expected SecurityViolation for NUL byte in symlink target, got: {other:?}")
}
}
}
#[test]
fn test_list_tar_symlink_target_empty_rejected() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
temp_file
.write_all(&tar_with_pax_linkpath(b"", b'2'))
.unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
match result {
Err(ArchiveError::SecurityViolation { reason, .. }) => {
assert_eq!(reason, "symlink target is empty");
}
other => panic!("expected SecurityViolation for empty symlink target, got: {other:?}"),
}
}
#[test]
fn test_list_tar_hardlink_target_nul_byte_rejected() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
temp_file
.write_all(&tar_with_pax_linkpath(b"foo\0bar", b'1'))
.unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
match result {
Err(ArchiveError::SecurityViolation { reason, .. }) => {
assert_eq!(reason, "hardlink target contains null bytes");
}
other => {
panic!("expected SecurityViolation for NUL byte in hardlink target, got: {other:?}")
}
}
}
#[test]
fn test_list_tar_hardlink_target_empty_rejected() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
temp_file
.write_all(&tar_with_pax_linkpath(b"", b'1'))
.unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
match result {
Err(ArchiveError::SecurityViolation { reason, .. }) => {
assert_eq!(reason, "hardlink target is empty");
}
other => panic!("expected SecurityViolation for empty hardlink target, got: {other:?}"),
}
}
#[test]
#[cfg(unix)]
fn test_list_zip_symlink_target_nul_byte_rejected() {
use crate::test_utils::create_zip_with_symlink;
use std::io::Cursor;
let zip_bytes = create_zip_with_symlink("link", "foo\0bar");
let mut archive = ZipArchive::new(Cursor::new(zip_bytes)).unwrap();
let config = SecurityConfig::default().validate().expect("valid config");
let result = list_zip_reader(&mut archive, &config, &mut ListPass::strict());
match result {
Err(ArchiveError::SecurityViolation { reason, .. }) => {
assert_eq!(reason, "symlink target contains null bytes");
}
other => panic!(
"expected SecurityViolation for NUL byte in ZIP symlink target, got: {other:?}"
),
}
}
#[test]
#[cfg(unix)]
fn test_list_zip_symlink_target_empty_rejected() {
use crate::test_utils::create_zip_with_symlink;
use std::io::Cursor;
let zip_bytes = create_zip_with_symlink("link", "");
let mut archive = ZipArchive::new(Cursor::new(zip_bytes)).unwrap();
let config = SecurityConfig::default().validate().expect("valid config");
let result = list_zip_reader(&mut archive, &config, &mut ListPass::strict());
match result {
Err(ArchiveError::SecurityViolation { reason, .. }) => {
assert_eq!(reason, "symlink target is empty");
}
other => {
panic!("expected SecurityViolation for empty ZIP symlink target, got: {other:?}")
}
}
}
#[test]
fn test_list_tar_symlink_missing_target_rejected() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
let mut builder = tar::Builder::new(Vec::new());
let mut header = tar::Header::new_gnu();
header.set_path("link").unwrap();
header.set_size(0);
header.set_entry_type(tar::EntryType::Symlink);
header.set_cksum();
builder.append(&header, &[][..]).unwrap();
let data = builder.into_inner().unwrap();
temp_file.write_all(&data).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
match result {
Err(ArchiveError::InvalidArchive(msg)) => {
assert_eq!(msg, "symlink missing target");
}
other => panic!("expected InvalidArchive for missing symlink target, got: {other:?}"),
}
}
#[test]
fn test_list_tar_hardlink_missing_target_rejected() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
let mut builder = tar::Builder::new(Vec::new());
let mut header = tar::Header::new_gnu();
header.set_path("link").unwrap();
header.set_size(0);
header.set_entry_type(tar::EntryType::Link);
header.set_cksum();
builder.append(&header, &[][..]).unwrap();
let data = builder.into_inner().unwrap();
temp_file.write_all(&data).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default();
let result = list_archive(temp_file.path(), &config);
match result {
Err(ArchiveError::InvalidArchive(msg)) => {
assert_eq!(msg, "hardlink missing target");
}
other => panic!("expected InvalidArchive for missing hardlink target, got: {other:?}"),
}
}
fn tar_with_paths(paths: &[&str]) -> NamedTempFile {
let mut builder = tar::Builder::new(Vec::new());
for path in paths {
let mut header = tar::Header::new_gnu();
header.set_size(1);
header.set_cksum();
builder.append_data(&mut header, path, &b"x"[..]).unwrap();
}
let mut file = NamedTempFile::with_suffix(".tar").unwrap();
file.write_all(&builder.into_inner().unwrap()).unwrap();
file.flush().unwrap();
file
}
fn zip_with_names(names: &[&str]) -> NamedTempFile {
let file = NamedTempFile::with_suffix(".zip").unwrap();
let mut zip = zip::ZipWriter::new(std::fs::File::create(file.path()).unwrap());
let options = zip::write::SimpleFileOptions::default();
for name in names {
zip.start_file(*name, options).unwrap();
zip.write_all(b"x").unwrap();
}
zip.finish().unwrap();
file
}
fn list_preflight(path: &Path, config: SecurityConfig) -> Result<ArchiveManifest> {
list_validated(
path,
&config.validate()?,
&mut ListPass::VerifyPreflight(None),
)
}
fn assert_name_too_long(result: &Result<ArchiveManifest>) {
assert!(
matches!(result, Err(ArchiveError::Io(e)) if e.to_string() == "file name too long"),
"expected name-too-long error, got {result:?}"
);
}
#[test]
fn test_list_tar_entry_path_limit_boundary() {
let at_limit = path_of_len(HOST_PATH_LIMIT);
let over = path_of_len(HOST_PATH_LIMIT + 1);
let config = SecurityConfig::default();
let ok = tar_with_paths(&[&at_limit]);
assert_eq!(list_archive(ok.path(), &config).unwrap().total_entries(), 1);
let bad = tar_with_paths(&[&over]);
assert_name_too_long(&list_archive(bad.path(), &config));
}
#[test]
fn test_list_tar_entry_component_limit() {
let long_component = "a".repeat(255 + 1);
let bad = tar_with_paths(&[&long_component]);
assert_name_too_long(&list_archive(bad.path(), &SecurityConfig::default()));
}
#[test]
fn test_list_tar_verify_preflight_records_oversized_entry() {
let over = path_of_len(HOST_PATH_LIMIT + 1);
let file = tar_with_paths(&["first.txt", &over, "last.txt"]);
let manifest = list_preflight(file.path(), SecurityConfig::default()).unwrap();
assert_eq!(manifest.total_entries(), 2);
assert_eq!(
manifest.oversized_entries(),
&[OversizedEntry::new(1, HOST_PATH_LIMIT + 1)]
);
assert_eq!(manifest.entries()[1].path, PathBuf::from("last.txt"));
}
#[test]
fn test_list_zip_entry_path_limit() {
let at_limit = path_of_len(HOST_PATH_LIMIT);
let over = path_of_len(HOST_PATH_LIMIT + 1);
let config = SecurityConfig::default();
let ok = zip_with_names(&[&at_limit]);
assert_eq!(list_archive(ok.path(), &config).unwrap().total_entries(), 1);
let bad = zip_with_names(&[&over]);
assert_name_too_long(&list_archive(bad.path(), &config));
}
#[test]
fn test_list_zip_verify_preflight_records_oversized_entry() {
let over = path_of_len(HOST_PATH_LIMIT + 1);
let file = zip_with_names(&[&over, "last.txt"]);
let manifest = list_preflight(file.path(), SecurityConfig::default()).unwrap();
assert_eq!(manifest.total_entries(), 1);
assert_eq!(
manifest.oversized_entries(),
&[OversizedEntry::new(0, HOST_PATH_LIMIT + 1)]
);
}
#[test]
fn test_verify_reports_one_high_issue_per_oversized_entry() {
let over = path_of_len(HOST_PATH_LIMIT + 1);
let way_over = path_of_len(HOST_PATH_LIMIT * 3);
let file = tar_with_paths(&["ok.txt", &over, &way_over]);
let report = crate::verify_archive(file.path(), &SecurityConfig::default()).unwrap();
assert_eq!(report.status(), crate::VerificationStatus::Fail);
let issues: Vec<_> = report
.issues()
.iter()
.filter(|i| i.message.contains("file name too long"))
.collect();
assert_eq!(issues.len(), 2);
assert!(
issues
.iter()
.all(|i| { i.severity == crate::IssueSeverity::High && i.entry_path.is_none() })
);
assert!(issues[0].message.contains("entry 1"));
assert!(
issues[1]
.message
.contains(&format!("{} bytes", HOST_PATH_LIMIT * 3))
);
}
#[test]
fn test_list_sevenz_entry_path_limit() {
let over = path_of_len(HOST_PATH_LIMIT + 1);
let data = make_sevenz_archive(&[(&over, b"x"), ("last.txt", b"x")]);
let mut file = NamedTempFile::with_suffix(".7z").unwrap();
file.write_all(&data).unwrap();
file.flush().unwrap();
assert_name_too_long(&list_archive(file.path(), &SecurityConfig::default()));
let manifest = list_preflight(file.path(), SecurityConfig::default()).unwrap();
assert_eq!(manifest.total_entries(), 1);
assert_eq!(manifest.oversized_entries().len(), 1);
}
#[cfg(not(windows))]
#[test]
fn test_list_tar_pax_path_of_5000_bytes_errors() {
let file = tar_with_paths(&[&path_of_len(5000)]);
assert_name_too_long(&list_archive(file.path(), &SecurityConfig::default()));
let manifest = list_preflight(file.path(), SecurityConfig::default()).unwrap();
assert_eq!(manifest.total_entries(), 0);
assert_eq!(
manifest.oversized_entries(),
&[OversizedEntry::new(0, 5000)]
);
}
#[test]
fn test_list_zip_name_of_65535_bytes_errors() {
let mut file = NamedTempFile::with_suffix(".zip").unwrap();
file.write_all(&create_raw_zip_entry(&path_of_len(65535), b""))
.unwrap();
file.flush().unwrap();
assert_name_too_long(&list_archive(file.path(), &SecurityConfig::default()));
let manifest = list_preflight(file.path(), SecurityConfig::default()).unwrap();
assert_eq!(manifest.total_entries(), 0);
assert_eq!(
manifest.oversized_entries(),
&[OversizedEntry::new(0, 65535)]
);
}
#[test]
fn test_verify_caps_oversized_entry_issues() {
let over = path_of_len(HOST_PATH_LIMIT + 1);
let paths = vec![over.as_str(); 20];
let file = tar_with_paths(&paths);
let report = crate::verify_archive(file.path(), &SecurityConfig::default()).unwrap();
let issues: Vec<_> = report
.issues()
.iter()
.filter(|i| i.message.contains("file name too long"))
.collect();
assert_eq!(issues.len(), 17);
assert!(issues[16].message.contains("4 more entries"));
}
#[test]
fn test_list_tar_verify_preflight_allows_nul_byte_path() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
temp_file
.write_all(&tar_with_pax_nul_path(b"foo\0bar.txt", b"hello"))
.unwrap();
temp_file.flush().unwrap();
let manifest = list_preflight(temp_file.path(), SecurityConfig::default())
.expect("the pre-flight must let a NUL-byte entry path through");
assert_eq!(manifest.total_entries(), 1);
assert!(
has_null_bytes(&manifest.entries()[0].path),
"the raw NUL-byte path must reach the manifest unmodified"
);
}
#[test]
fn test_list_tar_verify_preflight_allows_missing_link_target() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
let mut builder = tar::Builder::new(Vec::new());
let mut header = tar::Header::new_gnu();
header.set_path("link").unwrap();
header.set_size(0);
header.set_entry_type(tar::EntryType::Symlink);
header.set_cksum();
builder.append(&header, &[][..]).unwrap();
let data = builder.into_inner().unwrap();
temp_file.write_all(&data).unwrap();
temp_file.flush().unwrap();
let manifest = list_preflight(temp_file.path(), SecurityConfig::default())
.expect("the pre-flight must let a missing symlink target through");
assert_eq!(manifest.total_entries(), 1);
assert_eq!(manifest.entries()[0].kind.symlink_target(), None);
}
#[test]
fn test_list_tar_verify_preflight_allows_nul_byte_link_target() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
temp_file
.write_all(&tar_with_pax_linkpath(b"foo\0bar", b'2'))
.unwrap();
temp_file.flush().unwrap();
let manifest = list_preflight(temp_file.path(), SecurityConfig::default())
.expect("the pre-flight must let a NUL-byte symlink target through");
assert_eq!(manifest.total_entries(), 1);
assert!(
manifest.entries()[0]
.kind
.symlink_target()
.is_some_and(has_null_bytes),
"the raw NUL-byte target must reach the manifest unmodified"
);
}
#[test]
fn test_list_zip_verify_preflight_allows_nul_byte_path() {
let zip_bytes = create_raw_zip_entry("foo\0bar.txt", b"");
let mut archive = ZipArchive::new(std::io::Cursor::new(zip_bytes)).unwrap();
let config = SecurityConfig::default()
.with_allow_absolute_paths(true)
.validate()
.unwrap();
let manifest = list_zip_reader(&mut archive, &config, &mut ListPass::VerifyPreflight(None))
.expect("the pre-flight must let a NUL-byte ZIP entry path through");
assert_eq!(manifest.total_entries(), 1);
assert!(
has_null_bytes(&manifest.entries()[0].path),
"the raw NUL-byte path must reach the manifest unmodified"
);
}
#[test]
fn test_list_tar_quota_exceeded_file_size() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
let mut builder = tar::Builder::new(Vec::new());
let data = vec![0u8; 1000];
let mut header = tar::Header::new_gnu();
header.set_path("big.bin").unwrap();
header.set_size(data.len() as u64);
header.set_cksum();
builder.append(&header, &data[..]).unwrap();
let archive_data = builder.into_inner().unwrap();
temp_file.write_all(&archive_data).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default().with_max_file_size(500);
let result = list_archive(temp_file.path(), &config);
assert_matches!(
result,
Err(ArchiveError::QuotaExceeded {
resource: QuotaResource::FileSize {
size: 1000,
max: 500
}
}),
"expected FileSize quota error, got: {result:?}"
);
}
#[test]
fn test_list_zip_quota_exceeded_file_size() {
let temp_file = NamedTempFile::with_suffix(".zip").unwrap();
let file = std::fs::File::create(temp_file.path()).unwrap();
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
zip.start_file("big.bin", options).unwrap();
zip.write_all(&vec![0u8; 1000]).unwrap();
zip.finish().unwrap();
let config = SecurityConfig::default().with_max_file_size(500);
let result = list_archive(temp_file.path(), &config);
assert_matches!(
result,
Err(ArchiveError::QuotaExceeded {
resource: QuotaResource::FileSize {
size: 1000,
max: 500
}
}),
"expected FileSize quota error, got: {result:?}"
);
}
#[test]
fn test_list_sevenz_quota_exceeded_file_size() {
let data = vec![0u8; 1000];
let archive_bytes = make_sevenz_archive(&[("big.bin", &data)]);
let mut temp_file = NamedTempFile::with_suffix(".7z").unwrap();
temp_file.write_all(&archive_bytes).unwrap();
temp_file.flush().unwrap();
let config = SecurityConfig::default().with_max_file_size(500);
let result = list_archive(temp_file.path(), &config);
assert_matches!(
result,
Err(ArchiveError::QuotaExceeded {
resource: QuotaResource::FileSize {
size: 1000,
max: 500
}
}),
"expected FileSize quota error, got: {result:?}"
);
}
fn zip_with_zip64_declared_sizes(entries: &[(&str, u64)]) -> Vec<u8> {
let raw = entries
.iter()
.map(|(name, declared)| {
let mut zip64 = declared.to_le_bytes().to_vec();
zip64.extend_from_slice(&0u64.to_le_bytes());
RawEntry {
date: 0,
crc: 0,
size: u64::from(u32::MAX),
declared_compressed: Some(u64::from(u32::MAX)),
local_sizes: Some((0, 0)),
made_by: 0x032d,
central_version: 45,
external_attributes: 0,
central_extra: extra_field(1, &zip64),
data: Vec::new(),
..RawEntry::stored(name, b"")
}
})
.collect();
RawZip::new(raw).build().bytes
}
#[test]
fn test_list_zip_total_size_overflow_rejected_not_wrapped() {
use std::io::Cursor;
let zip_bytes = zip_with_zip64_declared_sizes(&[("a.bin", u64::MAX - 100), ("b.bin", 200)]);
let mut archive = ZipArchive::new(Cursor::new(zip_bytes)).unwrap();
let config = SecurityConfig::default()
.with_max_file_size(u64::MAX)
.with_max_file_count(usize::MAX)
.with_max_total_size(u64::MAX)
.validate()
.expect("valid config");
let result = list_zip_reader(&mut archive, &config, &mut ListPass::strict());
assert_matches!(
result,
Err(ArchiveError::QuotaExceeded {
resource: QuotaResource::IntegerOverflow
}),
"expected IntegerOverflow quota error instead of a silently wrapped total, got: {result:?}"
);
}
fn oversized_hardlink_tar(count: usize) -> Vec<u8> {
let one = tar_with_pax_linkpath(&vec![b'a'; 100_000], b'1');
let entry = &one[..one.len() - 1024];
let mut tar = entry.repeat(count);
tar.extend(std::iter::repeat_n(0u8, 1024));
tar
}
#[test]
fn test_list_tar_oversized_link_target_rejected() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
temp_file.write_all(&oversized_hardlink_tar(1)).unwrap();
temp_file.flush().unwrap();
let result = list_archive(temp_file.path(), &SecurityConfig::default());
assert_matches!(
result,
Err(ArchiveError::InvalidArchive(msg)) if msg == "hardlink target too long: 100000 bytes"
);
}
#[test]
fn test_list_tar_preflight_keeps_only_the_length_of_oversized_targets() {
let mut temp_file = NamedTempFile::with_suffix(".tar").unwrap();
temp_file.write_all(&oversized_hardlink_tar(3)).unwrap();
temp_file.flush().unwrap();
let manifest = list_preflight(temp_file.path(), SecurityConfig::default()).unwrap();
assert_eq!(manifest.total_entries(), 3);
for entry in manifest.entries() {
assert_matches!(
&entry.kind,
ManifestEntryKind::Hardlink {
target: LinkTarget::Oversized { bytes: 100_000 }
}
);
}
}
fn tar_with_sizes(sizes: &[usize]) -> NamedTempFile {
let mut builder = tar::Builder::new(Vec::new());
for (i, size) in sizes.iter().enumerate() {
let mut header = tar::Header::new_gnu();
header.set_size(*size as u64);
header.set_cksum();
builder
.append_data(&mut header, format!("f{i}.bin"), &vec![0u8; *size][..])
.unwrap();
}
let mut file = NamedTempFile::with_suffix(".tar").unwrap();
file.write_all(&builder.into_inner().unwrap()).unwrap();
file.flush().unwrap();
file
}
#[test]
fn test_list_preflight_lifts_only_file_size_limit() {
let file = tar_with_sizes(&[100, 10]);
let config = SecurityConfig::default().with_max_file_size(50);
assert_matches!(
list_archive(file.path(), &config),
Err(ArchiveError::QuotaExceeded {
resource: QuotaResource::FileSize { size: 100, max: 50 }
})
);
let manifest = list_preflight(file.path(), config.clone()).unwrap();
assert_eq!(manifest.total_entries(), 2);
assert_matches!(
list_preflight(file.path(), config.clone().with_max_file_count(1)),
Err(ArchiveError::QuotaExceeded {
resource: QuotaResource::FileCount { current: 2, max: 1 }
})
);
assert_matches!(
list_preflight(file.path(), config.with_max_total_size(105)),
Err(ArchiveError::QuotaExceeded {
resource: QuotaResource::TotalSize {
current: 110,
max: 105
}
})
);
}
fn assert_verify_reports_oversized_file(path: &Path) {
let config = SecurityConfig::default().with_max_file_size(500);
let report = crate::verify_archive(path, &config)
.expect("verify must report an oversized entry, not error out");
assert_eq!(report.status(), crate::VerificationStatus::Fail);
assert!(
report.issues().iter().any(|i| {
i.category == crate::IssueCategory::QuotaExceeded
&& i.message.contains("single file size")
}),
"expected a FileSize issue, got: {:?}",
report.issues()
);
}
#[test]
fn test_verify_zip_reports_oversized_entry_not_error() {
let mut file = NamedTempFile::with_suffix(".zip").unwrap();
let bytes = crate::test_utils::create_test_zip(vec![("big.bin", &[0u8; 1000])]);
file.write_all(&bytes).unwrap();
file.flush().unwrap();
assert_verify_reports_oversized_file(file.path());
}
#[test]
fn test_verify_sevenz_reports_oversized_entry_not_error() {
let mut file = NamedTempFile::with_suffix(".7z").unwrap();
file.write_all(&make_sevenz_archive(&[("big.bin", &[0u8; 1000])]))
.unwrap();
file.flush().unwrap();
assert_verify_reports_oversized_file(file.path());
}
#[test]
fn test_list_zip_oversized_symlink_target() {
use crate::test_utils::HOST_SYMLINK_TARGET_BYTES;
use crate::test_utils::create_zip_with_symlink;
use std::io::Cursor;
let zip_bytes = create_zip_with_symlink("link", &"a".repeat(HOST_SYMLINK_TARGET_BYTES + 1));
let mut archive = ZipArchive::new(Cursor::new(zip_bytes.clone())).unwrap();
let strict = SecurityConfig::default().validate().unwrap();
assert_matches!(
list_zip_reader(&mut archive, &strict, &mut ListPass::strict()),
Err(ArchiveError::SecurityViolation { .. })
);
let mut archive = ZipArchive::new(Cursor::new(zip_bytes)).unwrap();
let manifest =
list_zip_reader(&mut archive, &strict, &mut ListPass::VerifyPreflight(None)).unwrap();
assert_matches!(
&manifest.entries()[0].kind,
ManifestEntryKind::Symlink {
target: LinkTarget::Oversized { bytes }
} if *bytes == HOST_SYMLINK_TARGET_BYTES + 1
);
}
#[test]
fn test_list_zip_symlink_with_a_huge_declared_target_is_never_read() {
use crate::raw_zip::RawEntry;
use crate::raw_zip::RawZip;
use crate::raw_zip::deflate_zeros;
use std::io::Cursor;
const DECLARED: u64 = 64 * 1024 * 1024;
let mut link = RawEntry::stored("link", b"");
link.data = deflate_zeros(DECLARED);
link.method = 8;
link.size = DECLARED;
link.external_attributes = 0o120_777 << 16;
let bytes = RawZip::new(vec![link]).build().bytes;
let mut archive = ZipArchive::new(Cursor::new(bytes)).unwrap();
let config = SecurityConfig::default().validate().unwrap();
let manifest =
list_zip_reader(&mut archive, &config, &mut ListPass::VerifyPreflight(None)).unwrap();
assert_matches!(
&manifest.entries()[0].kind,
ManifestEntryKind::Symlink {
target: LinkTarget::Oversized { bytes }
} if *bytes == usize::try_from(DECLARED).unwrap()
);
}
}