1use std::path::{Path, PathBuf};
4use thiserror::Error;
5
6const WINDOWS_RESERVED_NAMES: &[&str] = &[
8 "CON", "PRN", "AUX", "NUL",
9 "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
10 "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
11];
12
13pub const MAX_FILENAME_BYTES: usize = 255;
15
16#[derive(Debug, Error, PartialEq, Eq, Clone)]
18pub enum PathSanitizeError {
19 #[error("Filename is empty or contains only whitespace")]
20 EmptyFilename,
21
22 #[error("Filename contains null byte or control character")]
23 ContainsControlChar,
24
25 #[error("Path traversal attempted: {0}")]
26 PathTraversal(String),
27
28 #[error("Filename contains Windows alternate data stream separator ':'")]
29 AlternateDataStream,
30
31 #[error("Filename matches Windows reserved device name: {0}")]
32 ReservedDeviceName(String),
33
34 #[error("Filename contains forbidden character: '{0}'")]
35 ForbiddenCharacter(char),
36
37 #[error("Filename exceeds maximum allowed length ({0} bytes > {MAX_FILENAME_BYTES})")]
38 FilenameTooLong(usize),
39
40 #[error("Resolved path escapes the download directory: {0}")]
41 EscapesBaseDir(String),
42
43 #[error("Failed to generate unique destination path after maximum attempts")]
44 CollisionExhausted,
45}
46
47pub fn is_reserved_windows_name(name: &str) -> bool {
51 let clean = name.trim();
52 if clean.is_empty() {
53 return false;
54 }
55 let stem = clean.split('.').next().unwrap_or(clean).trim();
57 WINDOWS_RESERVED_NAMES
58 .iter()
59 .any(|&reserved| reserved.eq_ignore_ascii_case(stem))
60}
61
62pub fn validate_filename(name: &str) -> Result<(), PathSanitizeError> {
68 if name.is_empty() || name.trim().is_empty() {
69 return Err(PathSanitizeError::EmptyFilename);
70 }
71
72 if name.len() > MAX_FILENAME_BYTES {
73 return Err(PathSanitizeError::FilenameTooLong(name.len()));
74 }
75
76 if name.chars().any(|c| c == '\0' || c.is_ascii_control()) {
77 return Err(PathSanitizeError::ContainsControlChar);
78 }
79
80 if name.contains(':') {
81 return Err(PathSanitizeError::AlternateDataStream);
82 }
83
84 if name.contains('/') || name.contains('\\') || name == "." || name == ".." {
85 return Err(PathSanitizeError::PathTraversal(name.to_string()));
86 }
87
88 for c in name.chars() {
89 if matches!(c, '<' | '>' | '"' | '|' | '?' | '*') {
90 return Err(PathSanitizeError::ForbiddenCharacter(c));
91 }
92 }
93
94 if name.ends_with(' ') || name.ends_with('.') {
95 return Err(PathSanitizeError::ForbiddenCharacter(name.chars().last().unwrap()));
96 }
97
98 if is_reserved_windows_name(name) {
99 return Err(PathSanitizeError::ReservedDeviceName(name.to_string()));
100 }
101
102 Ok(())
103}
104
105pub fn sanitize_filename(name: &str) -> Result<String, PathSanitizeError> {
117 if name.is_empty() {
118 return Err(PathSanitizeError::EmptyFilename);
119 }
120
121 if name.contains('\0') {
122 return Err(PathSanitizeError::ContainsControlChar);
123 }
124
125 let parts: Vec<&str> = name
127 .split(['/', '\\'])
128 .filter(|part| !part.is_empty())
129 .collect();
130
131 if parts.is_empty() {
132 return Err(PathSanitizeError::EmptyFilename);
133 }
134
135 let last = parts.last().copied().unwrap_or("");
136 if last == "." || last == ".." {
137 return Err(PathSanitizeError::PathTraversal(name.to_string()));
138 }
139
140 if last.contains(':') {
141 return Err(PathSanitizeError::AlternateDataStream);
142 }
143
144 let filtered: String = last
146 .chars()
147 .filter(|c| !c.is_ascii_control())
148 .map(|c| match c {
149 '<' | '>' | '"' | '|' | '?' | '*' => '_',
150 other => other,
151 })
152 .collect();
153
154 let trimmed = filtered.trim_end_matches([' ', '.']).trim();
156 if trimmed.is_empty() {
157 return Err(PathSanitizeError::EmptyFilename);
158 }
159
160 if is_reserved_windows_name(trimmed) {
161 return Err(PathSanitizeError::ReservedDeviceName(trimmed.to_string()));
162 }
163
164 if trimmed.len() > MAX_FILENAME_BYTES {
165 return Err(PathSanitizeError::FilenameTooLong(trimmed.len()));
166 }
167
168 Ok(trimmed.to_string())
169}
170
171pub fn sanitize_relative_path(path_str: &str) -> Result<PathBuf, PathSanitizeError> {
176 if path_str.is_empty() {
177 return Err(PathSanitizeError::EmptyFilename);
178 }
179 if path_str.contains('\0') {
180 return Err(PathSanitizeError::ContainsControlChar);
181 }
182
183 let raw_parts: Vec<&str> = path_str
184 .split(['/', '\\'])
185 .filter(|part| !part.is_empty())
186 .collect();
187
188 if raw_parts.is_empty() {
189 return Err(PathSanitizeError::EmptyFilename);
190 }
191
192 let mut safe_components = Vec::with_capacity(raw_parts.len());
193 for part in raw_parts {
194 if part == "." || part == ".." {
195 return Err(PathSanitizeError::PathTraversal(path_str.to_string()));
196 }
197 if part.contains(':') {
198 return Err(PathSanitizeError::AlternateDataStream);
199 }
200
201 let filtered: String = part
203 .chars()
204 .filter(|c| !c.is_ascii_control())
205 .map(|c| match c {
206 '<' | '>' | '"' | '|' | '?' | '*' => '_',
207 other => other,
208 })
209 .collect();
210
211 let trimmed = filtered.trim_end_matches([' ', '.']).trim();
212 if trimmed.is_empty() {
213 return Err(PathSanitizeError::EmptyFilename);
214 }
215
216 if is_reserved_windows_name(trimmed) {
217 return Err(PathSanitizeError::ReservedDeviceName(trimmed.to_string()));
218 }
219
220 if trimmed.len() > MAX_FILENAME_BYTES {
221 return Err(PathSanitizeError::FilenameTooLong(trimmed.len()));
222 }
223
224 safe_components.push(trimmed.to_string());
225 }
226
227 let mut res = PathBuf::new();
228 for comp in safe_components {
229 res.push(comp);
230 }
231 Ok(res)
232}
233
234pub fn resolve_destination_path(base_dir: &Path, file_name: &str) -> Result<PathBuf, PathSanitizeError> {
238 let sanitized = sanitize_filename(file_name)?;
239 let resolved = base_dir.join(&sanitized);
240
241 if let Some(parent) = resolved.parent()
243 && parent != base_dir
244 && !base_dir.as_os_str().is_empty()
245 {
246 return Err(PathSanitizeError::EscapesBaseDir(resolved.display().to_string()));
247 }
248
249 Ok(resolved)
250}
251
252pub fn resolve_unique_destination_path(
258 base_dir: &Path,
259 file_name: &str,
260) -> Result<PathBuf, PathSanitizeError> {
261 let sanitized = sanitize_relative_path(file_name)
262 .or_else(|_| sanitize_filename(file_name).map(PathBuf::from))?;
263 let target = base_dir.join(&sanitized);
264
265 if !target.starts_with(base_dir) {
267 return Err(PathSanitizeError::EscapesBaseDir(target.display().to_string()));
268 }
269
270 if !target.exists() {
271 return Ok(target);
272 }
273
274 let parent = target.parent().unwrap_or(base_dir);
275 let stem = target
276 .file_stem()
277 .and_then(|s| s.to_str())
278 .unwrap_or("file");
279 let ext = target.extension().and_then(|e| e.to_str());
280
281 for counter in 1..=10_000 {
282 let candidate_name = match ext {
283 Some(extension) => format!("{stem} ({counter}).{extension}"),
284 None => format!("{stem} ({counter})"),
285 };
286 let candidate = parent.join(candidate_name);
287 if !candidate.exists() {
288 return Ok(candidate);
289 }
290 }
291
292 Err(PathSanitizeError::CollisionExhausted)
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn test_validate_filename_valid() {
301 assert!(validate_filename("photo.jpg").is_ok());
302 assert!(validate_filename("my document (1).pdf").is_ok());
303 assert!(validate_filename("archive_2026-10-13.tar.gz").is_ok());
304 assert!(validate_filename("中文测试.png").is_ok());
305 assert!(validate_filename("café_au_lait.txt").is_ok());
306 }
307
308 #[test]
309 fn test_validate_filename_rejections() {
310 assert!(matches!(
312 validate_filename("../../etc/passwd"),
313 Err(PathSanitizeError::PathTraversal(_))
314 ));
315 assert!(matches!(
316 validate_filename(r"..\..\windows\system32"),
317 Err(PathSanitizeError::PathTraversal(_))
318 ));
319 assert!(matches!(
320 validate_filename(".."),
321 Err(PathSanitizeError::PathTraversal(_))
322 ));
323 assert!(matches!(
324 validate_filename("."),
325 Err(PathSanitizeError::PathTraversal(_))
326 ));
327
328 assert!(matches!(
330 validate_filename(r"C:\Windows\System32"),
331 Err(PathSanitizeError::AlternateDataStream)
332 ));
333
334 assert!(matches!(
336 validate_filename("file.txt:stream"),
337 Err(PathSanitizeError::AlternateDataStream)
338 ));
339 assert!(matches!(
340 validate_filename("data.txt::$DATA"),
341 Err(PathSanitizeError::AlternateDataStream)
342 ));
343
344 assert!(matches!(
346 validate_filename("test\0file.txt"),
347 Err(PathSanitizeError::ContainsControlChar)
348 ));
349 assert!(matches!(
350 validate_filename("line\nbreak.txt"),
351 Err(PathSanitizeError::ContainsControlChar)
352 ));
353
354 assert!(matches!(
356 validate_filename("invalid<char>.txt"),
357 Err(PathSanitizeError::ForbiddenCharacter('<'))
358 ));
359 assert!(matches!(
360 validate_filename("what?.txt"),
361 Err(PathSanitizeError::ForbiddenCharacter('?'))
362 ));
363 assert!(matches!(
364 validate_filename("wild*card.txt"),
365 Err(PathSanitizeError::ForbiddenCharacter('*'))
366 ));
367
368 assert!(matches!(
370 validate_filename("trailing. "),
371 Err(PathSanitizeError::ForbiddenCharacter(' '))
372 ));
373 assert!(matches!(
374 validate_filename("trailing."),
375 Err(PathSanitizeError::ForbiddenCharacter('.'))
376 ));
377
378 assert!(matches!(
380 validate_filename(""),
381 Err(PathSanitizeError::EmptyFilename)
382 ));
383 assert!(matches!(
384 validate_filename(" "),
385 Err(PathSanitizeError::EmptyFilename)
386 ));
387
388 assert!(matches!(
390 validate_filename("CON"),
391 Err(PathSanitizeError::ReservedDeviceName(_))
392 ));
393 assert!(matches!(
394 validate_filename("con.txt"),
395 Err(PathSanitizeError::ReservedDeviceName(_))
396 ));
397 assert!(matches!(
398 validate_filename("NUL.dat"),
399 Err(PathSanitizeError::ReservedDeviceName(_))
400 ));
401 assert!(matches!(
402 validate_filename("AUX.tar.gz"),
403 Err(PathSanitizeError::ReservedDeviceName(_))
404 ));
405 assert!(matches!(
406 validate_filename("COM1.exe"),
407 Err(PathSanitizeError::ReservedDeviceName(_))
408 ));
409 assert!(matches!(
410 validate_filename("LPT9"),
411 Err(PathSanitizeError::ReservedDeviceName(_))
412 ));
413 }
414
415 #[test]
416 fn test_sanitize_filename_neutralization() {
417 assert_eq!(
419 sanitize_filename("../../etc/passwd").unwrap(),
420 "passwd"
421 );
422 assert_eq!(
423 sanitize_filename(r"..\..\windows\system32\calc.exe").unwrap(),
424 "calc.exe"
425 );
426 assert_eq!(
427 sanitize_filename("/var/log/lanflare/output.log").unwrap(),
428 "output.log"
429 );
430
431 assert_eq!(
433 sanitize_filename("file<name>?with*bad|chars.txt").unwrap(),
434 "file_name__with_bad_chars.txt"
435 );
436
437 assert_eq!(
439 sanitize_filename("my_file.txt. ").unwrap(),
440 "my_file.txt"
441 );
442 assert_eq!(
443 sanitize_filename("document....").unwrap(),
444 "document"
445 );
446
447 assert!(matches!(
449 sanitize_filename("../../"),
450 Err(PathSanitizeError::PathTraversal(_))
451 ));
452 assert!(matches!(
453 sanitize_filename(".."),
454 Err(PathSanitizeError::PathTraversal(_))
455 ));
456 assert!(matches!(
457 sanitize_filename("/"),
458 Err(PathSanitizeError::EmptyFilename)
459 ));
460
461 assert!(matches!(
463 sanitize_filename("foo.txt:hidden"),
464 Err(PathSanitizeError::AlternateDataStream)
465 ));
466
467 assert!(matches!(
469 sanitize_filename("CON.txt"),
470 Err(PathSanitizeError::ReservedDeviceName(_))
471 ));
472 assert!(matches!(
473 sanitize_filename("path/to/prn.log"),
474 Err(PathSanitizeError::ReservedDeviceName(_))
475 ));
476
477 assert!(matches!(
479 sanitize_filename("exploit\0.png"),
480 Err(PathSanitizeError::ContainsControlChar)
481 ));
482 }
483
484 #[test]
485 fn test_is_reserved_windows_name() {
486 assert!(is_reserved_windows_name("CON"));
487 assert!(is_reserved_windows_name("con"));
488 assert!(is_reserved_windows_name("con.txt"));
489 assert!(is_reserved_windows_name("PRN"));
490 assert!(is_reserved_windows_name("prn.png"));
491 assert!(is_reserved_windows_name("AUX"));
492 assert!(is_reserved_windows_name("aux.tar.gz"));
493 assert!(is_reserved_windows_name("NUL"));
494 assert!(is_reserved_windows_name("COM1"));
495 assert!(is_reserved_windows_name("com9.dll"));
496 assert!(is_reserved_windows_name("LPT1"));
497 assert!(is_reserved_windows_name("lpt5.doc"));
498
499 assert!(!is_reserved_windows_name("contact.txt"));
501 assert!(!is_reserved_windows_name("normal_file.pdf"));
502 assert!(!is_reserved_windows_name("auxiliary.txt"));
503 assert!(!is_reserved_windows_name("printer.jpg"));
504 assert!(!is_reserved_windows_name(""));
505 }
506
507 #[test]
508 fn test_resolve_destination_path() {
509 let base = Path::new("/tmp/lanflare_downloads");
510 let resolved = resolve_destination_path(base, "photo.jpg").unwrap();
511 assert_eq!(resolved, base.join("photo.jpg"));
512
513 let resolved_traversal = resolve_destination_path(base, "../../etc/passwd").unwrap();
515 assert_eq!(resolved_traversal, base.join("passwd"));
516 }
517
518 #[test]
519 fn test_resolve_unique_destination_path() {
520 let temp_dir = std::env::temp_dir().join(format!("lanflare_unique_test_{}", uuid::Uuid::new_v4()));
521 std::fs::create_dir_all(&temp_dir).unwrap();
522
523 let file1 = temp_dir.join("report.pdf");
524 std::fs::write(&file1, b"first").unwrap();
525
526 let unique1 = resolve_unique_destination_path(&temp_dir, "report.pdf").unwrap();
527 assert_eq!(unique1, temp_dir.join("report (1).pdf"));
528
529 std::fs::write(&unique1, b"second").unwrap();
530
531 let unique2 = resolve_unique_destination_path(&temp_dir, "report.pdf").unwrap();
532 assert_eq!(unique2, temp_dir.join("report (2).pdf"));
533
534 let sub_dir = temp_dir.join("MyFolder").join("Docs");
536 std::fs::create_dir_all(&sub_dir).unwrap();
537 let nested_file = sub_dir.join("notes.txt");
538 std::fs::write(&nested_file, b"nested").unwrap();
539
540 let unique_nested = resolve_unique_destination_path(&temp_dir, "MyFolder/Docs/notes.txt").unwrap();
541 assert_eq!(unique_nested, temp_dir.join("MyFolder/Docs/notes (1).txt"));
542
543 let _ = std::fs::remove_dir_all(temp_dir);
544 }
545
546 #[test]
547 fn test_sanitize_relative_path() {
548 assert_eq!(
549 sanitize_relative_path("Photos/Summer/pic.jpg").unwrap(),
550 PathBuf::from("Photos/Summer/pic.jpg")
551 );
552 assert_eq!(
553 sanitize_relative_path("Projects\\src\\main.rs").unwrap(),
554 PathBuf::from("Projects/src/main.rs")
555 );
556 assert!(matches!(
557 sanitize_relative_path("../escape.txt"),
558 Err(PathSanitizeError::PathTraversal(_))
559 ));
560 assert!(matches!(
561 sanitize_relative_path("a/../../b.txt"),
562 Err(PathSanitizeError::PathTraversal(_))
563 ));
564 assert!(matches!(
565 sanitize_relative_path("folder/CON.txt"),
566 Err(PathSanitizeError::ReservedDeviceName(_))
567 ));
568 }
569}