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}