exarch_core/config.rs
1//! Security configuration for archive extraction.
2
3/// Feature flags controlling what archive features are allowed during
4/// extraction.
5///
6/// All features default to `false` (deny-by-default security policy).
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
8#[non_exhaustive]
9pub struct AllowedFeatures {
10 /// Allow symlinks in extracted archives.
11 pub symlinks: bool,
12
13 /// Allow hardlinks in extracted archives.
14 pub hardlinks: bool,
15
16 /// Allow absolute paths in archive entries.
17 pub absolute_paths: bool,
18
19 /// Allow world-writable files (mode 0o002).
20 ///
21 /// World-writable files pose security risks in multi-user environments.
22 pub world_writable: bool,
23}
24
25/// Security configuration with default-deny settings.
26///
27/// This configuration controls various security checks performed during
28/// archive extraction to prevent common vulnerabilities.
29///
30/// # Performance Note
31///
32/// This struct contains heap-allocated collections (`Vec<String>`). For
33/// performance, pass by reference (`&SecurityConfig`) rather than cloning. If
34/// shared ownership is needed across threads, consider wrapping in
35/// `Arc<SecurityConfig>`.
36///
37/// # Examples
38///
39/// ```
40/// use exarch_core::SecurityConfig;
41///
42/// // Use secure defaults
43/// let config = SecurityConfig::default();
44///
45/// // Customize via fluent builder
46/// let custom = SecurityConfig::default()
47/// .with_max_file_size(100 * 1024 * 1024)
48/// .with_max_total_size(1024 * 1024 * 1024)
49/// .with_allow_symlinks(true);
50/// ```
51#[derive(Debug, Clone)]
52#[non_exhaustive]
53pub struct SecurityConfig {
54 /// Maximum size for a single file in bytes.
55 pub max_file_size: u64,
56
57 /// Maximum total size for all extracted files in bytes.
58 pub max_total_size: u64,
59
60 /// Maximum compression ratio allowed (uncompressed / compressed).
61 pub max_compression_ratio: f64,
62
63 /// Maximum number of files that can be extracted.
64 pub max_file_count: usize,
65
66 /// Maximum path depth allowed.
67 pub max_path_depth: usize,
68
69 /// Feature flags controlling what archive features are allowed.
70 ///
71 /// Use this to enable symlinks, hardlinks, absolute paths, etc.
72 pub allowed: AllowedFeatures,
73
74 /// Preserve file permissions from archive.
75 pub preserve_permissions: bool,
76
77 /// List of allowed file extensions (empty = allow all).
78 ///
79 /// Extensions are matched case-insensitively (e.g., `"txt"` matches both
80 /// `file.txt` and `file.TXT`). The leading dot must be omitted.
81 ///
82 /// When this list is non-empty, files without a file extension are treated
83 /// as not allowed and will be skipped during extraction.
84 pub allowed_extensions: Vec<String>,
85
86 /// List of banned path components (e.g., ".git", ".ssh").
87 pub banned_path_components: Vec<String>,
88
89 /// Allow extraction from solid 7z archives.
90 ///
91 /// Solid archives compress multiple files together as a single block.
92 /// While this provides better compression ratios, it has security
93 /// implications:
94 ///
95 /// - **Memory exhaustion**: Extracting a single file requires decompressing
96 /// the entire solid block into memory
97 /// - **Denial of service**: Malicious archives can create large solid
98 /// blocks that exhaust available memory
99 ///
100 /// **Security Recommendation**: Only enable for trusted archives.
101 ///
102 /// Default: `false` (solid archives rejected)
103 pub allow_solid_archives: bool,
104
105 /// Maximum memory for solid archive extraction (bytes).
106 ///
107 /// **7z Solid Archive Memory Model:**
108 ///
109 /// Solid compression in 7z stores multiple files in a single compressed
110 /// block. Extracting ANY file requires decompressing the ENTIRE solid block
111 /// into memory, which can cause memory exhaustion attacks.
112 ///
113 /// **Validation Strategy:**
114 /// - Pre-validates total uncompressed size of all files in archive
115 /// - This is a conservative heuristic (assumes single solid block)
116 /// - Reason: `sevenz-rust2` v0.20 doesn't expose solid block boundaries
117 ///
118 /// **Security Guarantee:**
119 /// - Total uncompressed data cannot exceed this limit
120 /// - Combined with `max_file_size`, prevents unbounded memory growth
121 /// - Enforced ONLY when `allow_solid_archives` is `true`
122 ///
123 /// **Note**: Only applies when `allow_solid_archives` is `true`.
124 ///
125 /// Default: 512 MB (536,870,912 bytes)
126 ///
127 /// **Recommendation:** Set to 1-2x available RAM for trusted archives only.
128 pub max_solid_block_memory: u64,
129}
130
131impl Default for SecurityConfig {
132 /// Creates a `SecurityConfig` with secure default settings.
133 ///
134 /// Default values:
135 /// - `max_file_size`: 50 MB
136 /// - `max_total_size`: 500 MB
137 /// - `max_compression_ratio`: 100.0
138 /// - `max_file_count`: 10,000
139 /// - `max_path_depth`: 32
140 /// - `allowed`: All features disabled (deny-by-default)
141 /// - `preserve_permissions`: false
142 /// - `allowed_extensions`: empty (allow all)
143 /// - `banned_path_components`: `[".git", ".ssh", ".gnupg", ".aws", ".kube",
144 /// ".docker", ".env"]`
145 /// - `allow_solid_archives`: false (solid archives rejected)
146 /// - `max_solid_block_memory`: 512 MB
147 fn default() -> Self {
148 Self {
149 max_file_size: 50 * 1024 * 1024, // 50 MB
150 max_total_size: 500 * 1024 * 1024, // 500 MB
151 max_compression_ratio: 100.0,
152 max_file_count: 10_000,
153 max_path_depth: 32,
154 allowed: AllowedFeatures::default(), // All false
155 preserve_permissions: false,
156 allowed_extensions: Vec::new(),
157 banned_path_components: vec![
158 ".git".to_string(),
159 ".ssh".to_string(),
160 ".gnupg".to_string(),
161 ".aws".to_string(),
162 ".kube".to_string(),
163 ".docker".to_string(),
164 ".env".to_string(),
165 ],
166 allow_solid_archives: false,
167 max_solid_block_memory: 512 * 1024 * 1024, // 512 MB
168 }
169 }
170}
171
172impl SecurityConfig {
173 /// Creates a permissive configuration for trusted archives.
174 ///
175 /// This configuration allows symlinks, hardlinks, absolute paths, and
176 /// solid archives. Use only when extracting archives from trusted sources.
177 #[must_use]
178 pub fn permissive() -> Self {
179 Self {
180 allowed: AllowedFeatures {
181 symlinks: true,
182 hardlinks: true,
183 absolute_paths: true,
184 world_writable: true,
185 },
186 preserve_permissions: true,
187 max_compression_ratio: 1000.0,
188 banned_path_components: Vec::new(),
189 allow_solid_archives: true,
190 max_solid_block_memory: 1024 * 1024 * 1024, // 1 GB for permissive
191 ..Default::default()
192 }
193 }
194
195 /// Validates that the configuration values are logically consistent.
196 ///
197 /// Returns an error if any field has a value that would make security
198 /// enforcement impossible (zero limits or non-positive ratio).
199 ///
200 /// # Errors
201 ///
202 /// Returns `ExtractionError::InvalidConfiguration` if:
203 /// - `max_compression_ratio` is not positive
204 /// - `max_file_size` is zero
205 /// - `max_total_size` is zero
206 /// - `max_path_depth` is zero
207 /// - `max_file_count` is zero
208 /// - `max_solid_block_memory` is zero
209 ///
210 /// # Examples
211 ///
212 /// ```
213 /// use exarch_core::SecurityConfig;
214 ///
215 /// let config = SecurityConfig::default();
216 /// assert!(config.validate().is_ok());
217 ///
218 /// let bad = SecurityConfig::default().with_max_file_size(0);
219 /// assert!(bad.validate().is_err());
220 /// ```
221 pub fn validate(&self) -> crate::Result<()> {
222 if !self.max_compression_ratio.is_finite() || self.max_compression_ratio <= 0.0 {
223 return Err(crate::ExtractionError::InvalidConfiguration {
224 reason: "max_compression_ratio must be positive".into(),
225 });
226 }
227 if self.max_file_size == 0 {
228 return Err(crate::ExtractionError::InvalidConfiguration {
229 reason: "max_file_size must not be zero".into(),
230 });
231 }
232 if self.max_total_size == 0 {
233 return Err(crate::ExtractionError::InvalidConfiguration {
234 reason: "max_total_size must not be zero".into(),
235 });
236 }
237 if self.max_path_depth == 0 {
238 return Err(crate::ExtractionError::InvalidConfiguration {
239 reason: "max_path_depth must not be zero".into(),
240 });
241 }
242 if self.max_file_count == 0 {
243 return Err(crate::ExtractionError::InvalidConfiguration {
244 reason: "max_file_count must not be zero".into(),
245 });
246 }
247 if self.max_solid_block_memory == 0 {
248 return Err(crate::ExtractionError::InvalidConfiguration {
249 reason: "max_solid_block_memory must not be zero".into(),
250 });
251 }
252 Ok(())
253 }
254
255 /// Sets the maximum size for a single extracted file in bytes.
256 ///
257 /// # Examples
258 ///
259 /// ```
260 /// use exarch_core::SecurityConfig;
261 ///
262 /// let config = SecurityConfig::default().with_max_file_size(100 * 1024 * 1024);
263 /// assert_eq!(config.max_file_size, 100 * 1024 * 1024);
264 /// ```
265 #[must_use]
266 #[inline]
267 pub fn with_max_file_size(mut self, size: u64) -> Self {
268 self.max_file_size = size;
269 self
270 }
271
272 /// Sets the maximum total size for all extracted files in bytes.
273 ///
274 /// # Examples
275 ///
276 /// ```
277 /// use exarch_core::SecurityConfig;
278 ///
279 /// let config = SecurityConfig::default().with_max_total_size(1024 * 1024 * 1024);
280 /// assert_eq!(config.max_total_size, 1024 * 1024 * 1024);
281 /// ```
282 #[must_use]
283 #[inline]
284 pub fn with_max_total_size(mut self, size: u64) -> Self {
285 self.max_total_size = size;
286 self
287 }
288
289 /// Sets the maximum allowed compression ratio (uncompressed / compressed).
290 ///
291 /// # Examples
292 ///
293 /// ```
294 /// use exarch_core::SecurityConfig;
295 ///
296 /// let config = SecurityConfig::default().with_max_compression_ratio(50.0);
297 /// assert_eq!(config.max_compression_ratio, 50.0);
298 /// ```
299 #[must_use]
300 #[inline]
301 pub fn with_max_compression_ratio(mut self, ratio: f64) -> Self {
302 self.max_compression_ratio = ratio;
303 self
304 }
305
306 /// Sets the maximum number of files that can be extracted.
307 ///
308 /// # Examples
309 ///
310 /// ```
311 /// use exarch_core::SecurityConfig;
312 ///
313 /// let config = SecurityConfig::default().with_max_file_count(500);
314 /// assert_eq!(config.max_file_count, 500);
315 /// ```
316 #[must_use]
317 #[inline]
318 pub fn with_max_file_count(mut self, count: usize) -> Self {
319 self.max_file_count = count;
320 self
321 }
322
323 /// Sets the maximum path depth allowed.
324 ///
325 /// # Examples
326 ///
327 /// ```
328 /// use exarch_core::SecurityConfig;
329 ///
330 /// let config = SecurityConfig::default().with_max_path_depth(16);
331 /// assert_eq!(config.max_path_depth, 16);
332 /// ```
333 #[must_use]
334 #[inline]
335 pub fn with_max_path_depth(mut self, depth: usize) -> Self {
336 self.max_path_depth = depth;
337 self
338 }
339
340 /// Sets the feature flags controlling allowed archive features.
341 ///
342 /// # Examples
343 ///
344 /// ```
345 /// use exarch_core::SecurityConfig;
346 /// use exarch_core::config::AllowedFeatures;
347 ///
348 /// let features = AllowedFeatures::default();
349 /// let config = SecurityConfig::default().with_allowed(features);
350 /// assert!(!config.allowed.symlinks);
351 /// ```
352 #[must_use]
353 #[inline]
354 pub fn with_allowed(mut self, allowed: AllowedFeatures) -> Self {
355 self.allowed = allowed;
356 self
357 }
358
359 /// Enables or disables symlinks in extracted archives.
360 ///
361 /// # Examples
362 ///
363 /// ```
364 /// use exarch_core::SecurityConfig;
365 ///
366 /// let config = SecurityConfig::default().with_allow_symlinks(true);
367 /// assert!(config.allowed.symlinks);
368 /// ```
369 #[must_use]
370 #[inline]
371 pub fn with_allow_symlinks(mut self, allow: bool) -> Self {
372 self.allowed.symlinks = allow;
373 self
374 }
375
376 /// Enables or disables hardlinks in extracted archives.
377 ///
378 /// # Examples
379 ///
380 /// ```
381 /// use exarch_core::SecurityConfig;
382 ///
383 /// let config = SecurityConfig::default().with_allow_hardlinks(true);
384 /// assert!(config.allowed.hardlinks);
385 /// ```
386 #[must_use]
387 #[inline]
388 pub fn with_allow_hardlinks(mut self, allow: bool) -> Self {
389 self.allowed.hardlinks = allow;
390 self
391 }
392
393 /// Enables or disables absolute paths in archive entries.
394 ///
395 /// # Examples
396 ///
397 /// ```
398 /// use exarch_core::SecurityConfig;
399 ///
400 /// let config = SecurityConfig::default().with_allow_absolute_paths(true);
401 /// assert!(config.allowed.absolute_paths);
402 /// ```
403 #[must_use]
404 #[inline]
405 pub fn with_allow_absolute_paths(mut self, allow: bool) -> Self {
406 self.allowed.absolute_paths = allow;
407 self
408 }
409
410 /// Enables or disables world-writable files.
411 ///
412 /// # Examples
413 ///
414 /// ```
415 /// use exarch_core::SecurityConfig;
416 ///
417 /// let config = SecurityConfig::default().with_allow_world_writable(true);
418 /// assert!(config.allowed.world_writable);
419 /// ```
420 #[must_use]
421 #[inline]
422 pub fn with_allow_world_writable(mut self, allow: bool) -> Self {
423 self.allowed.world_writable = allow;
424 self
425 }
426
427 /// Enables or disables preserving file permissions from the archive.
428 ///
429 /// # Examples
430 ///
431 /// ```
432 /// use exarch_core::SecurityConfig;
433 ///
434 /// let config = SecurityConfig::default().with_preserve_permissions(true);
435 /// assert!(config.preserve_permissions);
436 /// ```
437 #[must_use]
438 #[inline]
439 pub fn with_preserve_permissions(mut self, preserve: bool) -> Self {
440 self.preserve_permissions = preserve;
441 self
442 }
443
444 /// Sets the list of allowed file extensions.
445 ///
446 /// An empty list allows all extensions.
447 ///
448 /// # Examples
449 ///
450 /// ```
451 /// use exarch_core::SecurityConfig;
452 ///
453 /// let config = SecurityConfig::default()
454 /// .with_allowed_extensions(vec!["txt".to_string(), "pdf".to_string()]);
455 /// assert!(config.is_extension_allowed("txt"));
456 /// assert!(!config.is_extension_allowed("exe"));
457 /// ```
458 #[must_use]
459 #[inline]
460 pub fn with_allowed_extensions(mut self, extensions: Vec<String>) -> Self {
461 self.allowed_extensions = extensions;
462 self
463 }
464
465 /// Sets the list of banned path components.
466 ///
467 /// # Examples
468 ///
469 /// ```
470 /// use exarch_core::SecurityConfig;
471 ///
472 /// let config = SecurityConfig::default().with_banned_path_components(vec![".git".to_string()]);
473 /// assert!(!config.is_path_component_allowed(".git"));
474 /// assert!(config.is_path_component_allowed(".ssh"));
475 /// ```
476 #[must_use]
477 #[inline]
478 pub fn with_banned_path_components(mut self, components: Vec<String>) -> Self {
479 self.banned_path_components = components;
480 self
481 }
482
483 /// Enables or disables extraction from solid 7z archives.
484 ///
485 /// # Examples
486 ///
487 /// ```
488 /// use exarch_core::SecurityConfig;
489 ///
490 /// let config = SecurityConfig::default().with_allow_solid_archives(true);
491 /// assert!(config.allow_solid_archives);
492 /// ```
493 #[must_use]
494 #[inline]
495 pub fn with_allow_solid_archives(mut self, allow: bool) -> Self {
496 self.allow_solid_archives = allow;
497 self
498 }
499
500 /// Sets the maximum memory for solid archive extraction in bytes.
501 ///
502 /// Only applies when `allow_solid_archives` is `true`.
503 ///
504 /// # Examples
505 ///
506 /// ```
507 /// use exarch_core::SecurityConfig;
508 ///
509 /// let config = SecurityConfig::default()
510 /// .with_allow_solid_archives(true)
511 /// .with_max_solid_block_memory(1024 * 1024 * 1024);
512 /// assert_eq!(config.max_solid_block_memory, 1024 * 1024 * 1024);
513 /// ```
514 #[must_use]
515 #[inline]
516 pub fn with_max_solid_block_memory(mut self, size: u64) -> Self {
517 self.max_solid_block_memory = size;
518 self
519 }
520
521 /// Validates whether a path component is allowed.
522 ///
523 /// Comparison is case-insensitive to prevent bypass on case-insensitive
524 /// filesystems (Windows, macOS default).
525 #[must_use]
526 pub fn is_path_component_allowed(&self, component: &str) -> bool {
527 !self
528 .banned_path_components
529 .iter()
530 .any(|banned| banned.eq_ignore_ascii_case(component))
531 }
532
533 /// Validates whether a file extension is allowed.
534 ///
535 /// When `allowed_extensions` is empty, all extensions are permitted.
536 /// When it is non-empty, only listed extensions are permitted.
537 #[must_use]
538 pub fn is_extension_allowed(&self, extension: &str) -> bool {
539 if self.allowed_extensions.is_empty() {
540 return true;
541 }
542 self.allowed_extensions
543 .iter()
544 .any(|ext| ext.eq_ignore_ascii_case(extension))
545 }
546
547 /// Returns `true` if a file with the given optional extension may be
548 /// extracted.
549 ///
550 /// When `allowed_extensions` is non-empty and `extension` is `None`
551 /// (the file has no extension), the file is treated as not allowed.
552 ///
553 /// # Examples
554 ///
555 /// ```
556 /// use exarch_core::SecurityConfig;
557 ///
558 /// let config = SecurityConfig::default().with_allowed_extensions(vec!["txt".to_string()]);
559 ///
560 /// assert!(config.is_path_extension_allowed(Some("txt")));
561 /// assert!(!config.is_path_extension_allowed(Some("exe")));
562 /// // Files without an extension are blocked when the allowlist is non-empty.
563 /// assert!(!config.is_path_extension_allowed(None));
564 ///
565 /// // Empty allowlist permits everything, including extension-less files.
566 /// let permissive = SecurityConfig::default();
567 /// assert!(permissive.is_path_extension_allowed(None));
568 /// ```
569 #[must_use]
570 pub fn is_path_extension_allowed(&self, extension: Option<&str>) -> bool {
571 if self.allowed_extensions.is_empty() {
572 return true;
573 }
574 extension.is_some_and(|ext| self.is_extension_allowed(ext))
575 }
576}
577
578/// Options controlling extraction behavior (non-security).
579///
580/// Separate from `SecurityConfig` to keep security settings focused.
581/// These options control operational behavior like atomicity.
582#[derive(Debug, Clone)]
583#[non_exhaustive]
584pub struct ExtractionOptions {
585 /// Extract atomically: use a temp dir in the same parent as the output
586 /// directory, rename on success, and delete on failure.
587 ///
588 /// When enabled, extraction is all-or-nothing: if extraction fails,
589 /// the output directory will not be created. This prevents partial
590 /// extraction artifacts from remaining on disk.
591 ///
592 /// Note: cleanup is best-effort if the process is terminated via SIGKILL.
593 pub atomic: bool,
594
595 /// Skip duplicate entries silently instead of aborting.
596 ///
597 /// When `true` (default), if an archive contains two entries with the same
598 /// destination path, the second entry is skipped and a warning is recorded
599 /// in `ExtractionReport`. When `false`, duplicate entries cause an error.
600 pub skip_duplicates: bool,
601}
602
603impl Default for ExtractionOptions {
604 fn default() -> Self {
605 Self {
606 atomic: false,
607 skip_duplicates: true,
608 }
609 }
610}
611
612impl ExtractionOptions {
613 /// Enables or disables atomic extraction.
614 ///
615 /// When enabled, extraction is all-or-nothing: the output directory is not
616 /// created if extraction fails.
617 ///
618 /// # Examples
619 ///
620 /// ```
621 /// use exarch_core::ExtractionOptions;
622 ///
623 /// let opts = ExtractionOptions::default().with_atomic(true);
624 /// assert!(opts.atomic);
625 /// ```
626 #[must_use]
627 #[inline]
628 pub fn with_atomic(mut self, atomic: bool) -> Self {
629 self.atomic = atomic;
630 self
631 }
632
633 /// Enables or disables skipping duplicate entries silently.
634 ///
635 /// # Examples
636 ///
637 /// ```
638 /// use exarch_core::ExtractionOptions;
639 ///
640 /// let opts = ExtractionOptions::default().with_skip_duplicates(false);
641 /// assert!(!opts.skip_duplicates);
642 /// ```
643 #[must_use]
644 #[inline]
645 pub fn with_skip_duplicates(mut self, skip: bool) -> Self {
646 self.skip_duplicates = skip;
647 self
648 }
649}
650
651#[cfg(test)]
652#[allow(clippy::unwrap_used, clippy::field_reassign_with_default)]
653mod tests {
654 use super::*;
655
656 #[test]
657 fn test_default_config() {
658 let config = SecurityConfig::default();
659 assert!(!config.allowed.symlinks);
660 assert!(!config.allowed.hardlinks);
661 assert!(!config.allowed.absolute_paths);
662 assert_eq!(config.max_file_size, 50 * 1024 * 1024);
663 }
664
665 #[test]
666 fn test_permissive_config() {
667 let config = SecurityConfig::permissive();
668 assert!(config.allowed.symlinks);
669 assert!(config.allowed.hardlinks);
670 assert!(config.allowed.absolute_paths);
671 }
672
673 #[test]
674 fn test_extension_allowed_empty_list() {
675 let config = SecurityConfig::default();
676 assert!(config.is_extension_allowed("txt"));
677 assert!(config.is_extension_allowed("pdf"));
678 }
679
680 #[test]
681 fn test_extension_allowed_with_list() {
682 let mut config = SecurityConfig::default();
683 config.allowed_extensions = vec!["txt".to_string(), "pdf".to_string()];
684 assert!(config.is_extension_allowed("txt"));
685 assert!(config.is_extension_allowed("TXT"));
686 assert!(!config.is_extension_allowed("exe"));
687 }
688
689 #[test]
690 fn test_path_component_allowed() {
691 let config = SecurityConfig::default();
692 assert!(config.is_path_component_allowed("src"));
693 assert!(!config.is_path_component_allowed(".git"));
694 assert!(!config.is_path_component_allowed(".ssh"));
695
696 // Case-insensitive matching prevents bypass
697 assert!(!config.is_path_component_allowed(".Git"));
698 assert!(!config.is_path_component_allowed(".GIT"));
699 assert!(!config.is_path_component_allowed(".SSH"));
700 assert!(!config.is_path_component_allowed(".Gnupg"));
701 }
702
703 // M-TEST-3: Config field validation
704 #[test]
705 fn test_config_default_security_flags() {
706 let config = SecurityConfig::default();
707
708 // All security-sensitive flags should be false by default (deny-by-default)
709 assert!(
710 !config.allowed.symlinks,
711 "symlinks should be denied by default"
712 );
713 assert!(
714 !config.allowed.hardlinks,
715 "hardlinks should be denied by default"
716 );
717 assert!(
718 !config.allowed.absolute_paths,
719 "absolute paths should be denied by default"
720 );
721 assert!(
722 !config.preserve_permissions,
723 "permissions should not be preserved by default"
724 );
725 assert!(
726 !config.allowed.world_writable,
727 "world-writable should be denied by default"
728 );
729 }
730
731 #[test]
732 fn test_config_permissive_security_flags() {
733 let config = SecurityConfig::permissive();
734
735 // Permissive config should allow all features
736 assert!(config.allowed.symlinks, "permissive allows symlinks");
737 assert!(config.allowed.hardlinks, "permissive allows hardlinks");
738 assert!(
739 config.allowed.absolute_paths,
740 "permissive allows absolute paths"
741 );
742 assert!(
743 config.preserve_permissions,
744 "permissive preserves permissions"
745 );
746 assert!(
747 config.allowed.world_writable,
748 "permissive allows world-writable"
749 );
750 }
751
752 #[test]
753 fn test_config_quota_limits() {
754 let config = SecurityConfig::default();
755
756 // Verify default quota values are sensible
757 assert_eq!(config.max_file_size, 50 * 1024 * 1024, "50 MB file limit");
758 assert_eq!(
759 config.max_total_size,
760 500 * 1024 * 1024,
761 "500 MB total limit"
762 );
763 assert_eq!(config.max_file_count, 10_000, "10k file count limit");
764 assert_eq!(config.max_path_depth, 32, "32 level depth limit");
765 #[allow(clippy::float_cmp)]
766 {
767 assert_eq!(
768 config.max_compression_ratio, 100.0,
769 "100x compression ratio limit"
770 );
771 }
772 }
773
774 #[test]
775 fn test_config_banned_components_not_empty() {
776 let config = SecurityConfig::default();
777
778 // Default should ban common sensitive directories
779 assert!(
780 !config.banned_path_components.is_empty(),
781 "should have banned components by default"
782 );
783 assert!(
784 config.banned_path_components.contains(&".git".to_string()),
785 "should ban .git"
786 );
787 assert!(
788 config.banned_path_components.contains(&".ssh".to_string()),
789 "should ban .ssh"
790 );
791 }
792
793 #[test]
794 fn test_config_solid_archives_default() {
795 let config = SecurityConfig::default();
796
797 // Solid archives should be denied by default (security)
798 assert!(
799 !config.allow_solid_archives,
800 "solid archives should be denied by default"
801 );
802 assert_eq!(
803 config.max_solid_block_memory,
804 512 * 1024 * 1024,
805 "max solid block memory should be 512 MB"
806 );
807 }
808
809 #[test]
810 fn test_config_permissive_solid_archives() {
811 let config = SecurityConfig::permissive();
812
813 // Permissive config should allow solid archives
814 assert!(
815 config.allow_solid_archives,
816 "permissive config should allow solid archives"
817 );
818 assert_eq!(
819 config.max_solid_block_memory,
820 1024 * 1024 * 1024,
821 "permissive should have 1 GB solid block limit"
822 );
823 }
824
825 // Regression tests for #172: SecurityConfig::validate() must reject configs
826 // that would make security enforcement impossible.
827
828 #[test]
829 fn test_validate_default_is_ok() {
830 assert!(SecurityConfig::default().validate().is_ok());
831 }
832
833 #[test]
834 fn test_validate_rejects_negative_compression_ratio() {
835 let cfg = SecurityConfig {
836 max_compression_ratio: -1.0,
837 ..SecurityConfig::default()
838 };
839 assert!(cfg.validate().is_err());
840 }
841
842 #[test]
843 fn test_validate_rejects_zero_compression_ratio() {
844 let cfg = SecurityConfig {
845 max_compression_ratio: 0.0,
846 ..SecurityConfig::default()
847 };
848 assert!(cfg.validate().is_err());
849 }
850
851 #[test]
852 fn test_validate_rejects_zero_max_file_size() {
853 let cfg = SecurityConfig {
854 max_file_size: 0,
855 ..SecurityConfig::default()
856 };
857 assert!(cfg.validate().is_err());
858 }
859
860 #[test]
861 fn test_validate_rejects_zero_max_total_size() {
862 let cfg = SecurityConfig {
863 max_total_size: 0,
864 ..SecurityConfig::default()
865 };
866 assert!(cfg.validate().is_err());
867 }
868
869 #[test]
870 fn test_validate_rejects_zero_max_path_depth() {
871 let cfg = SecurityConfig {
872 max_path_depth: 0,
873 ..SecurityConfig::default()
874 };
875 assert!(cfg.validate().is_err());
876 }
877
878 #[test]
879 fn test_validate_rejects_nan_compression_ratio() {
880 let cfg = SecurityConfig {
881 max_compression_ratio: f64::NAN,
882 ..SecurityConfig::default()
883 };
884 assert!(cfg.validate().is_err());
885 }
886
887 #[test]
888 fn test_validate_rejects_infinite_compression_ratio() {
889 let cfg = SecurityConfig {
890 max_compression_ratio: f64::INFINITY,
891 ..SecurityConfig::default()
892 };
893 assert!(cfg.validate().is_err());
894 }
895
896 #[test]
897 fn test_validate_rejects_zero_max_file_count() {
898 let cfg = SecurityConfig {
899 max_file_count: 0,
900 ..SecurityConfig::default()
901 };
902 assert!(cfg.validate().is_err());
903 }
904
905 #[test]
906 fn test_validate_rejects_zero_max_solid_block_memory() {
907 let cfg = SecurityConfig {
908 max_solid_block_memory: 0,
909 ..SecurityConfig::default()
910 };
911 assert!(cfg.validate().is_err());
912 }
913}