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}