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)]
8pub struct AllowedFeatures {
9    /// Allow symlinks in extracted archives.
10    pub symlinks: bool,
11
12    /// Allow hardlinks in extracted archives.
13    pub hardlinks: bool,
14
15    /// Allow absolute paths in archive entries.
16    pub absolute_paths: bool,
17
18    /// Allow world-writable files (mode 0o002).
19    ///
20    /// World-writable files pose security risks in multi-user environments.
21    pub world_writable: bool,
22}
23
24/// Security configuration with default-deny settings.
25///
26/// This configuration controls various security checks performed during
27/// archive extraction to prevent common vulnerabilities.
28///
29/// # Performance Note
30///
31/// This struct contains heap-allocated collections (`Vec<String>`). For
32/// performance, pass by reference (`&SecurityConfig`) rather than cloning. If
33/// shared ownership is needed across threads, consider wrapping in
34/// `Arc<SecurityConfig>`.
35///
36/// # Examples
37///
38/// ```
39/// use exarch_core::SecurityConfig;
40///
41/// // Use secure defaults
42/// let config = SecurityConfig::default();
43///
44/// // Customize for specific needs
45/// let custom = SecurityConfig {
46///     max_file_size: 100 * 1024 * 1024,   // 100 MB
47///     max_total_size: 1024 * 1024 * 1024, // 1 GB
48///     ..Default::default()
49/// };
50/// ```
51#[derive(Debug, Clone)]
52pub struct SecurityConfig {
53    /// Maximum size for a single file in bytes.
54    pub max_file_size: u64,
55
56    /// Maximum total size for all extracted files in bytes.
57    pub max_total_size: u64,
58
59    /// Maximum compression ratio allowed (uncompressed / compressed).
60    pub max_compression_ratio: f64,
61
62    /// Maximum number of files that can be extracted.
63    pub max_file_count: usize,
64
65    /// Maximum path depth allowed.
66    pub max_path_depth: usize,
67
68    /// Feature flags controlling what archive features are allowed.
69    ///
70    /// Use this to enable symlinks, hardlinks, absolute paths, etc.
71    pub allowed: AllowedFeatures,
72
73    /// Preserve file permissions from archive.
74    pub preserve_permissions: bool,
75
76    /// List of allowed file extensions (empty = allow all).
77    pub allowed_extensions: Vec<String>,
78
79    /// List of banned path components (e.g., ".git", ".ssh").
80    pub banned_path_components: Vec<String>,
81
82    /// Allow extraction from solid 7z archives.
83    ///
84    /// Solid archives compress multiple files together as a single block.
85    /// While this provides better compression ratios, it has security
86    /// implications:
87    ///
88    /// - **Memory exhaustion**: Extracting a single file requires decompressing
89    ///   the entire solid block into memory
90    /// - **Denial of service**: Malicious archives can create large solid
91    ///   blocks that exhaust available memory
92    ///
93    /// **Security Recommendation**: Only enable for trusted archives.
94    ///
95    /// Default: `false` (solid archives rejected)
96    pub allow_solid_archives: bool,
97
98    /// Maximum memory for solid archive extraction (bytes).
99    ///
100    /// **7z Solid Archive Memory Model:**
101    ///
102    /// Solid compression in 7z stores multiple files in a single compressed
103    /// block. Extracting ANY file requires decompressing the ENTIRE solid block
104    /// into memory, which can cause memory exhaustion attacks.
105    ///
106    /// **Validation Strategy:**
107    /// - Pre-validates total uncompressed size of all files in archive
108    /// - This is a conservative heuristic (assumes single solid block)
109    /// - Reason: `sevenz-rust2` v0.20 doesn't expose solid block boundaries
110    ///
111    /// **Security Guarantee:**
112    /// - Total uncompressed data cannot exceed this limit
113    /// - Combined with `max_file_size`, prevents unbounded memory growth
114    /// - Enforced ONLY when `allow_solid_archives` is `true`
115    ///
116    /// **Note**: Only applies when `allow_solid_archives` is `true`.
117    ///
118    /// Default: 512 MB (536,870,912 bytes)
119    ///
120    /// **Recommendation:** Set to 1-2x available RAM for trusted archives only.
121    pub max_solid_block_memory: u64,
122}
123
124impl Default for SecurityConfig {
125    /// Creates a `SecurityConfig` with secure default settings.
126    ///
127    /// Default values:
128    /// - `max_file_size`: 50 MB
129    /// - `max_total_size`: 500 MB
130    /// - `max_compression_ratio`: 100.0
131    /// - `max_file_count`: 10,000
132    /// - `max_path_depth`: 32
133    /// - `allowed`: All features disabled (deny-by-default)
134    /// - `preserve_permissions`: false
135    /// - `allowed_extensions`: empty (allow all)
136    /// - `banned_path_components`: `[".git", ".ssh", ".gnupg", ".aws", ".kube",
137    ///   ".docker", ".env"]`
138    /// - `allow_solid_archives`: false (solid archives rejected)
139    /// - `max_solid_block_memory`: 512 MB
140    fn default() -> Self {
141        Self {
142            max_file_size: 50 * 1024 * 1024,   // 50 MB
143            max_total_size: 500 * 1024 * 1024, // 500 MB
144            max_compression_ratio: 100.0,
145            max_file_count: 10_000,
146            max_path_depth: 32,
147            allowed: AllowedFeatures::default(), // All false
148            preserve_permissions: false,
149            allowed_extensions: Vec::new(),
150            banned_path_components: vec![
151                ".git".to_string(),
152                ".ssh".to_string(),
153                ".gnupg".to_string(),
154                ".aws".to_string(),
155                ".kube".to_string(),
156                ".docker".to_string(),
157                ".env".to_string(),
158            ],
159            allow_solid_archives: false,
160            max_solid_block_memory: 512 * 1024 * 1024, // 512 MB
161        }
162    }
163}
164
165impl SecurityConfig {
166    /// Creates a permissive configuration for trusted archives.
167    ///
168    /// This configuration allows symlinks, hardlinks, absolute paths, and
169    /// solid archives. Use only when extracting archives from trusted sources.
170    #[must_use]
171    pub fn permissive() -> Self {
172        Self {
173            allowed: AllowedFeatures {
174                symlinks: true,
175                hardlinks: true,
176                absolute_paths: true,
177                world_writable: true,
178            },
179            preserve_permissions: true,
180            max_compression_ratio: 1000.0,
181            banned_path_components: Vec::new(),
182            allow_solid_archives: true,
183            max_solid_block_memory: 1024 * 1024 * 1024, // 1 GB for permissive
184            ..Default::default()
185        }
186    }
187
188    /// Validates whether a path component is allowed.
189    ///
190    /// Comparison is case-insensitive to prevent bypass on case-insensitive
191    /// filesystems (Windows, macOS default).
192    #[must_use]
193    pub fn is_path_component_allowed(&self, component: &str) -> bool {
194        !self
195            .banned_path_components
196            .iter()
197            .any(|banned| banned.eq_ignore_ascii_case(component))
198    }
199
200    /// Validates whether a file extension is allowed.
201    #[must_use]
202    pub fn is_extension_allowed(&self, extension: &str) -> bool {
203        if self.allowed_extensions.is_empty() {
204            return true;
205        }
206        self.allowed_extensions
207            .iter()
208            .any(|ext| ext.eq_ignore_ascii_case(extension))
209    }
210}
211
212#[cfg(test)]
213#[allow(clippy::unwrap_used, clippy::field_reassign_with_default)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn test_default_config() {
219        let config = SecurityConfig::default();
220        assert!(!config.allowed.symlinks);
221        assert!(!config.allowed.hardlinks);
222        assert!(!config.allowed.absolute_paths);
223        assert_eq!(config.max_file_size, 50 * 1024 * 1024);
224    }
225
226    #[test]
227    fn test_permissive_config() {
228        let config = SecurityConfig::permissive();
229        assert!(config.allowed.symlinks);
230        assert!(config.allowed.hardlinks);
231        assert!(config.allowed.absolute_paths);
232    }
233
234    #[test]
235    fn test_extension_allowed_empty_list() {
236        let config = SecurityConfig::default();
237        assert!(config.is_extension_allowed("txt"));
238        assert!(config.is_extension_allowed("pdf"));
239    }
240
241    #[test]
242    fn test_extension_allowed_with_list() {
243        let mut config = SecurityConfig::default();
244        config.allowed_extensions = vec!["txt".to_string(), "pdf".to_string()];
245        assert!(config.is_extension_allowed("txt"));
246        assert!(config.is_extension_allowed("TXT"));
247        assert!(!config.is_extension_allowed("exe"));
248    }
249
250    #[test]
251    fn test_path_component_allowed() {
252        let config = SecurityConfig::default();
253        assert!(config.is_path_component_allowed("src"));
254        assert!(!config.is_path_component_allowed(".git"));
255        assert!(!config.is_path_component_allowed(".ssh"));
256
257        // Case-insensitive matching prevents bypass
258        assert!(!config.is_path_component_allowed(".Git"));
259        assert!(!config.is_path_component_allowed(".GIT"));
260        assert!(!config.is_path_component_allowed(".SSH"));
261        assert!(!config.is_path_component_allowed(".Gnupg"));
262    }
263
264    // M-TEST-3: Config field validation
265    #[test]
266    fn test_config_default_security_flags() {
267        let config = SecurityConfig::default();
268
269        // All security-sensitive flags should be false by default (deny-by-default)
270        assert!(
271            !config.allowed.symlinks,
272            "symlinks should be denied by default"
273        );
274        assert!(
275            !config.allowed.hardlinks,
276            "hardlinks should be denied by default"
277        );
278        assert!(
279            !config.allowed.absolute_paths,
280            "absolute paths should be denied by default"
281        );
282        assert!(
283            !config.preserve_permissions,
284            "permissions should not be preserved by default"
285        );
286        assert!(
287            !config.allowed.world_writable,
288            "world-writable should be denied by default"
289        );
290    }
291
292    #[test]
293    fn test_config_permissive_security_flags() {
294        let config = SecurityConfig::permissive();
295
296        // Permissive config should allow all features
297        assert!(config.allowed.symlinks, "permissive allows symlinks");
298        assert!(config.allowed.hardlinks, "permissive allows hardlinks");
299        assert!(
300            config.allowed.absolute_paths,
301            "permissive allows absolute paths"
302        );
303        assert!(
304            config.preserve_permissions,
305            "permissive preserves permissions"
306        );
307        assert!(
308            config.allowed.world_writable,
309            "permissive allows world-writable"
310        );
311    }
312
313    #[test]
314    fn test_config_quota_limits() {
315        let config = SecurityConfig::default();
316
317        // Verify default quota values are sensible
318        assert_eq!(config.max_file_size, 50 * 1024 * 1024, "50 MB file limit");
319        assert_eq!(
320            config.max_total_size,
321            500 * 1024 * 1024,
322            "500 MB total limit"
323        );
324        assert_eq!(config.max_file_count, 10_000, "10k file count limit");
325        assert_eq!(config.max_path_depth, 32, "32 level depth limit");
326        #[allow(clippy::float_cmp)]
327        {
328            assert_eq!(
329                config.max_compression_ratio, 100.0,
330                "100x compression ratio limit"
331            );
332        }
333    }
334
335    #[test]
336    fn test_config_banned_components_not_empty() {
337        let config = SecurityConfig::default();
338
339        // Default should ban common sensitive directories
340        assert!(
341            !config.banned_path_components.is_empty(),
342            "should have banned components by default"
343        );
344        assert!(
345            config.banned_path_components.contains(&".git".to_string()),
346            "should ban .git"
347        );
348        assert!(
349            config.banned_path_components.contains(&".ssh".to_string()),
350            "should ban .ssh"
351        );
352    }
353
354    #[test]
355    fn test_config_solid_archives_default() {
356        let config = SecurityConfig::default();
357
358        // Solid archives should be denied by default (security)
359        assert!(
360            !config.allow_solid_archives,
361            "solid archives should be denied by default"
362        );
363        assert_eq!(
364            config.max_solid_block_memory,
365            512 * 1024 * 1024,
366            "max solid block memory should be 512 MB"
367        );
368    }
369
370    #[test]
371    fn test_config_permissive_solid_archives() {
372        let config = SecurityConfig::permissive();
373
374        // Permissive config should allow solid archives
375        assert!(
376            config.allow_solid_archives,
377            "permissive config should allow solid archives"
378        );
379        assert_eq!(
380            config.max_solid_block_memory,
381            1024 * 1024 * 1024,
382            "permissive should have 1 GB solid block limit"
383        );
384    }
385}