#![allow(
clippy::expect_used,
clippy::field_reassign_with_default,
clippy::unwrap_used
)]
use std::path::PathBuf;
use exarch_core::ExtractionError;
use exarch_core::QuotaResource;
use exarch_core::SecurityConfig;
use exarch_core::security::HardlinkTracker;
use exarch_core::security::QuotaTracker;
use exarch_core::types::DestDir;
use exarch_core::types::SafePath;
use exarch_core::types::SafeSymlink;
use proptest::prelude::*;
use tempfile::TempDir;
fn create_test_dest() -> (TempDir, DestDir) {
let temp = TempDir::new().expect("failed to create temp dir");
let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
(temp, dest)
}
proptest! {
#[test]
fn prop_parent_traversal_rejected(
prefix in "([a-z]+/){0,5}",
suffix in "([a-z]+/?){0,5}"
) {
let (_temp, dest) = create_test_dest();
let config = SecurityConfig::default();
let path_str = if prefix.is_empty() {
format!("../{suffix}")
} else {
format!("{prefix}../{suffix}")
};
let path = PathBuf::from(path_str);
let result = SafePath::validate(&path, &dest, &config);
prop_assert!(result.is_err(), "path with .. should be rejected");
}
#[test]
fn prop_valid_relative_paths_accepted(
components in prop::collection::vec("[a-zA-Z0-9_-]{1,20}", 1..5)
) {
let (_temp, dest) = create_test_dest();
let config = SecurityConfig::default();
let path = PathBuf::from(components.join("/"));
let result = SafePath::validate(&path, &dest, &config);
prop_assert!(result.is_ok(), "valid path should be accepted");
}
#[test]
fn prop_excessive_depth_rejected(
depth in 33usize..100
) {
let (_temp, dest) = create_test_dest();
let config = SecurityConfig::default(); let components: Vec<String> = (0..depth).map(|i| format!("d{i}")).collect();
let path = PathBuf::from(components.join("/"));
let result = SafePath::validate(&path, &dest, &config);
prop_assert!(result.is_err(), "excessive depth should be rejected");
}
#[test]
fn prop_symlink_excessive_parent_refs(
parent_count in 50usize..100
) {
let (_temp, dest) = create_test_dest();
let mut config = SecurityConfig::default();
config.allowed.symlinks = true;
let link = SafePath::validate(&PathBuf::from("a/b/link"), &dest, &config)
.expect("link path should be valid");
let target = PathBuf::from("../".repeat(parent_count) + "file.txt");
let result = SafeSymlink::validate(&link, &target, &dest, &config);
prop_assert!(result.is_err(), "excessive parent refs should escape");
}
#[test]
fn prop_banned_components_case_insensitive(
case_variant in prop::sample::select(vec![
".git", ".Git", ".GIT", ".gIt",
".ssh", ".SSH", ".Ssh",
".gnupg", ".GNUPG", ".Gnupg"
])
) {
let (_temp, dest) = create_test_dest();
let config = SecurityConfig::default();
let path = PathBuf::from(format!("dir/{case_variant}/file"));
let result = SafePath::validate(&path, &dest, &config);
prop_assert!(result.is_err(), "banned component should be rejected");
}
#[test]
fn prop_within_depth_accepted(
depth in 1usize..32
) {
let (_temp, dest) = create_test_dest();
let config = SecurityConfig::default(); let components: Vec<String> = (0..depth).map(|i| format!("d{i}")).collect();
let path = PathBuf::from(components.join("/"));
let result = SafePath::validate(&path, &dest, &config);
prop_assert!(result.is_ok());
}
#[test]
fn prop_quota_no_overflow_with_checked_add(
file_sizes in prop::collection::vec(0u64..1_000_000, 1..100)
) {
let mut tracker = QuotaTracker::new();
let config = SecurityConfig::default();
let mut expected_total: u64 = 0;
let mut expected_count = 0;
for size in file_sizes {
if let Some(new_total) = expected_total.checked_add(size) {
expected_total = new_total;
expected_count += 1;
let result = tracker.record_file(size, &config);
prop_assert!(result.is_ok(), "recording file should succeed when no overflow");
} else {
let result = tracker.record_file(size, &config);
prop_assert!(result.is_err(), "tracker should detect overflow");
break;
}
}
prop_assert_eq!(tracker.bytes_written(), expected_total);
prop_assert_eq!(tracker.files_extracted(), expected_count);
}
#[test]
fn prop_quota_file_count_enforcement(
max_files in 1usize..1000,
num_files in 1usize..2000
) {
let mut tracker = QuotaTracker::new();
let mut config = SecurityConfig::default();
config.max_file_count = max_files;
config.max_total_size = u64::MAX;
config.max_file_size = u64::MAX;
let mut success_count = 0;
for _ in 0..num_files {
let result = tracker.record_file(100, &config);
if result.is_ok() {
success_count += 1;
} else {
break;
}
}
prop_assert_eq!(
success_count,
max_files.min(num_files),
"should extract exactly max_files or num_files, whichever is smaller"
);
if num_files > max_files {
let result = tracker.record_file(100, &config);
prop_assert!(
matches!(result, Err(ExtractionError::QuotaExceeded { .. })),
"exceeding file count should fail"
);
}
}
#[test]
fn prop_quota_total_size_enforcement(
max_size in 1000u64..100_000,
file_sizes in prop::collection::vec(100u64..1000, 1..50)
) {
let mut tracker = QuotaTracker::new();
let mut config = SecurityConfig::default();
config.max_total_size = max_size;
config.max_file_count = usize::MAX;
config.max_file_size = u64::MAX;
for size in file_sizes {
let result = tracker.record_file(size, &config);
if result.is_err() {
prop_assert!(
matches!(result, Err(ExtractionError::QuotaExceeded { .. })),
"error should be QuotaExceeded"
);
break;
}
}
}
#[test]
fn prop_quota_file_size_enforcement(
max_file_size in 1000u64..100_000,
file_size in 0u64..200_000
) {
let mut tracker = QuotaTracker::new();
let mut config = SecurityConfig::default();
config.max_file_size = max_file_size;
config.max_total_size = u64::MAX;
config.max_file_count = usize::MAX;
let result = tracker.record_file(file_size, &config);
if file_size <= max_file_size {
prop_assert!(result.is_ok(), "file within size limit should succeed");
prop_assert_eq!(tracker.bytes_written(), file_size);
} else {
prop_assert!(
matches!(result, Err(ExtractionError::QuotaExceeded {
resource: QuotaResource::FileSize { .. }
})),
"file exceeding size limit should fail"
);
prop_assert_eq!(tracker.bytes_written(), 0, "no bytes should be recorded on failure");
}
}
#[test]
fn prop_quota_fast_path_unlimited(
file_sizes in prop::collection::vec(1u64..100_000, 1..1000)
) {
let mut tracker = QuotaTracker::new();
let mut config = SecurityConfig::default();
config.max_file_size = u64::MAX;
config.max_file_count = usize::MAX;
config.max_total_size = u64::MAX;
let mut expected_total = 0u64;
for size in &file_sizes {
if let Some(new_total) = expected_total.checked_add(*size) {
expected_total = new_total;
} else {
break;
}
}
for size in file_sizes {
let result = tracker.record_file(size, &config);
if tracker.bytes_written().checked_add(size).is_some() {
prop_assert!(result.is_ok() || result.is_err(), "either succeeds or detects overflow");
}
if result.is_err() {
break;
}
}
}
#[test]
fn prop_compression_ratio_safe_range(
compressed in 1u64..1_000_000,
ratio in 1.0f64..100.0
) {
use exarch_core::security::validate_compression_ratio;
let config = SecurityConfig::default(); #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let uncompressed = (compressed as f64 * ratio) as u64;
let result = validate_compression_ratio(compressed, uncompressed, &config);
if ratio <= config.max_compression_ratio {
prop_assert!(result.is_ok(), "ratio {} should be safe", ratio);
} else {
prop_assert!(result.is_err(), "ratio {} should exceed limit", ratio);
}
}
#[test]
fn prop_compression_zero_compressed_nonzero_uncompressed(
uncompressed in 1u64..1_000_000
) {
use exarch_core::security::validate_compression_ratio;
let config = SecurityConfig::default();
let result = validate_compression_ratio(0, uncompressed, &config);
prop_assert!(
matches!(result, Err(ExtractionError::InvalidArchive(_))),
"zero compressed with non-zero uncompressed must be rejected"
);
}
#[test]
fn prop_compression_both_zero(_dummy in 0..100) {
use exarch_core::security::validate_compression_ratio;
let config = SecurityConfig::default();
let result = validate_compression_ratio(0, 0, &config);
prop_assert!(result.is_ok(), "empty file (0/0) should be valid");
}
#[test]
fn prop_compression_extreme_ratios_rejected(
compressed in 1u64..1000,
multiplier in 2000u64..10_000
) {
use exarch_core::security::validate_compression_ratio;
let config = SecurityConfig::default(); let uncompressed = compressed.saturating_mul(multiplier);
let result = validate_compression_ratio(compressed, uncompressed, &config);
prop_assert!(
matches!(result, Err(ExtractionError::ZipBomb { .. })),
"extreme compression ratio should be detected"
);
}
#[test]
fn prop_hardlink_safe_relative_paths(
components in prop::collection::vec("[a-z]{1,10}", 1..5)
) {
let (_temp, dest) = create_test_dest();
let mut config = SecurityConfig::default();
config.allowed.hardlinks = true;
let mut tracker = HardlinkTracker::new();
let link = SafePath::validate(&PathBuf::from("link"), &dest, &config)
.expect("link path should be valid");
let target = PathBuf::from(components.join("/"));
let result = tracker.validate_hardlink(&link, &target, &dest, &config);
prop_assert!(result.is_ok(), "safe relative target should be accepted");
}
#[test]
fn prop_hardlink_parent_traversal_rejected(
parent_count in 10usize..50
) {
let (_temp, dest) = create_test_dest();
let mut config = SecurityConfig::default();
config.allowed.hardlinks = true;
let mut tracker = HardlinkTracker::new();
let link = SafePath::validate(&PathBuf::from("link"), &dest, &config)
.expect("link path should be valid");
let target = PathBuf::from("../".repeat(parent_count) + "etc/passwd");
let result = tracker.validate_hardlink(&link, &target, &dest, &config);
prop_assert!(
matches!(result, Err(ExtractionError::HardlinkEscape { .. })),
"excessive parent traversal should be rejected"
);
}
#[test]
fn prop_hardlink_multiple_to_same_target(
num_links in 1usize..20
) {
let (_temp, dest) = create_test_dest();
let mut config = SecurityConfig::default();
config.allowed.hardlinks = true;
let mut tracker = HardlinkTracker::new();
let target = PathBuf::from("shared_target.txt");
for i in 0..num_links {
let link = SafePath::validate(&PathBuf::from(format!("link{i}")), &dest, &config)
.expect("link path should be valid");
let result = tracker.validate_hardlink(&link, &target, &dest, &config);
prop_assert!(result.is_ok(), "all links to same target should succeed");
}
prop_assert_eq!(tracker.count(), 1, "should track unique targets only");
}
#[test]
fn prop_hardlink_different_targets(
num_targets in 1usize..50
) {
let (_temp, dest) = create_test_dest();
let mut config = SecurityConfig::default();
config.allowed.hardlinks = true;
let mut tracker = HardlinkTracker::new();
for i in 0..num_targets {
let link = SafePath::validate(&PathBuf::from(format!("link{i}")), &dest, &config)
.expect("link path should be valid");
let target = PathBuf::from(format!("target{i}.txt"));
let result = tracker.validate_hardlink(&link, &target, &dest, &config);
prop_assert!(result.is_ok(), "each unique target should be accepted");
}
prop_assert_eq!(tracker.count(), num_targets, "should track all unique targets");
}
#[test]
fn prop_symlink_safe_relative_targets(
components in prop::collection::vec("[a-z]{1,10}", 1..8)
) {
let (_temp, dest) = create_test_dest();
let mut config = SecurityConfig::default();
config.allowed.symlinks = true;
let link = SafePath::validate(&PathBuf::from("link"), &dest, &config)
.expect("link path should be valid");
let target = PathBuf::from(components.join("/"));
let result = SafeSymlink::validate(&link, &target, &dest, &config);
prop_assert!(result.is_ok(), "safe relative symlink target should be accepted");
}
#[test]
fn prop_symlink_escape_detection(
parent_refs in 5usize..30,
link_depth in 0usize..3
) {
let (_temp, dest) = create_test_dest();
let mut config = SecurityConfig::default();
config.allowed.symlinks = true;
let link_path = if link_depth == 0 {
PathBuf::from("link")
} else {
let components: Vec<String> = (0..link_depth).map(|i| format!("d{i}")).collect();
PathBuf::from(components.join("/") + "/link")
};
let link = SafePath::validate(&link_path, &dest, &config)
.expect("link path should be valid");
let target = PathBuf::from("../".repeat(parent_refs) + "file.txt");
let result = SafeSymlink::validate(&link, &target, &dest, &config);
if parent_refs > link_depth {
prop_assert!(
result.is_err(),
"symlink escaping destination should be rejected"
);
}
}
#[test]
fn prop_symlink_disabled_always_rejected(
target in "[a-z/]{1,30}"
) {
let (_temp, dest) = create_test_dest();
let config = SecurityConfig::default();
let link = SafePath::validate(&PathBuf::from("link"), &dest, &config)
.expect("link path should be valid");
let target_path = PathBuf::from(target);
let result = SafeSymlink::validate(&link, &target_path, &dest, &config);
prop_assert!(
matches!(result, Err(ExtractionError::SecurityViolation { .. })),
"symlinks should be rejected when disabled"
);
}
}