use std::path::Path;
use crate::Result;
use crate::SecurityConfig;
use crate::formats::common::DirCache;
use crate::security::context::ValidationContext;
use crate::security::hardlink::HardlinkTracker;
use crate::security::permissions::sanitize_permissions;
use crate::security::quota::QuotaTracker;
use crate::security::symlink::validate_symlink;
use crate::security::zipbomb::validate_compression_ratio;
use crate::types::DestDir;
use crate::types::EntryType;
use crate::types::SafePath;
use crate::types::SafeSymlink;
#[derive(Debug)]
pub struct ValidatedEntry {
pub safe_path: SafePath,
pub entry_type: ValidatedEntryType,
pub mode: Option<u32>,
}
#[derive(Debug)]
pub enum ValidatedEntryType {
File,
Directory,
Symlink(SafeSymlink),
Hardlink {
target: SafePath,
},
}
pub struct EntryValidator<'a> {
config: &'a SecurityConfig,
dest: &'a DestDir,
quota_tracker: QuotaTracker,
hardlink_tracker: HardlinkTracker,
symlink_seen: bool,
}
impl<'a> EntryValidator<'a> {
#[must_use]
pub fn new(config: &'a SecurityConfig, dest: &'a DestDir) -> Self {
Self {
config,
dest,
quota_tracker: QuotaTracker::new(),
hardlink_tracker: HardlinkTracker::new(),
symlink_seen: false,
}
}
pub fn validate_entry(
&mut self,
path: &Path,
entry_type: &EntryType,
uncompressed_size: u64,
compressed_size: Option<u64>,
mode: Option<u32>,
dir_cache: Option<&DirCache>,
) -> Result<ValidatedEntry> {
let mut ctx = ValidationContext::new(self.config.allowed.symlinks);
if let Some(cache) = dir_cache {
ctx = ctx.with_dir_cache(cache);
}
if self.symlink_seen {
ctx.mark_symlink_seen();
}
let safe_path = SafePath::validate_with_context(path, self.dest, self.config, &ctx)?;
if matches!(entry_type, EntryType::File) {
self.quota_tracker
.record_file(uncompressed_size, self.config)?;
}
if let Some(compressed) = compressed_size {
validate_compression_ratio(compressed, uncompressed_size, self.config)?;
}
let (validated_type, sanitized_mode) = match entry_type {
EntryType::File => {
let sanitized = if let Some(m) = mode {
Some(sanitize_permissions(safe_path.as_path(), m, self.config)?)
} else {
None
};
(ValidatedEntryType::File, sanitized)
}
EntryType::Directory => (ValidatedEntryType::Directory, None),
EntryType::Symlink { target } => {
let safe_symlink = validate_symlink(&safe_path, target, self.dest, self.config)?;
self.symlink_seen = true;
(ValidatedEntryType::Symlink(safe_symlink), None)
}
EntryType::Hardlink { target } => {
self.hardlink_tracker.validate_hardlink(
&safe_path,
target,
self.dest,
self.config,
)?;
let target_safe = SafePath::new_unchecked(target.clone());
(
ValidatedEntryType::Hardlink {
target: target_safe,
},
None,
)
}
};
Ok(ValidatedEntry {
safe_path,
entry_type: validated_type,
mode: sanitized_mode,
})
}
#[must_use]
pub fn finish(self) -> ValidationReport {
ValidationReport {
files_validated: self.quota_tracker.files_extracted(),
total_bytes: self.quota_tracker.bytes_written(),
hardlinks_tracked: self.hardlink_tracker.count(),
}
}
}
#[derive(Debug)]
pub struct ValidationReport {
pub files_validated: usize,
pub total_bytes: u64,
pub hardlinks_tracked: usize,
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::field_reassign_with_default
)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
fn test_entry_validator_new() {
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();
let validator = EntryValidator::new(&config, &dest);
let report = validator.finish();
assert_eq!(report.files_validated, 0);
assert_eq!(report.total_bytes, 0);
assert_eq!(report.hardlinks_tracked, 0);
}
#[test]
fn test_validate_file_entry() {
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();
let mut validator = EntryValidator::new(&config, &dest);
let result = validator.validate_entry(
Path::new("file.txt"),
&EntryType::File,
1024,
None,
Some(0o644),
None,
);
assert!(result.is_ok());
let entry = result.unwrap();
assert_eq!(entry.safe_path.as_path(), Path::new("file.txt"));
assert!(matches!(entry.entry_type, ValidatedEntryType::File));
assert_eq!(entry.mode, Some(0o644));
}
#[test]
fn test_validate_directory_entry() {
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();
let mut validator = EntryValidator::new(&config, &dest);
let result =
validator.validate_entry(Path::new("dir"), &EntryType::Directory, 0, None, None, None);
assert!(result.is_ok());
let entry = result.unwrap();
assert!(matches!(entry.entry_type, ValidatedEntryType::Directory));
assert!(entry.mode.is_none());
}
#[test]
fn test_validate_path_traversal_rejected() {
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();
let mut validator = EntryValidator::new(&config, &dest);
let result = validator.validate_entry(
Path::new("../etc/passwd"),
&EntryType::File,
1024,
None,
Some(0o644),
None,
);
assert!(result.is_err());
}
#[test]
fn test_quota_exceeded_file_size() {
let temp = TempDir::new().unwrap();
let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
let mut config = SecurityConfig::default();
config.max_file_size = 100;
let mut validator = EntryValidator::new(&config, &dest);
let result = validator.validate_entry(
Path::new("large.txt"),
&EntryType::File,
1000,
None,
Some(0o644),
None,
);
assert!(result.is_err());
}
#[test]
fn test_quota_exceeded_file_count() {
let temp = TempDir::new().unwrap();
let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
let mut config = SecurityConfig::default();
config.max_file_count = 2;
let mut validator = EntryValidator::new(&config, &dest);
assert!(
validator
.validate_entry(
Path::new("file1.txt"),
&EntryType::File,
100,
None,
Some(0o644),
None,
)
.is_ok()
);
assert!(
validator
.validate_entry(
Path::new("file2.txt"),
&EntryType::File,
100,
None,
Some(0o644),
None,
)
.is_ok()
);
let result = validator.validate_entry(
Path::new("file3.txt"),
&EntryType::File,
100,
None,
Some(0o644),
None,
);
assert!(result.is_err());
}
#[test]
fn test_zip_bomb_detected() {
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();
let mut validator = EntryValidator::new(&config, &dest);
let result = validator.validate_entry(
Path::new("bomb.txt"),
&EntryType::File,
1_000_000,
Some(100),
Some(0o644),
None,
);
assert!(result.is_err());
}
#[test]
fn test_validation_report() {
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();
let mut validator = EntryValidator::new(&config, &dest);
validator
.validate_entry(
Path::new("file1.txt"),
&EntryType::File,
1024,
None,
Some(0o644),
None,
)
.unwrap();
validator
.validate_entry(
Path::new("file2.txt"),
&EntryType::File,
2048,
None,
Some(0o644),
None,
)
.unwrap();
let report = validator.finish();
assert_eq!(report.files_validated, 2);
assert_eq!(report.total_bytes, 1024 + 2048);
}
#[test]
fn test_sanitize_permissions_setuid() {
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();
let mut validator = EntryValidator::new(&config, &dest);
let result = validator.validate_entry(
Path::new("file.txt"),
&EntryType::File,
1024,
None,
Some(0o4755),
None,
);
assert!(result.is_ok());
let entry = result.unwrap();
assert_eq!(entry.mode, Some(0o755)); }
#[test]
fn test_symlink_validation() {
let temp = TempDir::new().unwrap();
let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
let mut config = SecurityConfig::default();
config.allowed.symlinks = true;
let mut validator = EntryValidator::new(&config, &dest);
let result = validator.validate_entry(
Path::new("link"),
&EntryType::Symlink {
target: PathBuf::from("target.txt"),
},
0,
None,
None,
None,
);
assert!(result.is_ok());
let entry = result.unwrap();
assert!(matches!(entry.entry_type, ValidatedEntryType::Symlink(_)));
assert!(validator.symlink_seen);
}
#[test]
fn test_hardlink_validation() {
let temp = TempDir::new().unwrap();
let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
let mut config = SecurityConfig::default();
config.allowed.hardlinks = true;
let mut validator = EntryValidator::new(&config, &dest);
let result = validator.validate_entry(
Path::new("link"),
&EntryType::Hardlink {
target: PathBuf::from("target.txt"),
},
0,
None,
None,
None,
);
assert!(result.is_ok());
let entry = result.unwrap();
assert!(matches!(
entry.entry_type,
ValidatedEntryType::Hardlink { .. }
));
}
#[test]
fn test_multiple_entries_with_report() {
let temp = TempDir::new().unwrap();
let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
let mut config = SecurityConfig::default();
config.allowed.hardlinks = true;
let mut validator = EntryValidator::new(&config, &dest);
validator
.validate_entry(
Path::new("file1.txt"),
&EntryType::File,
1024,
None,
Some(0o644),
None,
)
.unwrap();
validator
.validate_entry(Path::new("dir"), &EntryType::Directory, 0, None, None, None)
.unwrap();
validator
.validate_entry(
Path::new("hardlink"),
&EntryType::Hardlink {
target: PathBuf::from("file1.txt"),
},
0,
None,
None,
None,
)
.unwrap();
let report = validator.finish();
assert_eq!(report.files_validated, 1); assert_eq!(report.total_bytes, 1024);
assert_eq!(report.hardlinks_tracked, 1);
}
#[test]
fn test_empty_directory_validation() {
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();
let mut validator = EntryValidator::new(&config, &dest);
let result = validator.validate_entry(
Path::new("empty_dir/"),
&EntryType::Directory,
0,
None,
None,
None,
);
assert!(result.is_ok(), "empty directory should be valid");
let entry = result.unwrap();
assert!(
matches!(entry.entry_type, ValidatedEntryType::Directory),
"should be directory type"
);
assert!(entry.mode.is_none(), "directory should not have mode set");
}
#[test]
fn test_nested_empty_directories() {
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();
let mut validator = EntryValidator::new(&config, &dest);
let dirs = ["a/", "a/b/", "a/b/c/"];
for dir in &dirs {
let result = validator.validate_entry(
Path::new(dir),
&EntryType::Directory,
0,
None,
None,
None,
);
assert!(result.is_ok(), "nested directory {dir} should be valid");
}
let report = validator.finish();
assert_eq!(
report.files_validated, 0,
"directories are not counted as files"
);
}
#[test]
fn test_validator_uses_references() {
let temp = TempDir::new().unwrap();
let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
let config = SecurityConfig::default();
let validator = EntryValidator::new(&config, &dest);
assert_eq!(
config.max_file_size,
SecurityConfig::default().max_file_size
);
let _ = dest.as_path();
drop(validator);
}
#[test]
fn test_multiple_validators_share_config() {
let temp1 = TempDir::new().unwrap();
let temp2 = TempDir::new().unwrap();
let dest1 = DestDir::new(temp1.path().to_path_buf()).unwrap();
let dest2 = DestDir::new(temp2.path().to_path_buf()).unwrap();
let config = SecurityConfig::default();
let mut validator1 = EntryValidator::new(&config, &dest1);
let mut validator2 = EntryValidator::new(&config, &dest2);
let result1 = validator1.validate_entry(
Path::new("file1.txt"),
&EntryType::File,
1024,
None,
Some(0o644),
None,
);
assert!(result1.is_ok());
let result2 = validator2.validate_entry(
Path::new("file2.txt"),
&EntryType::File,
2048,
None,
Some(0o644),
None,
);
assert!(result2.is_ok());
assert_eq!(
config.max_file_size,
SecurityConfig::default().max_file_size
);
}
#[test]
fn test_validate_entry_with_dir_cache() {
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();
let mut validator = EntryValidator::new(&config, &dest);
let sub = dest.as_path().join("subdir");
let mut dir_cache = DirCache::new();
dir_cache.ensure_dir(&sub).expect("should create dir");
let result = validator.validate_entry(
Path::new("subdir/file.txt"),
&EntryType::File,
100,
None,
Some(0o644),
Some(&dir_cache),
);
assert!(
result.is_ok(),
"entry with dir_cache should validate: {result:?}"
);
}
#[test]
fn test_symlink_seen_flag_propagates() {
let temp = TempDir::new().unwrap();
let dest = DestDir::new(temp.path().to_path_buf()).unwrap();
let mut config = SecurityConfig::default();
config.allowed.symlinks = true;
let mut validator = EntryValidator::new(&config, &dest);
assert!(!validator.symlink_seen);
validator
.validate_entry(
Path::new("link"),
&EntryType::Symlink {
target: PathBuf::from("target.txt"),
},
0,
None,
None,
None,
)
.unwrap();
assert!(validator.symlink_seen);
}
}