Skip to main content

lanflare_core/storage/
path.rs

1//! Path sanitation and directory traversal defense.
2
3use std::path::{Path, PathBuf};
4use thiserror::Error;
5
6/// Windows reserved device names that cannot be used as filenames or base stems.
7const 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
13/// Maximum allowed length in bytes for a single filename component.
14pub const MAX_FILENAME_BYTES: usize = 255;
15
16/// Errors arising during filename sanitization, validation, or path resolution.
17#[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
47/// Checks if the given filename or stem matches a reserved Windows device name (case-insensitive).
48///
49/// Windows treats device names as reserved regardless of extension (e.g., `CON`, `con.txt`, `prn.log`).
50pub fn is_reserved_windows_name(name: &str) -> bool {
51    let clean = name.trim();
52    if clean.is_empty() {
53        return false;
54    }
55    // Extract stem before the first dot
56    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
62/// Validates that a string is a safe, single-component filename without any sanitization or modification.
63///
64/// Rejects path separators (`/`, `\`), traversal (`..`, `.`), drive letters, alternate data streams (`:`),
65/// control characters, null bytes, Windows forbidden characters (`<`, `>`, `"`, `|`, `?`, `*`),
66/// trailing spaces/dots, reserved device names, and names exceeding 255 bytes.
67pub 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
105/// Sanitizes an untrusted filename string to produce a safe single-component filename.
106///
107/// Operations performed:
108/// - Rejects null bytes and ASCII control characters.
109/// - Splits on Unix and Windows path separators (`/`, `\`) and extracts the last non-empty basename.
110/// - Rejects pure path traversal components (`.`, `..`).
111/// - Rejects Windows Alternate Data Streams (`:`).
112/// - Neutralizes Windows forbidden characters (`<`, `>`, `"`, `|`, `?`, `*`) by replacing them with `_`.
113/// - Trims trailing whitespace and dots.
114/// - Rejects Windows reserved device names (`CON`, `PRN`, `AUX`, `NUL`, `COM1-9`, `LPT1-9`).
115/// - Enforces the 255-byte component limit.
116pub 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    // Split on path separators to extract the final basename
126    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    // Replace invalid characters and strip control characters
145    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    // Trim trailing spaces and dots
155    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
171/// Sanitizes an untrusted relative path string to produce a safe relative PathBuf.
172///
173/// Prevents path traversal (`..`, `.`, absolute paths, leading slashes), Windows ADS (`:`),
174/// control characters, and reserved device names in any component.
175pub 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        // Replace invalid characters and strip control characters
202        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
234/// Resolves and secures a destination path inside `base_dir` from an untrusted filename.
235///
236/// Ensures the filename is sanitized and that the resolved path does not escape `base_dir`.
237pub 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    // Verify parent is base_dir to strictly forbid jailbreaks
242    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
252/// Resolves a non-colliding destination path inside `base_dir`.
253///
254/// Supports both single files and relative folder structures.
255/// If target already exists in `base_dir`, appends an incremental counter
256/// in the format `stem (1).ext`, `stem (2).ext`, etc.
257pub 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    // Verify target does not escape base_dir
266    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        // Path traversal
311        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        // Windows drive letters & separators
329        assert!(matches!(
330            validate_filename(r"C:\Windows\System32"),
331            Err(PathSanitizeError::AlternateDataStream)
332        ));
333
334        // Windows alternate data streams
335        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        // Control characters & null bytes
345        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        // Forbidden characters
355        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        // Trailing periods and spaces
369        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        // Empty
379        assert!(matches!(
380            validate_filename(""),
381            Err(PathSanitizeError::EmptyFilename)
382        ));
383        assert!(matches!(
384            validate_filename("   "),
385            Err(PathSanitizeError::EmptyFilename)
386        ));
387
388        // Reserved Windows names
389        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        // Directory traversal path neutralization
418        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        // Windows forbidden character substitution
432        assert_eq!(
433            sanitize_filename("file<name>?with*bad|chars.txt").unwrap(),
434            "file_name__with_bad_chars.txt"
435        );
436
437        // Trailing space and dot trimming
438        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        // Pure traversal rejection
448        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        // Alternate data stream rejection
462        assert!(matches!(
463            sanitize_filename("foo.txt:hidden"),
464            Err(PathSanitizeError::AlternateDataStream)
465        ));
466
467        // Reserved device name rejection
468        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        // Null byte rejection
478        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        // Non-reserved names
500        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        // Traversal neutralized to basename
514        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        // Test nested folder structure
535        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}