exarch_core/config.rs
1//! Security configuration for archive extraction.
2
3use std::marker::PhantomData;
4use std::ops::Deref;
5use std::ops::DerefMut;
6
7/// Marker type for a [`SecurityConfig`] whose invariants have not yet been
8/// checked.
9///
10/// This is the default type parameter for [`SecurityConfig`]. The fluent
11/// `with_*` builder methods and [`SecurityConfig::validate`] are only
12/// available in this state.
13#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
14pub struct Unvalidated;
15
16/// Marker type for a [`SecurityConfig`] whose invariants have been checked.
17///
18/// Reachable only through [`SecurityConfig::validate`]. Every entry point
19/// that performs security-sensitive work — the
20/// [`ArchiveFormat`](crate::formats::traits::ArchiveFormat) trait and
21/// everything it calls — requires a `SecurityConfig<Validated>`, so an
22/// unvalidated configuration (e.g. one with `max_file_size == 0`) can never
23/// reach extraction, listing, or verification. The compiler enforces this;
24/// it is not a convention callers must remember to follow.
25///
26/// Once validated, a config's fields can still be *read* (via [`Deref`]) but
27/// no longer *written*: [`DerefMut`] is implemented only for
28/// `SecurityConfig<Unvalidated>`, so `cfg.max_compression_ratio = f64::NAN`
29/// on a `SecurityConfig<Validated>` is a compile error, not a runtime
30/// invariant violation. This is what makes the typestate airtight — without
31/// it, a caller could validate a config and then mutate it back into an
32/// invalid state before passing it to `ArchiveFormat::extract`.
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
34pub struct Validated;
35
36/// Feature flags controlling what archive features are allowed during
37/// extraction.
38///
39/// All features default to `false` (deny-by-default security policy).
40#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
41#[non_exhaustive]
42pub struct AllowedFeatures {
43 /// Allow symlinks in extracted archives.
44 pub symlinks: bool,
45
46 /// Allow hardlinks in extracted archives.
47 pub hardlinks: bool,
48
49 /// Allow absolute paths in archive entries.
50 pub absolute_paths: bool,
51
52 /// Allow world-writable files (mode 0o002).
53 ///
54 /// World-writable files pose security risks in multi-user environments.
55 pub world_writable: bool,
56}
57
58/// The field data of a [`SecurityConfig`], reachable via [`Deref`]/[`DerefMut`]
59/// regardless of (or gated by) validation state.
60///
61/// This type is not itself part of the sealing boundary — it is a plain data
62/// bag with `pub` fields, and nothing stops external code from naming it or
63/// cloning one out of a `&SecurityConfig`. The boundary is
64/// [`SecurityConfig`]'s own private `fields` member: there is no public API
65/// that takes a bare `SecurityConfigFields` and wraps it back into a
66/// `SecurityConfig<Validated>`, so an externally-forged or externally-mutated
67/// `SecurityConfigFields` value can never be smuggled into a `Validated`
68/// config. It exists purely so [`SecurityConfig`] can implement `Deref`/
69/// `DerefMut` and keep `cfg.max_file_size`-style field access working for
70/// every existing caller instead of forcing a getter-method migration.
71#[derive(Debug, Clone)]
72#[non_exhaustive]
73pub struct SecurityConfigFields {
74 /// Maximum size for a single file in bytes.
75 pub max_file_size: u64,
76
77 /// Maximum total size for all extracted files in bytes.
78 pub max_total_size: u64,
79
80 /// Maximum compression ratio allowed (uncompressed / compressed).
81 pub max_compression_ratio: f64,
82
83 /// Maximum number of files that can be extracted.
84 pub max_file_count: usize,
85
86 /// Maximum path depth allowed.
87 pub max_path_depth: usize,
88
89 /// Feature flags controlling what archive features are allowed.
90 ///
91 /// Use this to enable symlinks, hardlinks, absolute paths, etc.
92 pub allowed: AllowedFeatures,
93
94 /// Preserve file permissions from archive.
95 pub preserve_permissions: bool,
96
97 /// List of allowed file extensions (empty = allow all).
98 ///
99 /// Extensions are matched case-insensitively (e.g., `"txt"` matches both
100 /// `file.txt` and `file.TXT`). The leading dot must be omitted.
101 ///
102 /// When this list is non-empty, files without a file extension are treated
103 /// as not allowed and will be skipped during extraction.
104 pub allowed_extensions: Vec<String>,
105
106 /// List of banned path components (e.g., ".git", ".ssh").
107 pub banned_path_components: Vec<String>,
108
109 /// Allow extraction from solid 7z archives.
110 ///
111 /// Solid archives compress multiple files together as a single block.
112 /// While this provides better compression ratios, it has security
113 /// implications:
114 ///
115 /// - **Memory exhaustion**: Extracting a single file requires decompressing
116 /// the entire solid block into memory
117 /// - **Denial of service**: Malicious archives can create large solid
118 /// blocks that exhaust available memory
119 ///
120 /// **Security Recommendation**: Only enable for trusted archives.
121 ///
122 /// Default: `false` (solid archives rejected)
123 pub allow_solid_archives: bool,
124
125 /// Maximum memory for solid archive extraction (bytes).
126 ///
127 /// **7z Solid Archive Memory Model:**
128 ///
129 /// Solid compression in 7z stores multiple files in a single compressed
130 /// block. Extracting ANY file requires decompressing the ENTIRE solid block
131 /// into memory, which can cause memory exhaustion attacks.
132 ///
133 /// **Validation Strategy:**
134 /// - Pre-validates total uncompressed size of all files in archive
135 /// - This is a conservative heuristic (assumes single solid block)
136 /// - Reason: `sevenz-rust2` v0.20 doesn't expose solid block boundaries
137 ///
138 /// **Security Guarantee:**
139 /// - Total uncompressed data cannot exceed this limit
140 /// - Combined with `max_file_size`, prevents unbounded memory growth
141 /// - Enforced ONLY when `allow_solid_archives` is `true`
142 ///
143 /// **Note**: Only applies when `allow_solid_archives` is `true`.
144 ///
145 /// Default: 512 MB (536,870,912 bytes)
146 ///
147 /// **Recommendation:** Set to 1-2x available RAM for trusted archives only.
148 pub max_solid_block_memory: u64,
149
150 /// Maximum bytes the TAR reader may consume for headers and metadata
151 /// records in the gap between two consecutive entries.
152 ///
153 /// **TAR Metadata Buffering Model:**
154 ///
155 /// GNU long-name (`L`), GNU long-link (`K`), and PAX extended header
156 /// (`x`/`g`) records are buffered fully into memory by the underlying
157 /// `tar` crate *before* any entry reaches the entry validator or quota
158 /// tracker — a crafted record can declare a multi-gigabyte length backed
159 /// by a tiny compressed stream, exhausting memory with no quota
160 /// enforcement (metadata-entry decompression bomb).
161 ///
162 /// **Enforcement Strategy:**
163 /// - A read-budget wrapper meters bytes the `tar` crate reads while
164 /// searching for the next entry (headers, long-name/long-link/PAX
165 /// records, GNU sparse extension blocks) and returns an error once
166 /// `max_tar_metadata_bytes` is exceeded, before any oversized allocation
167 /// completes
168 /// - Applies uniformly to `extract`, `list`, and `verify`
169 /// - The window this bounds contains no entry *data* — only metadata — so
170 /// it does not interact with `max_file_size`/`max_total_size`. Draining
171 /// an unread entry before the next header search is a separate concern
172 /// (see `formats::tar_metadata_limit`'s module docs) bounded by
173 /// synthesized-byte accounting, not by this or any other quota value
174 /// - Legitimate long-path/xattr metadata records are at most a few
175 /// kilobytes each, and a GNU tar sparse file with heavy fragmentation can
176 /// use up to a few thousand 512-byte extension blocks; either or both
177 /// share this single budget (not "plus" each other), so the default
178 /// leaves headroom for either shape, not necessarily both at their
179 /// extremes simultaneously
180 /// - The real peak memory this bounds is roughly 5x the configured value
181 /// (GNU sparse extension blocks expand into multiple `EntryIo` records
182 /// per block before the growth is charged against this budget), not an
183 /// exact multiple — treat it as an order-of-magnitude ceiling, not a
184 /// tight bound
185 ///
186 /// Default: 4 MiB (4,194,304 bytes)
187 pub max_tar_metadata_bytes: u64,
188
189 /// Internal only — not part of the public builder API, and never set by
190 /// `SecurityConfig::default()`/`permissive()` construction paths that
191 /// external callers use.
192 ///
193 /// When `true`, `list_archive`'s entry-path NUL-byte check, symlink/
194 /// hardlink target NUL-byte/emptiness check, and missing-link-target
195 /// check are all skipped, instead of aborting the listing pass. Set only
196 /// by `inspection::verify::listing_config_for_verify` for
197 /// `verify_archive`'s internal pre-flight listing step, so a NUL-byte or
198 /// empty/missing link target reaches `verify_entry`'s own equivalent
199 /// checks (`SafePath::validate`, `SafeSymlink::validate`) and surfaces as
200 /// a graceful `VerificationIssue` in the report, matching `verify`'s
201 /// behavior before these list-level checks existed. Bare `list_archive`
202 /// always leaves this `false`, so `exarch list` still hard-aborts on
203 /// these conditions.
204 pub(crate) relaxed_for_verify_preflight: bool,
205}
206
207impl Default for SecurityConfigFields {
208 fn default() -> Self {
209 Self {
210 max_file_size: 50 * 1024 * 1024, // 50 MB
211 max_total_size: 500 * 1024 * 1024, // 500 MB
212 max_compression_ratio: 100.0,
213 max_file_count: 10_000,
214 max_path_depth: 32,
215 allowed: AllowedFeatures::default(), // All false
216 preserve_permissions: false,
217 allowed_extensions: Vec::new(),
218 banned_path_components: vec![
219 ".git".to_string(),
220 ".ssh".to_string(),
221 ".gnupg".to_string(),
222 ".aws".to_string(),
223 ".kube".to_string(),
224 ".docker".to_string(),
225 ".env".to_string(),
226 ],
227 allow_solid_archives: false,
228 max_solid_block_memory: 512 * 1024 * 1024, // 512 MB
229 max_tar_metadata_bytes: 4 * 1024 * 1024, // 4 MiB
230 relaxed_for_verify_preflight: false,
231 }
232 }
233}
234
235/// Security configuration with default-deny settings.
236///
237/// This configuration controls various security checks performed during
238/// archive extraction to prevent common vulnerabilities.
239///
240/// # Performance Note
241///
242/// This struct contains heap-allocated collections (`Vec<String>`). For
243/// performance, pass by reference (`&SecurityConfig`) rather than cloning. If
244/// shared ownership is needed across threads, consider wrapping in
245/// `Arc<SecurityConfig>`.
246///
247/// # Examples
248///
249/// ```
250/// use exarch_core::SecurityConfig;
251///
252/// // Use secure defaults
253/// let config = SecurityConfig::default();
254///
255/// // Customize via fluent builder
256/// let custom = SecurityConfig::default()
257/// .with_max_file_size(100 * 1024 * 1024)
258/// .with_max_total_size(1024 * 1024 * 1024)
259/// .with_allow_symlinks(true);
260/// ```
261///
262/// # Typestate
263///
264/// `SecurityConfig` carries a phantom `State` type parameter — [`Unvalidated`]
265/// (the default) or [`Validated`] — that tracks whether
266/// [`validate`](SecurityConfig::validate) has been called. Builder methods
267/// are only available in the `Unvalidated` state; security-sensitive APIs
268/// (the [`ArchiveFormat`](crate::formats::traits::ArchiveFormat) trait and
269/// everything downstream of it) require `SecurityConfig<Validated>`. This
270/// makes skipping validation a compile error instead of a runtime gap.
271///
272/// # Sealing
273///
274/// Fields are private and reachable only through
275/// <code>[Deref]<Target = [SecurityConfigFields]></code>, so
276/// `cfg.max_file_size` continues to work as plain field access for both states.
277/// [`DerefMut`] is implemented only for
278/// `SecurityConfig<Unvalidated>`, so a `SecurityConfig<Validated>`'s fields
279/// cannot be reassigned after the fact — the only way to produce one is
280/// [`validate`](SecurityConfig::validate) itself, and it stays that way for
281/// its entire lifetime.
282#[derive(Debug, Clone)]
283pub struct SecurityConfig<State = Unvalidated> {
284 fields: SecurityConfigFields,
285
286 /// Typestate marker — see the "Typestate" section on the type-level docs.
287 _marker: PhantomData<State>,
288}
289
290impl<State> Deref for SecurityConfig<State> {
291 type Target = SecurityConfigFields;
292
293 #[inline]
294 fn deref(&self) -> &SecurityConfigFields {
295 &self.fields
296 }
297}
298
299/// Only `Unvalidated` configs are mutable — see the "Sealing" section on
300/// [`SecurityConfig`]'s type-level docs for why this is the crux of the
301/// typestate guarantee.
302impl DerefMut for SecurityConfig<Unvalidated> {
303 fn deref_mut(&mut self) -> &mut SecurityConfigFields {
304 &mut self.fields
305 }
306}
307
308impl Default for SecurityConfig<Unvalidated> {
309 /// Creates a `SecurityConfig` with secure default settings.
310 ///
311 /// Default values:
312 /// - `max_file_size`: 50 MB
313 /// - `max_total_size`: 500 MB
314 /// - `max_compression_ratio`: 100.0
315 /// - `max_file_count`: 10,000
316 /// - `max_path_depth`: 32
317 /// - `allowed`: All features disabled (deny-by-default)
318 /// - `preserve_permissions`: false
319 /// - `allowed_extensions`: empty (allow all)
320 /// - `banned_path_components`: `[".git", ".ssh", ".gnupg", ".aws", ".kube",
321 /// ".docker", ".env"]`
322 /// - `allow_solid_archives`: false (solid archives rejected)
323 /// - `max_solid_block_memory`: 512 MB
324 /// - `max_tar_metadata_bytes`: 4 MiB
325 fn default() -> Self {
326 Self {
327 fields: SecurityConfigFields::default(),
328 _marker: PhantomData,
329 }
330 }
331}
332
333impl SecurityConfig<Unvalidated> {
334 /// Creates a permissive configuration for trusted archives.
335 ///
336 /// This configuration allows symlinks, hardlinks, absolute paths, and
337 /// solid archives. Use only when extracting archives from trusted sources.
338 #[must_use]
339 pub fn permissive() -> Self {
340 Self {
341 fields: SecurityConfigFields {
342 allowed: AllowedFeatures {
343 symlinks: true,
344 hardlinks: true,
345 absolute_paths: true,
346 world_writable: true,
347 },
348 preserve_permissions: true,
349 max_compression_ratio: 1000.0,
350 banned_path_components: Vec::new(),
351 allow_solid_archives: true,
352 max_solid_block_memory: 1024 * 1024 * 1024, // 1 GB for permissive
353 max_tar_metadata_bytes: 16 * 1024 * 1024, // 16 MiB for permissive
354 ..SecurityConfigFields::default()
355 },
356 _marker: PhantomData,
357 }
358 }
359
360 /// Validates that the configuration values are logically consistent,
361 /// transitioning to the [`Validated`] typestate on success.
362 ///
363 /// Returns an error if any field has a value that would make security
364 /// enforcement impossible (zero limits or non-positive ratio). Consumes
365 /// `self`: the only way to obtain a `SecurityConfig<Validated>`, which is
366 /// what every security-sensitive API in this crate requires. Once
367 /// returned, the `Validated` config's fields can no longer be reassigned
368 /// (see the "Sealing" section on the type-level docs), so this check can
369 /// never be silently invalidated afterward.
370 ///
371 /// # Errors
372 ///
373 /// Returns `ArchiveError::InvalidConfiguration` if:
374 /// - `max_compression_ratio` is not positive
375 /// - `max_file_size` is zero
376 /// - `max_total_size` is zero
377 /// - `max_path_depth` is zero
378 /// - `max_file_count` is zero
379 /// - `max_solid_block_memory` is zero
380 /// - `max_tar_metadata_bytes` is zero
381 /// - any entry in `allowed_extensions` or `banned_path_components` is
382 /// empty, contains a null byte, or exceeds
383 /// [`crate::MAX_CONFIG_ENTRY_LENGTH`] bytes
384 ///
385 /// # Examples
386 ///
387 /// ```
388 /// use exarch_core::SecurityConfig;
389 ///
390 /// let config = SecurityConfig::default();
391 /// assert!(config.validate().is_ok());
392 ///
393 /// let bad = SecurityConfig::default().with_max_file_size(0);
394 /// assert!(bad.validate().is_err());
395 /// ```
396 pub fn validate(self) -> crate::Result<SecurityConfig<Validated>> {
397 if !self.max_compression_ratio.is_finite() || self.max_compression_ratio <= 0.0 {
398 return Err(crate::ArchiveError::InvalidConfiguration {
399 reason: "max_compression_ratio must be positive".into(),
400 });
401 }
402 if self.max_file_size == 0 {
403 return Err(crate::ArchiveError::InvalidConfiguration {
404 reason: "max_file_size must not be zero".into(),
405 });
406 }
407 if self.max_total_size == 0 {
408 return Err(crate::ArchiveError::InvalidConfiguration {
409 reason: "max_total_size must not be zero".into(),
410 });
411 }
412 if self.max_path_depth == 0 {
413 return Err(crate::ArchiveError::InvalidConfiguration {
414 reason: "max_path_depth must not be zero".into(),
415 });
416 }
417 if self.max_file_count == 0 {
418 return Err(crate::ArchiveError::InvalidConfiguration {
419 reason: "max_file_count must not be zero".into(),
420 });
421 }
422 if self.max_solid_block_memory == 0 {
423 return Err(crate::ArchiveError::InvalidConfiguration {
424 reason: "max_solid_block_memory must not be zero".into(),
425 });
426 }
427 if self.max_tar_metadata_bytes == 0 {
428 return Err(crate::ArchiveError::InvalidConfiguration {
429 reason: "max_tar_metadata_bytes must not be zero".into(),
430 });
431 }
432 for extension in &self.fields.allowed_extensions {
433 crate::security::boundary::validate_config_entry(extension, "allowed extension")?;
434 }
435 for component in &self.fields.banned_path_components {
436 crate::security::boundary::validate_config_entry(component, "banned path component")?;
437 }
438 Ok(SecurityConfig {
439 fields: self.fields,
440 _marker: PhantomData,
441 })
442 }
443
444 /// Sets the maximum size for a single extracted file in bytes.
445 ///
446 /// # Examples
447 ///
448 /// ```
449 /// use exarch_core::SecurityConfig;
450 ///
451 /// let config = SecurityConfig::default().with_max_file_size(100 * 1024 * 1024);
452 /// assert_eq!(config.max_file_size, 100 * 1024 * 1024);
453 /// ```
454 #[must_use]
455 #[inline]
456 pub fn with_max_file_size(mut self, size: u64) -> Self {
457 self.fields.max_file_size = size;
458 self
459 }
460
461 /// Sets the maximum total size for all extracted files in bytes.
462 ///
463 /// # Examples
464 ///
465 /// ```
466 /// use exarch_core::SecurityConfig;
467 ///
468 /// let config = SecurityConfig::default().with_max_total_size(1024 * 1024 * 1024);
469 /// assert_eq!(config.max_total_size, 1024 * 1024 * 1024);
470 /// ```
471 #[must_use]
472 #[inline]
473 pub fn with_max_total_size(mut self, size: u64) -> Self {
474 self.fields.max_total_size = size;
475 self
476 }
477
478 /// Sets the maximum allowed compression ratio (uncompressed / compressed).
479 ///
480 /// # Examples
481 ///
482 /// ```
483 /// use exarch_core::SecurityConfig;
484 ///
485 /// let config = SecurityConfig::default().with_max_compression_ratio(50.0);
486 /// assert_eq!(config.max_compression_ratio, 50.0);
487 /// ```
488 #[must_use]
489 #[inline]
490 pub fn with_max_compression_ratio(mut self, ratio: f64) -> Self {
491 self.fields.max_compression_ratio = ratio;
492 self
493 }
494
495 /// Sets the maximum number of files that can be extracted.
496 ///
497 /// # Examples
498 ///
499 /// ```
500 /// use exarch_core::SecurityConfig;
501 ///
502 /// let config = SecurityConfig::default().with_max_file_count(500);
503 /// assert_eq!(config.max_file_count, 500);
504 /// ```
505 #[must_use]
506 #[inline]
507 pub fn with_max_file_count(mut self, count: usize) -> Self {
508 self.fields.max_file_count = count;
509 self
510 }
511
512 /// Sets the maximum path depth allowed.
513 ///
514 /// # Examples
515 ///
516 /// ```
517 /// use exarch_core::SecurityConfig;
518 ///
519 /// let config = SecurityConfig::default().with_max_path_depth(16);
520 /// assert_eq!(config.max_path_depth, 16);
521 /// ```
522 #[must_use]
523 #[inline]
524 pub fn with_max_path_depth(mut self, depth: usize) -> Self {
525 self.fields.max_path_depth = depth;
526 self
527 }
528
529 /// Sets the feature flags controlling allowed archive features.
530 ///
531 /// # Examples
532 ///
533 /// ```
534 /// use exarch_core::SecurityConfig;
535 /// use exarch_core::config::AllowedFeatures;
536 ///
537 /// let features = AllowedFeatures::default();
538 /// let config = SecurityConfig::default().with_allowed(features);
539 /// assert!(!config.allowed.symlinks);
540 /// ```
541 #[must_use]
542 #[inline]
543 pub fn with_allowed(mut self, allowed: AllowedFeatures) -> Self {
544 self.fields.allowed = allowed;
545 self
546 }
547
548 /// Enables or disables symlinks in extracted archives.
549 ///
550 /// # Examples
551 ///
552 /// ```
553 /// use exarch_core::SecurityConfig;
554 ///
555 /// let config = SecurityConfig::default().with_allow_symlinks(true);
556 /// assert!(config.allowed.symlinks);
557 /// ```
558 #[must_use]
559 #[inline]
560 pub fn with_allow_symlinks(mut self, allow: bool) -> Self {
561 self.fields.allowed.symlinks = allow;
562 self
563 }
564
565 /// Enables or disables hardlinks in extracted archives.
566 ///
567 /// # Examples
568 ///
569 /// ```
570 /// use exarch_core::SecurityConfig;
571 ///
572 /// let config = SecurityConfig::default().with_allow_hardlinks(true);
573 /// assert!(config.allowed.hardlinks);
574 /// ```
575 #[must_use]
576 #[inline]
577 pub fn with_allow_hardlinks(mut self, allow: bool) -> Self {
578 self.fields.allowed.hardlinks = allow;
579 self
580 }
581
582 /// Enables or disables absolute paths in archive entries.
583 ///
584 /// # Examples
585 ///
586 /// ```
587 /// use exarch_core::SecurityConfig;
588 ///
589 /// let config = SecurityConfig::default().with_allow_absolute_paths(true);
590 /// assert!(config.allowed.absolute_paths);
591 /// ```
592 #[must_use]
593 #[inline]
594 pub fn with_allow_absolute_paths(mut self, allow: bool) -> Self {
595 self.fields.allowed.absolute_paths = allow;
596 self
597 }
598
599 /// Enables or disables world-writable files.
600 ///
601 /// # Examples
602 ///
603 /// ```
604 /// use exarch_core::SecurityConfig;
605 ///
606 /// let config = SecurityConfig::default().with_allow_world_writable(true);
607 /// assert!(config.allowed.world_writable);
608 /// ```
609 #[must_use]
610 #[inline]
611 pub fn with_allow_world_writable(mut self, allow: bool) -> Self {
612 self.fields.allowed.world_writable = allow;
613 self
614 }
615
616 /// Enables or disables preserving file permissions from the archive.
617 ///
618 /// # Examples
619 ///
620 /// ```
621 /// use exarch_core::SecurityConfig;
622 ///
623 /// let config = SecurityConfig::default().with_preserve_permissions(true);
624 /// assert!(config.preserve_permissions);
625 /// ```
626 #[must_use]
627 #[inline]
628 pub fn with_preserve_permissions(mut self, preserve: bool) -> Self {
629 self.fields.preserve_permissions = preserve;
630 self
631 }
632
633 /// Sets the list of allowed file extensions.
634 ///
635 /// An empty list allows all extensions.
636 ///
637 /// # Examples
638 ///
639 /// ```
640 /// use exarch_core::SecurityConfig;
641 ///
642 /// let config = SecurityConfig::default()
643 /// .with_allowed_extensions(vec!["txt".to_string(), "pdf".to_string()]);
644 /// assert!(config.is_extension_allowed("txt"));
645 /// assert!(!config.is_extension_allowed("exe"));
646 /// ```
647 #[must_use]
648 #[inline]
649 pub fn with_allowed_extensions(mut self, extensions: Vec<String>) -> Self {
650 self.fields.allowed_extensions = extensions;
651 self
652 }
653
654 /// Sets the list of banned path components.
655 ///
656 /// # Examples
657 ///
658 /// ```
659 /// use exarch_core::SecurityConfig;
660 ///
661 /// let config = SecurityConfig::default().with_banned_path_components(vec![".git".to_string()]);
662 /// assert!(!config.is_path_component_allowed(".git"));
663 /// assert!(config.is_path_component_allowed(".ssh"));
664 /// ```
665 #[must_use]
666 #[inline]
667 pub fn with_banned_path_components(mut self, components: Vec<String>) -> Self {
668 self.fields.banned_path_components = components;
669 self
670 }
671
672 /// Enables or disables extraction from solid 7z archives.
673 ///
674 /// # Examples
675 ///
676 /// ```
677 /// use exarch_core::SecurityConfig;
678 ///
679 /// let config = SecurityConfig::default().with_allow_solid_archives(true);
680 /// assert!(config.allow_solid_archives);
681 /// ```
682 #[must_use]
683 #[inline]
684 pub fn with_allow_solid_archives(mut self, allow: bool) -> Self {
685 self.fields.allow_solid_archives = allow;
686 self
687 }
688
689 /// Sets the maximum memory for solid archive extraction in bytes.
690 ///
691 /// Only applies when `allow_solid_archives` is `true`.
692 ///
693 /// # Examples
694 ///
695 /// ```
696 /// use exarch_core::SecurityConfig;
697 ///
698 /// let config = SecurityConfig::default()
699 /// .with_allow_solid_archives(true)
700 /// .with_max_solid_block_memory(1024 * 1024 * 1024);
701 /// assert_eq!(config.max_solid_block_memory, 1024 * 1024 * 1024);
702 /// ```
703 #[must_use]
704 #[inline]
705 pub fn with_max_solid_block_memory(mut self, size: u64) -> Self {
706 self.fields.max_solid_block_memory = size;
707 self
708 }
709
710 /// Sets the maximum bytes the TAR reader may consume for headers and
711 /// metadata records (GNU long-name/long-link, PAX extended headers, GNU
712 /// sparse extension blocks) in the gap between two consecutive entries.
713 ///
714 /// # Examples
715 ///
716 /// ```
717 /// use exarch_core::SecurityConfig;
718 ///
719 /// let config = SecurityConfig::default().with_max_tar_metadata_bytes(64 * 1024);
720 /// assert_eq!(config.max_tar_metadata_bytes, 64 * 1024);
721 /// ```
722 #[must_use]
723 #[inline]
724 pub fn with_max_tar_metadata_bytes(mut self, size: u64) -> Self {
725 self.fields.max_tar_metadata_bytes = size;
726 self
727 }
728
729 /// Marks this config as the internal pre-flight listing pass inside
730 /// `verify_archive`. Not part of the public API — see
731 /// `relaxed_for_verify_preflight`'s field doc for what this relaxes.
732 ///
733 /// Production code builds this state via
734 /// `SecurityConfig::as_relaxed_for_verify_preflight` instead (it must
735 /// work on both typestates); this builder-style variant exists only so
736 /// `Unvalidated`-only test call sites don't need direct field writes.
737 #[cfg(test)]
738 #[must_use]
739 #[inline]
740 pub(crate) fn with_relaxed_for_verify_preflight(mut self) -> Self {
741 self.fields.relaxed_for_verify_preflight = true;
742 self
743 }
744}
745
746/// Read-only queries and crate-internal helpers available regardless of
747/// validation state.
748impl<State> SecurityConfig<State> {
749 /// Validates whether a path component is allowed.
750 ///
751 /// Comparison is case-insensitive to prevent bypass on case-insensitive
752 /// filesystems (Windows, macOS default).
753 #[must_use]
754 pub fn is_path_component_allowed(&self, component: &str) -> bool {
755 !self
756 .banned_path_components
757 .iter()
758 .any(|banned| banned.eq_ignore_ascii_case(component))
759 }
760
761 /// Validates whether a file extension is allowed.
762 ///
763 /// When `allowed_extensions` is empty, all extensions are permitted.
764 /// When it is non-empty, only listed extensions are permitted.
765 #[must_use]
766 pub fn is_extension_allowed(&self, extension: &str) -> bool {
767 if self.allowed_extensions.is_empty() {
768 return true;
769 }
770 self.allowed_extensions
771 .iter()
772 .any(|ext| ext.eq_ignore_ascii_case(extension))
773 }
774
775 /// Returns `true` if a file with the given optional extension may be
776 /// extracted.
777 ///
778 /// When `allowed_extensions` is non-empty and `extension` is `None`
779 /// (the file has no extension), the file is treated as not allowed.
780 ///
781 /// # Examples
782 ///
783 /// ```
784 /// use exarch_core::SecurityConfig;
785 ///
786 /// let config = SecurityConfig::default().with_allowed_extensions(vec!["txt".to_string()]);
787 ///
788 /// assert!(config.is_path_extension_allowed(Some("txt")));
789 /// assert!(!config.is_path_extension_allowed(Some("exe")));
790 /// // Files without an extension are blocked when the allowlist is non-empty.
791 /// assert!(!config.is_path_extension_allowed(None));
792 ///
793 /// // Empty allowlist permits everything, including extension-less files.
794 /// let permissive = SecurityConfig::default();
795 /// assert!(permissive.is_path_extension_allowed(None));
796 /// ```
797 #[must_use]
798 pub fn is_path_extension_allowed(&self, extension: Option<&str>) -> bool {
799 if self.allowed_extensions.is_empty() {
800 return true;
801 }
802 extension.is_some_and(|ext| self.is_extension_allowed(ext))
803 }
804
805 /// Returns a clone with `max_file_size` relaxed to unlimited and
806 /// `relaxed_for_verify_preflight` set, preserving `State`.
807 ///
808 /// Crate-internal escape hatch backing
809 /// `inspection::verify::listing_config_for_verify`. Sound for both
810 /// typestates: it only ever *relaxes* two fields whose overridden values
811 /// (`u64::MAX`, `true`) can never fail
812 /// [`validate`](SecurityConfig::validate)'s checks, so a `Validated`
813 /// input yields a still-genuinely-valid `Validated` output without
814 /// re-running `validate()`. This must not be generalized into an
815 /// arbitrary-field mutator — that would reopen the exact hole
816 /// `DerefMut`'s state-gating exists to close.
817 #[must_use]
818 pub(crate) fn as_relaxed_for_verify_preflight(&self) -> Self {
819 let mut fields = self.fields.clone();
820 fields.max_file_size = u64::MAX;
821 fields.relaxed_for_verify_preflight = true;
822 Self {
823 fields,
824 _marker: PhantomData,
825 }
826 }
827}
828
829/// Options controlling extraction behavior (non-security).
830///
831/// Separate from `SecurityConfig` to keep security settings focused.
832/// These options control operational behavior like atomicity.
833#[derive(Debug, Clone)]
834#[non_exhaustive]
835pub struct ExtractionOptions {
836 /// Extract atomically: use a temp dir in the same parent as the output
837 /// directory, rename on success, and delete on failure.
838 ///
839 /// When enabled, extraction is all-or-nothing: if extraction fails,
840 /// the output directory will not be created. This prevents partial
841 /// extraction artifacts from remaining on disk.
842 ///
843 /// Note: cleanup is best-effort if the process is terminated via SIGKILL.
844 pub atomic: bool,
845
846 /// Skip duplicate entries silently instead of aborting.
847 ///
848 /// When `true` (default), if an archive contains two entries with the same
849 /// destination path, the second entry is skipped and a warning is recorded
850 /// in `ExtractionReport`. When `false`, duplicate entries cause an error.
851 pub skip_duplicates: bool,
852}
853
854impl Default for ExtractionOptions {
855 fn default() -> Self {
856 Self {
857 atomic: false,
858 skip_duplicates: true,
859 }
860 }
861}
862
863impl ExtractionOptions {
864 /// Enables or disables atomic extraction.
865 ///
866 /// When enabled, extraction is all-or-nothing: the output directory is not
867 /// created if extraction fails.
868 ///
869 /// # Examples
870 ///
871 /// ```
872 /// use exarch_core::ExtractionOptions;
873 ///
874 /// let opts = ExtractionOptions::default().with_atomic(true);
875 /// assert!(opts.atomic);
876 /// ```
877 #[must_use]
878 #[inline]
879 pub fn with_atomic(mut self, atomic: bool) -> Self {
880 self.atomic = atomic;
881 self
882 }
883
884 /// Enables or disables skipping duplicate entries silently.
885 ///
886 /// # Examples
887 ///
888 /// ```
889 /// use exarch_core::ExtractionOptions;
890 ///
891 /// let opts = ExtractionOptions::default().with_skip_duplicates(false);
892 /// assert!(!opts.skip_duplicates);
893 /// ```
894 #[must_use]
895 #[inline]
896 pub fn with_skip_duplicates(mut self, skip: bool) -> Self {
897 self.skip_duplicates = skip;
898 self
899 }
900}
901
902#[cfg(test)]
903#[allow(
904 clippy::unwrap_used,
905 clippy::expect_used,
906 clippy::field_reassign_with_default
907)]
908mod tests {
909 use super::*;
910
911 #[test]
912 fn test_default_config() {
913 let config = SecurityConfig::default();
914 assert!(!config.allowed.symlinks);
915 assert!(!config.allowed.hardlinks);
916 assert!(!config.allowed.absolute_paths);
917 assert_eq!(config.max_file_size, 50 * 1024 * 1024);
918 }
919
920 #[test]
921 fn test_permissive_config() {
922 let config = SecurityConfig::permissive();
923 assert!(config.allowed.symlinks);
924 assert!(config.allowed.hardlinks);
925 assert!(config.allowed.absolute_paths);
926 }
927
928 #[test]
929 fn test_extension_allowed_empty_list() {
930 let config = SecurityConfig::default();
931 assert!(config.is_extension_allowed("txt"));
932 assert!(config.is_extension_allowed("pdf"));
933 }
934
935 #[test]
936 fn test_extension_allowed_with_list() {
937 let mut config = SecurityConfig::default();
938 config.allowed_extensions = vec!["txt".to_string(), "pdf".to_string()];
939 assert!(config.is_extension_allowed("txt"));
940 assert!(config.is_extension_allowed("TXT"));
941 assert!(!config.is_extension_allowed("exe"));
942 }
943
944 #[test]
945 fn test_path_component_allowed() {
946 let config = SecurityConfig::default();
947 assert!(config.is_path_component_allowed("src"));
948 assert!(!config.is_path_component_allowed(".git"));
949 assert!(!config.is_path_component_allowed(".ssh"));
950
951 // Case-insensitive matching prevents bypass
952 assert!(!config.is_path_component_allowed(".Git"));
953 assert!(!config.is_path_component_allowed(".GIT"));
954 assert!(!config.is_path_component_allowed(".SSH"));
955 assert!(!config.is_path_component_allowed(".Gnupg"));
956 }
957
958 // M-TEST-3: Config field validation
959 #[test]
960 fn test_config_default_security_flags() {
961 let config = SecurityConfig::default();
962
963 // All security-sensitive flags should be false by default
964 // (deny-by-default)
965 assert!(
966 !config.allowed.symlinks,
967 "symlinks should be denied by default"
968 );
969 assert!(
970 !config.allowed.hardlinks,
971 "hardlinks should be denied by default"
972 );
973 assert!(
974 !config.allowed.absolute_paths,
975 "absolute paths should be denied by default"
976 );
977 assert!(
978 !config.preserve_permissions,
979 "permissions should not be preserved by default"
980 );
981 assert!(
982 !config.allowed.world_writable,
983 "world-writable should be denied by default"
984 );
985 }
986
987 #[test]
988 fn test_config_permissive_security_flags() {
989 let config = SecurityConfig::permissive();
990
991 // Permissive config should allow all features
992 assert!(config.allowed.symlinks, "permissive allows symlinks");
993 assert!(config.allowed.hardlinks, "permissive allows hardlinks");
994 assert!(
995 config.allowed.absolute_paths,
996 "permissive allows absolute paths"
997 );
998 assert!(
999 config.preserve_permissions,
1000 "permissive preserves permissions"
1001 );
1002 assert!(
1003 config.allowed.world_writable,
1004 "permissive allows world-writable"
1005 );
1006 }
1007
1008 #[test]
1009 fn test_config_quota_limits() {
1010 let config = SecurityConfig::default();
1011
1012 // Verify default quota values are sensible
1013 assert_eq!(config.max_file_size, 50 * 1024 * 1024, "50 MB file limit");
1014 assert_eq!(
1015 config.max_total_size,
1016 500 * 1024 * 1024,
1017 "500 MB total limit"
1018 );
1019 assert_eq!(config.max_file_count, 10_000, "10k file count limit");
1020 assert_eq!(config.max_path_depth, 32, "32 level depth limit");
1021 #[allow(clippy::float_cmp)]
1022 {
1023 assert_eq!(
1024 config.max_compression_ratio, 100.0,
1025 "100x compression ratio limit"
1026 );
1027 }
1028 }
1029
1030 #[test]
1031 fn test_config_banned_components_not_empty() {
1032 let config = SecurityConfig::default();
1033
1034 // Default should ban common sensitive directories
1035 assert!(
1036 !config.banned_path_components.is_empty(),
1037 "should have banned components by default"
1038 );
1039 assert!(
1040 config.banned_path_components.contains(&".git".to_string()),
1041 "should ban .git"
1042 );
1043 assert!(
1044 config.banned_path_components.contains(&".ssh".to_string()),
1045 "should ban .ssh"
1046 );
1047 }
1048
1049 #[test]
1050 fn test_config_solid_archives_default() {
1051 let config = SecurityConfig::default();
1052
1053 // Solid archives should be denied by default (security)
1054 assert!(
1055 !config.allow_solid_archives,
1056 "solid archives should be denied by default"
1057 );
1058 assert_eq!(
1059 config.max_solid_block_memory,
1060 512 * 1024 * 1024,
1061 "max solid block memory should be 512 MB"
1062 );
1063 }
1064
1065 #[test]
1066 fn test_config_permissive_solid_archives() {
1067 let config = SecurityConfig::permissive();
1068
1069 // Permissive config should allow solid archives
1070 assert!(
1071 config.allow_solid_archives,
1072 "permissive config should allow solid archives"
1073 );
1074 assert_eq!(
1075 config.max_solid_block_memory,
1076 1024 * 1024 * 1024,
1077 "permissive should have 1 GB solid block limit"
1078 );
1079 }
1080
1081 #[test]
1082 fn test_config_tar_metadata_bytes_default() {
1083 let config = SecurityConfig::default();
1084 assert_eq!(
1085 config.max_tar_metadata_bytes,
1086 4 * 1024 * 1024,
1087 "max TAR metadata bytes should be 4 MiB by default"
1088 );
1089 }
1090
1091 #[test]
1092 fn test_config_permissive_tar_metadata_bytes() {
1093 let config = SecurityConfig::permissive();
1094 assert_eq!(
1095 config.max_tar_metadata_bytes,
1096 16 * 1024 * 1024,
1097 "permissive should have 16 MiB TAR metadata budget"
1098 );
1099 }
1100
1101 #[test]
1102 fn test_with_max_tar_metadata_bytes_builder() {
1103 let config = SecurityConfig::default().with_max_tar_metadata_bytes(2048);
1104 assert_eq!(config.max_tar_metadata_bytes, 2048);
1105 }
1106
1107 // Regression tests for #172: SecurityConfig::validate() must reject configs
1108 // that would make security enforcement impossible.
1109
1110 #[test]
1111 fn test_validate_default_is_ok() {
1112 assert!(SecurityConfig::default().validate().is_ok());
1113 }
1114
1115 #[test]
1116 fn test_validate_rejects_negative_compression_ratio() {
1117 let mut cfg = SecurityConfig::default();
1118 cfg.max_compression_ratio = -1.0;
1119 assert!(cfg.validate().is_err());
1120 }
1121
1122 #[test]
1123 fn test_validate_rejects_zero_compression_ratio() {
1124 let mut cfg = SecurityConfig::default();
1125 cfg.max_compression_ratio = 0.0;
1126 assert!(cfg.validate().is_err());
1127 }
1128
1129 #[test]
1130 fn test_validate_rejects_zero_max_file_size() {
1131 let mut cfg = SecurityConfig::default();
1132 cfg.max_file_size = 0;
1133 assert!(cfg.validate().is_err());
1134 }
1135
1136 #[test]
1137 fn test_validate_rejects_zero_max_total_size() {
1138 let mut cfg = SecurityConfig::default();
1139 cfg.max_total_size = 0;
1140 assert!(cfg.validate().is_err());
1141 }
1142
1143 #[test]
1144 fn test_validate_rejects_zero_max_path_depth() {
1145 let mut cfg = SecurityConfig::default();
1146 cfg.max_path_depth = 0;
1147 assert!(cfg.validate().is_err());
1148 }
1149
1150 #[test]
1151 fn test_validate_rejects_nan_compression_ratio() {
1152 let mut cfg = SecurityConfig::default();
1153 cfg.max_compression_ratio = f64::NAN;
1154 assert!(cfg.validate().is_err());
1155 }
1156
1157 #[test]
1158 fn test_validate_rejects_infinite_compression_ratio() {
1159 let mut cfg = SecurityConfig::default();
1160 cfg.max_compression_ratio = f64::INFINITY;
1161 assert!(cfg.validate().is_err());
1162 }
1163
1164 #[test]
1165 fn test_validate_rejects_zero_max_file_count() {
1166 let mut cfg = SecurityConfig::default();
1167 cfg.max_file_count = 0;
1168 assert!(cfg.validate().is_err());
1169 }
1170
1171 #[test]
1172 fn test_validate_rejects_zero_max_solid_block_memory() {
1173 let mut cfg = SecurityConfig::default();
1174 cfg.max_solid_block_memory = 0;
1175 assert!(cfg.validate().is_err());
1176 }
1177
1178 #[test]
1179 fn test_validate_rejects_zero_max_tar_metadata_bytes() {
1180 let mut cfg = SecurityConfig::default();
1181 cfg.max_tar_metadata_bytes = 0;
1182 assert!(cfg.validate().is_err());
1183 }
1184
1185 // Regression tests for #449: SecurityConfig::validate() must reject
1186 // malformed allowed_extensions/banned_path_components entries.
1187
1188 #[test]
1189 fn test_validate_permissive_is_ok() {
1190 assert!(SecurityConfig::permissive().validate().is_ok());
1191 }
1192
1193 #[test]
1194 fn test_validate_accepts_empty_allowed_extensions_vec() {
1195 let cfg = SecurityConfig::default().with_allowed_extensions(Vec::new());
1196 assert!(cfg.validate().is_ok());
1197 }
1198
1199 #[test]
1200 fn test_validate_rejects_empty_allowed_extension_entry() {
1201 let cfg = SecurityConfig::default().with_allowed_extensions(vec![String::new()]);
1202 assert!(cfg.validate().is_err());
1203 }
1204
1205 #[test]
1206 fn test_validate_rejects_empty_banned_path_component_entry() {
1207 let cfg = SecurityConfig::default().with_banned_path_components(vec![String::new()]);
1208 assert!(cfg.validate().is_err());
1209 }
1210
1211 #[test]
1212 fn test_validate_rejects_null_byte_in_allowed_extension() {
1213 let cfg = SecurityConfig::default().with_allowed_extensions(vec!["bad\0ext".to_string()]);
1214 assert!(cfg.validate().is_err());
1215 }
1216
1217 #[test]
1218 fn test_validate_rejects_overlong_banned_path_component() {
1219 let long_component = "x".repeat(256);
1220 let cfg = SecurityConfig::default().with_banned_path_components(vec![long_component]);
1221 assert!(cfg.validate().is_err());
1222 }
1223
1224 // Regression test for the C1 finding on #433/#434/#435: a
1225 // `SecurityConfig<Validated>` must not be mutable. `DerefMut` is only
1226 // implemented for `SecurityConfig<Unvalidated>`, so this is enforced at
1227 // compile time — see `tests/ui/validated_config_field_mutation.rs` for
1228 // the corresponding compile-fail fixture. This test only pins the
1229 // read-side behavior: fields remain readable after validation.
1230 #[test]
1231 fn test_validated_config_fields_remain_readable() {
1232 let validated = SecurityConfig::default()
1233 .with_max_file_size(123)
1234 .validate()
1235 .expect("valid config");
1236 assert_eq!(validated.max_file_size, 123);
1237 }
1238}