Skip to main content

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 (deny-by-default)
964        assert!(
965            !config.allowed.symlinks,
966            "symlinks should be denied by default"
967        );
968        assert!(
969            !config.allowed.hardlinks,
970            "hardlinks should be denied by default"
971        );
972        assert!(
973            !config.allowed.absolute_paths,
974            "absolute paths should be denied by default"
975        );
976        assert!(
977            !config.preserve_permissions,
978            "permissions should not be preserved by default"
979        );
980        assert!(
981            !config.allowed.world_writable,
982            "world-writable should be denied by default"
983        );
984    }
985
986    #[test]
987    fn test_config_permissive_security_flags() {
988        let config = SecurityConfig::permissive();
989
990        // Permissive config should allow all features
991        assert!(config.allowed.symlinks, "permissive allows symlinks");
992        assert!(config.allowed.hardlinks, "permissive allows hardlinks");
993        assert!(
994            config.allowed.absolute_paths,
995            "permissive allows absolute paths"
996        );
997        assert!(
998            config.preserve_permissions,
999            "permissive preserves permissions"
1000        );
1001        assert!(
1002            config.allowed.world_writable,
1003            "permissive allows world-writable"
1004        );
1005    }
1006
1007    #[test]
1008    fn test_config_quota_limits() {
1009        let config = SecurityConfig::default();
1010
1011        // Verify default quota values are sensible
1012        assert_eq!(config.max_file_size, 50 * 1024 * 1024, "50 MB file limit");
1013        assert_eq!(
1014            config.max_total_size,
1015            500 * 1024 * 1024,
1016            "500 MB total limit"
1017        );
1018        assert_eq!(config.max_file_count, 10_000, "10k file count limit");
1019        assert_eq!(config.max_path_depth, 32, "32 level depth limit");
1020        #[allow(clippy::float_cmp)]
1021        {
1022            assert_eq!(
1023                config.max_compression_ratio, 100.0,
1024                "100x compression ratio limit"
1025            );
1026        }
1027    }
1028
1029    #[test]
1030    fn test_config_banned_components_not_empty() {
1031        let config = SecurityConfig::default();
1032
1033        // Default should ban common sensitive directories
1034        assert!(
1035            !config.banned_path_components.is_empty(),
1036            "should have banned components by default"
1037        );
1038        assert!(
1039            config.banned_path_components.contains(&".git".to_string()),
1040            "should ban .git"
1041        );
1042        assert!(
1043            config.banned_path_components.contains(&".ssh".to_string()),
1044            "should ban .ssh"
1045        );
1046    }
1047
1048    #[test]
1049    fn test_config_solid_archives_default() {
1050        let config = SecurityConfig::default();
1051
1052        // Solid archives should be denied by default (security)
1053        assert!(
1054            !config.allow_solid_archives,
1055            "solid archives should be denied by default"
1056        );
1057        assert_eq!(
1058            config.max_solid_block_memory,
1059            512 * 1024 * 1024,
1060            "max solid block memory should be 512 MB"
1061        );
1062    }
1063
1064    #[test]
1065    fn test_config_permissive_solid_archives() {
1066        let config = SecurityConfig::permissive();
1067
1068        // Permissive config should allow solid archives
1069        assert!(
1070            config.allow_solid_archives,
1071            "permissive config should allow solid archives"
1072        );
1073        assert_eq!(
1074            config.max_solid_block_memory,
1075            1024 * 1024 * 1024,
1076            "permissive should have 1 GB solid block limit"
1077        );
1078    }
1079
1080    #[test]
1081    fn test_config_tar_metadata_bytes_default() {
1082        let config = SecurityConfig::default();
1083        assert_eq!(
1084            config.max_tar_metadata_bytes,
1085            4 * 1024 * 1024,
1086            "max TAR metadata bytes should be 4 MiB by default"
1087        );
1088    }
1089
1090    #[test]
1091    fn test_config_permissive_tar_metadata_bytes() {
1092        let config = SecurityConfig::permissive();
1093        assert_eq!(
1094            config.max_tar_metadata_bytes,
1095            16 * 1024 * 1024,
1096            "permissive should have 16 MiB TAR metadata budget"
1097        );
1098    }
1099
1100    #[test]
1101    fn test_with_max_tar_metadata_bytes_builder() {
1102        let config = SecurityConfig::default().with_max_tar_metadata_bytes(2048);
1103        assert_eq!(config.max_tar_metadata_bytes, 2048);
1104    }
1105
1106    // Regression tests for #172: SecurityConfig::validate() must reject configs
1107    // that would make security enforcement impossible.
1108
1109    #[test]
1110    fn test_validate_default_is_ok() {
1111        assert!(SecurityConfig::default().validate().is_ok());
1112    }
1113
1114    #[test]
1115    fn test_validate_rejects_negative_compression_ratio() {
1116        let mut cfg = SecurityConfig::default();
1117        cfg.max_compression_ratio = -1.0;
1118        assert!(cfg.validate().is_err());
1119    }
1120
1121    #[test]
1122    fn test_validate_rejects_zero_compression_ratio() {
1123        let mut cfg = SecurityConfig::default();
1124        cfg.max_compression_ratio = 0.0;
1125        assert!(cfg.validate().is_err());
1126    }
1127
1128    #[test]
1129    fn test_validate_rejects_zero_max_file_size() {
1130        let mut cfg = SecurityConfig::default();
1131        cfg.max_file_size = 0;
1132        assert!(cfg.validate().is_err());
1133    }
1134
1135    #[test]
1136    fn test_validate_rejects_zero_max_total_size() {
1137        let mut cfg = SecurityConfig::default();
1138        cfg.max_total_size = 0;
1139        assert!(cfg.validate().is_err());
1140    }
1141
1142    #[test]
1143    fn test_validate_rejects_zero_max_path_depth() {
1144        let mut cfg = SecurityConfig::default();
1145        cfg.max_path_depth = 0;
1146        assert!(cfg.validate().is_err());
1147    }
1148
1149    #[test]
1150    fn test_validate_rejects_nan_compression_ratio() {
1151        let mut cfg = SecurityConfig::default();
1152        cfg.max_compression_ratio = f64::NAN;
1153        assert!(cfg.validate().is_err());
1154    }
1155
1156    #[test]
1157    fn test_validate_rejects_infinite_compression_ratio() {
1158        let mut cfg = SecurityConfig::default();
1159        cfg.max_compression_ratio = f64::INFINITY;
1160        assert!(cfg.validate().is_err());
1161    }
1162
1163    #[test]
1164    fn test_validate_rejects_zero_max_file_count() {
1165        let mut cfg = SecurityConfig::default();
1166        cfg.max_file_count = 0;
1167        assert!(cfg.validate().is_err());
1168    }
1169
1170    #[test]
1171    fn test_validate_rejects_zero_max_solid_block_memory() {
1172        let mut cfg = SecurityConfig::default();
1173        cfg.max_solid_block_memory = 0;
1174        assert!(cfg.validate().is_err());
1175    }
1176
1177    #[test]
1178    fn test_validate_rejects_zero_max_tar_metadata_bytes() {
1179        let mut cfg = SecurityConfig::default();
1180        cfg.max_tar_metadata_bytes = 0;
1181        assert!(cfg.validate().is_err());
1182    }
1183
1184    // Regression tests for #449: SecurityConfig::validate() must reject
1185    // malformed allowed_extensions/banned_path_components entries.
1186
1187    #[test]
1188    fn test_validate_permissive_is_ok() {
1189        assert!(SecurityConfig::permissive().validate().is_ok());
1190    }
1191
1192    #[test]
1193    fn test_validate_accepts_empty_allowed_extensions_vec() {
1194        let cfg = SecurityConfig::default().with_allowed_extensions(Vec::new());
1195        assert!(cfg.validate().is_ok());
1196    }
1197
1198    #[test]
1199    fn test_validate_rejects_empty_allowed_extension_entry() {
1200        let cfg = SecurityConfig::default().with_allowed_extensions(vec![String::new()]);
1201        assert!(cfg.validate().is_err());
1202    }
1203
1204    #[test]
1205    fn test_validate_rejects_empty_banned_path_component_entry() {
1206        let cfg = SecurityConfig::default().with_banned_path_components(vec![String::new()]);
1207        assert!(cfg.validate().is_err());
1208    }
1209
1210    #[test]
1211    fn test_validate_rejects_null_byte_in_allowed_extension() {
1212        let cfg = SecurityConfig::default().with_allowed_extensions(vec!["bad\0ext".to_string()]);
1213        assert!(cfg.validate().is_err());
1214    }
1215
1216    #[test]
1217    fn test_validate_rejects_overlong_banned_path_component() {
1218        let long_component = "x".repeat(256);
1219        let cfg = SecurityConfig::default().with_banned_path_components(vec![long_component]);
1220        assert!(cfg.validate().is_err());
1221    }
1222
1223    // Regression test for the C1 finding on #433/#434/#435: a
1224    // `SecurityConfig<Validated>` must not be mutable. `DerefMut` is only
1225    // implemented for `SecurityConfig<Unvalidated>`, so this is enforced at
1226    // compile time — see `tests/ui/validated_config_field_mutation.rs` for
1227    // the corresponding compile-fail fixture. This test only pins the
1228    // read-side behavior: fields remain readable after validation.
1229    #[test]
1230    fn test_validated_config_fields_remain_readable() {
1231        let validated = SecurityConfig::default()
1232            .with_max_file_size(123)
1233            .validate()
1234            .expect("valid config");
1235        assert_eq!(validated.max_file_size, 123);
1236    }
1237}