exarch_core/creation/
config.rs

1//! Configuration for archive creation operations.
2
3use crate::ExtractionError;
4use crate::Result;
5use crate::formats::detect::ArchiveType;
6use std::path::PathBuf;
7
8/// Configuration for archive creation operations.
9///
10/// Controls how archives are created from filesystem sources, including
11/// security options, compression settings, and file filtering.
12///
13/// # Examples
14///
15/// ```
16/// use exarch_core::creation::CreationConfig;
17///
18/// // Use secure defaults
19/// let config = CreationConfig::default();
20///
21/// // Customize for specific needs
22/// let custom = CreationConfig::default()
23///     .with_follow_symlinks(true)
24///     .with_compression_level(9);
25/// ```
26#[derive(Debug, Clone)]
27pub struct CreationConfig {
28    /// Follow symlinks when adding files to archive.
29    ///
30    /// Default: `false` (store symlinks as symlinks).
31    ///
32    /// Security note: Following symlinks may include unintended files
33    /// from outside the source directory.
34    pub follow_symlinks: bool,
35
36    /// Include hidden files (files starting with '.').
37    ///
38    /// Default: `false` (skip hidden files).
39    pub include_hidden: bool,
40
41    /// Maximum size for a single file in bytes.
42    ///
43    /// Files larger than this limit will be skipped.
44    /// `None` means no limit.
45    ///
46    /// Default: `None`.
47    pub max_file_size: Option<u64>,
48
49    /// Patterns to exclude from the archive.
50    ///
51    /// Files matching these patterns will be skipped.
52    ///
53    /// Default: `[".git", ".DS_Store", "*.tmp"]`.
54    pub exclude_patterns: Vec<String>,
55
56    /// Prefix to strip from entry paths in the archive.
57    ///
58    /// If set, this prefix will be removed from all entry paths.
59    /// Useful for creating archives without deep directory nesting.
60    ///
61    /// Default: `None`.
62    pub strip_prefix: Option<PathBuf>,
63
64    /// Compression level (1-9).
65    ///
66    /// Higher values provide better compression but slower speed.
67    /// `None` uses format-specific defaults.
68    ///
69    /// Default: `Some(6)` (balanced).
70    ///
71    /// Valid range: 1 (fastest) to 9 (best compression).
72    pub compression_level: Option<u8>,
73
74    /// Preserve file permissions in the archive.
75    ///
76    /// Default: `true`.
77    pub preserve_permissions: bool,
78
79    /// Archive format to create.
80    ///
81    /// `None` means auto-detect from output file extension.
82    ///
83    /// Default: `None`.
84    pub format: Option<ArchiveType>,
85}
86
87impl Default for CreationConfig {
88    /// Creates a `CreationConfig` with secure default settings.
89    ///
90    /// Default values:
91    /// - `follow_symlinks`: `false`
92    /// - `include_hidden`: `false`
93    /// - `max_file_size`: `None`
94    /// - `exclude_patterns`: `[".git", ".DS_Store", "*.tmp"]`
95    /// - `strip_prefix`: `None`
96    /// - `compression_level`: `Some(6)`
97    /// - `preserve_permissions`: `true`
98    /// - `format`: `None`
99    fn default() -> Self {
100        Self {
101            follow_symlinks: false,
102            include_hidden: false,
103            max_file_size: None,
104            exclude_patterns: vec![
105                ".git".to_string(),
106                ".DS_Store".to_string(),
107                "*.tmp".to_string(),
108            ],
109            strip_prefix: None,
110            compression_level: Some(6),
111            preserve_permissions: true,
112            format: None,
113        }
114    }
115}
116
117impl CreationConfig {
118    /// Creates a new `CreationConfig` with default settings.
119    #[must_use]
120    pub fn new() -> Self {
121        Self::default()
122    }
123
124    /// Sets whether to follow symlinks.
125    #[must_use]
126    pub fn with_follow_symlinks(mut self, follow: bool) -> Self {
127        self.follow_symlinks = follow;
128        self
129    }
130
131    /// Sets whether to include hidden files.
132    #[must_use]
133    pub fn with_include_hidden(mut self, include: bool) -> Self {
134        self.include_hidden = include;
135        self
136    }
137
138    /// Sets the maximum file size.
139    #[must_use]
140    pub fn with_max_file_size(mut self, max_size: Option<u64>) -> Self {
141        self.max_file_size = max_size;
142        self
143    }
144
145    /// Sets the exclude patterns.
146    #[must_use]
147    pub fn with_exclude_patterns(mut self, patterns: Vec<String>) -> Self {
148        self.exclude_patterns = patterns;
149        self
150    }
151
152    /// Sets the strip prefix.
153    #[must_use]
154    pub fn with_strip_prefix(mut self, prefix: Option<PathBuf>) -> Self {
155        self.strip_prefix = prefix;
156        self
157    }
158
159    /// Sets the compression level.
160    ///
161    /// # Panics
162    ///
163    /// Panics if the compression level is not in the range 1-9.
164    /// Use `validate()` for non-panicking validation.
165    #[must_use]
166    pub fn with_compression_level(mut self, level: u8) -> Self {
167        assert!((1..=9).contains(&level), "compression level must be 1-9");
168        self.compression_level = Some(level);
169        self
170    }
171
172    /// Sets whether to preserve permissions.
173    #[must_use]
174    pub fn with_preserve_permissions(mut self, preserve: bool) -> Self {
175        self.preserve_permissions = preserve;
176        self
177    }
178
179    /// Sets the archive format.
180    #[must_use]
181    pub fn with_format(mut self, format: Option<ArchiveType>) -> Self {
182        self.format = format;
183        self
184    }
185
186    /// Validates the configuration.
187    ///
188    /// # Errors
189    ///
190    /// Returns an error if:
191    /// - Compression level is set but not in range 1-9
192    pub fn validate(&self) -> Result<()> {
193        if let Some(level) = self.compression_level
194            && !(1..=9).contains(&level)
195        {
196            return Err(ExtractionError::InvalidCompressionLevel { level });
197        }
198        Ok(())
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_creation_config_default() {
208        let config = CreationConfig::default();
209        assert!(!config.follow_symlinks);
210        assert!(!config.include_hidden);
211        assert_eq!(config.max_file_size, None);
212        assert_eq!(config.exclude_patterns.len(), 3);
213        assert!(config.exclude_patterns.contains(&".git".to_string()));
214        assert!(config.exclude_patterns.contains(&".DS_Store".to_string()));
215        assert!(config.exclude_patterns.contains(&"*.tmp".to_string()));
216        assert_eq!(config.strip_prefix, None);
217        assert_eq!(config.compression_level, Some(6));
218        assert!(config.preserve_permissions);
219        assert_eq!(config.format, None);
220    }
221
222    #[test]
223    fn test_creation_config_builder() {
224        let config = CreationConfig::default()
225            .with_follow_symlinks(true)
226            .with_include_hidden(true)
227            .with_max_file_size(Some(1024 * 1024))
228            .with_exclude_patterns(vec!["*.log".to_string()])
229            .with_strip_prefix(Some(PathBuf::from("/base")))
230            .with_compression_level(9)
231            .with_preserve_permissions(false)
232            .with_format(Some(ArchiveType::TarGz));
233
234        assert!(config.follow_symlinks);
235        assert!(config.include_hidden);
236        assert_eq!(config.max_file_size, Some(1024 * 1024));
237        assert_eq!(config.exclude_patterns, vec!["*.log".to_string()]);
238        assert_eq!(config.strip_prefix, Some(PathBuf::from("/base")));
239        assert_eq!(config.compression_level, Some(9));
240        assert!(!config.preserve_permissions);
241        assert_eq!(config.format, Some(ArchiveType::TarGz));
242    }
243
244    #[test]
245    fn test_creation_config_validate_valid() {
246        let config = CreationConfig::default();
247        assert!(config.validate().is_ok());
248
249        let config = CreationConfig::default().with_compression_level(1);
250        assert!(config.validate().is_ok());
251
252        let config = CreationConfig::default().with_compression_level(9);
253        assert!(config.validate().is_ok());
254
255        let config = CreationConfig {
256            compression_level: None,
257            ..Default::default()
258        };
259        assert!(config.validate().is_ok());
260    }
261
262    #[test]
263    #[allow(clippy::unwrap_used)]
264    fn test_creation_config_validate_invalid() {
265        let config = CreationConfig {
266            compression_level: Some(0),
267            ..Default::default()
268        };
269        let result = config.validate();
270        assert!(result.is_err());
271        assert!(matches!(
272            result.unwrap_err(),
273            ExtractionError::InvalidCompressionLevel { level: 0 }
274        ));
275
276        let config = CreationConfig {
277            compression_level: Some(10),
278            ..Default::default()
279        };
280        let result = config.validate();
281        assert!(result.is_err());
282        assert!(matches!(
283            result.unwrap_err(),
284            ExtractionError::InvalidCompressionLevel { level: 10 }
285        ));
286    }
287
288    #[test]
289    #[should_panic(expected = "compression level must be 1-9")]
290    fn test_creation_config_builder_invalid_compression() {
291        let _config = CreationConfig::default().with_compression_level(0);
292    }
293
294    #[test]
295    fn test_creation_config_new() {
296        let config = CreationConfig::new();
297        assert_eq!(config.compression_level, Some(6));
298        assert!(config.preserve_permissions);
299    }
300
301    #[test]
302    fn test_creation_config_secure_defaults() {
303        let config = CreationConfig::default();
304
305        // Security: Don't follow symlinks by default
306        assert!(
307            !config.follow_symlinks,
308            "should not follow symlinks by default (security)"
309        );
310
311        // Security: Don't include hidden files by default
312        assert!(
313            !config.include_hidden,
314            "should not include hidden files by default"
315        );
316
317        // Security: Exclude sensitive patterns
318        assert!(
319            config.exclude_patterns.contains(&".git".to_string()),
320            "should exclude .git by default"
321        );
322    }
323}