1use std::path::PathBuf;
4
5#[derive(Debug, Clone)]
26pub struct SecurityConfig {
27 pub max_file_size: u64,
29
30 pub max_total_size: u64,
32
33 pub max_compression_ratio: f64,
35
36 pub max_file_count: usize,
38
39 pub max_path_depth: usize,
41
42 pub allow_symlinks: bool,
44
45 pub allow_hardlinks: bool,
47
48 pub allow_absolute_paths: bool,
50
51 pub preserve_permissions: bool,
53
54 pub allowed_extensions: Vec<String>,
56
57 pub banned_path_components: Vec<String>,
59}
60
61impl Default for SecurityConfig {
62 fn default() -> Self {
77 Self {
78 max_file_size: 50 * 1024 * 1024, max_total_size: 500 * 1024 * 1024, 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 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
138pub struct SafePath(PathBuf);
139
140impl SafePath {
141 pub fn new(path: PathBuf, config: &SecurityConfig) -> crate::Result<Self> {
148 use crate::ExtractionError;
149
150 if path.is_absolute() && !config.allow_absolute_paths {
152 return Err(ExtractionError::PathTraversal { path });
153 }
154
155 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 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 #[must_use]
186 pub fn as_path(&self) -> &std::path::Path {
187 &self.0
188 }
189
190 #[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}