Skip to main content

exarch_core/creation/
config.rs

1//! Configuration for archive creation operations.
2
3use crate::ArchiveError;
4use crate::Result;
5use crate::config::Unvalidated;
6use crate::config::Validated;
7use crate::formats::detect::ArchiveType;
8use std::marker::PhantomData;
9use std::ops::Deref;
10use std::ops::DerefMut;
11use std::path::PathBuf;
12
13/// The field data of a [`CreationConfig`], reachable via [`Deref`]/[`DerefMut`]
14/// regardless of (or gated by) validation state.
15///
16/// Mirrors [`SecurityConfigFields`](crate::config::SecurityConfigFields): a
17/// plain `#[non_exhaustive]` data bag that blocks external struct-literal
18/// forging, while [`CreationConfig`]'s own private `fields` member blocks
19/// re-wrapping a mutated bag into a `Validated` config. See that type's docs
20/// for the full rationale — the same sealing boundary applies here.
21#[derive(Debug, Clone)]
22#[non_exhaustive]
23pub struct CreationConfigFields {
24    /// Follow symlinks when adding files to archive.
25    ///
26    /// Default: `false` (store symlinks as symlinks).
27    ///
28    /// Security note: Following symlinks may include unintended files
29    /// from outside the source directory.
30    pub follow_symlinks: bool,
31
32    /// Include hidden files (files starting with '.').
33    ///
34    /// Default: `false` (skip hidden files).
35    pub include_hidden: bool,
36
37    /// Maximum size for a single file in bytes.
38    ///
39    /// Files larger than this limit will be skipped.
40    /// `None` means no limit.
41    ///
42    /// Default: `None`.
43    pub max_file_size: Option<u64>,
44
45    /// Patterns to exclude from the archive.
46    ///
47    /// Files matching these patterns will be skipped.
48    ///
49    /// Default: `[".git", ".DS_Store", "*.tmp"]`.
50    pub exclude_patterns: Vec<String>,
51
52    /// Prefix to strip from entry paths in the archive.
53    ///
54    /// If set, this prefix will be removed from all entry paths.
55    /// Useful for creating archives without deep directory nesting.
56    ///
57    /// Default: `None`.
58    pub strip_prefix: Option<PathBuf>,
59
60    /// Compression level (1-9).
61    ///
62    /// Higher values provide better compression but slower speed.
63    /// `None` uses format-specific defaults.
64    ///
65    /// Default: `Some(6)` (balanced).
66    ///
67    /// Valid range: 1 (fastest) to 9 (best compression).
68    pub compression_level: Option<u8>,
69
70    /// Preserve file permissions in the archive.
71    ///
72    /// Default: `true`.
73    pub preserve_permissions: bool,
74
75    /// Archive format to create.
76    ///
77    /// `None` means auto-detect from output file extension.
78    ///
79    /// Default: `None`.
80    pub format: Option<ArchiveType>,
81}
82
83impl Default for CreationConfigFields {
84    fn default() -> Self {
85        Self {
86            follow_symlinks: false,
87            include_hidden: false,
88            max_file_size: None,
89            exclude_patterns: vec![
90                ".git".to_string(),
91                ".DS_Store".to_string(),
92                "*.tmp".to_string(),
93            ],
94            strip_prefix: None,
95            compression_level: Some(6),
96            preserve_permissions: true,
97            format: None,
98        }
99    }
100}
101
102/// Configuration for archive creation operations.
103///
104/// Controls how archives are created from filesystem sources, including
105/// security options, compression settings, and file filtering.
106///
107/// # Examples
108///
109/// ```
110/// use exarch_core::creation::CreationConfig;
111///
112/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
113/// // Use secure defaults
114/// let config = CreationConfig::default();
115///
116/// // Customize for specific needs
117/// let custom = CreationConfig::default()
118///     .with_follow_symlinks(true)
119///     .with_compression_level(9)?;
120/// # Ok(())
121/// # }
122/// ```
123///
124/// # Typestate
125///
126/// `CreationConfig` carries a phantom `State` type parameter —
127/// [`Unvalidated`] (the default) or [`Validated`] — that tracks whether
128/// [`validate`](CreationConfig::validate) has been called. Builder methods
129/// are only available in the `Unvalidated` state; the low-level
130/// `creation::tar::*` / `creation::zip::*` functions and
131/// [`FormatCreator::create`](crate::formats::traits::FormatCreator::create)
132/// require `CreationConfig<Validated>`. This makes skipping validation a
133/// compile error instead of a runtime gap — a forged or hand-mutated
134/// `compression_level` can no longer reach the `flate2`/`xz2` backends,
135/// which panic on out-of-range values instead of returning an error.
136///
137/// # Sealing
138///
139/// Fields are private and reachable only through
140/// <code>[Deref]<Target = [CreationConfigFields]></code>, so
141/// `config.compression_level` continues to work as plain field access for
142/// both states. [`DerefMut`] is implemented only for
143/// `CreationConfig<Unvalidated>`, so a `CreationConfig<Validated>`'s fields
144/// cannot be reassigned after the fact — the only way to produce one is
145/// [`validate`](CreationConfig::validate) itself, and it stays that way for
146/// its entire lifetime.
147#[derive(Debug, Clone)]
148pub struct CreationConfig<State = Unvalidated> {
149    fields: CreationConfigFields,
150
151    /// Typestate marker — see the "Typestate" section on the type-level docs.
152    _marker: PhantomData<State>,
153}
154
155impl<State> Deref for CreationConfig<State> {
156    type Target = CreationConfigFields;
157
158    fn deref(&self) -> &CreationConfigFields {
159        &self.fields
160    }
161}
162
163/// Only `Unvalidated` configs are mutable — see the "Sealing" section on
164/// [`CreationConfig`]'s type-level docs for why this is the crux of the
165/// typestate guarantee.
166impl DerefMut for CreationConfig<Unvalidated> {
167    fn deref_mut(&mut self) -> &mut CreationConfigFields {
168        &mut self.fields
169    }
170}
171
172impl Default for CreationConfig<Unvalidated> {
173    /// Creates a `CreationConfig` with secure default settings.
174    ///
175    /// Default values:
176    /// - `follow_symlinks`: `false`
177    /// - `include_hidden`: `false`
178    /// - `max_file_size`: `None`
179    /// - `exclude_patterns`: `[".git", ".DS_Store", "*.tmp"]`
180    /// - `strip_prefix`: `None`
181    /// - `compression_level`: `Some(6)`
182    /// - `preserve_permissions`: `true`
183    /// - `format`: `None`
184    fn default() -> Self {
185        Self {
186            fields: CreationConfigFields::default(),
187            _marker: PhantomData,
188        }
189    }
190}
191
192impl CreationConfig<Unvalidated> {
193    /// Creates a new `CreationConfig` with default settings.
194    #[must_use]
195    pub fn new() -> Self {
196        Self::default()
197    }
198
199    /// Sets whether to follow symlinks.
200    ///
201    /// # Examples
202    ///
203    /// ```
204    /// use exarch_core::creation::CreationConfig;
205    ///
206    /// let config = CreationConfig::default().with_follow_symlinks(true);
207    /// assert!(config.follow_symlinks);
208    /// ```
209    #[must_use]
210    pub fn with_follow_symlinks(mut self, follow: bool) -> Self {
211        self.fields.follow_symlinks = follow;
212        self
213    }
214
215    /// Sets whether to include hidden files.
216    ///
217    /// # Examples
218    ///
219    /// ```
220    /// use exarch_core::creation::CreationConfig;
221    ///
222    /// let config = CreationConfig::default().with_include_hidden(true);
223    /// assert!(config.include_hidden);
224    /// ```
225    #[must_use]
226    pub fn with_include_hidden(mut self, include: bool) -> Self {
227        self.fields.include_hidden = include;
228        self
229    }
230
231    /// Sets the maximum file size.
232    ///
233    /// # Examples
234    ///
235    /// ```
236    /// use exarch_core::creation::CreationConfig;
237    ///
238    /// let config = CreationConfig::default().with_max_file_size(Some(1024 * 1024));
239    /// assert_eq!(config.max_file_size, Some(1024 * 1024));
240    /// ```
241    #[must_use]
242    pub fn with_max_file_size(mut self, max_size: Option<u64>) -> Self {
243        self.fields.max_file_size = max_size;
244        self
245    }
246
247    /// Sets the exclude patterns.
248    ///
249    /// # Examples
250    ///
251    /// ```
252    /// use exarch_core::creation::CreationConfig;
253    ///
254    /// let config = CreationConfig::default().with_exclude_patterns(vec!["*.log".to_string()]);
255    /// assert_eq!(config.exclude_patterns, vec!["*.log".to_string()]);
256    /// ```
257    #[must_use]
258    pub fn with_exclude_patterns(mut self, patterns: Vec<String>) -> Self {
259        self.fields.exclude_patterns = patterns;
260        self
261    }
262
263    /// Sets the strip prefix.
264    ///
265    /// # Examples
266    ///
267    /// ```
268    /// use exarch_core::creation::CreationConfig;
269    /// use std::path::PathBuf;
270    ///
271    /// let config = CreationConfig::default().with_strip_prefix(Some(PathBuf::from("/base")));
272    /// assert_eq!(config.strip_prefix, Some(PathBuf::from("/base")));
273    /// ```
274    #[must_use]
275    pub fn with_strip_prefix(mut self, prefix: Option<PathBuf>) -> Self {
276        self.fields.strip_prefix = prefix;
277        self
278    }
279
280    /// Sets the compression level.
281    ///
282    /// # Errors
283    ///
284    /// Returns [`ArchiveError::InvalidCompressionLevel`] if `level` is not
285    /// in the range 1–9.
286    ///
287    /// # Examples
288    ///
289    /// ```
290    /// use exarch_core::creation::CreationConfig;
291    ///
292    /// let config = CreationConfig::default().with_compression_level(9)?;
293    /// assert_eq!(config.compression_level, Some(9));
294    /// # Ok::<(), exarch_core::ArchiveError>(())
295    /// ```
296    pub fn with_compression_level(mut self, level: u8) -> Result<Self> {
297        if !(1..=9).contains(&level) {
298            return Err(ArchiveError::InvalidCompressionLevel { level });
299        }
300        self.fields.compression_level = Some(level);
301        Ok(self)
302    }
303
304    /// Sets whether to preserve permissions.
305    ///
306    /// # Examples
307    ///
308    /// ```
309    /// use exarch_core::creation::CreationConfig;
310    ///
311    /// let config = CreationConfig::default().with_preserve_permissions(false);
312    /// assert!(!config.preserve_permissions);
313    /// ```
314    #[must_use]
315    pub fn with_preserve_permissions(mut self, preserve: bool) -> Self {
316        self.fields.preserve_permissions = preserve;
317        self
318    }
319
320    /// Sets the archive format.
321    ///
322    /// # Examples
323    ///
324    /// ```
325    /// use exarch_core::creation::CreationConfig;
326    /// use exarch_core::formats::detect::ArchiveType;
327    ///
328    /// let config = CreationConfig::default().with_format(Some(ArchiveType::TarGz));
329    /// assert_eq!(config.format, Some(ArchiveType::TarGz));
330    /// ```
331    #[must_use]
332    pub fn with_format(mut self, format: Option<ArchiveType>) -> Self {
333        self.fields.format = format;
334        self
335    }
336
337    /// Validates the configuration, transitioning to the [`Validated`]
338    /// typestate on success.
339    ///
340    /// Consumes `self`: the only way to obtain a `CreationConfig<Validated>`,
341    /// which is what the low-level `creation::tar::*` / `creation::zip::*`
342    /// functions and
343    /// [`FormatCreator::create`](crate::formats::traits::FormatCreator::create)
344    /// require. Once returned, the `Validated` config's fields can no longer
345    /// be reassigned (see the "Sealing" section on the type-level docs), so
346    /// this check can never be silently invalidated afterward.
347    ///
348    /// # Errors
349    ///
350    /// Returns [`ArchiveError::InvalidCompressionLevel`] if `compression_level`
351    /// is set but not in the range 1–9.
352    ///
353    /// # Examples
354    ///
355    /// ```
356    /// use exarch_core::creation::CreationConfig;
357    ///
358    /// let config = CreationConfig::default();
359    /// assert!(config.validate().is_ok());
360    /// ```
361    pub fn validate(self) -> Result<CreationConfig<Validated>> {
362        if let Some(level) = self.fields.compression_level
363            && !(1..=9).contains(&level)
364        {
365            return Err(ArchiveError::InvalidCompressionLevel { level });
366        }
367        Ok(CreationConfig {
368            fields: self.fields,
369            _marker: PhantomData,
370        })
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use std::assert_matches;
378
379    #[test]
380    fn test_creation_config_default() {
381        let config = CreationConfig::default();
382        assert!(!config.follow_symlinks);
383        assert!(!config.include_hidden);
384        assert_eq!(config.max_file_size, None);
385        assert_eq!(config.exclude_patterns.len(), 3);
386        assert!(config.exclude_patterns.contains(&".git".to_string()));
387        assert!(config.exclude_patterns.contains(&".DS_Store".to_string()));
388        assert!(config.exclude_patterns.contains(&"*.tmp".to_string()));
389        assert_eq!(config.strip_prefix, None);
390        assert_eq!(config.compression_level, Some(6));
391        assert!(config.preserve_permissions);
392        assert_eq!(config.format, None);
393    }
394
395    #[test]
396    #[allow(clippy::unwrap_used)]
397    fn test_creation_config_builder() {
398        let config = CreationConfig::default()
399            .with_follow_symlinks(true)
400            .with_include_hidden(true)
401            .with_max_file_size(Some(1024 * 1024))
402            .with_exclude_patterns(vec!["*.log".to_string()])
403            .with_strip_prefix(Some(PathBuf::from("/base")))
404            .with_compression_level(9)
405            .unwrap()
406            .with_preserve_permissions(false)
407            .with_format(Some(ArchiveType::TarGz));
408
409        assert!(config.follow_symlinks);
410        assert!(config.include_hidden);
411        assert_eq!(config.max_file_size, Some(1024 * 1024));
412        assert_eq!(config.exclude_patterns, vec!["*.log".to_string()]);
413        assert_eq!(config.strip_prefix, Some(PathBuf::from("/base")));
414        assert_eq!(config.compression_level, Some(9));
415        assert!(!config.preserve_permissions);
416        assert_eq!(config.format, Some(ArchiveType::TarGz));
417    }
418
419    #[test]
420    #[allow(clippy::unwrap_used, clippy::field_reassign_with_default)]
421    fn test_creation_config_validate_valid() {
422        let config = CreationConfig::default();
423        assert!(config.validate().is_ok());
424
425        let config = CreationConfig::default().with_compression_level(1).unwrap();
426        assert!(config.validate().is_ok());
427
428        let config = CreationConfig::default().with_compression_level(9).unwrap();
429        assert!(config.validate().is_ok());
430
431        let mut config = CreationConfig::default();
432        config.compression_level = None;
433        assert!(config.validate().is_ok());
434    }
435
436    #[test]
437    #[allow(clippy::unwrap_used, clippy::field_reassign_with_default)]
438    fn test_creation_config_validate_invalid() {
439        let mut config = CreationConfig::default();
440        config.compression_level = Some(0);
441        let result = config.validate();
442        assert!(result.is_err());
443        assert_matches!(
444            result.unwrap_err(),
445            ArchiveError::InvalidCompressionLevel { level: 0 }
446        );
447
448        let mut config = CreationConfig::default();
449        config.compression_level = Some(10);
450        let result = config.validate();
451        assert!(result.is_err());
452        assert_matches!(
453            result.unwrap_err(),
454            ArchiveError::InvalidCompressionLevel { level: 10 }
455        );
456    }
457
458    #[test]
459    fn test_creation_config_builder_invalid_compression() {
460        assert_matches!(
461            CreationConfig::default().with_compression_level(0),
462            Err(ArchiveError::InvalidCompressionLevel { level: 0 })
463        );
464        assert_matches!(
465            CreationConfig::default().with_compression_level(10),
466            Err(ArchiveError::InvalidCompressionLevel { level: 10 })
467        );
468    }
469
470    #[test]
471    fn test_creation_config_new() {
472        let config = CreationConfig::new();
473        assert_eq!(config.compression_level, Some(6));
474        assert!(config.preserve_permissions);
475    }
476
477    #[test]
478    fn test_creation_config_secure_defaults() {
479        let config = CreationConfig::default();
480
481        // Security: Don't follow symlinks by default
482        assert!(
483            !config.follow_symlinks,
484            "should not follow symlinks by default (security)"
485        );
486
487        // Security: Don't include hidden files by default
488        assert!(
489            !config.include_hidden,
490            "should not include hidden files by default"
491        );
492
493        // Security: Exclude sensitive patterns
494        assert!(
495            config.exclude_patterns.contains(&".git".to_string()),
496            "should exclude .git by default"
497        );
498    }
499
500    /// Regression test for #443: a validated config must not let levels
501    /// 10-255 reach the `flate2`/`xz2` backends, which panic (rather than
502    /// error) on out-of-range values. `validate()` is the only path to
503    /// `CreationConfig<Validated>`, and it rejects out-of-range levels
504    /// regardless of how `compression_level` was set.
505    #[test]
506    #[allow(clippy::field_reassign_with_default)]
507    fn test_validate_rejects_forged_out_of_range_compression_level() {
508        let mut config = CreationConfig::default();
509        config.compression_level = Some(200);
510        let result = config.validate();
511        assert_matches!(
512            result,
513            Err(ArchiveError::InvalidCompressionLevel { level: 200 })
514        );
515    }
516}