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}