Skip to main content

exarch_core/
config.rs

1//! Security configuration for archive extraction.
2
3use std::marker::PhantomData;
4use std::num::NonZeroU64;
5use std::ops::Deref;
6use std::ops::DerefMut;
7
8use crate::closed_enum::closed_enum;
9
10/// Marker type for a [`SecurityConfig`] whose invariants have not yet been
11/// checked.
12///
13/// This is the default type parameter for [`SecurityConfig`]. The fluent
14/// `with_*` builder methods and [`SecurityConfig::validate`] are only
15/// available in this state.
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
17pub struct Unvalidated;
18
19/// Marker type for a [`SecurityConfig`] whose invariants have been checked.
20///
21/// Reachable only through [`SecurityConfig::validate`]. Every entry point
22/// that performs security-sensitive work — the
23/// [`ArchiveFormat`](crate::formats::traits::ArchiveFormat) trait and
24/// everything it calls — requires a `SecurityConfig<Validated>`, so an
25/// unvalidated configuration (e.g. one with `max_file_size == 0`) can never
26/// reach extraction, listing, or verification. The compiler enforces this;
27/// it is not a convention callers must remember to follow.
28///
29/// Once validated, a config's fields can still be *read* (via [`Deref`]) but
30/// no longer *written*: [`DerefMut`] is implemented only for
31/// `SecurityConfig<Unvalidated>`, so `cfg.max_compression_ratio = f64::NAN`
32/// on a `SecurityConfig<Validated>` is a compile error, not a runtime
33/// invariant violation. This is what makes the typestate airtight — without
34/// it, a caller could validate a config and then mutate it back into an
35/// invalid state before passing it to `ArchiveFormat::extract`.
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
37pub struct Validated;
38
39/// Feature flags controlling what archive features are allowed during
40/// extraction.
41///
42/// All features default to `false` (deny-by-default security policy).
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
44#[non_exhaustive]
45pub struct AllowedFeatures {
46    /// Allow symlinks in extracted archives.
47    pub symlinks: bool,
48
49    /// Allow hardlinks in extracted archives.
50    pub hardlinks: bool,
51
52    /// Allow absolute paths in archive entries.
53    pub absolute_paths: bool,
54
55    /// Allow world-writable files (mode 0o002).
56    ///
57    /// World-writable files pose security risks in multi-user environments.
58    pub world_writable: bool,
59
60    /// Allow Windows reserved device names (`CON`, `NUL`, `COM1`, ...) as path
61    /// components.
62    ///
63    /// Only consulted when extracting on Windows; has no effect elsewhere.
64    /// There is no CLI or binding opt-out, and the permissive preset does not
65    /// enable it.
66    pub windows_reserved_names: bool,
67}
68
69closed_enum! {
70    /// How ZIP structure that different readers interpret differently is
71    /// handled.
72    ///
73    /// A ZIP archive stores each entry's metadata twice, in a local header and
74    /// in the central directory. Random-access readers follow the central
75    /// directory and streaming readers follow the local headers, so an
76    /// archive whose copies disagree, that hides records, repeats a name or
77    /// overlaps entries is a parser differential ("Zip Cloaking").
78    ///
79    /// `Enforce` (the default, also for [`SecurityConfig::permissive`])
80    /// rejects such archives in `extract` and `list` and reports them in
81    /// `verify`. `Ignore` keeps the central-directory interpretation and
82    /// raises no structural finding; resource limits still apply.
83    ///
84    /// # Examples
85    ///
86    /// ```
87    /// use exarch_core::SecurityConfig;
88    /// use exarch_core::ZipConsistencyPolicy;
89    ///
90    /// assert_eq!(
91    ///     SecurityConfig::default().zip_consistency,
92    ///     ZipConsistencyPolicy::Enforce
93    /// );
94    /// let relaxed = SecurityConfig::default().with_zip_consistency(ZipConsistencyPolicy::Ignore);
95    /// assert_eq!(relaxed.zip_consistency.as_str(), "ignore");
96    /// ```
97    #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
98    #[non_exhaustive]
99    pub enum ZipConsistencyPolicy {
100        /// Reject or report structurally ambiguous ZIP archives
101        #[default]
102        Enforce => "enforce",
103
104        /// Use the central directory interpretation without structural findings
105        Ignore => "ignore",
106    }
107}
108
109/// How much extra memory a 7z block may use to decode LZMA2 runs ahead on
110/// worker threads.
111///
112/// An LZMA2 stream written with multiple threads (7-Zip's default for large
113/// inputs) consists of independent runs, so several can be decoded at once.
114/// Decoded runs wait in memory until the consumer reaches them, so the
115/// lookahead is bounded: `Bounded(n)` lets in-flight runs hold at most `n`
116/// bytes on top of the decoder window that
117/// [`max_decoder_memory`](SecurityConfigFields::max_decoder_memory) already
118/// admits. `Sequential` decodes on the calling thread only.
119///
120/// Archives written with runs of a few MiB decode within about 10% of an
121/// unbounded multi-threaded reader at the default; a stock 7-Zip archive has
122/// 64 MiB runs and needs about 100 MiB of lookahead
123/// per extra concurrent run.
124///
125/// A valid archive decodes to the same bytes in either mode, and a corrupt one
126/// fails the same file with the same error kind; only how many leading bytes
127/// that file received can differ.
128///
129/// # Concurrency
130///
131/// The limit applies to one extraction or verification. Each call may start
132/// up to 16 worker threads and hold its own lookahead, so concurrent calls
133/// (for example from several Python threads or Node promises) multiply both
134/// the threads and the memory. Size the setting for the number of calls that
135/// can run at once, or use `Sequential`.
136///
137/// # Examples
138///
139/// ```
140/// use std::num::NonZeroU64;
141///
142/// use exarch_core::ParallelDecode;
143/// use exarch_core::SecurityConfig;
144///
145/// assert_eq!(
146///     SecurityConfig::default().parallel_decode,
147///     ParallelDecode::Bounded(NonZeroU64::new(128 * 1024 * 1024).unwrap())
148/// );
149/// let config = SecurityConfig::default().with_parallel_decode(ParallelDecode::Sequential);
150/// assert_eq!(config.parallel_decode, ParallelDecode::Sequential);
151/// ```
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153#[non_exhaustive]
154pub enum ParallelDecode {
155    /// Decode on the calling thread only
156    Sequential,
157
158    /// Decode runs ahead on worker threads, holding at most this many bytes
159    Bounded(NonZeroU64),
160}
161
162/// Builds a non-zero constant; a zero literal fails compilation.
163const fn non_zero(n: u64) -> NonZeroU64 {
164    match NonZeroU64::new(n) {
165        Some(n) => n,
166        None => panic!("constant must be non-zero"),
167    }
168}
169
170impl ParallelDecode {
171    /// Default lookahead: 128 MiB.
172    pub const DEFAULT_BYTES: NonZeroU64 = non_zero(128 * 1024 * 1024);
173
174    /// The lookahead memory in bytes, or `None` for [`Sequential`].
175    ///
176    /// [`Sequential`]: ParallelDecode::Sequential
177    ///
178    /// # Examples
179    ///
180    /// ```
181    /// use std::num::NonZeroU64;
182    ///
183    /// use exarch_core::ParallelDecode;
184    ///
185    /// assert_eq!(ParallelDecode::Sequential.lookahead(), None);
186    /// let bytes = NonZeroU64::new(1 << 20).unwrap();
187    /// assert_eq!(ParallelDecode::Bounded(bytes).lookahead(), Some(bytes));
188    /// ```
189    #[must_use]
190    pub const fn lookahead(self) -> Option<NonZeroU64> {
191        match self {
192            Self::Sequential => None,
193            Self::Bounded(bytes) => Some(bytes),
194        }
195    }
196}
197
198impl From<Option<NonZeroU64>> for ParallelDecode {
199    /// `None` is [`ParallelDecode::Sequential`].
200    fn from(lookahead: Option<NonZeroU64>) -> Self {
201        lookahead.map_or(Self::Sequential, Self::Bounded)
202    }
203}
204
205impl Default for ParallelDecode {
206    fn default() -> Self {
207        Self::Bounded(Self::DEFAULT_BYTES)
208    }
209}
210
211/// The field data of a [`SecurityConfig`], reachable via [`Deref`]/[`DerefMut`]
212/// regardless of (or gated by) validation state.
213///
214/// This type is not itself part of the sealing boundary — it is a plain data
215/// bag with `pub` fields, and nothing stops external code from naming it or
216/// cloning one out of a `&SecurityConfig`. The boundary is
217/// [`SecurityConfig`]'s own private `fields` member: there is no public API
218/// that takes a bare `SecurityConfigFields` and wraps it back into a
219/// `SecurityConfig<Validated>`, so an externally-forged or externally-mutated
220/// `SecurityConfigFields` value can never be smuggled into a `Validated`
221/// config. It exists purely so [`SecurityConfig`] can implement `Deref`/
222/// `DerefMut` and keep `cfg.max_file_size`-style field access working for
223/// every existing caller instead of forcing a getter-method migration.
224#[derive(Debug, Clone)]
225#[non_exhaustive]
226pub struct SecurityConfigFields {
227    /// Maximum size for a single file in bytes.
228    pub max_file_size: u64,
229
230    /// Maximum total size for all extracted files in bytes.
231    pub max_total_size: u64,
232
233    /// Maximum compression ratio allowed (uncompressed / compressed).
234    pub max_compression_ratio: f64,
235
236    /// Maximum compression ratio of a whole solid 7z block (summed
237    /// uncompressed size of its files / compressed size of its pack streams).
238    ///
239    /// Solid members share one compressed stream, so no per-file ratio exists;
240    /// this limit replaces `max_compression_ratio` for them. It is not exposed
241    /// by the CLI or the Python/Node bindings.
242    pub max_solid_compression_ratio: f64,
243
244    /// Maximum number of entries that can be extracted.
245    ///
246    /// The extraction API counts files, symlinks and hardlinks, not
247    /// directories. `list` and `verify`, and the CLI's `extract` (which lists
248    /// the archive first), count every entry.
249    pub max_file_count: usize,
250
251    /// Maximum path depth allowed.
252    pub max_path_depth: usize,
253
254    /// Feature flags controlling what archive features are allowed.
255    ///
256    /// Use this to enable symlinks, hardlinks, absolute paths, etc.
257    pub allowed: AllowedFeatures,
258
259    /// Preserve file permissions from archive.
260    pub preserve_permissions: bool,
261
262    /// List of allowed file extensions (empty = allow all).
263    ///
264    /// Extensions are matched case-insensitively (e.g., `"txt"` matches both
265    /// `file.txt` and `file.TXT`). The leading dot must be omitted.
266    ///
267    /// When this list is non-empty, files without a file extension are treated
268    /// as not allowed and will be skipped during extraction.
269    pub allowed_extensions: Vec<String>,
270
271    /// List of banned path components (e.g., ".git", ".ssh").
272    pub banned_path_components: Vec<String>,
273
274    /// Allow extraction from solid 7z archives.
275    ///
276    /// Solid archives compress multiple files together as a single block.
277    /// While this provides better compression ratios, it has security
278    /// implications:
279    ///
280    /// - **Memory exhaustion**: Extracting a single file requires decompressing
281    ///   the entire solid block into memory
282    /// - **Denial of service**: Malicious archives can create large solid
283    ///   blocks that exhaust available memory
284    ///
285    /// **Security Recommendation**: Only enable for trusted archives.
286    ///
287    /// Default: `false` (solid archives rejected)
288    pub allow_solid_archives: bool,
289
290    /// Maximum memory for solid archive extraction (bytes).
291    ///
292    /// **7z Solid Archive Memory Model:**
293    ///
294    /// Solid compression in 7z stores multiple files in a single compressed
295    /// block. Extracting ANY file requires decompressing the ENTIRE solid block
296    /// into memory, which can cause memory exhaustion attacks.
297    ///
298    /// **Validation Strategy:**
299    /// - Pre-validates total uncompressed size of all files in archive
300    /// - This is a conservative heuristic (assumes single solid block)
301    /// - Reason: `sevenz-rust2` v0.20 doesn't expose solid block boundaries
302    ///
303    /// **Security Guarantee:**
304    /// - Total uncompressed data cannot exceed this limit
305    /// - Combined with `max_file_size`, prevents unbounded memory growth
306    /// - Enforced ONLY when `allow_solid_archives` is `true`
307    ///
308    /// **Note**: Only applies when `allow_solid_archives` is `true`.
309    ///
310    /// Default: 512 MB (536,870,912 bytes)
311    ///
312    /// **Recommendation:** Set to 1-2x available RAM for trusted archives only.
313    pub max_solid_block_memory: u64,
314
315    /// Maximum bytes the TAR reader may consume for headers and metadata
316    /// records in the gap between two consecutive entries.
317    ///
318    /// **TAR Metadata Buffering Model:**
319    ///
320    /// GNU long-name (`L`), GNU long-link (`K`), and PAX extended header
321    /// (`x`/`g`) records are buffered fully into memory by the underlying
322    /// `tar` crate *before* any entry reaches the entry validator or quota
323    /// tracker — a crafted record can declare a multi-gigabyte length backed
324    /// by a tiny compressed stream, exhausting memory with no quota
325    /// enforcement (metadata-entry decompression bomb).
326    ///
327    /// **Enforcement Strategy:**
328    /// - A read-budget wrapper meters bytes the `tar` crate reads while
329    ///   searching for the next entry (headers, long-name/long-link/PAX
330    ///   records, GNU sparse extension blocks) and returns an error once
331    ///   `max_tar_metadata_bytes` is exceeded, before any oversized allocation
332    ///   completes
333    /// - Applies uniformly to `extract`, `list`, and `verify`
334    /// - The window this bounds contains no entry *data* — only metadata — so
335    ///   it does not interact with `max_file_size`/`max_total_size`. Draining
336    ///   an unread entry before the next header search is a separate concern
337    ///   (see `formats::tar_metadata_limit`'s module docs) bounded by
338    ///   synthesized-byte accounting, not by this or any other quota value
339    /// - Legitimate long-path/xattr metadata records are at most a few
340    ///   kilobytes each, and a GNU tar sparse file with heavy fragmentation can
341    ///   use up to a few thousand 512-byte extension blocks; either or both
342    ///   share this single budget (not "plus" each other), so the default
343    ///   leaves headroom for either shape, not necessarily both at their
344    ///   extremes simultaneously
345    /// - The real peak memory this bounds is roughly 5x the configured value
346    ///   (GNU sparse extension blocks expand into multiple `EntryIo` records
347    ///   per block before the growth is charged against this budget), not an
348    ///   exact multiple — treat it as an order-of-magnitude ceiling, not a
349    ///   tight bound
350    ///
351    /// Default: 4 MiB (4,194,304 bytes)
352    pub max_tar_metadata_bytes: u64,
353
354    /// Maximum memory in bytes an xz decoder may use (dictionary and state).
355    ///
356    /// The xz block header declares the LZMA2 dictionary size (up to 4 GiB),
357    /// so without a limit a tiny archive can drive resident memory far beyond
358    /// what the output quotas allow. Archives that need more fail with
359    /// `QuotaExceeded { DecoderMemory }`. Applies to `extract`, `list` and
360    /// `verify` of `.tar.xz`. Other codecs are bounded by their format
361    /// (bzip2 blocks of at most 900 KB; zstd windows above 128 MiB are
362    /// rejected) and are not governed by this value. Not exposed through the
363    /// CLI or language bindings. Also admits the 7z decoder window; the
364    /// lookahead of parallel 7z decoding is budgeted separately by
365    /// [`parallel_decode`](Self::parallel_decode).
366    ///
367    /// Default: 256 MiB (268,435,456 bytes)
368    pub max_decoder_memory: u64,
369
370    /// Lookahead memory for decoding independent LZMA2 runs of a 7z block on
371    /// worker threads.
372    ///
373    /// Applies to `extract` and `verify` with integrity checking of 7z
374    /// archives. It is separate from `max_decoder_memory`, which keeps
375    /// bounding the decoder window. See [`ParallelDecode`].
376    ///
377    /// Default: `Bounded` at 128 MiB (1 GiB for [`SecurityConfig::permissive`])
378    pub parallel_decode: ParallelDecode,
379
380    /// How ZIP archives with ambiguous structure are handled.
381    ///
382    /// Default: [`ZipConsistencyPolicy::Enforce`], including for
383    /// [`SecurityConfig::permissive`]. Pre-ZIP64 archives with more than
384    /// 65535 entries (wrapped end-record count) and archives with orphaned
385    /// local headers, or with a central directory signature in a gap before an
386    /// entry (left by in-place updaters), are rejected under `Enforce`.
387    pub zip_consistency: ZipConsistencyPolicy,
388}
389
390impl Default for SecurityConfigFields {
391    fn default() -> Self {
392        Self {
393            max_file_size: 50 * 1024 * 1024,   // 50 MB
394            max_total_size: 500 * 1024 * 1024, // 500 MB
395            max_compression_ratio: 100.0,
396            max_solid_compression_ratio: 1000.0,
397            max_file_count: 10_000,
398            max_path_depth: 32,
399            allowed: AllowedFeatures::default(), // All false
400            preserve_permissions: false,
401            allowed_extensions: Vec::new(),
402            banned_path_components: vec![
403                ".git".to_string(),
404                ".ssh".to_string(),
405                ".gnupg".to_string(),
406                ".aws".to_string(),
407                ".kube".to_string(),
408                ".docker".to_string(),
409                ".env".to_string(),
410            ],
411            allow_solid_archives: false,
412            max_solid_block_memory: 512 * 1024 * 1024, // 512 MB
413            max_tar_metadata_bytes: 4 * 1024 * 1024,   // 4 MiB
414            max_decoder_memory: 256 * 1024 * 1024,     // 256 MiB
415            parallel_decode: ParallelDecode::Bounded(ParallelDecode::DEFAULT_BYTES),
416            zip_consistency: ZipConsistencyPolicy::Enforce,
417        }
418    }
419}
420
421/// Security configuration with default-deny settings.
422///
423/// This configuration controls various security checks performed during
424/// archive extraction to prevent common vulnerabilities.
425///
426/// # Performance Note
427///
428/// This struct contains heap-allocated collections (`Vec<String>`). For
429/// performance, pass by reference (`&SecurityConfig`) rather than cloning. If
430/// shared ownership is needed across threads, consider wrapping in
431/// `Arc<SecurityConfig>`.
432///
433/// # Examples
434///
435/// ```
436/// use exarch_core::SecurityConfig;
437///
438/// // Use secure defaults
439/// let config = SecurityConfig::default();
440///
441/// // Customize via fluent builder
442/// let custom = SecurityConfig::default()
443///     .with_max_file_size(100 * 1024 * 1024)
444///     .with_max_total_size(1024 * 1024 * 1024)
445///     .with_allow_symlinks(true);
446/// ```
447///
448/// # Typestate
449///
450/// `SecurityConfig` carries a phantom `State` type parameter — [`Unvalidated`]
451/// (the default) or [`Validated`] — that tracks whether
452/// [`validate`](SecurityConfig::validate) has been called. Builder methods
453/// are only available in the `Unvalidated` state; security-sensitive APIs
454/// (the [`ArchiveFormat`](crate::formats::traits::ArchiveFormat) trait and
455/// everything downstream of it) require `SecurityConfig<Validated>`. This
456/// makes skipping validation a compile error instead of a runtime gap.
457///
458/// # Sealing
459///
460/// Fields are private and reachable only through
461/// <code>[Deref]<Target = [SecurityConfigFields]></code>, so
462/// `cfg.max_file_size` continues to work as plain field access for both states.
463/// [`DerefMut`] is implemented only for
464/// `SecurityConfig<Unvalidated>`, so a `SecurityConfig<Validated>`'s fields
465/// cannot be reassigned after the fact — the only way to produce one is
466/// [`validate`](SecurityConfig::validate) itself, and it stays that way for
467/// its entire lifetime.
468#[derive(Debug, Clone)]
469pub struct SecurityConfig<State = Unvalidated> {
470    fields: SecurityConfigFields,
471
472    /// Typestate marker — see the "Typestate" section on the type-level docs.
473    _marker: PhantomData<State>,
474}
475
476impl<State> Deref for SecurityConfig<State> {
477    type Target = SecurityConfigFields;
478
479    #[inline]
480    fn deref(&self) -> &SecurityConfigFields {
481        &self.fields
482    }
483}
484
485/// Only `Unvalidated` configs are mutable — see the "Sealing" section on
486/// [`SecurityConfig`]'s type-level docs for why this is the crux of the
487/// typestate guarantee.
488impl DerefMut for SecurityConfig<Unvalidated> {
489    fn deref_mut(&mut self) -> &mut SecurityConfigFields {
490        &mut self.fields
491    }
492}
493
494impl Default for SecurityConfig<Unvalidated> {
495    /// Creates a `SecurityConfig` with secure default settings.
496    ///
497    /// Default values:
498    /// - `max_file_size`: 50 MB
499    /// - `max_total_size`: 500 MB
500    /// - `max_compression_ratio`: 100.0
501    /// - `max_solid_compression_ratio`: 1000.0
502    /// - `max_file_count`: 10,000
503    /// - `max_path_depth`: 32
504    /// - `allowed`: All features disabled (deny-by-default)
505    /// - `preserve_permissions`: false
506    /// - `allowed_extensions`: empty (allow all)
507    /// - `banned_path_components`: `[".git", ".ssh", ".gnupg", ".aws", ".kube",
508    ///   ".docker", ".env"]`
509    /// - `allow_solid_archives`: false (solid archives rejected)
510    /// - `max_solid_block_memory`: 512 MB
511    /// - `max_tar_metadata_bytes`: 4 MiB
512    /// - `max_decoder_memory`: 256 MiB
513    /// - `parallel_decode`: `Bounded` at 128 MiB
514    /// - `zip_consistency`: `Enforce`
515    fn default() -> Self {
516        Self {
517            fields: SecurityConfigFields::default(),
518            _marker: PhantomData,
519        }
520    }
521}
522
523/// Lookahead memory of [`SecurityConfig::permissive`]: 1 GiB.
524const PERMISSIVE_PARALLEL_DECODE: NonZeroU64 = non_zero(1024 * 1024 * 1024);
525
526impl SecurityConfig<Unvalidated> {
527    /// Creates a permissive configuration for trusted archives.
528    ///
529    /// This configuration allows symlinks, hardlinks, absolute paths, and
530    /// solid archives. Use only when extracting archives from trusted sources.
531    #[must_use]
532    pub fn permissive() -> Self {
533        Self {
534            fields: SecurityConfigFields {
535                allowed: AllowedFeatures {
536                    symlinks: true,
537                    hardlinks: true,
538                    absolute_paths: true,
539                    world_writable: true,
540                    windows_reserved_names: false,
541                },
542                preserve_permissions: true,
543                max_compression_ratio: 1000.0,
544                max_solid_compression_ratio: 10_000.0,
545                banned_path_components: Vec::new(),
546                allow_solid_archives: true,
547                max_solid_block_memory: 1024 * 1024 * 1024, // 1 GB for permissive
548                max_tar_metadata_bytes: 16 * 1024 * 1024,   // 16 MiB for permissive
549                max_decoder_memory: 1024 * 1024 * 1024,     // 1 GiB for permissive
550                parallel_decode: ParallelDecode::Bounded(PERMISSIVE_PARALLEL_DECODE),
551                ..SecurityConfigFields::default()
552            },
553            _marker: PhantomData,
554        }
555    }
556
557    /// Validates that the configuration values are logically consistent,
558    /// transitioning to the [`Validated`] typestate on success.
559    ///
560    /// Returns an error if any field has a value that would make security
561    /// enforcement impossible (zero limits or non-positive ratio). Consumes
562    /// `self`: the only way to obtain a `SecurityConfig<Validated>`, which is
563    /// what every security-sensitive API in this crate requires. Once
564    /// returned, the `Validated` config's fields can no longer be reassigned
565    /// (see the "Sealing" section on the type-level docs), so this check can
566    /// never be silently invalidated afterward.
567    ///
568    /// # Errors
569    ///
570    /// Returns `ArchiveError::InvalidConfiguration` if:
571    /// - `max_compression_ratio` or `max_solid_compression_ratio` is not
572    ///   positive and finite
573    /// - `max_file_size` is zero
574    /// - `max_total_size` is zero
575    /// - `max_path_depth` is zero
576    /// - `max_file_count` is zero
577    /// - `max_solid_block_memory` is zero
578    /// - `max_tar_metadata_bytes` is zero
579    /// - `max_decoder_memory` is zero
580    /// - any entry in `allowed_extensions` or `banned_path_components` is
581    ///   empty, contains a null byte, or exceeds
582    ///   [`crate::MAX_CONFIG_ENTRY_LENGTH`] bytes
583    ///
584    /// # Examples
585    ///
586    /// ```
587    /// use exarch_core::SecurityConfig;
588    ///
589    /// let config = SecurityConfig::default();
590    /// assert!(config.validate().is_ok());
591    ///
592    /// let bad = SecurityConfig::default().with_max_file_size(0);
593    /// assert!(bad.validate().is_err());
594    /// ```
595    pub fn validate(self) -> crate::Result<SecurityConfig<Validated>> {
596        if !self.max_compression_ratio.is_finite() || self.max_compression_ratio <= 0.0 {
597            return Err(crate::ArchiveError::InvalidConfiguration {
598                reason: "max_compression_ratio must be positive".into(),
599            });
600        }
601        if !self.max_solid_compression_ratio.is_finite() || self.max_solid_compression_ratio <= 0.0
602        {
603            return Err(crate::ArchiveError::InvalidConfiguration {
604                reason: "max_solid_compression_ratio must be positive".into(),
605            });
606        }
607        if self.max_file_size == 0 {
608            return Err(crate::ArchiveError::InvalidConfiguration {
609                reason: "max_file_size must not be zero".into(),
610            });
611        }
612        if self.max_total_size == 0 {
613            return Err(crate::ArchiveError::InvalidConfiguration {
614                reason: "max_total_size must not be zero".into(),
615            });
616        }
617        if self.max_path_depth == 0 {
618            return Err(crate::ArchiveError::InvalidConfiguration {
619                reason: "max_path_depth must not be zero".into(),
620            });
621        }
622        if self.max_file_count == 0 {
623            return Err(crate::ArchiveError::InvalidConfiguration {
624                reason: "max_file_count must not be zero".into(),
625            });
626        }
627        if self.max_solid_block_memory == 0 {
628            return Err(crate::ArchiveError::InvalidConfiguration {
629                reason: "max_solid_block_memory must not be zero".into(),
630            });
631        }
632        if self.max_tar_metadata_bytes == 0 {
633            return Err(crate::ArchiveError::InvalidConfiguration {
634                reason: "max_tar_metadata_bytes must not be zero".into(),
635            });
636        }
637        if self.max_decoder_memory == 0 {
638            return Err(crate::ArchiveError::InvalidConfiguration {
639                reason: "max_decoder_memory must not be zero".into(),
640            });
641        }
642        for extension in &self.fields.allowed_extensions {
643            crate::security::boundary::validate_config_entry(extension, "allowed extension")?;
644        }
645        for component in &self.fields.banned_path_components {
646            crate::security::boundary::validate_config_entry(component, "banned path component")?;
647        }
648        Ok(SecurityConfig {
649            fields: self.fields,
650            _marker: PhantomData,
651        })
652    }
653
654    /// Sets the maximum size for a single extracted file in bytes.
655    ///
656    /// # Examples
657    ///
658    /// ```
659    /// use exarch_core::SecurityConfig;
660    ///
661    /// let config = SecurityConfig::default().with_max_file_size(100 * 1024 * 1024);
662    /// assert_eq!(config.max_file_size, 100 * 1024 * 1024);
663    /// ```
664    #[must_use]
665    #[inline]
666    pub fn with_max_file_size(mut self, size: u64) -> Self {
667        self.fields.max_file_size = size;
668        self
669    }
670
671    /// Sets the maximum total size for all extracted files in bytes.
672    ///
673    /// # Examples
674    ///
675    /// ```
676    /// use exarch_core::SecurityConfig;
677    ///
678    /// let config = SecurityConfig::default().with_max_total_size(1024 * 1024 * 1024);
679    /// assert_eq!(config.max_total_size, 1024 * 1024 * 1024);
680    /// ```
681    #[must_use]
682    #[inline]
683    pub fn with_max_total_size(mut self, size: u64) -> Self {
684        self.fields.max_total_size = size;
685        self
686    }
687
688    /// Sets the maximum allowed compression ratio (uncompressed / compressed).
689    ///
690    /// # Examples
691    ///
692    /// ```
693    /// use exarch_core::SecurityConfig;
694    ///
695    /// let config = SecurityConfig::default().with_max_compression_ratio(50.0);
696    /// assert_eq!(config.max_compression_ratio, 50.0);
697    /// ```
698    #[must_use]
699    #[inline]
700    pub fn with_max_compression_ratio(mut self, ratio: f64) -> Self {
701        self.fields.max_compression_ratio = ratio;
702        self
703    }
704
705    /// Sets the maximum compression ratio of a whole solid 7z block.
706    ///
707    /// # Examples
708    ///
709    /// ```
710    /// use exarch_core::SecurityConfig;
711    ///
712    /// let config = SecurityConfig::default().with_max_solid_compression_ratio(5000.0);
713    /// assert_eq!(config.max_solid_compression_ratio, 5000.0);
714    /// ```
715    #[must_use]
716    #[inline]
717    pub fn with_max_solid_compression_ratio(mut self, ratio: f64) -> Self {
718        self.fields.max_solid_compression_ratio = ratio;
719        self
720    }
721
722    /// Sets the maximum number of files that can be extracted.
723    ///
724    /// # Examples
725    ///
726    /// ```
727    /// use exarch_core::SecurityConfig;
728    ///
729    /// let config = SecurityConfig::default().with_max_file_count(500);
730    /// assert_eq!(config.max_file_count, 500);
731    /// ```
732    #[must_use]
733    #[inline]
734    pub fn with_max_file_count(mut self, count: usize) -> Self {
735        self.fields.max_file_count = count;
736        self
737    }
738
739    /// Sets the maximum path depth allowed.
740    ///
741    /// # Examples
742    ///
743    /// ```
744    /// use exarch_core::SecurityConfig;
745    ///
746    /// let config = SecurityConfig::default().with_max_path_depth(16);
747    /// assert_eq!(config.max_path_depth, 16);
748    /// ```
749    #[must_use]
750    #[inline]
751    pub fn with_max_path_depth(mut self, depth: usize) -> Self {
752        self.fields.max_path_depth = depth;
753        self
754    }
755
756    /// Sets the feature flags controlling allowed archive features.
757    ///
758    /// # Examples
759    ///
760    /// ```
761    /// use exarch_core::SecurityConfig;
762    /// use exarch_core::config::AllowedFeatures;
763    ///
764    /// let features = AllowedFeatures::default();
765    /// let config = SecurityConfig::default().with_allowed(features);
766    /// assert!(!config.allowed.symlinks);
767    /// ```
768    #[must_use]
769    #[inline]
770    pub fn with_allowed(mut self, allowed: AllowedFeatures) -> Self {
771        self.fields.allowed = allowed;
772        self
773    }
774
775    /// Enables or disables symlinks in extracted archives.
776    ///
777    /// # Examples
778    ///
779    /// ```
780    /// use exarch_core::SecurityConfig;
781    ///
782    /// let config = SecurityConfig::default().with_allow_symlinks(true);
783    /// assert!(config.allowed.symlinks);
784    /// ```
785    #[must_use]
786    #[inline]
787    pub fn with_allow_symlinks(mut self, allow: bool) -> Self {
788        self.fields.allowed.symlinks = allow;
789        self
790    }
791
792    /// Enables or disables hardlinks in extracted archives.
793    ///
794    /// # Examples
795    ///
796    /// ```
797    /// use exarch_core::SecurityConfig;
798    ///
799    /// let config = SecurityConfig::default().with_allow_hardlinks(true);
800    /// assert!(config.allowed.hardlinks);
801    /// ```
802    #[must_use]
803    #[inline]
804    pub fn with_allow_hardlinks(mut self, allow: bool) -> Self {
805        self.fields.allowed.hardlinks = allow;
806        self
807    }
808
809    /// Enables or disables absolute paths in archive entries.
810    ///
811    /// # Examples
812    ///
813    /// ```
814    /// use exarch_core::SecurityConfig;
815    ///
816    /// let config = SecurityConfig::default().with_allow_absolute_paths(true);
817    /// assert!(config.allowed.absolute_paths);
818    /// ```
819    #[must_use]
820    #[inline]
821    pub fn with_allow_absolute_paths(mut self, allow: bool) -> Self {
822        self.fields.allowed.absolute_paths = allow;
823        self
824    }
825
826    /// Enables or disables world-writable files.
827    ///
828    /// # Examples
829    ///
830    /// ```
831    /// use exarch_core::SecurityConfig;
832    ///
833    /// let config = SecurityConfig::default().with_allow_world_writable(true);
834    /// assert!(config.allowed.world_writable);
835    /// ```
836    #[must_use]
837    #[inline]
838    pub fn with_allow_world_writable(mut self, allow: bool) -> Self {
839        self.fields.allowed.world_writable = allow;
840        self
841    }
842
843    /// Enables or disables Windows reserved device names (`CON`, `NUL`,
844    /// `COM1`, ...) as path components. Only consulted on Windows.
845    ///
846    /// # Examples
847    ///
848    /// ```
849    /// use exarch_core::SecurityConfig;
850    ///
851    /// let config = SecurityConfig::default().with_allow_windows_reserved_names(true);
852    /// assert!(config.allowed.windows_reserved_names);
853    /// ```
854    #[must_use]
855    #[inline]
856    pub fn with_allow_windows_reserved_names(mut self, allow: bool) -> Self {
857        self.fields.allowed.windows_reserved_names = allow;
858        self
859    }
860
861    /// Enables or disables preserving file permissions from the archive.
862    ///
863    /// # Examples
864    ///
865    /// ```
866    /// use exarch_core::SecurityConfig;
867    ///
868    /// let config = SecurityConfig::default().with_preserve_permissions(true);
869    /// assert!(config.preserve_permissions);
870    /// ```
871    #[must_use]
872    #[inline]
873    pub fn with_preserve_permissions(mut self, preserve: bool) -> Self {
874        self.fields.preserve_permissions = preserve;
875        self
876    }
877
878    /// Sets the list of allowed file extensions.
879    ///
880    /// An empty list allows all extensions.
881    ///
882    /// # Examples
883    ///
884    /// ```
885    /// use exarch_core::SecurityConfig;
886    ///
887    /// let config = SecurityConfig::default()
888    ///     .with_allowed_extensions(vec!["txt".to_string(), "pdf".to_string()]);
889    /// assert!(config.is_extension_allowed("txt"));
890    /// assert!(!config.is_extension_allowed("exe"));
891    /// ```
892    #[must_use]
893    #[inline]
894    pub fn with_allowed_extensions(mut self, extensions: Vec<String>) -> Self {
895        self.fields.allowed_extensions = extensions;
896        self
897    }
898
899    /// Sets the list of banned path components.
900    ///
901    /// # Examples
902    ///
903    /// ```
904    /// use exarch_core::SecurityConfig;
905    ///
906    /// let config = SecurityConfig::default().with_banned_path_components(vec![".git".to_string()]);
907    /// assert!(!config.is_path_component_allowed(".git"));
908    /// assert!(config.is_path_component_allowed(".ssh"));
909    /// ```
910    #[must_use]
911    #[inline]
912    pub fn with_banned_path_components(mut self, components: Vec<String>) -> Self {
913        self.fields.banned_path_components = components;
914        self
915    }
916
917    /// Enables or disables extraction from solid 7z archives.
918    ///
919    /// # Examples
920    ///
921    /// ```
922    /// use exarch_core::SecurityConfig;
923    ///
924    /// let config = SecurityConfig::default().with_allow_solid_archives(true);
925    /// assert!(config.allow_solid_archives);
926    /// ```
927    #[must_use]
928    #[inline]
929    pub fn with_allow_solid_archives(mut self, allow: bool) -> Self {
930        self.fields.allow_solid_archives = allow;
931        self
932    }
933
934    /// Sets the maximum memory for solid archive extraction in bytes.
935    ///
936    /// Only applies when `allow_solid_archives` is `true`.
937    ///
938    /// # Examples
939    ///
940    /// ```
941    /// use exarch_core::SecurityConfig;
942    ///
943    /// let config = SecurityConfig::default()
944    ///     .with_allow_solid_archives(true)
945    ///     .with_max_solid_block_memory(1024 * 1024 * 1024);
946    /// assert_eq!(config.max_solid_block_memory, 1024 * 1024 * 1024);
947    /// ```
948    #[must_use]
949    #[inline]
950    pub fn with_max_solid_block_memory(mut self, size: u64) -> Self {
951        self.fields.max_solid_block_memory = size;
952        self
953    }
954
955    /// Sets the maximum bytes the TAR reader may consume for headers and
956    /// metadata records (GNU long-name/long-link, PAX extended headers, GNU
957    /// sparse extension blocks) in the gap between two consecutive entries.
958    ///
959    /// # Examples
960    ///
961    /// ```
962    /// use exarch_core::SecurityConfig;
963    ///
964    /// let config = SecurityConfig::default().with_max_tar_metadata_bytes(64 * 1024);
965    /// assert_eq!(config.max_tar_metadata_bytes, 64 * 1024);
966    /// ```
967    #[must_use]
968    #[inline]
969    pub fn with_max_tar_metadata_bytes(mut self, size: u64) -> Self {
970        self.fields.max_tar_metadata_bytes = size;
971        self
972    }
973
974    /// Sets the maximum memory an xz decoder may use, in bytes.
975    ///
976    /// # Examples
977    ///
978    /// ```
979    /// use exarch_core::SecurityConfig;
980    ///
981    /// let config = SecurityConfig::default().with_max_decoder_memory(64 * 1024 * 1024);
982    /// assert_eq!(config.max_decoder_memory, 64 * 1024 * 1024);
983    /// ```
984    #[must_use]
985    #[inline]
986    pub fn with_max_decoder_memory(mut self, size: u64) -> Self {
987        self.fields.max_decoder_memory = size;
988        self
989    }
990
991    /// Sets the lookahead memory for parallel 7z LZMA2 decoding.
992    ///
993    /// # Examples
994    ///
995    /// ```
996    /// use exarch_core::ParallelDecode;
997    /// use exarch_core::SecurityConfig;
998    ///
999    /// let config = SecurityConfig::default().with_parallel_decode(ParallelDecode::Sequential);
1000    /// assert_eq!(config.parallel_decode, ParallelDecode::Sequential);
1001    /// ```
1002    #[must_use]
1003    #[inline]
1004    pub fn with_parallel_decode(mut self, mode: ParallelDecode) -> Self {
1005        self.fields.parallel_decode = mode;
1006        self
1007    }
1008
1009    /// Sets how ZIP archives with ambiguous structure are handled.
1010    ///
1011    /// # Examples
1012    ///
1013    /// ```
1014    /// use exarch_core::SecurityConfig;
1015    /// use exarch_core::ZipConsistencyPolicy;
1016    ///
1017    /// let config = SecurityConfig::default().with_zip_consistency(ZipConsistencyPolicy::Ignore);
1018    /// assert_eq!(config.zip_consistency, ZipConsistencyPolicy::Ignore);
1019    /// ```
1020    #[must_use]
1021    #[inline]
1022    pub fn with_zip_consistency(mut self, policy: ZipConsistencyPolicy) -> Self {
1023        self.fields.zip_consistency = policy;
1024        self
1025    }
1026}
1027
1028/// Read-only queries and crate-internal helpers available regardless of
1029/// validation state.
1030impl<State> SecurityConfig<State> {
1031    /// Validates whether a path component is allowed.
1032    ///
1033    /// Comparison is case-insensitive to prevent bypass on case-insensitive
1034    /// filesystems (Windows, macOS default).
1035    #[must_use]
1036    pub fn is_path_component_allowed(&self, component: &str) -> bool {
1037        !self
1038            .banned_path_components
1039            .iter()
1040            .any(|banned| banned.eq_ignore_ascii_case(component))
1041    }
1042
1043    /// Validates whether a file extension is allowed.
1044    ///
1045    /// When `allowed_extensions` is empty, all extensions are permitted.
1046    /// When it is non-empty, only listed extensions are permitted.
1047    #[must_use]
1048    pub fn is_extension_allowed(&self, extension: &str) -> bool {
1049        if self.allowed_extensions.is_empty() {
1050            return true;
1051        }
1052        self.allowed_extensions
1053            .iter()
1054            .any(|ext| ext.eq_ignore_ascii_case(extension))
1055    }
1056
1057    /// Returns `true` if a file with the given optional extension may be
1058    /// extracted.
1059    ///
1060    /// When `allowed_extensions` is non-empty and `extension` is `None`
1061    /// (the file has no extension), the file is treated as not allowed.
1062    ///
1063    /// # Examples
1064    ///
1065    /// ```
1066    /// use exarch_core::SecurityConfig;
1067    ///
1068    /// let config = SecurityConfig::default().with_allowed_extensions(vec!["txt".to_string()]);
1069    ///
1070    /// assert!(config.is_path_extension_allowed(Some("txt")));
1071    /// assert!(!config.is_path_extension_allowed(Some("exe")));
1072    /// // Files without an extension are blocked when the allowlist is non-empty.
1073    /// assert!(!config.is_path_extension_allowed(None));
1074    ///
1075    /// // Empty allowlist permits everything, including extension-less files.
1076    /// let permissive = SecurityConfig::default();
1077    /// assert!(permissive.is_path_extension_allowed(None));
1078    /// ```
1079    #[must_use]
1080    pub fn is_path_extension_allowed(&self, extension: Option<&str>) -> bool {
1081        if self.allowed_extensions.is_empty() {
1082            return true;
1083        }
1084        extension.is_some_and(|ext| self.is_extension_allowed(ext))
1085    }
1086}
1087
1088/// Options controlling extraction behavior (non-security).
1089///
1090/// Separate from `SecurityConfig` to keep security settings focused.
1091/// These options control operational behavior like atomicity.
1092#[derive(Debug, Clone)]
1093#[non_exhaustive]
1094pub struct ExtractionOptions {
1095    /// Extract atomically: use a temp dir in the same parent as the output
1096    /// directory, rename on success, and delete on failure.
1097    ///
1098    /// When enabled, extraction is all-or-nothing: if extraction fails,
1099    /// the output directory will not be created. This prevents partial
1100    /// extraction artifacts from remaining on disk.
1101    ///
1102    /// Note: cleanup is best-effort if the process is terminated via SIGKILL.
1103    pub atomic: bool,
1104
1105    /// Skip duplicate entries silently instead of aborting.
1106    ///
1107    /// When `true` (default), if an archive contains two entries with the same
1108    /// destination path, the second entry is skipped and a warning is recorded
1109    /// in `ExtractionReport`. When `false`, duplicate entries cause an error.
1110    pub skip_duplicates: bool,
1111}
1112
1113impl Default for ExtractionOptions {
1114    fn default() -> Self {
1115        Self {
1116            atomic: false,
1117            skip_duplicates: true,
1118        }
1119    }
1120}
1121
1122impl ExtractionOptions {
1123    /// Enables or disables atomic extraction.
1124    ///
1125    /// When enabled, extraction is all-or-nothing: the output directory is not
1126    /// created if extraction fails.
1127    ///
1128    /// # Examples
1129    ///
1130    /// ```
1131    /// use exarch_core::ExtractionOptions;
1132    ///
1133    /// let opts = ExtractionOptions::default().with_atomic(true);
1134    /// assert!(opts.atomic);
1135    /// ```
1136    #[must_use]
1137    #[inline]
1138    pub fn with_atomic(mut self, atomic: bool) -> Self {
1139        self.atomic = atomic;
1140        self
1141    }
1142
1143    /// Enables or disables skipping duplicate entries silently.
1144    ///
1145    /// # Examples
1146    ///
1147    /// ```
1148    /// use exarch_core::ExtractionOptions;
1149    ///
1150    /// let opts = ExtractionOptions::default().with_skip_duplicates(false);
1151    /// assert!(!opts.skip_duplicates);
1152    /// ```
1153    #[must_use]
1154    #[inline]
1155    pub fn with_skip_duplicates(mut self, skip: bool) -> Self {
1156        self.skip_duplicates = skip;
1157        self
1158    }
1159}
1160
1161/// Upper bound on the bytes `verify` decodes to check payload integrity.
1162///
1163/// The budget bounds the work done **beyond what listing already does**: ZIP
1164/// entries and 7z blocks are charged their declared uncompressed size before
1165/// they are decoded, and for compressed TAR it bounds the read from the end
1166/// marker to decoder EOF. TAR entry content is never charged because listing
1167/// decodes it anyway. The default is a fixed 500 MiB, equal to the default
1168/// `max_total_size`; it does not follow a raised `max_total_size`.
1169///
1170/// # Examples
1171///
1172/// ```
1173/// use exarch_core::DecompressionBudget;
1174///
1175/// assert!(DecompressionBudget::try_from_bytes(0).is_err());
1176/// let budget = DecompressionBudget::try_from_bytes(1024)?;
1177/// assert_eq!(budget.get(), 1024);
1178/// assert_eq!(DecompressionBudget::default(), DecompressionBudget::DEFAULT);
1179/// # Ok::<(), exarch_core::ArchiveError>(())
1180/// ```
1181#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1182pub struct DecompressionBudget(std::num::NonZeroU64);
1183
1184impl DecompressionBudget {
1185    /// The default budget: 500 MiB.
1186    pub const DEFAULT: Self = match std::num::NonZeroU64::new(500 * 1024 * 1024) {
1187        Some(bytes) => Self(bytes),
1188        None => unreachable!(),
1189    };
1190
1191    /// Creates a budget from a non-zero byte count.
1192    #[must_use]
1193    pub const fn new(bytes: std::num::NonZeroU64) -> Self {
1194        Self(bytes)
1195    }
1196
1197    /// Creates a budget from a byte count.
1198    ///
1199    /// # Errors
1200    ///
1201    /// Returns [`ArchiveError::InvalidConfiguration`](crate::ArchiveError) when
1202    /// `bytes` is zero.
1203    pub fn try_from_bytes(bytes: u64) -> crate::Result<Self> {
1204        std::num::NonZeroU64::new(bytes).map(Self).ok_or_else(|| {
1205            crate::ArchiveError::InvalidConfiguration {
1206                reason: "decompression budget must be greater than zero".into(),
1207            }
1208        })
1209    }
1210
1211    /// Returns the budget in bytes.
1212    #[must_use]
1213    pub const fn get(self) -> u64 {
1214        self.0.get()
1215    }
1216}
1217
1218impl Default for DecompressionBudget {
1219    fn default() -> Self {
1220        Self::DEFAULT
1221    }
1222}
1223
1224/// Whether `verify` checks payload integrity.
1225///
1226/// A budget without the check is unrepresentable: the budget lives inside the
1227/// [`DecodeToEof`](Self::DecodeToEof) variant.
1228#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1229#[non_exhaustive]
1230pub enum IntegrityCheck {
1231    /// Metadata-only verification (the default).
1232    #[default]
1233    Skip,
1234
1235    /// Decode payload to the decoder's EOF so checksums and codec framing are
1236    /// verified, spending at most the given budget on extra decoding.
1237    DecodeToEof(DecompressionBudget),
1238}
1239
1240/// Options controlling `verify` behavior.
1241///
1242/// # Examples
1243///
1244/// ```
1245/// use exarch_core::DecompressionBudget;
1246/// use exarch_core::IntegrityCheck;
1247/// use exarch_core::VerificationOptions;
1248///
1249/// let options = VerificationOptions::default();
1250/// assert_eq!(options.integrity, IntegrityCheck::Skip);
1251///
1252/// let options = options.with_integrity(IntegrityCheck::DecodeToEof(DecompressionBudget::DEFAULT));
1253/// assert_ne!(options.integrity, IntegrityCheck::Skip);
1254/// ```
1255#[derive(Debug, Clone, Copy, Default)]
1256#[non_exhaustive]
1257pub struct VerificationOptions {
1258    /// Payload integrity check mode.
1259    pub integrity: IntegrityCheck,
1260}
1261
1262impl VerificationOptions {
1263    /// Sets the payload integrity check mode.
1264    #[must_use]
1265    pub fn with_integrity(mut self, integrity: IntegrityCheck) -> Self {
1266        self.integrity = integrity;
1267        self
1268    }
1269}
1270
1271/// Whether listing reads the symlink targets a format keeps in compressed
1272/// entry data.
1273///
1274/// 7z keeps link targets in compressed entry data, so reading them decodes
1275/// part of the archive, which in a solid block can be most of it. Listing
1276/// therefore skips them by default. Other formats always report their targets:
1277/// TAR keeps them in headers, and ZIP reads them from a small local entry. This
1278/// is a Rust API option only; the Python and Node `list_archive` skip 7z
1279/// targets.
1280///
1281/// # Examples
1282///
1283/// ```
1284/// use exarch_core::StoredTargets;
1285///
1286/// assert_eq!(StoredTargets::default(), StoredTargets::Skip);
1287/// ```
1288#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1289#[non_exhaustive]
1290pub enum StoredTargets {
1291    /// Leave 7z link targets unread, so listing decodes no entry data (the
1292    /// default).
1293    #[default]
1294    Skip,
1295
1296    /// Read the targets, decoding the blocks that hold them.
1297    Read,
1298}
1299
1300/// Options controlling `list` behavior.
1301///
1302/// # Examples
1303///
1304/// ```
1305/// use exarch_core::ListOptions;
1306/// use exarch_core::StoredTargets;
1307///
1308/// let options = ListOptions::default();
1309/// assert_eq!(options.stored_targets, StoredTargets::Skip);
1310///
1311/// let options = options.with_stored_targets(StoredTargets::Read);
1312/// assert_eq!(options.stored_targets, StoredTargets::Read);
1313/// ```
1314#[derive(Debug, Clone, Copy, Default)]
1315#[non_exhaustive]
1316pub struct ListOptions {
1317    /// Whether to read the link targets a format keeps in entry data.
1318    pub stored_targets: StoredTargets,
1319}
1320
1321impl ListOptions {
1322    /// Sets whether link targets kept in entry data are read.
1323    #[must_use]
1324    pub fn with_stored_targets(mut self, stored_targets: StoredTargets) -> Self {
1325        self.stored_targets = stored_targets;
1326        self
1327    }
1328}
1329
1330#[cfg(test)]
1331#[allow(
1332    clippy::unwrap_used,
1333    clippy::expect_used,
1334    clippy::field_reassign_with_default
1335)]
1336mod tests {
1337    use super::*;
1338
1339    #[test]
1340    fn test_default_config() {
1341        let config = SecurityConfig::default();
1342        assert!(!config.allowed.symlinks);
1343        assert!(!config.allowed.hardlinks);
1344        assert!(!config.allowed.absolute_paths);
1345        assert_eq!(config.max_file_size, 50 * 1024 * 1024);
1346    }
1347
1348    #[test]
1349    fn test_permissive_config() {
1350        let config = SecurityConfig::permissive();
1351        assert!(config.allowed.symlinks);
1352        assert!(config.allowed.hardlinks);
1353        assert!(config.allowed.absolute_paths);
1354    }
1355
1356    #[test]
1357    fn test_extension_allowed_empty_list() {
1358        let config = SecurityConfig::default();
1359        assert!(config.is_extension_allowed("txt"));
1360        assert!(config.is_extension_allowed("pdf"));
1361    }
1362
1363    #[test]
1364    fn test_extension_allowed_with_list() {
1365        let mut config = SecurityConfig::default();
1366        config.allowed_extensions = vec!["txt".to_string(), "pdf".to_string()];
1367        assert!(config.is_extension_allowed("txt"));
1368        assert!(config.is_extension_allowed("TXT"));
1369        assert!(!config.is_extension_allowed("exe"));
1370    }
1371
1372    #[test]
1373    fn test_path_component_allowed() {
1374        let config = SecurityConfig::default();
1375        assert!(config.is_path_component_allowed("src"));
1376        assert!(!config.is_path_component_allowed(".git"));
1377        assert!(!config.is_path_component_allowed(".ssh"));
1378
1379        // Case-insensitive matching prevents bypass
1380        assert!(!config.is_path_component_allowed(".Git"));
1381        assert!(!config.is_path_component_allowed(".GIT"));
1382        assert!(!config.is_path_component_allowed(".SSH"));
1383        assert!(!config.is_path_component_allowed(".Gnupg"));
1384    }
1385
1386    // M-TEST-3: Config field validation
1387    #[test]
1388    fn test_config_default_security_flags() {
1389        let config = SecurityConfig::default();
1390
1391        // All security-sensitive flags should be false by default
1392        // (deny-by-default)
1393        assert!(
1394            !config.allowed.symlinks,
1395            "symlinks should be denied by default"
1396        );
1397        assert!(
1398            !config.allowed.hardlinks,
1399            "hardlinks should be denied by default"
1400        );
1401        assert!(
1402            !config.allowed.absolute_paths,
1403            "absolute paths should be denied by default"
1404        );
1405        assert!(
1406            !config.preserve_permissions,
1407            "permissions should not be preserved by default"
1408        );
1409        assert!(
1410            !config.allowed.world_writable,
1411            "world-writable should be denied by default"
1412        );
1413    }
1414
1415    #[test]
1416    fn test_config_permissive_security_flags() {
1417        let config = SecurityConfig::permissive();
1418
1419        // Permissive config should allow all features
1420        assert!(config.allowed.symlinks, "permissive allows symlinks");
1421        assert!(config.allowed.hardlinks, "permissive allows hardlinks");
1422        assert!(
1423            config.allowed.absolute_paths,
1424            "permissive allows absolute paths"
1425        );
1426        assert!(
1427            config.preserve_permissions,
1428            "permissive preserves permissions"
1429        );
1430        assert!(
1431            config.allowed.world_writable,
1432            "permissive allows world-writable"
1433        );
1434    }
1435
1436    #[test]
1437    fn test_config_quota_limits() {
1438        let config = SecurityConfig::default();
1439
1440        // Verify default quota values are sensible
1441        assert_eq!(config.max_file_size, 50 * 1024 * 1024, "50 MB file limit");
1442        assert_eq!(
1443            config.max_total_size,
1444            500 * 1024 * 1024,
1445            "500 MB total limit"
1446        );
1447        assert_eq!(config.max_file_count, 10_000, "10k file count limit");
1448        assert_eq!(config.max_path_depth, 32, "32 level depth limit");
1449        #[allow(clippy::float_cmp)]
1450        {
1451            assert_eq!(
1452                config.max_compression_ratio, 100.0,
1453                "100x compression ratio limit"
1454            );
1455        }
1456    }
1457
1458    #[test]
1459    fn test_config_banned_components_not_empty() {
1460        let config = SecurityConfig::default();
1461
1462        // Default should ban common sensitive directories
1463        assert!(
1464            !config.banned_path_components.is_empty(),
1465            "should have banned components by default"
1466        );
1467        assert!(
1468            config.banned_path_components.contains(&".git".to_string()),
1469            "should ban .git"
1470        );
1471        assert!(
1472            config.banned_path_components.contains(&".ssh".to_string()),
1473            "should ban .ssh"
1474        );
1475    }
1476
1477    #[test]
1478    fn test_config_solid_archives_default() {
1479        let config = SecurityConfig::default();
1480
1481        // Solid archives should be denied by default (security)
1482        assert!(
1483            !config.allow_solid_archives,
1484            "solid archives should be denied by default"
1485        );
1486        assert_eq!(
1487            config.max_solid_block_memory,
1488            512 * 1024 * 1024,
1489            "max solid block memory should be 512 MB"
1490        );
1491    }
1492
1493    #[test]
1494    fn test_config_permissive_solid_archives() {
1495        let config = SecurityConfig::permissive();
1496
1497        // Permissive config should allow solid archives
1498        assert!(
1499            config.allow_solid_archives,
1500            "permissive config should allow solid archives"
1501        );
1502        assert_eq!(
1503            config.max_solid_block_memory,
1504            1024 * 1024 * 1024,
1505            "permissive should have 1 GB solid block limit"
1506        );
1507    }
1508
1509    #[test]
1510    fn test_config_tar_metadata_bytes_default() {
1511        let config = SecurityConfig::default();
1512        assert_eq!(
1513            config.max_tar_metadata_bytes,
1514            4 * 1024 * 1024,
1515            "max TAR metadata bytes should be 4 MiB by default"
1516        );
1517    }
1518
1519    #[test]
1520    fn test_config_permissive_tar_metadata_bytes() {
1521        let config = SecurityConfig::permissive();
1522        assert_eq!(
1523            config.max_tar_metadata_bytes,
1524            16 * 1024 * 1024,
1525            "permissive should have 16 MiB TAR metadata budget"
1526        );
1527    }
1528
1529    #[test]
1530    fn test_with_max_tar_metadata_bytes_builder() {
1531        let config = SecurityConfig::default().with_max_tar_metadata_bytes(2048);
1532        assert_eq!(config.max_tar_metadata_bytes, 2048);
1533    }
1534
1535    // Regression tests for #172: SecurityConfig::validate() must reject configs
1536    // that would make security enforcement impossible.
1537
1538    #[test]
1539    fn test_validate_default_is_ok() {
1540        assert!(SecurityConfig::default().validate().is_ok());
1541    }
1542
1543    #[test]
1544    fn test_validate_rejects_negative_compression_ratio() {
1545        let mut cfg = SecurityConfig::default();
1546        cfg.max_compression_ratio = -1.0;
1547        assert!(cfg.validate().is_err());
1548    }
1549
1550    #[test]
1551    fn test_validate_rejects_zero_compression_ratio() {
1552        let mut cfg = SecurityConfig::default();
1553        cfg.max_compression_ratio = 0.0;
1554        assert!(cfg.validate().is_err());
1555    }
1556
1557    #[test]
1558    fn test_validate_rejects_invalid_solid_compression_ratio() {
1559        for ratio in [0.0, -1.0, f64::NAN, f64::INFINITY] {
1560            let mut cfg = SecurityConfig::default();
1561            cfg.max_solid_compression_ratio = ratio;
1562            assert!(cfg.validate().is_err(), "{ratio} must be rejected");
1563        }
1564    }
1565
1566    #[test]
1567    fn test_solid_compression_ratio_defaults() {
1568        assert_eq!(
1569            SecurityConfig::default().max_solid_compression_ratio,
1570            1000.0
1571        );
1572        assert_eq!(
1573            SecurityConfig::permissive().max_solid_compression_ratio,
1574            10_000.0
1575        );
1576    }
1577
1578    #[test]
1579    fn test_validate_rejects_zero_max_file_size() {
1580        let mut cfg = SecurityConfig::default();
1581        cfg.max_file_size = 0;
1582        assert!(cfg.validate().is_err());
1583    }
1584
1585    #[test]
1586    fn test_validate_rejects_zero_max_total_size() {
1587        let mut cfg = SecurityConfig::default();
1588        cfg.max_total_size = 0;
1589        assert!(cfg.validate().is_err());
1590    }
1591
1592    #[test]
1593    fn test_validate_rejects_zero_max_path_depth() {
1594        let mut cfg = SecurityConfig::default();
1595        cfg.max_path_depth = 0;
1596        assert!(cfg.validate().is_err());
1597    }
1598
1599    #[test]
1600    fn test_validate_rejects_nan_compression_ratio() {
1601        let mut cfg = SecurityConfig::default();
1602        cfg.max_compression_ratio = f64::NAN;
1603        assert!(cfg.validate().is_err());
1604    }
1605
1606    #[test]
1607    fn test_validate_rejects_infinite_compression_ratio() {
1608        let mut cfg = SecurityConfig::default();
1609        cfg.max_compression_ratio = f64::INFINITY;
1610        assert!(cfg.validate().is_err());
1611    }
1612
1613    #[test]
1614    fn test_validate_rejects_zero_max_file_count() {
1615        let mut cfg = SecurityConfig::default();
1616        cfg.max_file_count = 0;
1617        assert!(cfg.validate().is_err());
1618    }
1619
1620    #[test]
1621    fn test_validate_rejects_zero_max_solid_block_memory() {
1622        let mut cfg = SecurityConfig::default();
1623        cfg.max_solid_block_memory = 0;
1624        assert!(cfg.validate().is_err());
1625    }
1626
1627    #[test]
1628    fn test_validate_rejects_zero_max_tar_metadata_bytes() {
1629        let mut cfg = SecurityConfig::default();
1630        cfg.max_tar_metadata_bytes = 0;
1631        assert!(cfg.validate().is_err());
1632    }
1633
1634    #[test]
1635    fn test_validate_rejects_zero_max_decoder_memory() {
1636        let mut cfg = SecurityConfig::default();
1637        cfg.max_decoder_memory = 0;
1638        assert!(cfg.validate().is_err());
1639    }
1640
1641    #[test]
1642    fn test_parallel_decode_defaults() {
1643        let bytes = |config: SecurityConfig| match config.parallel_decode {
1644            ParallelDecode::Bounded(n) => n.get(),
1645            ParallelDecode::Sequential => 0,
1646        };
1647        assert_eq!(bytes(SecurityConfig::default()), 128 * 1024 * 1024);
1648        assert_eq!(bytes(SecurityConfig::permissive()), 1024 * 1024 * 1024);
1649    }
1650
1651    #[test]
1652    fn test_max_decoder_memory_defaults() {
1653        assert_eq!(
1654            SecurityConfig::default().max_decoder_memory,
1655            256 * 1024 * 1024
1656        );
1657        assert_eq!(
1658            SecurityConfig::permissive().max_decoder_memory,
1659            1024 * 1024 * 1024
1660        );
1661    }
1662
1663    // Regression tests for #449: SecurityConfig::validate() must reject
1664    // malformed allowed_extensions/banned_path_components entries.
1665
1666    #[test]
1667    fn test_validate_permissive_is_ok() {
1668        assert!(SecurityConfig::permissive().validate().is_ok());
1669    }
1670
1671    #[test]
1672    fn test_validate_accepts_empty_allowed_extensions_vec() {
1673        let cfg = SecurityConfig::default().with_allowed_extensions(Vec::new());
1674        assert!(cfg.validate().is_ok());
1675    }
1676
1677    #[test]
1678    fn test_validate_rejects_empty_allowed_extension_entry() {
1679        let cfg = SecurityConfig::default().with_allowed_extensions(vec![String::new()]);
1680        assert!(cfg.validate().is_err());
1681    }
1682
1683    #[test]
1684    fn test_validate_rejects_empty_banned_path_component_entry() {
1685        let cfg = SecurityConfig::default().with_banned_path_components(vec![String::new()]);
1686        assert!(cfg.validate().is_err());
1687    }
1688
1689    #[test]
1690    fn test_validate_rejects_null_byte_in_allowed_extension() {
1691        let cfg = SecurityConfig::default().with_allowed_extensions(vec!["bad\0ext".to_string()]);
1692        assert!(cfg.validate().is_err());
1693    }
1694
1695    #[test]
1696    fn test_validate_rejects_overlong_banned_path_component() {
1697        let long_component = "x".repeat(256);
1698        let cfg = SecurityConfig::default().with_banned_path_components(vec![long_component]);
1699        assert!(cfg.validate().is_err());
1700    }
1701
1702    // Regression test for the C1 finding on #433/#434/#435: a
1703    // `SecurityConfig<Validated>` must not be mutable. `DerefMut` is only
1704    // implemented for `SecurityConfig<Unvalidated>`, so this is enforced at
1705    // compile time — see `tests/ui/validated_config_field_mutation.rs` for
1706    // the corresponding compile-fail fixture. This test only pins the
1707    // read-side behavior: fields remain readable after validation.
1708    #[test]
1709    fn test_validated_config_fields_remain_readable() {
1710        let validated = SecurityConfig::default()
1711            .with_max_file_size(123)
1712            .validate()
1713            .expect("valid config");
1714        assert_eq!(validated.max_file_size, 123);
1715    }
1716}