exarch_core/
config.rs

1//! Security configuration for archive extraction.
2
3use std::path::PathBuf;
4
5/// Security configuration with default-deny settings.
6///
7/// This configuration controls various security checks performed during
8/// archive extraction to prevent common vulnerabilities.
9///
10/// # Examples
11///
12/// ```
13/// use exarch_core::SecurityConfig;
14///
15/// // Use secure defaults
16/// let config = SecurityConfig::default();
17///
18/// // Customize for specific needs
19/// let custom = SecurityConfig {
20///     max_file_size: 100 * 1024 * 1024,   // 100 MB
21///     max_total_size: 1024 * 1024 * 1024, // 1 GB
22///     ..Default::default()
23/// };
24/// ```
25#[derive(Debug, Clone)]
26pub struct SecurityConfig {
27    /// Maximum size for a single file in bytes.
28    pub max_file_size: u64,
29
30    /// Maximum total size for all extracted files in bytes.
31    pub max_total_size: u64,
32
33    /// Maximum compression ratio allowed (uncompressed / compressed).
34    pub max_compression_ratio: f64,
35
36    /// Maximum number of files that can be extracted.
37    pub max_file_count: usize,
38
39    /// Maximum path depth allowed.
40    pub max_path_depth: usize,
41
42    /// Allow symlinks in extracted archives.
43    pub allow_symlinks: bool,
44
45    /// Allow hardlinks in extracted archives.
46    pub allow_hardlinks: bool,
47
48    /// Allow absolute paths in archive entries.
49    pub allow_absolute_paths: bool,
50
51    /// Preserve file permissions from archive.
52    pub preserve_permissions: bool,
53
54    /// List of allowed file extensions (empty = allow all).
55    pub allowed_extensions: Vec<String>,
56
57    /// List of banned path components (e.g., ".git", ".ssh").
58    pub banned_path_components: Vec<String>,
59}
60
61impl Default for SecurityConfig {
62    /// Creates a `SecurityConfig` with secure default settings.
63    ///
64    /// Default values:
65    /// - `max_file_size`: 50 MB
66    /// - `max_total_size`: 500 MB
67    /// - `max_compression_ratio`: 100.0
68    /// - `max_file_count`: 10,000
69    /// - `max_path_depth`: 32
70    /// - `allow_symlinks`: false (deny)
71    /// - `allow_hardlinks`: false (deny)
72    /// - `allow_absolute_paths`: false (deny)
73    /// - `preserve_permissions`: false
74    /// - `allowed_extensions`: empty (allow all)
75    /// - `banned_path_components`: `[".git", ".ssh", ".gnupg"]`
76    fn default() -> Self {
77        Self {
78            max_file_size: 50 * 1024 * 1024,   // 50 MB
79            max_total_size: 500 * 1024 * 1024, // 500 MB
80            max_compression_ratio: 100.0,
81            max_file_count: 10_000,
82            max_path_depth: 32,
83            allow_symlinks: false,
84            allow_hardlinks: false,
85            allow_absolute_paths: false,
86            preserve_permissions: false,
87            allowed_extensions: Vec::new(),
88            banned_path_components: vec![
89                ".git".to_string(),
90                ".ssh".to_string(),
91                ".gnupg".to_string(),
92            ],
93        }
94    }
95}
96
97impl SecurityConfig {
98    /// Creates a permissive configuration for trusted archives.
99    ///
100    /// This configuration allows symlinks, hardlinks, and absolute paths.
101    /// Use only when extracting archives from trusted sources.
102    #[must_use]
103    pub fn permissive() -> Self {
104        Self {
105            allow_symlinks: true,
106            allow_hardlinks: true,
107            allow_absolute_paths: true,
108            preserve_permissions: true,
109            max_compression_ratio: 1000.0,
110            banned_path_components: Vec::new(),
111            ..Default::default()
112        }
113    }
114
115    /// Validates whether a path component is allowed.
116    #[must_use]
117    pub fn is_path_component_allowed(&self, component: &str) -> bool {
118        !self.banned_path_components.contains(&component.to_string())
119    }
120
121    /// Validates whether a file extension is allowed.
122    #[must_use]
123    pub fn is_extension_allowed(&self, extension: &str) -> bool {
124        if self.allowed_extensions.is_empty() {
125            return true;
126        }
127        self.allowed_extensions
128            .iter()
129            .any(|ext| ext.eq_ignore_ascii_case(extension))
130    }
131}
132
133/// Newtype wrapper for validated safe paths.
134///
135/// Paths wrapped in `SafePath` have been validated to not contain
136/// path traversal attempts, absolute paths, or banned components.
137#[derive(Debug, Clone, PartialEq, Eq, Hash)]
138pub struct SafePath(PathBuf);
139
140impl SafePath {
141    /// Creates a new `SafePath` after validation.
142    ///
143    /// # Errors
144    ///
145    /// Returns an error if the path contains traversal attempts,
146    /// is absolute, or contains banned components.
147    pub fn new(path: PathBuf, config: &SecurityConfig) -> crate::Result<Self> {
148        use crate::ExtractionError;
149
150        // Check for absolute paths
151        if path.is_absolute() && !config.allow_absolute_paths {
152            return Err(ExtractionError::PathTraversal { path });
153        }
154
155        // Check path depth
156        let depth = path.components().count();
157        if depth > config.max_path_depth {
158            return Err(ExtractionError::SecurityViolation {
159                reason: format!(
160                    "path depth {} exceeds maximum {}",
161                    depth, config.max_path_depth
162                ),
163            });
164        }
165
166        // Check for path traversal and banned components
167        for component in path.components() {
168            let comp_str = component.as_os_str().to_string_lossy();
169
170            if comp_str == ".." {
171                return Err(ExtractionError::PathTraversal { path: path.clone() });
172            }
173
174            if !config.is_path_component_allowed(&comp_str) {
175                return Err(ExtractionError::SecurityViolation {
176                    reason: format!("banned path component: {comp_str}"),
177                });
178            }
179        }
180
181        Ok(Self(path))
182    }
183
184    /// Returns the inner `PathBuf`.
185    #[must_use]
186    pub fn as_path(&self) -> &std::path::Path {
187        &self.0
188    }
189
190    /// Converts into the inner `PathBuf`.
191    #[must_use]
192    pub fn into_path_buf(self) -> PathBuf {
193        self.0
194    }
195}
196
197#[cfg(test)]
198#[allow(clippy::unwrap_used, clippy::field_reassign_with_default)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn test_default_config() {
204        let config = SecurityConfig::default();
205        assert!(!config.allow_symlinks);
206        assert!(!config.allow_hardlinks);
207        assert!(!config.allow_absolute_paths);
208        assert_eq!(config.max_file_size, 50 * 1024 * 1024);
209    }
210
211    #[test]
212    fn test_permissive_config() {
213        let config = SecurityConfig::permissive();
214        assert!(config.allow_symlinks);
215        assert!(config.allow_hardlinks);
216        assert!(config.allow_absolute_paths);
217    }
218
219    #[test]
220    fn test_safe_path_valid() {
221        let config = SecurityConfig::default();
222        let path = PathBuf::from("foo/bar/baz.txt");
223        let safe_path = SafePath::new(path.clone(), &config);
224        assert!(safe_path.is_ok());
225        assert_eq!(safe_path.unwrap().as_path(), path.as_path());
226    }
227
228    #[test]
229    fn test_safe_path_traversal() {
230        let config = SecurityConfig::default();
231        let path = PathBuf::from("../etc/passwd");
232        let result = SafePath::new(path, &config);
233        assert!(matches!(
234            result,
235            Err(crate::ExtractionError::PathTraversal { .. })
236        ));
237    }
238
239    #[test]
240    #[cfg(unix)]
241    fn test_safe_path_absolute_unix() {
242        let config = SecurityConfig::default();
243        let path = PathBuf::from("/etc/passwd");
244        let result = SafePath::new(path, &config);
245        assert!(matches!(
246            result,
247            Err(crate::ExtractionError::PathTraversal { .. })
248        ));
249    }
250
251    #[test]
252    #[cfg(windows)]
253    fn test_safe_path_absolute_windows() {
254        let config = SecurityConfig::default();
255        let path = PathBuf::from("C:\\Windows\\System32\\config");
256        let result = SafePath::new(path, &config);
257        assert!(matches!(
258            result,
259            Err(crate::ExtractionError::PathTraversal { .. })
260        ));
261    }
262
263    #[test]
264    fn test_safe_path_banned_component() {
265        let config = SecurityConfig::default();
266        let path = PathBuf::from("project/.git/config");
267        let result = SafePath::new(path, &config);
268        assert!(matches!(
269            result,
270            Err(crate::ExtractionError::SecurityViolation { .. })
271        ));
272    }
273
274    #[test]
275    fn test_extension_allowed_empty_list() {
276        let config = SecurityConfig::default();
277        assert!(config.is_extension_allowed("txt"));
278        assert!(config.is_extension_allowed("pdf"));
279    }
280
281    #[test]
282    fn test_extension_allowed_with_list() {
283        let mut config = SecurityConfig::default();
284        config.allowed_extensions = vec!["txt".to_string(), "pdf".to_string()];
285        assert!(config.is_extension_allowed("txt"));
286        assert!(config.is_extension_allowed("TXT"));
287        assert!(!config.is_extension_allowed("exe"));
288    }
289
290    #[test]
291    fn test_path_component_allowed() {
292        let config = SecurityConfig::default();
293        assert!(config.is_path_component_allowed("src"));
294        assert!(!config.is_path_component_allowed(".git"));
295        assert!(!config.is_path_component_allowed(".ssh"));
296    }
297}