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)]
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/// Options controlling extraction behavior (non-security).
213///
214/// Separate from `SecurityConfig` to keep security settings focused.
215/// These options control operational behavior like atomicity.
216#[derive(Debug, Clone, Default)]
217pub struct ExtractionOptions {
218    /// Extract atomically: use a temp dir in the same parent as the output
219    /// directory, rename on success, and delete on failure.
220    ///
221    /// When enabled, extraction is all-or-nothing: if extraction fails,
222    /// the output directory will not be created. This prevents partial
223    /// extraction artifacts from remaining on disk.
224    ///
225    /// Note: cleanup is best-effort if the process is terminated via SIGKILL.
226    pub atomic: bool,
227}
228
229#[cfg(test)]
230#[allow(clippy::unwrap_used, clippy::field_reassign_with_default)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn test_default_config() {
236        let config = SecurityConfig::default();
237        assert!(!config.allowed.symlinks);
238        assert!(!config.allowed.hardlinks);
239        assert!(!config.allowed.absolute_paths);
240        assert_eq!(config.max_file_size, 50 * 1024 * 1024);
241    }
242
243    #[test]
244    fn test_permissive_config() {
245        let config = SecurityConfig::permissive();
246        assert!(config.allowed.symlinks);
247        assert!(config.allowed.hardlinks);
248        assert!(config.allowed.absolute_paths);
249    }
250
251    #[test]
252    fn test_extension_allowed_empty_list() {
253        let config = SecurityConfig::default();
254        assert!(config.is_extension_allowed("txt"));
255        assert!(config.is_extension_allowed("pdf"));
256    }
257
258    #[test]
259    fn test_extension_allowed_with_list() {
260        let mut config = SecurityConfig::default();
261        config.allowed_extensions = vec!["txt".to_string(), "pdf".to_string()];
262        assert!(config.is_extension_allowed("txt"));
263        assert!(config.is_extension_allowed("TXT"));
264        assert!(!config.is_extension_allowed("exe"));
265    }
266
267    #[test]
268    fn test_path_component_allowed() {
269        let config = SecurityConfig::default();
270        assert!(config.is_path_component_allowed("src"));
271        assert!(!config.is_path_component_allowed(".git"));
272        assert!(!config.is_path_component_allowed(".ssh"));
273
274        // Case-insensitive matching prevents bypass
275        assert!(!config.is_path_component_allowed(".Git"));
276        assert!(!config.is_path_component_allowed(".GIT"));
277        assert!(!config.is_path_component_allowed(".SSH"));
278        assert!(!config.is_path_component_allowed(".Gnupg"));
279    }
280
281    // M-TEST-3: Config field validation
282    #[test]
283    fn test_config_default_security_flags() {
284        let config = SecurityConfig::default();
285
286        // All security-sensitive flags should be false by default (deny-by-default)
287        assert!(
288            !config.allowed.symlinks,
289            "symlinks should be denied by default"
290        );
291        assert!(
292            !config.allowed.hardlinks,
293            "hardlinks should be denied by default"
294        );
295        assert!(
296            !config.allowed.absolute_paths,
297            "absolute paths should be denied by default"
298        );
299        assert!(
300            !config.preserve_permissions,
301            "permissions should not be preserved by default"
302        );
303        assert!(
304            !config.allowed.world_writable,
305            "world-writable should be denied by default"
306        );
307    }
308
309    #[test]
310    fn test_config_permissive_security_flags() {
311        let config = SecurityConfig::permissive();
312
313        // Permissive config should allow all features
314        assert!(config.allowed.symlinks, "permissive allows symlinks");
315        assert!(config.allowed.hardlinks, "permissive allows hardlinks");
316        assert!(
317            config.allowed.absolute_paths,
318            "permissive allows absolute paths"
319        );
320        assert!(
321            config.preserve_permissions,
322            "permissive preserves permissions"
323        );
324        assert!(
325            config.allowed.world_writable,
326            "permissive allows world-writable"
327        );
328    }
329
330    #[test]
331    fn test_config_quota_limits() {
332        let config = SecurityConfig::default();
333
334        // Verify default quota values are sensible
335        assert_eq!(config.max_file_size, 50 * 1024 * 1024, "50 MB file limit");
336        assert_eq!(
337            config.max_total_size,
338            500 * 1024 * 1024,
339            "500 MB total limit"
340        );
341        assert_eq!(config.max_file_count, 10_000, "10k file count limit");
342        assert_eq!(config.max_path_depth, 32, "32 level depth limit");
343        #[allow(clippy::float_cmp)]
344        {
345            assert_eq!(
346                config.max_compression_ratio, 100.0,
347                "100x compression ratio limit"
348            );
349        }
350    }
351
352    #[test]
353    fn test_config_banned_components_not_empty() {
354        let config = SecurityConfig::default();
355
356        // Default should ban common sensitive directories
357        assert!(
358            !config.banned_path_components.is_empty(),
359            "should have banned components by default"
360        );
361        assert!(
362            config.banned_path_components.contains(&".git".to_string()),
363            "should ban .git"
364        );
365        assert!(
366            config.banned_path_components.contains(&".ssh".to_string()),
367            "should ban .ssh"
368        );
369    }
370
371    #[test]
372    fn test_config_solid_archives_default() {
373        let config = SecurityConfig::default();
374
375        // Solid archives should be denied by default (security)
376        assert!(
377            !config.allow_solid_archives,
378            "solid archives should be denied by default"
379        );
380        assert_eq!(
381            config.max_solid_block_memory,
382            512 * 1024 * 1024,
383            "max solid block memory should be 512 MB"
384        );
385    }
386
387    #[test]
388    fn test_config_permissive_solid_archives() {
389        let config = SecurityConfig::permissive();
390
391        // Permissive config should allow solid archives
392        assert!(
393            config.allow_solid_archives,
394            "permissive config should allow solid archives"
395        );
396        assert_eq!(
397            config.max_solid_block_memory,
398            1024 * 1024 * 1024,
399            "permissive should have 1 GB solid block limit"
400        );
401    }
402}