Skip to main content

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}