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}