#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct AllowedFeatures {
pub symlinks: bool,
pub hardlinks: bool,
pub absolute_paths: bool,
pub world_writable: bool,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SecurityConfig {
pub max_file_size: u64,
pub max_total_size: u64,
pub max_compression_ratio: f64,
pub max_file_count: usize,
pub max_path_depth: usize,
pub allowed: AllowedFeatures,
pub preserve_permissions: bool,
pub allowed_extensions: Vec<String>,
pub banned_path_components: Vec<String>,
pub allow_solid_archives: bool,
pub max_solid_block_memory: u64,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
max_file_size: 50 * 1024 * 1024, max_total_size: 500 * 1024 * 1024, max_compression_ratio: 100.0,
max_file_count: 10_000,
max_path_depth: 32,
allowed: AllowedFeatures::default(), preserve_permissions: false,
allowed_extensions: Vec::new(),
banned_path_components: vec![
".git".to_string(),
".ssh".to_string(),
".gnupg".to_string(),
".aws".to_string(),
".kube".to_string(),
".docker".to_string(),
".env".to_string(),
],
allow_solid_archives: false,
max_solid_block_memory: 512 * 1024 * 1024, }
}
}
impl SecurityConfig {
#[must_use]
pub fn permissive() -> Self {
Self {
allowed: AllowedFeatures {
symlinks: true,
hardlinks: true,
absolute_paths: true,
world_writable: true,
},
preserve_permissions: true,
max_compression_ratio: 1000.0,
banned_path_components: Vec::new(),
allow_solid_archives: true,
max_solid_block_memory: 1024 * 1024 * 1024, ..Default::default()
}
}
pub fn validate(&self) -> crate::Result<()> {
if !self.max_compression_ratio.is_finite() || self.max_compression_ratio <= 0.0 {
return Err(crate::ExtractionError::InvalidConfiguration {
reason: "max_compression_ratio must be positive".into(),
});
}
if self.max_file_size == 0 {
return Err(crate::ExtractionError::InvalidConfiguration {
reason: "max_file_size must not be zero".into(),
});
}
if self.max_total_size == 0 {
return Err(crate::ExtractionError::InvalidConfiguration {
reason: "max_total_size must not be zero".into(),
});
}
if self.max_path_depth == 0 {
return Err(crate::ExtractionError::InvalidConfiguration {
reason: "max_path_depth must not be zero".into(),
});
}
if self.max_file_count == 0 {
return Err(crate::ExtractionError::InvalidConfiguration {
reason: "max_file_count must not be zero".into(),
});
}
if self.max_solid_block_memory == 0 {
return Err(crate::ExtractionError::InvalidConfiguration {
reason: "max_solid_block_memory must not be zero".into(),
});
}
Ok(())
}
#[must_use]
#[inline]
pub fn with_max_file_size(mut self, size: u64) -> Self {
self.max_file_size = size;
self
}
#[must_use]
#[inline]
pub fn with_max_total_size(mut self, size: u64) -> Self {
self.max_total_size = size;
self
}
#[must_use]
#[inline]
pub fn with_max_compression_ratio(mut self, ratio: f64) -> Self {
self.max_compression_ratio = ratio;
self
}
#[must_use]
#[inline]
pub fn with_max_file_count(mut self, count: usize) -> Self {
self.max_file_count = count;
self
}
#[must_use]
#[inline]
pub fn with_max_path_depth(mut self, depth: usize) -> Self {
self.max_path_depth = depth;
self
}
#[must_use]
#[inline]
pub fn with_allowed(mut self, allowed: AllowedFeatures) -> Self {
self.allowed = allowed;
self
}
#[must_use]
#[inline]
pub fn with_allow_symlinks(mut self, allow: bool) -> Self {
self.allowed.symlinks = allow;
self
}
#[must_use]
#[inline]
pub fn with_allow_hardlinks(mut self, allow: bool) -> Self {
self.allowed.hardlinks = allow;
self
}
#[must_use]
#[inline]
pub fn with_allow_absolute_paths(mut self, allow: bool) -> Self {
self.allowed.absolute_paths = allow;
self
}
#[must_use]
#[inline]
pub fn with_allow_world_writable(mut self, allow: bool) -> Self {
self.allowed.world_writable = allow;
self
}
#[must_use]
#[inline]
pub fn with_preserve_permissions(mut self, preserve: bool) -> Self {
self.preserve_permissions = preserve;
self
}
#[must_use]
#[inline]
pub fn with_allowed_extensions(mut self, extensions: Vec<String>) -> Self {
self.allowed_extensions = extensions;
self
}
#[must_use]
#[inline]
pub fn with_banned_path_components(mut self, components: Vec<String>) -> Self {
self.banned_path_components = components;
self
}
#[must_use]
#[inline]
pub fn with_allow_solid_archives(mut self, allow: bool) -> Self {
self.allow_solid_archives = allow;
self
}
#[must_use]
#[inline]
pub fn with_max_solid_block_memory(mut self, size: u64) -> Self {
self.max_solid_block_memory = size;
self
}
#[must_use]
pub fn is_path_component_allowed(&self, component: &str) -> bool {
!self
.banned_path_components
.iter()
.any(|banned| banned.eq_ignore_ascii_case(component))
}
#[must_use]
pub fn is_extension_allowed(&self, extension: &str) -> bool {
if self.allowed_extensions.is_empty() {
return true;
}
self.allowed_extensions
.iter()
.any(|ext| ext.eq_ignore_ascii_case(extension))
}
#[must_use]
pub fn is_path_extension_allowed(&self, extension: Option<&str>) -> bool {
if self.allowed_extensions.is_empty() {
return true;
}
extension.is_some_and(|ext| self.is_extension_allowed(ext))
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ExtractionOptions {
pub atomic: bool,
pub skip_duplicates: bool,
}
impl Default for ExtractionOptions {
fn default() -> Self {
Self {
atomic: false,
skip_duplicates: true,
}
}
}
impl ExtractionOptions {
#[must_use]
#[inline]
pub fn with_atomic(mut self, atomic: bool) -> Self {
self.atomic = atomic;
self
}
#[must_use]
#[inline]
pub fn with_skip_duplicates(mut self, skip: bool) -> Self {
self.skip_duplicates = skip;
self
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::field_reassign_with_default)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = SecurityConfig::default();
assert!(!config.allowed.symlinks);
assert!(!config.allowed.hardlinks);
assert!(!config.allowed.absolute_paths);
assert_eq!(config.max_file_size, 50 * 1024 * 1024);
}
#[test]
fn test_permissive_config() {
let config = SecurityConfig::permissive();
assert!(config.allowed.symlinks);
assert!(config.allowed.hardlinks);
assert!(config.allowed.absolute_paths);
}
#[test]
fn test_extension_allowed_empty_list() {
let config = SecurityConfig::default();
assert!(config.is_extension_allowed("txt"));
assert!(config.is_extension_allowed("pdf"));
}
#[test]
fn test_extension_allowed_with_list() {
let mut config = SecurityConfig::default();
config.allowed_extensions = vec!["txt".to_string(), "pdf".to_string()];
assert!(config.is_extension_allowed("txt"));
assert!(config.is_extension_allowed("TXT"));
assert!(!config.is_extension_allowed("exe"));
}
#[test]
fn test_path_component_allowed() {
let config = SecurityConfig::default();
assert!(config.is_path_component_allowed("src"));
assert!(!config.is_path_component_allowed(".git"));
assert!(!config.is_path_component_allowed(".ssh"));
assert!(!config.is_path_component_allowed(".Git"));
assert!(!config.is_path_component_allowed(".GIT"));
assert!(!config.is_path_component_allowed(".SSH"));
assert!(!config.is_path_component_allowed(".Gnupg"));
}
#[test]
fn test_config_default_security_flags() {
let config = SecurityConfig::default();
assert!(
!config.allowed.symlinks,
"symlinks should be denied by default"
);
assert!(
!config.allowed.hardlinks,
"hardlinks should be denied by default"
);
assert!(
!config.allowed.absolute_paths,
"absolute paths should be denied by default"
);
assert!(
!config.preserve_permissions,
"permissions should not be preserved by default"
);
assert!(
!config.allowed.world_writable,
"world-writable should be denied by default"
);
}
#[test]
fn test_config_permissive_security_flags() {
let config = SecurityConfig::permissive();
assert!(config.allowed.symlinks, "permissive allows symlinks");
assert!(config.allowed.hardlinks, "permissive allows hardlinks");
assert!(
config.allowed.absolute_paths,
"permissive allows absolute paths"
);
assert!(
config.preserve_permissions,
"permissive preserves permissions"
);
assert!(
config.allowed.world_writable,
"permissive allows world-writable"
);
}
#[test]
fn test_config_quota_limits() {
let config = SecurityConfig::default();
assert_eq!(config.max_file_size, 50 * 1024 * 1024, "50 MB file limit");
assert_eq!(
config.max_total_size,
500 * 1024 * 1024,
"500 MB total limit"
);
assert_eq!(config.max_file_count, 10_000, "10k file count limit");
assert_eq!(config.max_path_depth, 32, "32 level depth limit");
#[allow(clippy::float_cmp)]
{
assert_eq!(
config.max_compression_ratio, 100.0,
"100x compression ratio limit"
);
}
}
#[test]
fn test_config_banned_components_not_empty() {
let config = SecurityConfig::default();
assert!(
!config.banned_path_components.is_empty(),
"should have banned components by default"
);
assert!(
config.banned_path_components.contains(&".git".to_string()),
"should ban .git"
);
assert!(
config.banned_path_components.contains(&".ssh".to_string()),
"should ban .ssh"
);
}
#[test]
fn test_config_solid_archives_default() {
let config = SecurityConfig::default();
assert!(
!config.allow_solid_archives,
"solid archives should be denied by default"
);
assert_eq!(
config.max_solid_block_memory,
512 * 1024 * 1024,
"max solid block memory should be 512 MB"
);
}
#[test]
fn test_config_permissive_solid_archives() {
let config = SecurityConfig::permissive();
assert!(
config.allow_solid_archives,
"permissive config should allow solid archives"
);
assert_eq!(
config.max_solid_block_memory,
1024 * 1024 * 1024,
"permissive should have 1 GB solid block limit"
);
}
#[test]
fn test_validate_default_is_ok() {
assert!(SecurityConfig::default().validate().is_ok());
}
#[test]
fn test_validate_rejects_negative_compression_ratio() {
let cfg = SecurityConfig {
max_compression_ratio: -1.0,
..SecurityConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn test_validate_rejects_zero_compression_ratio() {
let cfg = SecurityConfig {
max_compression_ratio: 0.0,
..SecurityConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn test_validate_rejects_zero_max_file_size() {
let cfg = SecurityConfig {
max_file_size: 0,
..SecurityConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn test_validate_rejects_zero_max_total_size() {
let cfg = SecurityConfig {
max_total_size: 0,
..SecurityConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn test_validate_rejects_zero_max_path_depth() {
let cfg = SecurityConfig {
max_path_depth: 0,
..SecurityConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn test_validate_rejects_nan_compression_ratio() {
let cfg = SecurityConfig {
max_compression_ratio: f64::NAN,
..SecurityConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn test_validate_rejects_infinite_compression_ratio() {
let cfg = SecurityConfig {
max_compression_ratio: f64::INFINITY,
..SecurityConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn test_validate_rejects_zero_max_file_count() {
let cfg = SecurityConfig {
max_file_count: 0,
..SecurityConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn test_validate_rejects_zero_max_solid_block_memory() {
let cfg = SecurityConfig {
max_solid_block_memory: 0,
..SecurityConfig::default()
};
assert!(cfg.validate().is_err());
}
}