1use crate::error::{CliError, CliResult};
8use std::path::{Component, Path, PathBuf};
9
10const MAX_FILENAME_LENGTH: usize = 255;
12
13const RESERVED_FILENAMES: &[&str] = &[
15 ".", "..", "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7",
16 "com8", "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
17];
18
19pub fn validate_output_path(base_dir: &Path, requested_path: &str) -> CliResult<PathBuf> {
52 if requested_path.contains("..") {
55 return Err(CliError::SecurityViolation {
56 reason: format!("Path traversal detected: '{}'", requested_path),
57 details: "Paths containing '..' are not allowed for security reasons".to_string(),
58 });
59 }
60
61 let requested = PathBuf::from(requested_path);
62
63 if requested.is_absolute() {
65 return Err(CliError::SecurityViolation {
66 reason: format!("Absolute path not allowed: '{}'", requested_path),
67 details: "All output files must use relative paths within the output directory"
68 .to_string(),
69 });
70 }
71
72 for component in requested.components() {
75 if matches!(component, Component::ParentDir) {
76 return Err(CliError::SecurityViolation {
77 reason: format!("Path traversal detected: '{}'", requested_path),
78 details: "Paths containing '..' components are not allowed for security reasons"
79 .to_string(),
80 });
81 }
82 }
83
84 let full_path = base_dir.join(&requested);
86
87 let base_canonical = base_dir.canonicalize().map_err(CliError::Io)?;
89
90 if full_path.exists() {
93 let canonical = full_path.canonicalize().map_err(CliError::Io)?;
94
95 if !canonical.starts_with(&base_canonical) {
97 return Err(CliError::SecurityViolation {
98 reason: format!("Path escapes output directory: '{}'", canonical.display()),
99 details: format!(
100 "Resolved path '{}' is outside base directory '{}'",
101 canonical.display(),
102 base_canonical.display()
103 ),
104 });
105 }
106
107 return Ok(canonical);
108 }
109
110 let relative_to_base =
115 full_path
116 .strip_prefix(base_dir)
117 .map_err(|_| CliError::SecurityViolation {
118 reason: "Internal error: path not relative to base".to_string(),
119 details: "Path validation failed unexpectedly".to_string(),
120 })?;
121
122 Ok(base_canonical.join(relative_to_base))
123}
124
125pub fn sanitize_filename(name: &str) -> CliResult<String> {
155 if name.is_empty() {
156 return Err(CliError::SecurityViolation {
157 reason: "Empty filename".to_string(),
158 details: "Filename cannot be empty".to_string(),
159 });
160 }
161
162 let sanitized: String = name
165 .chars()
166 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
167 .collect();
168
169 if sanitized.is_empty() {
170 return Err(CliError::SecurityViolation {
171 reason: format!("Invalid filename: '{}'", name),
172 details: "Filename must contain at least one alphanumeric character".to_string(),
173 });
174 }
175
176 if sanitized.contains("..") {
179 return Err(CliError::SecurityViolation {
180 reason: format!("Invalid filename pattern: '{}'", sanitized),
181 details: "Filenames containing '..' patterns are not allowed".to_string(),
182 });
183 }
184
185 if sanitized.len() > MAX_FILENAME_LENGTH {
187 return Err(CliError::SecurityViolation {
188 reason: format!("Filename too long: {} characters", sanitized.len()),
189 details: format!(
190 "Filename must be at most {} characters",
191 MAX_FILENAME_LENGTH
192 ),
193 });
194 }
195
196 let lower = sanitized.to_lowercase();
198 if RESERVED_FILENAMES.contains(&lower.as_str()) {
199 return Err(CliError::SecurityViolation {
200 reason: format!("Reserved filename: '{}'", sanitized),
201 details: "This filename is reserved by the operating system".to_string(),
202 });
203 }
204
205 if sanitized.starts_with('.') && sanitized.len() <= 2 {
207 return Err(CliError::SecurityViolation {
208 reason: format!("Invalid filename: '{}'", sanitized),
209 details: "Filenames starting with '.' are not allowed".to_string(),
210 });
211 }
212
213 Ok(sanitized)
214}
215
216pub fn safe_output_path(base_dir: &Path, name: &str, extension: &str) -> CliResult<PathBuf> {
235 let sanitized = sanitize_filename(name)?;
236 let filename = if extension.is_empty() {
237 sanitized
238 } else {
239 format!("{}.{}", sanitized, extension)
240 };
241 validate_output_path(base_dir, &filename)
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use std::fs;
248 use tempfile::TempDir;
249
250 #[test]
251 fn test_sanitize_valid_filenames() {
252 assert_eq!(sanitize_filename("my_tool").unwrap(), "my_tool");
253 assert_eq!(sanitize_filename("tool-123").unwrap(), "tool-123");
254 assert_eq!(sanitize_filename("tool.v1").unwrap(), "tool.v1");
255 assert_eq!(sanitize_filename("Tool_Name_123").unwrap(), "Tool_Name_123");
256 }
257
258 #[test]
259 fn test_sanitize_removes_unsafe_chars() {
260 assert_eq!(sanitize_filename("my/tool").unwrap(), "mytool");
262 assert_eq!(sanitize_filename("my\\tool").unwrap(), "mytool");
263 assert_eq!(sanitize_filename("tool:name").unwrap(), "toolname");
264 assert_eq!(sanitize_filename("tool*name").unwrap(), "toolname");
265 }
266
267 #[test]
268 fn test_sanitize_rejects_reserved_names() {
269 assert!(sanitize_filename(".").is_err());
270 assert!(sanitize_filename("..").is_err());
271 assert!(sanitize_filename("con").is_err());
272 assert!(sanitize_filename("CON").is_err());
273 assert!(sanitize_filename("prn").is_err());
274 assert!(sanitize_filename("aux").is_err());
275 assert!(sanitize_filename("nul").is_err());
276 assert!(sanitize_filename("com1").is_err());
277 assert!(sanitize_filename("lpt1").is_err());
278 }
279
280 #[test]
281 fn test_sanitize_rejects_empty() {
282 assert!(sanitize_filename("").is_err());
283 assert!(sanitize_filename("///").is_err()); assert!(sanitize_filename("***").is_err()); }
286
287 #[test]
288 fn test_validate_accepts_relative_paths() {
289 let temp_dir = TempDir::new().unwrap();
290 let base = temp_dir.path();
291
292 let result = validate_output_path(base, "tool.json");
294 assert!(result.is_ok());
295
296 fs::create_dir_all(base.join("subdir")).unwrap();
298 let result = validate_output_path(base, "subdir/tool.json");
299 assert!(result.is_ok());
300 }
301
302 #[test]
303 fn test_validate_rejects_absolute_paths() {
304 let temp_dir = TempDir::new().unwrap();
305 let base = temp_dir.path();
306
307 assert!(validate_output_path(base, "/etc/passwd").is_err());
308 assert!(validate_output_path(base, "/tmp/evil").is_err());
309
310 #[cfg(windows)]
312 {
313 assert!(validate_output_path(base, "C:\\Windows\\System32").is_err());
314 }
315 }
316
317 #[test]
318 fn test_validate_rejects_parent_directory() {
319 let temp_dir = TempDir::new().unwrap();
320 let base = temp_dir.path();
321
322 assert!(validate_output_path(base, "..").is_err());
323 assert!(validate_output_path(base, "../etc/passwd").is_err());
324 assert!(validate_output_path(base, "../../.ssh/authorized_keys").is_err());
325 assert!(validate_output_path(base, "subdir/../../../etc/passwd").is_err());
326 }
327
328 #[test]
329 fn test_validate_handles_existing_files() {
330 let temp_dir = TempDir::new().unwrap();
331 let base = temp_dir.path();
332
333 let test_file = base.join("test.json");
335 fs::write(&test_file, "{}").unwrap();
336
337 let result = validate_output_path(base, "test.json");
339 assert!(result.is_ok());
340 }
341
342 #[test]
343 fn test_validate_handles_nonexistent_files() {
344 let temp_dir = TempDir::new().unwrap();
345 let base = temp_dir.path();
346
347 let result = validate_output_path(base, "new_file.json");
349 assert!(result.is_ok());
350
351 let result = validate_output_path(base, "newdir/file.json");
353 assert!(result.is_ok());
354 }
355
356 #[test]
357 fn test_safe_output_path_integration() {
358 let temp_dir = TempDir::new().unwrap();
359 let base = temp_dir.path();
360
361 let result = safe_output_path(base, "my_tool", "json");
363 assert!(result.is_ok());
364 assert!(result.unwrap().ends_with("my_tool.json"));
365
366 let result = safe_output_path(base, "../../../etc/passwd", "json");
368 assert!(result.is_err(), "Should reject path traversal attempts");
369 }
370
371 #[test]
372 fn test_comprehensive_attack_scenarios() {
373 let temp_dir = TempDir::new().unwrap();
374 let base = temp_dir.path();
375 let base_canonical = base.canonicalize().unwrap();
377
378 let malicious_inputs = vec![
380 "../../../etc/passwd",
381 "../../.ssh/authorized_keys",
382 "../../../.bash_history",
383 "/etc/shadow",
384 "../../../../../../../../etc/passwd",
385 "..\\..\\..\\windows\\system32",
386 "subdir/../../etc/passwd",
387 ];
388
389 for input in malicious_inputs {
390 let result = validate_output_path(base, input);
392 assert!(
393 result.is_err(),
394 "Should reject malicious path directly: {}",
395 input
396 );
397
398 match sanitize_filename(input) {
402 Ok(sanitized) => {
403 let result = validate_output_path(base, &sanitized);
406 if let Ok(path) = result {
407 assert!(
408 path.starts_with(&base_canonical),
409 "Sanitized path must be within base dir: {} -> {} (base: {})",
410 input,
411 path.display(),
412 base_canonical.display()
413 );
414 }
415 }
416 Err(_) => {
417 }
420 }
421 }
422 }
423}