use std::io::ErrorKind;
use std::io::Read;
use std::io::Seek;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::process::id;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use sevenz_rust2::Archive;
use sevenz_rust2::ArchiveReader;
use sevenz_rust2::Password;
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
const MAX_TEMP_FILE_CREATE_ATTEMPTS: u32 = 8;
use crate::ArchiveError;
use crate::ExtractionOptions;
use crate::ExtractionReport;
use crate::IoContext;
use crate::ProgressCallback;
use crate::Result;
use crate::SecurityConfig;
use crate::config::Validated;
use crate::copy::CopyBuffer;
use crate::copy::copy_with_buffer;
use crate::error::QuotaResource;
use crate::security::EntryValidator;
use crate::security::quota::QuotaPermit;
use crate::types::DestDir;
use crate::types::EntryType;
use super::common;
use super::traits::ArchiveFormat;
#[derive(Debug, Clone)]
struct CachedEntry {
name: String,
size: u64,
is_directory: bool,
}
#[derive(Debug)]
pub struct SevenZArchive<R: Read + Seek> {
source: R,
entries: Vec<CachedEntry>,
is_solid: bool,
archive: Archive,
}
impl<R: Read + Seek> SevenZArchive<R> {
pub fn new(mut source: R) -> Result<Self> {
let password = Password::empty();
let archive = match Archive::read(&mut source, &password) {
Ok(a) => a,
Err(e) => {
let err_str = e.to_string().to_lowercase();
if err_str.contains("encrypt") || err_str.contains("password") {
return Err(ArchiveError::SecurityViolation {
reason: "encrypted 7z archive detected. Password-protected archives are not supported. \
Decrypt the archive externally and try again.".into(),
});
}
if is_empty_sevenz_archive(&e, &mut source) {
return Ok(Self {
source,
entries: vec![],
is_solid: false,
archive: Archive::default(),
});
}
return Err(ArchiveError::InvalidArchive(format!(
"failed to open 7z archive: {e}"
)));
}
};
let is_solid = archive.is_solid;
let entries: Vec<CachedEntry> = archive
.files
.iter()
.map(|e| CachedEntry {
name: e.name.clone(),
size: e.size,
is_directory: e.is_directory(),
})
.collect();
source.rewind().map_err(ArchiveError::Io)?;
Ok(Self {
source,
entries,
is_solid,
archive,
})
}
}
fn write_file_direct(
reader: &mut dyn Read,
dest_path: &Path,
expected_size: u64,
copy_buffer: &mut CopyBuffer,
_permit: QuotaPermit,
) -> Result<u64> {
let file = common::create_file_with_mode(dest_path, None, true)?;
let guard = common::TempFileGuard::new(dest_path.to_path_buf());
let mut writer = std::io::BufWriter::with_capacity(64 * 1024, file);
let bytes_written = copy_with_buffer(reader, &mut writer, copy_buffer, Some(expected_size))?;
writer.flush()?;
guard.persist();
Ok(bytes_written)
}
fn write_file_with_permit(
reader: &mut dyn Read,
dest_path: &Path,
expected_size: u64,
copy_buffer: &mut CopyBuffer,
permit: QuotaPermit,
) -> Result<u64> {
let pid = id();
let original_name = dest_path
.file_name()
.map_or_else(|| "file".to_string(), |n| n.to_string_lossy().to_string());
write_file_with_permit_using(
reader,
dest_path,
expected_size,
copy_buffer,
permit,
|| {
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let temp_name = format!(".{original_name}.exarch-tmp-{pid}-{counter}");
dest_path.with_file_name(&temp_name)
},
)
}
fn write_file_with_permit_using(
reader: &mut dyn Read,
dest_path: &Path,
expected_size: u64,
copy_buffer: &mut CopyBuffer,
_permit: QuotaPermit,
mut next_candidate_path: impl FnMut() -> PathBuf,
) -> Result<u64> {
let mut created: Option<(PathBuf, std::fs::File)> = None;
for _ in 0..MAX_TEMP_FILE_CREATE_ATTEMPTS {
let candidate_path = next_candidate_path();
match common::create_file_with_mode(&candidate_path, None, true) {
Ok(file) => {
created = Some((candidate_path, file));
break;
}
Err(e) if e.kind() == ErrorKind::AlreadyExists => {}
Err(e) => return Err(e.into()),
}
}
let (temp_path, temp_file) = created.ok_or_else(|| {
std::io::Error::new(
ErrorKind::AlreadyExists,
format!(
"failed to create a unique temp file for {} after {MAX_TEMP_FILE_CREATE_ATTEMPTS} attempts",
dest_path.display()
),
)
})?;
let temp_guard = common::TempFileGuard::new(temp_path.clone());
let mut writer = std::io::BufWriter::with_capacity(64 * 1024, temp_file);
let bytes_written = copy_with_buffer(reader, &mut writer, copy_buffer, Some(expected_size))?;
writer.flush()?;
drop(writer);
std::fs::rename(&temp_path, dest_path)?;
temp_guard.persist();
Ok(bytes_written)
}
fn lstat_dest(dest_path: &Path) -> std::io::Result<Option<std::fs::Metadata>> {
match std::fs::symlink_metadata(dest_path) {
Ok(meta) => Ok(Some(meta)),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
#[cfg(unix)]
fn symlink_at_dest_error() -> std::io::Error {
std::io::Error::from_raw_os_error(libc::ELOOP)
}
impl<R: Read + Seek> SevenZArchive<R> {
#[allow(clippy::too_many_arguments)]
fn process_entry_inner(
entry: &sevenz_rust2::ArchiveEntry,
reader: &mut dyn Read,
entry_path: &std::path::Path,
validator: &mut EntryValidator,
dest: &DestDir,
report: &mut ExtractionReport,
dir_cache: &mut common::DirCache,
skip_duplicates: bool,
config: &SecurityConfig<Validated>,
duplicate_skips: &mut u64,
disallowed_extension_skips: &mut u64,
pending_error: &mut Option<ArchiveError>,
copy_buffer: &mut CopyBuffer,
) -> std::result::Result<u64, sevenz_rust2::Error> {
let entry_type = SevenZEntryAdapter::to_entry_type(entry).map_err(|e| {
sevenz_rust2::Error::Other(format!("entry type detection failed: {e}").into())
})?;
if matches!(entry_type, EntryType::File)
&& !common::check_extension_allowed(
entry_path,
config,
report,
disallowed_extension_skips,
)
{
return Ok(0);
}
let safe_path = validator
.validate_entry_path(entry_path, None)
.map_err(|e| sevenz_rust2::Error::Other(format!("validation failed: {e}").into()))?;
let dest_path = dest.join_path(safe_path.as_path());
match entry_type {
EntryType::Directory => {
dir_cache.ensure_dir(&dest_path)?;
report.directories_created += 1;
Ok(0)
}
EntryType::File => {
dir_cache.ensure_parent_dir(&dest_path)?;
let existing = lstat_dest(&dest_path)?;
if existing.is_some() && skip_duplicates {
report.files_skipped =
report.files_skipped.checked_add(1).ok_or_else(|| {
sevenz_rust2::Error::Other("files_skipped overflow".into())
})?;
*duplicate_skips = duplicate_skips.saturating_add(1);
return Ok(0);
}
#[cfg(unix)]
if let Some(meta) = &existing
&& meta.file_type().is_symlink()
{
return Err(symlink_at_dest_error().into());
}
if let Some(meta) = &existing
&& meta.is_dir()
{
*pending_error = Some(ArchiveError::Io(std::io::Error::from(
ErrorKind::IsADirectory,
)));
return Err(sevenz_rust2::Error::Other(
"destination path is a pre-existing directory".into(),
));
}
let permit = validator.reserve_file(entry.size).map_err(|e| {
sevenz_rust2::Error::Other(format!("validation failed: {e}").into())
})?;
let bytes_written = if existing.is_some() {
std::fs::remove_file(&dest_path)?;
write_file_with_permit(reader, &dest_path, entry.size, copy_buffer, permit)
} else {
write_file_direct(reader, &dest_path, entry.size, copy_buffer, permit)
}
.map_err(|e| {
*pending_error = Some(e);
sevenz_rust2::Error::Other("extraction aborted by security policy".into())
})?;
report.bytes_written = report
.bytes_written
.checked_add(bytes_written)
.ok_or_else(|| sevenz_rust2::Error::Other("bytes_written overflow".into()))?;
report.files_extracted += 1;
Ok(bytes_written)
}
_ => Err(sevenz_rust2::Error::Other(
"symlinks/hardlinks not supported".into(),
)),
}
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn extract_with_callback(
source: &mut R,
archive: Archive,
dest: &DestDir,
validator: &mut EntryValidator,
dir_cache: &mut common::DirCache,
skip_duplicates: bool,
progress: &mut dyn ProgressCallback,
total: usize,
config: &SecurityConfig<Validated>,
) -> Result<ExtractionReport> {
struct SzContext<'a> {
report: ExtractionReport,
dir_cache: &'a mut common::DirCache,
progress: &'a mut dyn ProgressCallback,
current_idx: usize,
duplicate_skips: u64,
disallowed_extension_skips: u64,
pending_error: Option<ArchiveError>,
copy_buffer: CopyBuffer,
}
let mut ctx = SzContext {
report: ExtractionReport::new(),
dir_cache,
progress,
current_idx: 0,
duplicate_skips: 0,
disallowed_extension_skips: 0,
pending_error: None,
copy_buffer: CopyBuffer::new(),
};
let mut extract_fn = |entry: &sevenz_rust2::ArchiveEntry,
reader: &mut dyn Read|
-> std::result::Result<bool, sevenz_rust2::Error> {
let entry_path = std::path::PathBuf::from(common::normalize_entry_name(&entry.name));
ctx.current_idx = ctx.current_idx.saturating_add(1);
let idx = ctx.current_idx;
ctx.progress
.on_entry_start(entry_path.as_path(), total, idx);
let result = Self::process_entry_inner(
entry,
reader,
&entry_path,
validator,
dest,
&mut ctx.report,
ctx.dir_cache,
skip_duplicates,
config,
&mut ctx.duplicate_skips,
&mut ctx.disallowed_extension_skips,
&mut ctx.pending_error,
&mut ctx.copy_buffer,
);
match result {
Ok(bytes_written) => {
if bytes_written > 0 {
ctx.progress.on_bytes_written(bytes_written);
}
ctx.progress.on_entry_complete(entry_path.as_path());
Ok(true)
}
Err(e) => {
ctx.progress.on_entry_complete(entry_path.as_path());
Err(e)
}
}
};
let mut archive_reader = ArchiveReader::from_archive(archive, source, Password::empty());
let result = archive_reader.for_each_entries(&mut extract_fn);
let mut accumulated = ctx.report;
common::push_duplicate_skip_warning(
&mut accumulated,
ctx.duplicate_skips,
"entry",
"entries",
);
common::push_disallowed_extension_warning(&mut accumulated, ctx.disallowed_extension_skips);
let e = match result {
Ok(()) => {
debug_assert!(
ctx.pending_error.is_none(),
"pending_error must be unset when for_each_entries reports success"
);
return Ok(accumulated);
}
Err(e) => ctx
.pending_error
.take()
.unwrap_or_else(|| ArchiveError::from(e)),
};
Err(ArchiveError::partial_or(accumulated, e))
}
}
impl<R: Read + Seek> ArchiveFormat for SevenZArchive<R> {
fn extract(
&mut self,
output_dir: &Path,
config: &SecurityConfig<Validated>,
options: &ExtractionOptions,
progress: &mut dyn ProgressCallback,
) -> Result<ExtractionReport> {
if self.is_solid {
if !config.allow_solid_archives {
return Err(ArchiveError::SecurityViolation {
reason: "solid 7z archives are not allowed (enable allow_solid_archives)"
.into(),
});
}
let total_uncompressed: u64 = self
.entries
.iter()
.try_fold(0u64, |acc, e| acc.checked_add(e.size))
.ok_or(ArchiveError::QuotaExceeded {
resource: QuotaResource::TotalSize {
current: u64::MAX,
max: config.max_solid_block_memory,
},
})?;
if total_uncompressed > config.max_solid_block_memory {
return Err(ArchiveError::QuotaExceeded {
resource: QuotaResource::TotalSize {
current: total_uncompressed,
max: config.max_solid_block_memory,
},
});
}
}
let dest = DestDir::new_or_create(output_dir.to_path_buf())?;
let mut prevalidator = EntryValidator::new(config, &dest);
for entry in &self.entries {
let path = std::path::PathBuf::from(common::normalize_entry_name(&entry.name));
let safe_path = prevalidator.validate_entry_path(&path, None)?;
if entry.is_directory {
continue;
}
let dest_path = dest.join_path(safe_path.as_path());
let existing = lstat_dest(&dest_path)?;
if existing.is_some() && options.skip_duplicates {
continue;
}
#[cfg(unix)]
if let Some(meta) = &existing
&& meta.file_type().is_symlink()
{
return Err(symlink_at_dest_error().into());
}
let _permit = prevalidator.reserve_file(entry.size)?;
}
if self.entries.is_empty() {
return Ok(ExtractionReport::new());
}
let mut validator = EntryValidator::new(config, &dest);
let mut dir_cache = common::DirCache::new();
let total = self.entries.len();
let archive = self.archive.clone();
let report = Self::extract_with_callback(
&mut self.source,
archive,
&dest,
&mut validator,
&mut dir_cache,
options.skip_duplicates,
progress,
total,
config,
)?;
progress.on_complete();
Ok(report)
}
fn list(
&mut self,
config: &SecurityConfig<Validated>,
) -> Result<crate::inspection::ArchiveManifest> {
use crate::inspection::list::list_sevenz_reader;
self.source.rewind().map_err(ArchiveError::Io)?;
list_sevenz_reader(&mut self.source, config)
}
fn verify(
&mut self,
config: &SecurityConfig<Validated>,
) -> Result<crate::inspection::VerificationReport> {
let manifest = self.list(&crate::inspection::verify::listing_config_for_verify(
config,
))?;
crate::inspection::verify::verify_manifest(&manifest, config)
}
fn format_name(&self) -> &'static str {
"7z"
}
}
struct SevenZEntryAdapter;
impl SevenZEntryAdapter {
fn to_entry_type(entry: &sevenz_rust2::ArchiveEntry) -> Result<EntryType> {
if Self::is_windows_reparse_point(entry) {
return Err(ArchiveError::SecurityViolation {
reason: format!(
"symlink detected in 7z archive: {} \
(Windows reparse point attribute set). \
7z symlink extraction is not supported due to sevenz-rust2 API limitations.",
entry.name
),
});
}
if entry.is_directory() {
return Ok(EntryType::Directory);
}
Ok(EntryType::File)
}
fn is_windows_reparse_point(entry: &sevenz_rust2::ArchiveEntry) -> bool {
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
entry.has_windows_attributes
&& (entry.windows_attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0
}
}
fn is_empty_sevenz_archive<R: Read + Seek>(err: &sevenz_rust2::Error, source: &mut R) -> bool {
const SEVENZ_MAGIC: [u8; 6] = [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C];
const EMPTY_ARCHIVE_SIZE: u64 = 32;
let is_eof = matches!(err, sevenz_rust2::Error::Io(io_err, _) if io_err.kind() == ErrorKind::UnexpectedEof);
if !is_eof {
return false;
}
let Ok(size) = source.seek(std::io::SeekFrom::End(0)) else {
return false;
};
if size != EMPTY_ARCHIVE_SIZE {
return false;
}
let Ok(_) = source.seek(std::io::SeekFrom::Start(0)) else {
return false;
};
let mut magic = [0u8; 6];
source.read_exact(&mut magic).is_ok() && magic == SEVENZ_MAGIC
}
impl From<sevenz_rust2::Error> for ArchiveError {
fn from(err: sevenz_rust2::Error) -> Self {
let err_str = err.to_string();
let err_lower = err_str.to_lowercase();
if err_lower.contains("password") || err_lower.contains("encrypt") {
return Self::SecurityViolation {
reason: format!("encrypted archive: {err_str}"),
};
}
if err_lower.contains("i/o") || err_lower.contains("read") || err_lower.contains("write") {
return Self::Io(std::io::Error::other(IoContext::new(
"7z I/O error",
err_str,
)));
}
Self::InvalidArchive(format!("7z error: {err_str}"))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::assert_matches;
use std::io::Cursor;
use tempfile::TempDir;
const SEVENZ_MAGIC: [u8; 6] = [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C];
fn load_fixture(name: &str) -> Vec<u8> {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let fixture_path = std::path::PathBuf::from(manifest_dir)
.parent()
.unwrap()
.parent()
.unwrap()
.join("tests/fixtures")
.join(name);
std::fs::read(&fixture_path).unwrap_or_else(|e| {
panic!(
"Failed to load fixture {name}. Run tests/fixtures/generate_7z_fixtures.sh first. Error: {e}"
)
})
}
#[test]
fn test_format_name() {
let data = SEVENZ_MAGIC.to_vec();
let cursor = Cursor::new(data);
let result = SevenZArchive::new(cursor);
assert!(result.is_err(), "invalid archive should fail to parse");
assert_matches!(result, Err(ArchiveError::InvalidArchive(_)));
}
#[test]
fn test_invalid_magic_rejected() {
let data = vec![0x00, 0x01, 0x02, 0x03, 0x04, 0x05];
let cursor = Cursor::new(data);
let result = SevenZArchive::new(cursor);
assert!(result.is_err());
assert_matches!(result, Err(ArchiveError::InvalidArchive(_)));
}
#[test]
fn test_load_fixture_helper() {
let data = load_fixture("simple.7z");
assert!(!data.is_empty());
assert_eq!(&data[0..6], &SEVENZ_MAGIC);
}
#[cfg(unix)]
fn test_permit() -> QuotaPermit {
let config = SecurityConfig::default().validate().expect("valid config");
crate::security::quota::QuotaTracker::new()
.reserve(0, &config)
.expect("reservation should succeed")
}
#[test]
#[cfg(unix)]
fn test_write_file_with_permit_skips_planted_symlinks() {
let temp = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let dest_path = temp.path().join("real-output.txt");
let mut victims = Vec::new();
for offset in 0..(MAX_TEMP_FILE_CREATE_ATTEMPTS - 1) {
let planted_path = temp.path().join(format!(".candidate-{offset}.tmp"));
let victim_path = outside.path().join(format!("victim-{offset}.txt"));
std::os::unix::fs::symlink(&victim_path, &planted_path).unwrap();
victims.push((planted_path, victim_path));
}
let mut next_candidate = 0u32;
let temp_dir_path = temp.path().to_path_buf();
let mut reader = Cursor::new(b"legit content".to_vec());
let mut copy_buffer = CopyBuffer::new();
let bytes_written = write_file_with_permit_using(
&mut reader,
&dest_path,
13,
&mut copy_buffer,
test_permit(),
|| {
let path = temp_dir_path.join(format!(".candidate-{next_candidate}.tmp"));
next_candidate += 1;
path
},
)
.expect("should retry past every planted symlink and succeed");
assert_eq!(bytes_written, 13);
assert_eq!(std::fs::read(&dest_path).unwrap(), b"legit content");
assert_eq!(next_candidate, MAX_TEMP_FILE_CREATE_ATTEMPTS);
for (planted_path, victim_path) in &victims {
assert!(
!victim_path.exists(),
"write followed a planted symlink outside the extraction root"
);
let metadata = std::fs::symlink_metadata(planted_path).unwrap();
assert!(
metadata.file_type().is_symlink(),
"planted symlink should be left untouched, not consumed or replaced"
);
}
}
#[test]
#[cfg(unix)]
fn test_write_file_with_permit_gives_up_after_max_attempts() {
let temp = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let dest_path = temp.path().join("real-output.txt");
let mut victims = Vec::new();
for offset in 0..MAX_TEMP_FILE_CREATE_ATTEMPTS {
let planted_path = temp.path().join(format!(".candidate-{offset}.tmp"));
let victim_path = outside.path().join(format!("victim-{offset}.txt"));
std::os::unix::fs::symlink(&victim_path, &planted_path).unwrap();
victims.push(victim_path);
}
let mut next_candidate = 0u32;
let temp_dir_path = temp.path().to_path_buf();
let mut reader = Cursor::new(b"legit content".to_vec());
let mut copy_buffer = CopyBuffer::new();
let result = write_file_with_permit_using(
&mut reader,
&dest_path,
13,
&mut copy_buffer,
test_permit(),
|| {
let path = temp_dir_path.join(format!(".candidate-{next_candidate}.tmp"));
next_candidate += 1;
path
},
);
assert!(
result.is_err(),
"expected exhaustion error, got: {result:?}"
);
assert_eq!(next_candidate, MAX_TEMP_FILE_CREATE_ATTEMPTS);
assert!(!dest_path.exists());
for victim_path in &victims {
assert!(!victim_path.exists());
}
}
#[test]
#[cfg(unix)]
fn test_write_file_direct_forged_size_aborts_and_cleans_up() {
let temp = TempDir::new().unwrap();
let dest_path = temp.path().join("bomb.bin");
let real_data = vec![0x41u8; 200 * 1024];
let mut reader = Cursor::new(&real_data);
let mut copy_buffer = CopyBuffer::new();
let result =
write_file_direct(&mut reader, &dest_path, 50, &mut copy_buffer, test_permit());
assert!(
result.is_err(),
"streaming past expected_size must abort, got: {result:?}"
);
match result {
Err(ArchiveError::SecurityViolation { reason }) => {
assert!(
reason.contains("50 bytes"),
"error must name the declared ceiling (50), got: {reason:?}"
);
}
other => {
panic!("expected SecurityViolation naming the 50-byte ceiling, got: {other:?}")
}
}
assert!(
!dest_path.exists(),
"aborted write_file_direct must not leave a file on disk"
);
}
#[test]
#[cfg(unix)]
fn test_write_file_with_permit_forged_size_aborts_and_leaves_original_untouched() {
let temp = TempDir::new().unwrap();
let dest_path = temp.path().join("existing.bin");
std::fs::write(&dest_path, b"original content").unwrap();
let real_data = vec![0x41u8; 200 * 1024];
let mut reader = Cursor::new(&real_data);
let mut copy_buffer = CopyBuffer::new();
let result =
write_file_with_permit(&mut reader, &dest_path, 50, &mut copy_buffer, test_permit());
assert!(
result.is_err(),
"streaming past expected_size must abort, got: {result:?}"
);
assert_eq!(
std::fs::read(&dest_path).unwrap(),
b"original content",
"aborted overwrite must leave the pre-existing destination untouched"
);
let leftovers: Vec<_> = std::fs::read_dir(temp.path())
.unwrap()
.filter_map(std::result::Result::ok)
.filter(|e| e.file_name() != dest_path.file_name().unwrap())
.collect();
assert!(
leftovers.is_empty(),
"aborted write must not leave a temp file behind, found: {leftovers:?}"
);
}
#[test]
fn test_extract_simple_file() {
let data = load_fixture("simple.7z");
let cursor = Cursor::new(data);
let mut archive = SevenZArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default().validate().expect("valid config");
let report = archive
.extract(
temp.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
)
.unwrap();
assert_eq!(report.files_extracted, 2);
assert!(temp.path().join("simple/file1.txt").exists());
assert!(temp.path().join("simple/file2.txt").exists());
let content1 = std::fs::read_to_string(temp.path().join("simple/file1.txt")).unwrap();
assert_eq!(content1, "hello world\n");
}
#[test]
fn test_extract_nested_directories() {
let data = load_fixture("nested-dirs.7z");
let cursor = Cursor::new(data);
let mut archive = SevenZArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default().validate().expect("valid config");
let report = archive
.extract(
temp.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
)
.unwrap();
assert!(report.files_extracted >= 1);
assert!(temp.path().join("nested/subdir1/subdir2/deep.txt").exists());
assert!(temp.path().join("nested/subdir1/file.txt").exists());
}
#[test]
fn test_solid_archive_rejected() {
let data = load_fixture("solid.7z");
let cursor = Cursor::new(data);
let mut archive = SevenZArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let result = archive.extract(
temp.path(),
&SecurityConfig::default().validate().unwrap(),
&ExtractionOptions::default(),
&mut crate::NoopProgress,
);
assert!(result.is_err());
assert_matches!(result.unwrap_err(), ArchiveError::SecurityViolation { .. });
}
#[test]
fn test_encrypted_archive_rejected() {
let data = load_fixture("encrypted.7z");
let cursor = Cursor::new(data);
let result = SevenZArchive::new(cursor);
assert!(result.is_err());
assert_matches!(result.unwrap_err(), ArchiveError::SecurityViolation { .. });
}
#[test]
fn test_empty_archive() {
let data = load_fixture("empty.7z");
let cursor = Cursor::new(data);
let mut archive = SevenZArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default().validate().expect("valid config");
let report = archive
.extract(
temp.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
)
.unwrap();
assert_eq!(report.files_extracted, 0);
assert_eq!(report.directories_created, 0);
}
#[test]
fn test_empty_archive_extract() {
let path = std::path::Path::new("../../tests/fixtures/empty.7z");
let file = std::fs::File::open(path).unwrap();
let mut archive = SevenZArchive::new(file).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default().validate().expect("valid config");
let report = archive
.extract(
temp.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
)
.unwrap();
assert_eq!(report.files_extracted, 0);
assert_eq!(report.bytes_written, 0);
}
#[test]
fn test_quota_exceeded() {
let data = load_fixture("large-file.7z");
let cursor = Cursor::new(data);
let mut archive = SevenZArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default()
.with_max_file_size(1024)
.validate()
.unwrap();
let result = archive.extract(
temp.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
);
assert!(result.is_err());
assert_matches!(result.unwrap_err(), ArchiveError::QuotaExceeded { .. });
}
#[test]
fn test_multiple_files_quota_not_double_counted() {
let data = load_fixture("simple.7z"); let cursor = Cursor::new(data);
let mut archive = SevenZArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default()
.with_max_file_count(3)
.validate()
.unwrap();
let result = archive.extract(
temp.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
);
assert!(
result.is_ok(),
"2 files should not exceed quota of 3: {result:?}"
);
assert_eq!(result.unwrap().files_extracted, 2);
}
#[test]
fn test_path_traversal_integration() {
let data = load_fixture("simple.7z");
let cursor = Cursor::new(data);
let archive = SevenZArchive::new(cursor);
assert!(archive.is_ok());
}
#[test]
fn test_solid_archive_allowed_with_config() {
let data = load_fixture("solid.7z");
let cursor = Cursor::new(data);
let mut archive = SevenZArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default()
.with_allow_solid_archives(true)
.with_max_solid_block_memory(100 * 1024 * 1024)
.validate()
.unwrap();
let result = archive.extract(
temp.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
);
assert!(result.is_ok(), "solid archive should extract: {result:?}");
assert!(result.unwrap().files_extracted > 0);
}
#[test]
fn test_solid_archive_rejected_by_default() {
let data = load_fixture("solid.7z");
let cursor = Cursor::new(data);
let mut archive = SevenZArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default().validate().expect("valid config");
let result = archive.extract(
temp.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
);
assert!(result.is_err());
assert_matches!(result.unwrap_err(), ArchiveError::SecurityViolation { .. });
}
#[test]
fn test_solid_archive_memory_limit_exceeded() {
let data = load_fixture("solid.7z");
let cursor = Cursor::new(data);
let mut archive = SevenZArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default()
.with_allow_solid_archives(true)
.with_max_solid_block_memory(1)
.validate()
.unwrap();
let result = archive.extract(
temp.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
);
assert!(result.is_err());
assert_matches!(result.unwrap_err(), ArchiveError::QuotaExceeded { .. });
}
#[test]
fn test_non_solid_archive_unaffected_by_solid_config() {
let data = load_fixture("simple.7z");
let cursor = Cursor::new(data);
let mut archive = SevenZArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default().validate().expect("valid config");
let result = archive.extract(
temp.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
);
assert!(result.is_ok(), "non-solid should work: {result:?}");
}
#[test]
fn test_is_solid_flag_detected_correctly() {
let solid_data = load_fixture("solid.7z");
let solid_cursor = Cursor::new(solid_data);
let solid_archive = SevenZArchive::new(solid_cursor).unwrap();
assert!(solid_archive.is_solid, "solid.7z should have is_solid=true");
let non_solid_data = load_fixture("simple.7z");
let non_solid_cursor = Cursor::new(non_solid_data);
let non_solid_archive = SevenZArchive::new(non_solid_cursor).unwrap();
assert!(
!non_solid_archive.is_solid,
"simple.7z should have is_solid=false"
);
}
#[test]
fn test_solid_archive_memory_limit_exact_boundary() {
let data = load_fixture("solid.7z");
let archive_for_size = SevenZArchive::new(Cursor::new(data.clone())).unwrap();
let total_size: u64 = archive_for_size.entries.iter().map(|e| e.size).sum();
let mut archive = SevenZArchive::new(Cursor::new(data)).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default()
.with_allow_solid_archives(true)
.with_max_solid_block_memory(total_size)
.validate()
.unwrap();
let result = archive.extract(
temp.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
);
assert!(
result.is_ok(),
"exact limit should allow extraction: {result:?}"
);
}
#[test]
fn test_solid_archive_memory_limit_one_under_boundary() {
let data = load_fixture("solid.7z");
let archive_for_size = SevenZArchive::new(Cursor::new(data.clone())).unwrap();
let total_size: u64 = archive_for_size.entries.iter().map(|e| e.size).sum();
if total_size < 2 {
return; }
let mut archive = SevenZArchive::new(Cursor::new(data)).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default()
.with_allow_solid_archives(true)
.with_max_solid_block_memory(total_size - 1)
.validate()
.unwrap();
let result = archive.extract(
temp.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
);
assert!(result.is_err(), "one byte under limit should reject");
assert_matches!(result.unwrap_err(), ArchiveError::QuotaExceeded { .. });
}
#[test]
fn test_solid_archive_rejected_error_message() {
let data = load_fixture("solid.7z");
let cursor = Cursor::new(data);
let mut archive = SevenZArchive::new(cursor).unwrap();
let temp = TempDir::new().unwrap();
let result = archive.extract(
temp.path(),
&SecurityConfig::default().validate().unwrap(),
&ExtractionOptions::default(),
&mut crate::NoopProgress,
);
assert!(result.is_err());
match result.unwrap_err() {
ArchiveError::SecurityViolation { reason } => {
assert!(
reason.contains("solid") && reason.contains("allow_solid_archives"),
"error should mention 'solid' and 'allow_solid_archives', got: {reason}"
);
}
other => panic!("expected SecurityViolation, got {other:?}"),
}
}
#[test]
fn test_windows_reparse_point_detected() {
let mut entry = sevenz_rust2::ArchiveEntry::new_file("symlink.txt");
entry.has_windows_attributes = true;
entry.windows_attributes = 0x0400;
assert!(
SevenZEntryAdapter::is_windows_reparse_point(&entry),
"reparse point attribute should be detected"
);
let result = SevenZEntryAdapter::to_entry_type(&entry);
assert!(result.is_err(), "should return error for reparse point");
assert_matches!(
result.unwrap_err(),
ArchiveError::SecurityViolation { .. },
"should be SecurityViolation error"
);
}
#[test]
fn test_windows_reparse_point_not_set() {
let mut entry = sevenz_rust2::ArchiveEntry::new_file("file.txt");
entry.has_windows_attributes = true;
entry.windows_attributes = 0x0080;
assert!(
!SevenZEntryAdapter::is_windows_reparse_point(&entry),
"normal file should not be detected as reparse point"
);
let result = SevenZEntryAdapter::to_entry_type(&entry);
assert!(result.is_ok(), "normal file should succeed");
assert_eq!(result.unwrap(), EntryType::File);
}
#[test]
fn test_no_windows_attributes() {
let mut entry = sevenz_rust2::ArchiveEntry::new_file("file.txt");
entry.has_windows_attributes = false;
entry.windows_attributes = 0;
assert!(
!SevenZEntryAdapter::is_windows_reparse_point(&entry),
"entry without Windows attributes should not be detected as reparse point"
);
let result = SevenZEntryAdapter::to_entry_type(&entry);
assert!(result.is_ok(), "file without attributes should succeed");
assert_eq!(result.unwrap(), EntryType::File);
}
#[test]
fn test_windows_reparse_point_with_other_attributes() {
let mut entry = sevenz_rust2::ArchiveEntry::new_file("symlink.txt");
entry.has_windows_attributes = true;
entry.windows_attributes = 0x0400 | 0x0020;
assert!(
SevenZEntryAdapter::is_windows_reparse_point(&entry),
"reparse point should be detected even with other attributes"
);
let result = SevenZEntryAdapter::to_entry_type(&entry);
assert!(result.is_err(), "should return error for reparse point");
}
#[test]
fn test_directory_junction_reparse_point_rejected() {
let mut entry = sevenz_rust2::ArchiveEntry::new_directory("dir/");
entry.has_windows_attributes = true;
entry.windows_attributes = 0x0400;
let result = SevenZEntryAdapter::to_entry_type(&entry);
assert!(result.is_err(), "directory junction should be rejected");
assert_matches!(result.unwrap_err(), ArchiveError::SecurityViolation { .. });
}
#[test]
fn test_windows_reparse_point_error_message() {
let mut entry = sevenz_rust2::ArchiveEntry::new_file("link.txt");
entry.has_windows_attributes = true;
entry.windows_attributes = 0x0400;
let result = SevenZEntryAdapter::to_entry_type(&entry);
assert!(result.is_err());
match result.unwrap_err() {
ArchiveError::SecurityViolation { reason } => {
assert!(
reason.contains("symlink") && reason.contains("link.txt"),
"error should mention 'symlink' and entry name, got: {reason}"
);
assert!(
reason.contains("sevenz-rust2"),
"error should mention library limitation, got: {reason}"
);
}
other => panic!("expected SecurityViolation, got {other:?}"),
}
}
#[test]
fn test_sevenz_io_error_maps_to_io_context() {
let inner = std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"unexpected EOF while reading entry data",
);
let sevenz_err = sevenz_rust2::Error::Io(inner, "test.7z".into());
let archive_err: ArchiveError = sevenz_err.into();
match archive_err {
ArchiveError::Io(io_err) => {
assert_eq!(io_err.kind(), std::io::ErrorKind::Other);
let ctx = io_err
.get_ref()
.and_then(|inner| inner.downcast_ref::<IoContext>())
.expect("expected IoContext to be attached to the io::Error");
assert_eq!(ctx.context, "7z I/O error");
assert!(
ctx.detail.contains("unexpected EOF"),
"detail should retain the original sevenz-rust2 message, got: {}",
ctx.detail
);
}
other => panic!("expected ArchiveError::Io, got {other:?}"),
}
}
#[test]
fn test_list_returns_manifest_with_entries() {
let data = load_fixture("simple.7z");
let mut archive = SevenZArchive::new(Cursor::new(data)).unwrap();
let config = SecurityConfig::default().validate().expect("valid config");
let manifest = archive.list(&config).unwrap();
assert!(
manifest.total_entries > 0,
"simple.7z must have at least one entry"
);
}
#[test]
fn test_verify_clean_sevenz_is_safe() {
let data = load_fixture("simple.7z");
let mut archive = SevenZArchive::new(Cursor::new(data)).unwrap();
let config = SecurityConfig::default().validate().expect("valid config");
let report = archive.verify(&config).unwrap();
assert!(report.is_safe());
}
#[test]
fn test_allowed_extensions_filters_out_disallowed() {
let data = load_fixture("mixed-extensions.7z");
let dest = TempDir::new().unwrap();
let config = SecurityConfig::default()
.with_allowed_extensions(vec!["txt".to_string()])
.validate()
.unwrap();
let report = SevenZArchive::new(Cursor::new(data))
.unwrap()
.extract(
dest.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
)
.unwrap();
assert_eq!(
report.files_extracted, 2,
"only .txt files should be extracted"
);
assert_eq!(report.files_skipped, 1, ".exe file should be skipped");
assert!(
!dest
.path()
.join("mixed-ext-fixture")
.join("program.exe")
.exists(),
".exe must not be extracted"
);
assert!(
dest.path()
.join("mixed-ext-fixture")
.join("document.txt")
.exists(),
".txt files must be extracted"
);
assert_eq!(
report.warnings,
vec!["skipped 1 entry with disallowed extension".to_string()],
"the disallowed-extension skip must be aggregated into a single warning \
instead of a per-entry, path-bearing one (issue #495)"
);
}
#[test]
fn test_disallowed_extension_aggregates_single_warning() {
const ENTRY_COUNT: usize = 30;
let names: Vec<String> = (0..ENTRY_COUNT).map(|i| format!("skip-{i}.exe")).collect();
let entries: Vec<(&str, &[u8])> = names
.iter()
.map(|n| (n.as_str(), b"payload".as_slice()))
.collect();
let data = make_sevenz_archive(&entries);
let dest = TempDir::new().unwrap();
let config = SecurityConfig::default()
.with_allowed_extensions(vec!["txt".to_string()])
.validate()
.expect("valid config");
let report = SevenZArchive::new(Cursor::new(data))
.unwrap()
.extract(
dest.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
)
.unwrap();
assert_eq!(report.files_skipped, ENTRY_COUNT);
assert_eq!(report.files_extracted, 0);
assert_eq!(
report.warnings,
vec![format!(
"skipped {ENTRY_COUNT} entries with disallowed extensions"
)],
"disallowed-extension skips must be aggregated into a single warning, got: {:?}",
report.warnings
);
}
#[test]
fn test_duplicate_and_disallowed_extension_skips_aggregate_independently() {
const DUPLICATE_COUNT: usize = 5;
const DISALLOWED_COUNT: usize = 7;
let mut names: Vec<String> = (0..DUPLICATE_COUNT)
.map(|i| format!("dup-{i}.txt"))
.collect();
names.extend((0..DISALLOWED_COUNT).map(|i| format!("skip-{i}.exe")));
let entries: Vec<(&str, &[u8])> = names
.iter()
.map(|n| (n.as_str(), b"payload".as_slice()))
.collect();
let data = make_sevenz_archive(&entries);
let temp = TempDir::new().unwrap();
for name in names.iter().take(DUPLICATE_COUNT) {
std::fs::write(temp.path().join(name), b"already here").unwrap();
}
let config = SecurityConfig::default()
.with_allowed_extensions(vec!["txt".to_string()])
.validate()
.expect("valid config");
let report = SevenZArchive::new(Cursor::new(data))
.unwrap()
.extract(
temp.path(),
&config,
&ExtractionOptions::default(), &mut crate::NoopProgress,
)
.unwrap();
assert_eq!(report.files_skipped, DUPLICATE_COUNT + DISALLOWED_COUNT);
assert_eq!(report.files_extracted, 0);
assert_eq!(
report.warnings.len(),
2,
"each skip reason must aggregate into its own single warning, got: {:?}",
report.warnings
);
assert!(
report
.warnings
.iter()
.any(|w| w.contains(&DUPLICATE_COUNT.to_string()) && w.contains("duplicate")),
"expected a duplicate-skip warning reporting {DUPLICATE_COUNT}, got: {:?}",
report.warnings
);
assert!(
report
.warnings
.iter()
.any(|w| w.contains(&DISALLOWED_COUNT.to_string())
&& w.contains("disallowed extension")),
"expected a disallowed-extension warning reporting {DISALLOWED_COUNT}, got: {:?}",
report.warnings
);
}
#[test]
fn test_empty_allowed_extensions_allows_all() {
let data = load_fixture("simple.7z");
let dest = TempDir::new().unwrap();
let config = SecurityConfig::default().validate().expect("valid config");
let report = SevenZArchive::new(Cursor::new(data))
.unwrap()
.extract(
dest.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
)
.unwrap();
assert_eq!(report.files_skipped, 0);
assert!(report.files_extracted > 0);
}
#[test]
fn test_extension_less_files_blocked_when_allowlist_nonempty() {
let data = load_fixture("no-extension.7z");
let dest = TempDir::new().unwrap();
let config = SecurityConfig::default()
.with_allowed_extensions(vec!["txt".to_string()])
.validate()
.unwrap();
let report = SevenZArchive::new(Cursor::new(data))
.unwrap()
.extract(
dest.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
)
.unwrap();
assert_eq!(report.files_extracted, 1, "only .txt should be extracted");
assert_eq!(
report.files_skipped, 1,
"extension-less file must be skipped"
);
assert!(!dest.path().join("no-ext-fixture").join("Makefile").exists());
assert!(
dest.path()
.join("no-ext-fixture")
.join("document.txt")
.exists()
);
}
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;
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(Cursor::new(*data)))
.unwrap();
}
writer.finish().unwrap().into_inner()
}
#[test]
fn test_7z_backslash_entry_rejected() {
let data = make_sevenz_archive(&[("..\\..\\x", b"payload")]);
let mut archive = SevenZArchive::new(Cursor::new(data)).unwrap();
let temp = TempDir::new().unwrap();
let result = archive.extract(
temp.path(),
&SecurityConfig::default().validate().unwrap(),
&ExtractionOptions::default(),
&mut crate::NoopProgress,
);
assert!(
result.is_err(),
"backslash-encoded traversal must be rejected, got: {result:?}"
);
assert_matches!(
result.unwrap_err(),
ArchiveError::PathTraversal { .. },
"expected PathTraversal error"
);
}
#[test]
fn test_7z_absolute_path_rejected_by_default() {
let data = make_sevenz_archive(&[("/etc/shadow", b"secret")]);
let mut archive = SevenZArchive::new(Cursor::new(data)).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default().validate().expect("valid config");
let result = archive.extract(
temp.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
);
assert!(
result.is_err(),
"absolute path must be rejected by default, got: {result:?}"
);
}
#[test]
fn test_7z_absolute_path_with_flag_writes_to_dest() {
let data = make_sevenz_archive(&[("/etc/shadow", b"content")]);
let mut archive = SevenZArchive::new(Cursor::new(data)).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default()
.with_allow_absolute_paths(true)
.validate()
.unwrap();
let report = archive
.extract(
temp.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
)
.unwrap();
assert_eq!(report.files_extracted, 1);
assert!(
temp.path().join("etc/shadow").exists(),
"file must land inside dest, not at real /etc/shadow"
);
}
#[test]
fn test_7z_absolute_path_traversal_still_rejected_with_flag() {
let data = make_sevenz_archive(&[("/../etc/passwd", b"secret")]);
let mut archive = SevenZArchive::new(Cursor::new(data)).unwrap();
let temp = TempDir::new().unwrap();
let config = SecurityConfig::default()
.with_allow_absolute_paths(true)
.validate()
.unwrap();
let result = archive.extract(
temp.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
);
assert!(
result.is_err(),
"traversal-after-root must be rejected even with allow_absolute_paths"
);
}
#[test]
fn test_process_entry_inner_rejects_traversal_independently() {
let temp = TempDir::new().unwrap();
let dest = DestDir::new_or_create(temp.path().to_path_buf()).unwrap();
let config = SecurityConfig::default().validate().expect("valid config");
let mut validator = EntryValidator::new(&config, &dest);
let mut dir_cache = common::DirCache::new();
let mut report = ExtractionReport::new();
let mut duplicate_skips = 0u64;
let mut disallowed_extension_skips = 0u64;
let mut pending_error = None;
let mut copy_buffer = CopyBuffer::new();
let mut entry = sevenz_rust2::ArchiveEntry::new_file("../../evil.txt");
entry.has_stream = true;
entry.size = 5;
let entry_path = std::path::PathBuf::from(common::normalize_entry_name(&entry.name));
let result = SevenZArchive::<Cursor<Vec<u8>>>::process_entry_inner(
&entry,
&mut std::io::empty(),
&entry_path,
&mut validator,
&dest,
&mut report,
&mut dir_cache,
false,
&config,
&mut duplicate_skips,
&mut disallowed_extension_skips,
&mut pending_error,
&mut copy_buffer,
);
assert_matches!(
&result,
Err(sevenz_rust2::Error::Other(m)) if m.contains("validation failed"),
"callback re-validation must independently reject a traversal entry via its own \
validate_entry call, got: {result:?}"
);
}
#[test]
fn test_process_entry_inner_duplicate_skips_saturates_at_max() {
let temp = TempDir::new().unwrap();
let dest = DestDir::new_or_create(temp.path().to_path_buf()).unwrap();
std::fs::write(temp.path().join("existing.txt"), b"already here").unwrap();
let config = SecurityConfig::default().validate().expect("valid config");
let mut validator = EntryValidator::new(&config, &dest);
let mut dir_cache = common::DirCache::new();
let mut report = ExtractionReport::new();
let mut duplicate_skips = u64::MAX;
let mut disallowed_extension_skips = 0u64;
let mut pending_error = None;
let mut copy_buffer = CopyBuffer::new();
let mut entry = sevenz_rust2::ArchiveEntry::new_file("existing.txt");
entry.has_stream = true;
entry.size = 5;
let entry_path = std::path::PathBuf::from(common::normalize_entry_name(&entry.name));
let result = SevenZArchive::<Cursor<Vec<u8>>>::process_entry_inner(
&entry,
&mut std::io::empty(),
&entry_path,
&mut validator,
&dest,
&mut report,
&mut dir_cache,
true, &config,
&mut duplicate_skips,
&mut disallowed_extension_skips,
&mut pending_error,
&mut copy_buffer,
);
assert_matches!(
result,
Ok(0),
"duplicate skip must not error, got: {result:?}"
);
assert_eq!(
duplicate_skips,
u64::MAX,
"duplicate_skips must saturate at u64::MAX instead of wrapping or panicking"
);
}
#[test]
#[cfg(unix)]
fn test_skip_duplicates_detects_dangling_symlink_at_destination() {
let data = make_sevenz_archive(&[("target.txt", b"payload")]);
let mut archive = SevenZArchive::new(Cursor::new(data)).unwrap();
let temp = TempDir::new().unwrap();
let link_path = temp.path().join("target.txt");
std::os::unix::fs::symlink(temp.path().join("does-not-exist"), &link_path).unwrap();
assert!(
!link_path.exists(),
"sanity check: dangling symlink must be invisible to exists()"
);
let config = SecurityConfig::default().validate().expect("valid config");
let report = archive
.extract(
temp.path(),
&config,
&ExtractionOptions::default(), &mut crate::NoopProgress,
)
.unwrap();
assert_eq!(
report.files_skipped, 1,
"entry must be skipped as a duplicate of the dangling symlink"
);
assert_eq!(report.files_extracted, 0);
let metadata = std::fs::symlink_metadata(&link_path).unwrap();
assert!(
metadata.file_type().is_symlink(),
"dangling symlink must survive untouched, not be replaced by extracted content"
);
}
#[test]
#[cfg(unix)]
fn test_duplicate_rejects_dangling_symlink_when_not_skipping() {
let data = make_sevenz_archive(&[("target.txt", b"payload")]);
let mut archive = SevenZArchive::new(Cursor::new(data)).unwrap();
let temp = TempDir::new().unwrap();
let link_path = temp.path().join("target.txt");
std::os::unix::fs::symlink(temp.path().join("does-not-exist"), &link_path).unwrap();
let config = SecurityConfig::default().validate().expect("valid config");
let options = ExtractionOptions {
skip_duplicates: false,
..ExtractionOptions::default()
};
let result = archive.extract(temp.path(), &config, &options, &mut crate::NoopProgress);
assert!(
result.is_err(),
"a pre-existing symlink at the destination must be rejected, not silently \
replaced: {result:?}"
);
let metadata = std::fs::symlink_metadata(&link_path).unwrap();
assert!(
metadata.file_type().is_symlink(),
"dangling symlink must survive untouched, not be replaced by extracted content"
);
}
#[test]
fn test_overwrite_directory_returns_error_without_deleting_it() {
let data = make_sevenz_archive(&[("target", b"payload")]);
let mut archive = SevenZArchive::new(Cursor::new(data)).unwrap();
let temp = TempDir::new().unwrap();
let dir_path = temp.path().join("target");
std::fs::create_dir(&dir_path).unwrap();
let inner_file = dir_path.join("keep-me.txt");
std::fs::write(&inner_file, b"do not delete").unwrap();
let config = SecurityConfig::default().validate().expect("valid config");
let options = ExtractionOptions {
skip_duplicates: false,
..ExtractionOptions::default()
};
let result = archive.extract(temp.path(), &config, &options, &mut crate::NoopProgress);
let err =
result.expect_err("extracting a file entry onto a pre-existing directory must fail");
assert_matches!(
&err,
ArchiveError::Io(io_err) if io_err.kind() == std::io::ErrorKind::IsADirectory,
"must fail with ErrorKind::IsADirectory specifically, not a generic or \
misclassified error, got: {err:?}"
);
assert!(
dir_path.is_dir(),
"pre-existing directory must survive the failed extraction, not be deleted"
);
assert_eq!(
std::fs::read(&inner_file).unwrap(),
b"do not delete",
"directory contents must be untouched by the failed extraction"
);
}
#[test]
#[cfg(unix)]
fn test_symlink_to_directory_rejected_via_symlink_check_not_directory_check() {
let data = make_sevenz_archive(&[("target", b"payload")]);
let mut archive = SevenZArchive::new(Cursor::new(data)).unwrap();
let temp = TempDir::new().unwrap();
let real_dir = temp.path().join("real-dir");
std::fs::create_dir(&real_dir).unwrap();
let link_path = temp.path().join("target");
std::os::unix::fs::symlink(&real_dir, &link_path).unwrap();
let config = SecurityConfig::default().validate().expect("valid config");
let options = ExtractionOptions {
skip_duplicates: false,
..ExtractionOptions::default()
};
let result = archive.extract(temp.path(), &config, &options, &mut crate::NoopProgress);
assert!(
result.is_err(),
"a pre-existing symlink at the destination must be rejected, even when its \
target is a directory: {result:?}"
);
let metadata = std::fs::symlink_metadata(&link_path).unwrap();
assert!(
metadata.file_type().is_symlink(),
"the symlink itself must survive untouched, not be replaced"
);
assert!(
real_dir.is_dir(),
"the symlink's target directory itself must be untouched"
);
}
#[test]
fn test_skip_duplicates_aggregates_single_warning() {
const ENTRY_COUNT: usize = 30;
let names: Vec<String> = (0..ENTRY_COUNT).map(|i| format!("dup-{i}.txt")).collect();
let entries: Vec<(&str, &[u8])> = names
.iter()
.map(|n| (n.as_str(), b"payload".as_slice()))
.collect();
let data = make_sevenz_archive(&entries);
let mut archive = SevenZArchive::new(Cursor::new(data)).unwrap();
let temp = TempDir::new().unwrap();
for name in &names {
std::fs::write(temp.path().join(name), b"already here").unwrap();
}
let config = SecurityConfig::default().validate().expect("valid config");
let report = archive
.extract(
temp.path(),
&config,
&ExtractionOptions::default(), &mut crate::NoopProgress,
)
.unwrap();
assert_eq!(report.files_skipped, ENTRY_COUNT);
assert_eq!(report.files_extracted, 0);
assert_eq!(
report.warnings.len(),
1,
"duplicate skips must be aggregated into a single warning, got: {:?}",
report.warnings
);
assert!(
report.warnings[0].contains(&ENTRY_COUNT.to_string()),
"aggregated warning must report the correct skipped count, got: {}",
report.warnings[0]
);
}
#[test]
#[cfg(unix)]
fn test_dir_swapped_for_symlink_between_entries_is_rejected() {
struct DirSwapper {
dest_root: std::path::PathBuf,
outside_root: std::path::PathBuf,
}
impl crate::ProgressCallback for DirSwapper {
fn on_entry_start(&mut self, _path: &std::path::Path, _total: usize, _current: usize) {}
fn on_bytes_written(&mut self, _bytes: u64) {}
fn on_entry_complete(&mut self, path: &std::path::Path) {
if path == std::path::Path::new("a/file1.txt") {
let a = self.dest_root.join("a");
std::fs::remove_dir_all(&a).unwrap();
std::os::unix::fs::symlink(&self.outside_root, &a).unwrap();
}
}
fn on_complete(&mut self) {}
}
let data = make_sevenz_archive(&[("a/file1.txt", b"one"), ("a/file2.txt", b"two")]);
let mut archive = SevenZArchive::new(Cursor::new(data)).unwrap();
let dest = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let mut swapper = DirSwapper {
dest_root: dest.path().to_path_buf(),
outside_root: outside.path().to_path_buf(),
};
let config = SecurityConfig::default().validate().expect("valid config");
let result = archive.extract(
dest.path(),
&config,
&ExtractionOptions::default(),
&mut swapper,
);
assert!(
result.is_err(),
"extraction must fail once a parent directory is swapped for a symlink \
mid-extraction, not silently continue writing through it"
);
assert!(
!outside.path().join("file2.txt").exists(),
"file2.txt must not be written through the swapped symlink outside the \
destination root"
);
}
#[test]
fn test_extract_can_be_called_twice_on_same_instance() {
let data = make_sevenz_archive(&[("file.txt", b"payload")]);
let mut archive = SevenZArchive::new(Cursor::new(data)).unwrap();
let config = SecurityConfig::default().validate().expect("valid config");
let first_dest = TempDir::new().unwrap();
let first_report = archive
.extract(
first_dest.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
)
.unwrap();
assert_eq!(first_report.files_extracted, 1);
let second_dest = TempDir::new().unwrap();
let second_report = archive
.extract(
second_dest.path(),
&config,
&ExtractionOptions::default(),
&mut crate::NoopProgress,
)
.unwrap();
assert_eq!(
second_report.files_extracted, 1,
"second extract() call on the same instance must extract the real archive \
contents, not silently return zero files"
);
assert_eq!(
std::fs::read(second_dest.path().join("file.txt")).unwrap(),
b"payload"
);
}
}