use rustc_hash::FxHashSet;
use std::fs::File;
use std::fs::create_dir_all;
use std::io::BufWriter;
use std::io::Read;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use crate::ArchiveError;
use crate::ExtractionReport;
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::quota::QuotaPermit;
use crate::types::DestDir;
use crate::types::SafePath;
use crate::types::SafeSymlink;
pub struct EntryCompleteGuard<'a> {
progress: &'a mut dyn ProgressCallback,
path: &'a std::path::Path,
fired: bool,
}
impl<'a> EntryCompleteGuard<'a> {
pub fn new(progress: &'a mut dyn ProgressCallback, path: &'a std::path::Path) -> Self {
Self {
progress,
path,
fired: false,
}
}
pub fn progress_mut(&mut self) -> &mut dyn ProgressCallback {
self.progress
}
pub fn complete(mut self) {
self.progress.on_entry_complete(self.path);
self.fired = true;
}
}
impl Drop for EntryCompleteGuard<'_> {
fn drop(&mut self) {
if !self.fired {
self.progress.on_entry_complete(self.path);
}
}
}
#[derive(Debug)]
pub struct DirCache {
created: FxHashSet<PathBuf>,
}
impl DirCache {
#[must_use]
#[inline]
pub fn new() -> Self {
Self::with_capacity(128)
}
#[must_use]
pub fn with_capacity(capacity: usize) -> Self {
use rustc_hash::FxBuildHasher;
Self {
created: FxHashSet::with_capacity_and_hasher(capacity, FxBuildHasher),
}
}
fn cache_ancestors(&mut self, path: &Path) {
let mut current = path;
while !current.as_os_str().is_empty() {
self.created.insert(current.to_path_buf());
match current.parent() {
Some(p) if !p.as_os_str().is_empty() => current = p,
_ => break,
}
}
}
#[inline]
pub fn contains(&self, path: &Path) -> bool {
self.created.contains(path)
}
#[inline]
pub fn ensure_parent_dir(&mut self, file_path: &Path) -> std::io::Result<bool> {
if let Some(parent) = file_path.parent() {
if parent.as_os_str().is_empty() {
return Ok(false);
}
if !self.created.contains(parent) {
create_dir_all(parent)?;
self.cache_ancestors(parent);
return Ok(true);
}
}
Ok(false)
}
#[inline]
pub fn ensure_dir(&mut self, dir_path: &Path) -> std::io::Result<bool> {
if dir_path.as_os_str().is_empty() {
return Ok(false);
}
if !self.created.contains(dir_path) {
create_dir_all(dir_path)?;
self.cache_ancestors(dir_path);
return Ok(true);
}
Ok(false)
}
}
impl Default for DirCache {
fn default() -> Self {
Self::new()
}
}
#[inline]
#[must_use]
pub fn normalize_entry_name(name: &str) -> String {
if name.contains('\\') {
name.replace('\\', "/")
} else {
name.to_owned()
}
}
pub fn push_duplicate_skip_warning(
report: &mut ExtractionReport,
count: u64,
noun_singular: &str,
noun_plural: &str,
) {
if count > 0 {
let noun = if count == 1 {
noun_singular
} else {
noun_plural
};
report
.warnings
.push(format!("skipped {count} {noun} as pre-existing duplicates"));
}
}
pub fn push_disallowed_extension_warning(report: &mut ExtractionReport, count: u64) {
if count > 0 {
let (noun, ext_noun) = if count == 1 {
("entry", "extension")
} else {
("entries", "extensions")
};
report
.warnings
.push(format!("skipped {count} {noun} with disallowed {ext_noun}"));
}
}
#[inline]
pub fn checked_increment_files_skipped(report: &mut ExtractionReport) -> Result<()> {
report.files_skipped =
report
.files_skipped
.checked_add(1)
.ok_or(ArchiveError::QuotaExceeded {
resource: QuotaResource::IntegerOverflow,
})?;
Ok(())
}
#[must_use]
#[inline]
pub fn check_extension_allowed(
path: &Path,
config: &SecurityConfig<Validated>,
report: &mut ExtractionReport,
disallowed_extension_skips: &mut u64,
) -> bool {
let ext = path.extension().and_then(|e| e.to_str());
if config.is_path_extension_allowed(ext) {
return true;
}
report.files_skipped = report.files_skipped.saturating_add(1);
*disallowed_extension_skips = disallowed_extension_skips.saturating_add(1);
false
}
#[inline]
#[cfg(unix)]
pub fn create_file_with_mode(
path: &Path,
mode: Option<u32>,
create_new: bool,
) -> std::io::Result<File> {
use std::fs::OpenOptions;
use std::fs::Permissions;
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::fs::PermissionsExt;
let mut opts = OpenOptions::new();
opts.write(true);
opts.custom_flags(libc::O_NOFOLLOW);
if create_new {
opts.create_new(true);
} else {
opts.create(true).truncate(true);
}
if let Some(m) = mode {
opts.mode(m);
}
let file = opts.open(path)?;
if let Some(m) = mode {
file.set_permissions(Permissions::from_mode(m))?;
}
Ok(file)
}
#[inline]
#[cfg(not(unix))]
pub fn create_file_with_mode(
path: &Path,
_mode: Option<u32>,
create_new: bool,
) -> std::io::Result<File> {
if create_new {
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
} else {
File::create(path)
}
}
#[allow(clippy::too_many_arguments)]
#[inline]
pub fn extract_file_with_permit<R: Read>(
reader: &mut R,
safe_path: &SafePath,
mode: Option<u32>,
_permit: QuotaPermit,
dest: &DestDir,
report: &mut ExtractionReport,
expected_size: Option<u64>,
copy_buffer: &mut CopyBuffer,
dir_cache: &mut DirCache,
skip_duplicates: bool,
duplicate_skips: &mut u64,
progress: &mut dyn ProgressCallback,
) -> Result<()> {
let output_path = dest.join(safe_path);
dir_cache.ensure_parent_dir(&output_path)?;
if let Some(size) = expected_size {
report
.bytes_written
.checked_add(size)
.ok_or(ArchiveError::QuotaExceeded {
resource: QuotaResource::IntegerOverflow,
})?;
}
let output_file = match create_file_with_mode(&output_path, mode, skip_duplicates) {
Ok(file) => file,
Err(e) if skip_duplicates && e.kind() == std::io::ErrorKind::AlreadyExists => {
checked_increment_files_skipped(report)?;
*duplicate_skips = duplicate_skips.saturating_add(1);
return Ok(());
}
Err(e) => return Err(e.into()),
};
let guard = skip_duplicates.then(|| TempFileGuard::new(output_path));
let mut buffered_writer = BufWriter::with_capacity(64 * 1024, output_file);
let bytes_written = copy_with_buffer(reader, &mut buffered_writer, copy_buffer, expected_size)?;
buffered_writer.flush()?;
if let Some(guard) = guard {
guard.persist();
}
if bytes_written > 0 {
progress.on_bytes_written(bytes_written);
}
report.files_extracted += 1;
report.bytes_written =
report
.bytes_written
.checked_add(bytes_written)
.ok_or(ArchiveError::QuotaExceeded {
resource: QuotaResource::IntegerOverflow,
})?;
Ok(())
}
pub fn create_directory(
safe_path: &SafePath,
dest: &DestDir,
report: &mut ExtractionReport,
dir_cache: &mut DirCache,
) -> Result<()> {
let dir_path = dest.join(safe_path);
dir_cache.ensure_dir(&dir_path)?;
report.directories_created += 1;
Ok(())
}
#[allow(unused_variables)]
pub fn create_symlink(
safe_symlink: &SafeSymlink,
dest: &DestDir,
report: &mut ExtractionReport,
dir_cache: &mut DirCache,
skip_duplicates: bool,
duplicate_skips: &mut u64,
) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
let link_path = dest.join_path(safe_symlink.link_path());
let target_path = safe_symlink.target_path();
dir_cache.ensure_parent_dir(&link_path)?;
if link_path.symlink_metadata().is_ok() {
if skip_duplicates {
checked_increment_files_skipped(report)?;
*duplicate_skips = duplicate_skips.saturating_add(1);
return Ok(());
}
std::fs::remove_file(&link_path)?;
}
symlink(target_path, &link_path)?;
report.symlinks_created += 1;
Ok(())
}
#[cfg(not(unix))]
{
Err(ArchiveError::SecurityViolation {
reason: "symlinks are not supported on this platform".into(),
})
}
}
pub fn open_no_follow(path: &Path) -> std::io::Result<File> {
let mut opts = std::fs::OpenOptions::new();
opts.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.custom_flags(libc::O_NOFOLLOW);
}
opts.open(path)
}
#[must_use]
pub fn is_filesystem_loop_error(error: &std::io::Error) -> bool {
#[cfg(unix)]
{
error.raw_os_error() == Some(libc::ELOOP)
}
#[cfg(not(unix))]
{
let _ = error;
false
}
}
pub fn copy_file_content_with_permit(
mut from: File,
mut to: File,
_permit: QuotaPermit,
) -> std::io::Result<u64> {
let bytes_copied = std::io::copy(&mut from, &mut to)?;
#[cfg(unix)]
{
let permissions = from.metadata()?.permissions();
to.set_permissions(permissions)?;
}
Ok(bytes_copied)
}
pub struct TempFileGuard {
path: PathBuf,
should_cleanup: bool,
}
impl TempFileGuard {
pub fn new(path: PathBuf) -> Self {
Self {
path,
should_cleanup: true,
}
}
pub fn persist(mut self) {
self.should_cleanup = false;
}
}
impl Drop for TempFileGuard {
fn drop(&mut self) {
if self.should_cleanup {
let _ = std::fs::remove_file(&self.path);
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use crate::ArchiveError;
use crate::ExtractionReport;
use crate::NoopProgress;
use crate::SecurityConfig;
use crate::copy::CopyBuffer;
use crate::security::quota::QuotaTracker;
use std::assert_matches;
use std::io::Cursor;
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
fn test_extract_file_with_permit_integer_overflow_check() {
let temp = TempDir::new().expect("failed to create temp dir");
let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
let mut report = ExtractionReport::default();
let mut copy_buffer = CopyBuffer::new();
let mut dir_cache = DirCache::new();
report.bytes_written = u64::MAX - 100;
let expected_size = Some(200u64);
let config = SecurityConfig::default().validate().expect("valid config");
let permit = QuotaTracker::new()
.reserve(0, &config)
.expect("reservation should succeed");
let safe_path = SafePath::validate(&PathBuf::from("test.txt"), &dest, &config)
.expect("path should be valid");
let mut reader = Cursor::new(b"test data");
let result = extract_file_with_permit(
&mut reader,
&safe_path,
Some(0o644),
permit,
&dest,
&mut report,
expected_size,
&mut copy_buffer,
&mut dir_cache,
true,
&mut 0u64,
&mut NoopProgress,
);
assert!(result.is_err());
assert_matches!(
result.unwrap_err(),
ArchiveError::QuotaExceeded {
resource: QuotaResource::IntegerOverflow
}
);
assert!(
!temp.path().join("test.txt").exists(),
"overflow-rejected entry must not touch the filesystem"
);
}
#[test]
fn test_extract_file_with_permit_files_skipped_overflow_check() {
let temp = TempDir::new().expect("failed to create temp dir");
let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
let mut report = ExtractionReport::default();
let mut copy_buffer = CopyBuffer::new();
let mut dir_cache = DirCache::new();
let mut duplicate_skips = 0u64;
report.files_skipped = usize::MAX;
let config = SecurityConfig::default().validate().expect("valid config");
let permit = QuotaTracker::new()
.reserve(0, &config)
.expect("reservation should succeed");
let safe_path = SafePath::validate(&PathBuf::from("test.txt"), &dest, &config)
.expect("path should be valid");
std::fs::write(temp.path().join("test.txt"), b"existing").expect("failed to seed file");
let mut reader = Cursor::new(b"test data");
let result = extract_file_with_permit(
&mut reader,
&safe_path,
Some(0o644),
permit,
&dest,
&mut report,
None,
&mut copy_buffer,
&mut dir_cache,
true,
&mut duplicate_skips,
&mut NoopProgress,
);
assert!(result.is_err());
assert_matches!(
result.unwrap_err(),
ArchiveError::QuotaExceeded {
resource: QuotaResource::IntegerOverflow
}
);
}
#[test]
fn test_extract_file_with_permit_forged_size_aborts_and_cleans_up() {
let temp = TempDir::new().expect("failed to create temp dir");
let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
let mut report = ExtractionReport::default();
let mut copy_buffer = CopyBuffer::new();
let mut dir_cache = DirCache::new();
let config = SecurityConfig::default().validate().expect("valid config");
let permit = QuotaTracker::new()
.reserve(50, &config)
.expect("reservation should succeed");
let safe_path = SafePath::validate(&PathBuf::from("bomb.txt"), &dest, &config)
.expect("path should be valid");
let real_data = vec![0x41u8; 200 * 1024];
let mut reader = Cursor::new(&real_data);
let result = extract_file_with_permit(
&mut reader,
&safe_path,
Some(0o644),
permit,
&dest,
&mut report,
Some(50),
&mut copy_buffer,
&mut dir_cache,
true,
&mut 0u64,
&mut NoopProgress,
);
assert_matches!(
result,
Err(ArchiveError::SecurityViolation { .. }),
"streaming past the declared size must abort with a security error, got: {result:?}"
);
assert!(
!temp.path().join("bomb.txt").exists(),
"aborted extraction must not leave a partial or oversized file on disk"
);
}
#[test]
fn test_extract_file_with_permit_undersized_stream_cleans_up() {
let temp = TempDir::new().expect("failed to create temp dir");
let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
let mut report = ExtractionReport::default();
let mut copy_buffer = CopyBuffer::new();
let mut dir_cache = DirCache::new();
let config = SecurityConfig::default().validate().expect("valid config");
let permit = QuotaTracker::new()
.reserve(1000, &config)
.expect("reservation should succeed");
let safe_path = SafePath::validate(&PathBuf::from("short.txt"), &dest, &config)
.expect("path should be valid");
let mut reader = Cursor::new(b"too short");
let result = extract_file_with_permit(
&mut reader,
&safe_path,
Some(0o644),
permit,
&dest,
&mut report,
Some(1000),
&mut copy_buffer,
&mut dir_cache,
true,
&mut 0u64,
&mut NoopProgress,
);
assert_matches!(
result,
Err(ArchiveError::SecurityViolation { .. }),
"actual size short of declared size must be rejected, got: {result:?}"
);
assert!(
!temp.path().join("short.txt").exists(),
"rejected entry must not leave a truncated file on disk"
);
}
#[test]
fn test_extract_file_with_permit_force_overwrite_forged_size_does_not_delete_destination() {
let temp = TempDir::new().expect("failed to create temp dir");
let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
let mut report = ExtractionReport::default();
let mut copy_buffer = CopyBuffer::new();
let mut dir_cache = DirCache::new();
std::fs::write(temp.path().join("target.txt"), b"pre-existing content")
.expect("failed to seed file");
let config = SecurityConfig::default().validate().expect("valid config");
let permit = QuotaTracker::new()
.reserve(50, &config)
.expect("reservation should succeed");
let safe_path = SafePath::validate(&PathBuf::from("target.txt"), &dest, &config)
.expect("path should be valid");
let real_data = vec![0x41u8; 200 * 1024];
let mut reader = Cursor::new(&real_data);
let result = extract_file_with_permit(
&mut reader,
&safe_path,
Some(0o644),
permit,
&dest,
&mut report,
Some(50),
&mut copy_buffer,
&mut dir_cache,
false, &mut 0u64,
&mut NoopProgress,
);
assert_matches!(result, Err(ArchiveError::SecurityViolation { .. }));
assert!(
temp.path().join("target.txt").exists(),
"aborted --force overwrite must not delete the destination path entirely"
);
}
#[test]
fn test_dir_cache_basic() {
let temp = TempDir::new().expect("failed to create temp dir");
let mut cache = DirCache::new();
let file_path = temp.path().join("a/b/c/file.txt");
let created = cache
.ensure_parent_dir(&file_path)
.expect("should create dir");
assert!(created, "first call should create directory");
assert!(temp.path().join("a/b/c").exists());
let created = cache
.ensure_parent_dir(&file_path)
.expect("should use cache");
assert!(!created, "second call should use cache");
}
#[test]
fn test_dir_cache_nested_paths() {
let temp = TempDir::new().expect("failed to create temp dir");
let mut cache = DirCache::new();
let file1 = temp.path().join("a/b/c/d/file1.txt");
cache.ensure_parent_dir(&file1).expect("should create");
assert!(temp.path().join("a/b/c/d").exists());
let file2 = temp.path().join("a/b/other.txt");
let created = cache.ensure_parent_dir(&file2).expect("should use cache");
assert!(!created, "ancestor should be cached");
}
#[test]
fn test_dir_cache_ensure_dir() {
let temp = TempDir::new().expect("failed to create temp dir");
let mut cache = DirCache::new();
let dir_path = temp.path().join("a/b/c");
let created = cache.ensure_dir(&dir_path).expect("should create dir");
assert!(created, "first call should create directory");
assert!(dir_path.exists());
let created = cache.ensure_dir(&dir_path).expect("should use cache");
assert!(!created, "second call should use cache");
}
#[test]
fn test_dir_cache_empty_parent() {
use std::path::PathBuf;
let mut cache = DirCache::new();
let file_path = PathBuf::from("file.txt");
let created = cache
.ensure_parent_dir(&file_path)
.expect("should handle empty parent");
assert!(!created, "file with no directory should return false");
}
#[test]
fn test_dir_cache_single_component() {
let temp = TempDir::new().expect("failed to create temp dir");
let mut cache = DirCache::new();
let file_path = temp.path().join("file.txt");
let created = cache
.ensure_parent_dir(&file_path)
.expect("should handle single component");
assert!(created, "parent directory gets cached on first call");
let file_path2 = temp.path().join("file2.txt");
let created = cache
.ensure_parent_dir(&file_path2)
.expect("should use cache");
assert!(!created, "second call uses cached parent");
}
#[test]
fn test_dir_cache_preexisting_directory() {
let temp = TempDir::new().expect("failed to create temp dir");
let mut cache = DirCache::new();
let dir_path = temp.path().join("existing/dir");
std::fs::create_dir_all(&dir_path).expect("should create dir");
let created = cache.ensure_dir(&dir_path).expect("should succeed");
assert!(created, "first call creates cache entry even if dir exists");
let created = cache.ensure_dir(&dir_path).expect("should succeed");
assert!(!created, "second call uses cache");
}
#[test]
fn test_dir_cache_deep_nesting() {
let temp = TempDir::new().expect("failed to create temp dir");
let mut cache = DirCache::new();
let mut path = temp.path().to_path_buf();
for i in 0..100 {
path.push(format!("level{i}"));
}
path.push("file.txt");
let created = cache
.ensure_parent_dir(&path)
.expect("should create deep nesting");
assert!(created, "deep nesting should be created");
let parent = path.parent().expect("should have parent");
assert!(parent.exists(), "all levels should exist");
let created = cache.ensure_parent_dir(&path).expect("should use cache");
assert!(!created, "deep nesting should be cached");
}
#[test]
fn test_dir_cache_multiple_files_same_dir() {
let temp = TempDir::new().expect("failed to create temp dir");
let mut cache = DirCache::new();
let dir = temp.path().join("shared/directory");
let file1 = dir.join("file1.txt");
let created = cache.ensure_parent_dir(&file1).expect("should create dir");
assert!(created, "first file creates directory");
for i in 2..=10 {
let file = dir.join(format!("file{i}.txt"));
let created = cache.ensure_parent_dir(&file).expect("should use cache");
assert!(!created, "file {i} should use cached directory");
}
}
#[test]
fn test_dir_cache_with_capacity() {
let cache = DirCache::with_capacity(1000);
assert_eq!(cache.created.len(), 0, "should start empty");
}
#[test]
fn test_dir_cache_contains() {
let temp = TempDir::new().expect("failed to create temp dir");
let mut cache = DirCache::new();
let dir_path = temp.path().join("a/b/c");
assert!(
!cache.contains(&dir_path),
"should not contain before creation"
);
cache.ensure_dir(&dir_path).expect("should create dir");
assert!(cache.contains(&dir_path), "should contain after creation");
assert!(
cache.contains(&temp.path().join("a/b")),
"ancestor should be cached"
);
assert!(
cache.contains(&temp.path().join("a")),
"ancestor should be cached"
);
}
#[cfg(unix)]
#[test]
fn test_create_file_with_mode_0o644() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().expect("failed to create temp dir");
let file_path = temp.path().join("test_0o644.txt");
let file =
create_file_with_mode(&file_path, Some(0o644), false).expect("should create file");
drop(file);
assert!(file_path.exists(), "file should exist");
let metadata = std::fs::metadata(&file_path).expect("should read metadata");
let mode = metadata.permissions().mode();
let permission_bits = mode & 0o777;
assert_eq!(
permission_bits, 0o644,
"file should have permissions 0o644, got 0o{permission_bits:o}"
);
}
#[cfg(unix)]
#[test]
fn test_create_file_with_mode_0o755() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().expect("failed to create temp dir");
let file_path = temp.path().join("test_0o755.txt");
let file =
create_file_with_mode(&file_path, Some(0o755), false).expect("should create file");
drop(file);
assert!(file_path.exists(), "file should exist");
let metadata = std::fs::metadata(&file_path).expect("should read metadata");
let mode = metadata.permissions().mode();
let permission_bits = mode & 0o777;
assert_eq!(
permission_bits, 0o755,
"file should have permissions 0o755, got 0o{permission_bits:o}"
);
}
#[cfg(unix)]
#[test]
fn test_create_file_with_mode_0o600() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().expect("failed to create temp dir");
let file_path = temp.path().join("test_0o600.txt");
let file =
create_file_with_mode(&file_path, Some(0o600), false).expect("should create file");
drop(file);
assert!(file_path.exists(), "file should exist");
let metadata = std::fs::metadata(&file_path).expect("should read metadata");
let mode = metadata.permissions().mode();
let permission_bits = mode & 0o777;
assert_eq!(
permission_bits, 0o600,
"file should have permissions 0o600, got 0o{permission_bits:o}"
);
}
#[test]
fn test_create_file_with_mode_none() {
let temp = TempDir::new().expect("failed to create temp dir");
let file_path = temp.path().join("test_none.txt");
let file = create_file_with_mode(&file_path, None, false).expect("should create file");
drop(file);
assert!(file_path.exists(), "file should exist");
}
#[cfg(unix)]
#[test]
fn test_create_file_with_mode_none_unix() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().expect("failed to create temp dir");
let file_path = temp.path().join("test_none_unix.txt");
let file = create_file_with_mode(&file_path, None, false).expect("should create file");
drop(file);
assert!(file_path.exists(), "file should exist");
let metadata = std::fs::metadata(&file_path).expect("should read metadata");
let mode = metadata.permissions().mode();
let permission_bits = mode & 0o777;
assert_ne!(
permission_bits, 0,
"file should have non-zero permissions with mode=None"
);
}
#[cfg(unix)]
#[test]
fn test_extract_file_permissions_bypass_umask() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().expect("failed to create temp dir");
let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
let mut report = ExtractionReport::default();
let mut copy_buffer = CopyBuffer::new();
let mut dir_cache = DirCache::new();
let config = SecurityConfig::default().validate().expect("valid config");
let sanitized_mode = 0o775u32;
let permit = QuotaTracker::new()
.reserve(0, &config)
.expect("reservation should succeed");
let safe_path = SafePath::validate(&PathBuf::from("perm_test.txt"), &dest, &config)
.expect("path should be valid");
let mut reader = Cursor::new(b"content");
extract_file_with_permit(
&mut reader,
&safe_path,
Some(sanitized_mode),
permit,
&dest,
&mut report,
None,
&mut copy_buffer,
&mut dir_cache,
true,
&mut 0u64,
&mut NoopProgress,
)
.expect("extraction should succeed");
let extracted = temp.path().join("perm_test.txt");
assert!(extracted.exists(), "file should exist");
let metadata = std::fs::metadata(&extracted).expect("should read metadata");
let permission_bits = metadata.permissions().mode() & 0o777;
assert_eq!(
permission_bits, 0o775,
"extracted file must have exact sanitized mode 0o775, got 0o{permission_bits:o}; \
umask may have incorrectly reduced permissions"
);
}
#[cfg(unix)]
#[test]
#[allow(unsafe_code)]
fn test_create_file_with_mode_bypasses_strict_umask() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().expect("failed to create temp dir");
let file_path = temp.path().join("strict_umask_test.txt");
let previous_umask = unsafe { libc::umask(0o077) };
let result = create_file_with_mode(&file_path, Some(0o755), false);
unsafe { libc::umask(previous_umask) };
let file = result.expect("should create file under strict umask");
drop(file);
let metadata = std::fs::metadata(&file_path).expect("should read metadata");
let permission_bits = metadata.permissions().mode() & 0o777;
assert_eq!(
permission_bits, 0o755,
"file must have exact mode 0o755 despite strict umask 0o077; \
got 0o{permission_bits:o} — set_permissions bypass not working"
);
}
#[test]
#[cfg(unix)]
fn test_duplicate_symlink_overwrites_when_skip_disabled() {
use crate::types::SafeSymlink;
let temp = TempDir::new().expect("failed to create temp dir");
let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
let config = SecurityConfig::default()
.with_allow_symlinks(true)
.validate()
.expect("valid config");
std::fs::write(temp.path().join("target.txt"), b"data").expect("write target");
let link_safe_path =
SafePath::validate(&PathBuf::from("link.txt"), &dest, &config).expect("safe path");
let safe_symlink =
SafeSymlink::validate(&link_safe_path, Path::new("target.txt"), &dest, &config)
.expect("safe symlink");
let mut report = ExtractionReport::default();
let mut dir_cache = DirCache::new();
create_symlink(
&safe_symlink,
&dest,
&mut report,
&mut dir_cache,
false,
&mut 0u64,
)
.expect("first create_symlink should succeed");
assert_eq!(report.symlinks_created, 1);
create_symlink(
&safe_symlink,
&dest,
&mut report,
&mut dir_cache,
false,
&mut 0u64,
)
.expect("second create_symlink should overwrite");
assert_eq!(report.symlinks_created, 2);
assert_eq!(report.files_skipped, 0);
assert!(temp.path().join("link.txt").exists());
}
#[test]
fn test_check_extension_allowed_increments_counter_not_warnings() {
let config = SecurityConfig::default()
.with_allowed_extensions(vec!["txt".to_string()])
.validate()
.expect("valid config");
let mut report = ExtractionReport::default();
let path = PathBuf::from("skip.exe");
let mut disallowed_extension_skips = 0u64;
let allowed =
check_extension_allowed(&path, &config, &mut report, &mut disallowed_extension_skips);
assert!(!allowed, "disallowed extension must be rejected");
assert_eq!(report.files_skipped, 1);
assert_eq!(disallowed_extension_skips, 1);
assert!(report.warnings.is_empty());
}
#[test]
fn test_check_extension_allowed_allowed_extension() {
let config = SecurityConfig::default()
.with_allowed_extensions(vec!["txt".to_string()])
.validate()
.expect("valid config");
let mut report = ExtractionReport::default();
let path = PathBuf::from("keep.txt");
let mut disallowed_extension_skips = 0u64;
let allowed =
check_extension_allowed(&path, &config, &mut report, &mut disallowed_extension_skips);
assert!(allowed, "allowed extension must pass");
assert_eq!(report.files_skipped, 0);
assert_eq!(disallowed_extension_skips, 0);
assert!(report.warnings.is_empty());
}
#[test]
fn test_push_disallowed_extension_warning_text() {
let mut report = ExtractionReport::default();
push_disallowed_extension_warning(&mut report, 0);
assert!(report.warnings.is_empty(), "zero count must be a no-op");
push_disallowed_extension_warning(&mut report, 1);
assert_eq!(
report.warnings,
vec!["skipped 1 entry with disallowed extension".to_string()]
);
report.warnings.clear();
push_disallowed_extension_warning(&mut report, 3);
assert_eq!(
report.warnings,
vec!["skipped 3 entries with disallowed extensions".to_string()]
);
}
#[test]
fn test_push_duplicate_skip_warning_text() {
let mut report = ExtractionReport::default();
push_duplicate_skip_warning(&mut report, 0, "entry", "entries");
assert!(report.warnings.is_empty(), "zero count must be a no-op");
push_duplicate_skip_warning(&mut report, 1, "entry", "entries");
assert_eq!(
report.warnings,
vec!["skipped 1 entry as pre-existing duplicates".to_string()]
);
report.warnings.clear();
push_duplicate_skip_warning(&mut report, 3, "entry", "entries");
assert_eq!(
report.warnings,
vec!["skipped 3 entries as pre-existing duplicates".to_string()]
);
}
}