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}