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/// Resolves and secures a destination path inside `base_dir` from an untrusted filename.
172///
173/// Ensures the filename is sanitized and that the resolved path does not escape `base_dir`.
174pub fn resolve_destination_path(base_dir: &Path, file_name: &str) -> Result<PathBuf, PathSanitizeError> {
175    let sanitized = sanitize_filename(file_name)?;
176    let resolved = base_dir.join(&sanitized);
177
178    // Verify parent is base_dir to strictly forbid jailbreaks
179    if let Some(parent) = resolved.parent()
180        && parent != base_dir
181        && !base_dir.as_os_str().is_empty()
182    {
183        return Err(PathSanitizeError::EscapesBaseDir(resolved.display().to_string()));
184    }
185
186    Ok(resolved)
187}
188
189/// Resolves a non-colliding destination path inside `base_dir`.
190///
191/// If `file_name` already exists in `base_dir`, appends an incremental counter
192/// in the format `stem (1).ext`, `stem (2).ext`, etc.
193pub fn resolve_unique_destination_path(
194    base_dir: &Path,
195    file_name: &str,
196) -> Result<PathBuf, PathSanitizeError> {
197    let sanitized = sanitize_filename(file_name)?;
198    let target = base_dir.join(&sanitized);
199
200    if !target.exists() {
201        return Ok(target);
202    }
203
204    let stem = target
205        .file_stem()
206        .and_then(|s| s.to_str())
207        .unwrap_or("file");
208    let ext = target.extension().and_then(|e| e.to_str());
209
210    for counter in 1..=10_000 {
211        let candidate_name = match ext {
212            Some(extension) => format!("{stem} ({counter}).{extension}"),
213            None => format!("{stem} ({counter})"),
214        };
215        let candidate = base_dir.join(candidate_name);
216        if !candidate.exists() {
217            return Ok(candidate);
218        }
219    }
220
221    Err(PathSanitizeError::CollisionExhausted)
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_validate_filename_valid() {
230        assert!(validate_filename("photo.jpg").is_ok());
231        assert!(validate_filename("my document (1).pdf").is_ok());
232        assert!(validate_filename("archive_2026-10-13.tar.gz").is_ok());
233        assert!(validate_filename("中文测试.png").is_ok());
234        assert!(validate_filename("café_au_lait.txt").is_ok());
235    }
236
237    #[test]
238    fn test_validate_filename_rejections() {
239        // Path traversal
240        assert!(matches!(
241            validate_filename("../../etc/passwd"),
242            Err(PathSanitizeError::PathTraversal(_))
243        ));
244        assert!(matches!(
245            validate_filename(r"..\..\windows\system32"),
246            Err(PathSanitizeError::PathTraversal(_))
247        ));
248        assert!(matches!(
249            validate_filename(".."),
250            Err(PathSanitizeError::PathTraversal(_))
251        ));
252        assert!(matches!(
253            validate_filename("."),
254            Err(PathSanitizeError::PathTraversal(_))
255        ));
256
257        // Windows drive letters & separators
258        assert!(matches!(
259            validate_filename(r"C:\Windows\System32"),
260            Err(PathSanitizeError::AlternateDataStream)
261        ));
262
263        // Windows alternate data streams
264        assert!(matches!(
265            validate_filename("file.txt:stream"),
266            Err(PathSanitizeError::AlternateDataStream)
267        ));
268        assert!(matches!(
269            validate_filename("data.txt::$DATA"),
270            Err(PathSanitizeError::AlternateDataStream)
271        ));
272
273        // Control characters & null bytes
274        assert!(matches!(
275            validate_filename("test\0file.txt"),
276            Err(PathSanitizeError::ContainsControlChar)
277        ));
278        assert!(matches!(
279            validate_filename("line\nbreak.txt"),
280            Err(PathSanitizeError::ContainsControlChar)
281        ));
282
283        // Forbidden characters
284        assert!(matches!(
285            validate_filename("invalid<char>.txt"),
286            Err(PathSanitizeError::ForbiddenCharacter('<'))
287        ));
288        assert!(matches!(
289            validate_filename("what?.txt"),
290            Err(PathSanitizeError::ForbiddenCharacter('?'))
291        ));
292        assert!(matches!(
293            validate_filename("wild*card.txt"),
294            Err(PathSanitizeError::ForbiddenCharacter('*'))
295        ));
296
297        // Trailing periods and spaces
298        assert!(matches!(
299            validate_filename("trailing. "),
300            Err(PathSanitizeError::ForbiddenCharacter(' '))
301        ));
302        assert!(matches!(
303            validate_filename("trailing."),
304            Err(PathSanitizeError::ForbiddenCharacter('.'))
305        ));
306
307        // Empty
308        assert!(matches!(
309            validate_filename(""),
310            Err(PathSanitizeError::EmptyFilename)
311        ));
312        assert!(matches!(
313            validate_filename("   "),
314            Err(PathSanitizeError::EmptyFilename)
315        ));
316
317        // Reserved Windows names
318        assert!(matches!(
319            validate_filename("CON"),
320            Err(PathSanitizeError::ReservedDeviceName(_))
321        ));
322        assert!(matches!(
323            validate_filename("con.txt"),
324            Err(PathSanitizeError::ReservedDeviceName(_))
325        ));
326        assert!(matches!(
327            validate_filename("NUL.dat"),
328            Err(PathSanitizeError::ReservedDeviceName(_))
329        ));
330        assert!(matches!(
331            validate_filename("AUX.tar.gz"),
332            Err(PathSanitizeError::ReservedDeviceName(_))
333        ));
334        assert!(matches!(
335            validate_filename("COM1.exe"),
336            Err(PathSanitizeError::ReservedDeviceName(_))
337        ));
338        assert!(matches!(
339            validate_filename("LPT9"),
340            Err(PathSanitizeError::ReservedDeviceName(_))
341        ));
342    }
343
344    #[test]
345    fn test_sanitize_filename_neutralization() {
346        // Directory traversal path neutralization
347        assert_eq!(
348            sanitize_filename("../../etc/passwd").unwrap(),
349            "passwd"
350        );
351        assert_eq!(
352            sanitize_filename(r"..\..\windows\system32\calc.exe").unwrap(),
353            "calc.exe"
354        );
355        assert_eq!(
356            sanitize_filename("/var/log/lanflare/output.log").unwrap(),
357            "output.log"
358        );
359
360        // Windows forbidden character substitution
361        assert_eq!(
362            sanitize_filename("file<name>?with*bad|chars.txt").unwrap(),
363            "file_name__with_bad_chars.txt"
364        );
365
366        // Trailing space and dot trimming
367        assert_eq!(
368            sanitize_filename("my_file.txt.   ").unwrap(),
369            "my_file.txt"
370        );
371        assert_eq!(
372            sanitize_filename("document....").unwrap(),
373            "document"
374        );
375
376        // Pure traversal rejection
377        assert!(matches!(
378            sanitize_filename("../../"),
379            Err(PathSanitizeError::PathTraversal(_))
380        ));
381        assert!(matches!(
382            sanitize_filename(".."),
383            Err(PathSanitizeError::PathTraversal(_))
384        ));
385        assert!(matches!(
386            sanitize_filename("/"),
387            Err(PathSanitizeError::EmptyFilename)
388        ));
389
390        // Alternate data stream rejection
391        assert!(matches!(
392            sanitize_filename("foo.txt:hidden"),
393            Err(PathSanitizeError::AlternateDataStream)
394        ));
395
396        // Reserved device name rejection
397        assert!(matches!(
398            sanitize_filename("CON.txt"),
399            Err(PathSanitizeError::ReservedDeviceName(_))
400        ));
401        assert!(matches!(
402            sanitize_filename("path/to/prn.log"),
403            Err(PathSanitizeError::ReservedDeviceName(_))
404        ));
405
406        // Null byte rejection
407        assert!(matches!(
408            sanitize_filename("exploit\0.png"),
409            Err(PathSanitizeError::ContainsControlChar)
410        ));
411    }
412
413    #[test]
414    fn test_is_reserved_windows_name() {
415        assert!(is_reserved_windows_name("CON"));
416        assert!(is_reserved_windows_name("con"));
417        assert!(is_reserved_windows_name("con.txt"));
418        assert!(is_reserved_windows_name("PRN"));
419        assert!(is_reserved_windows_name("prn.png"));
420        assert!(is_reserved_windows_name("AUX"));
421        assert!(is_reserved_windows_name("aux.tar.gz"));
422        assert!(is_reserved_windows_name("NUL"));
423        assert!(is_reserved_windows_name("COM1"));
424        assert!(is_reserved_windows_name("com9.dll"));
425        assert!(is_reserved_windows_name("LPT1"));
426        assert!(is_reserved_windows_name("lpt5.doc"));
427
428        // Non-reserved names
429        assert!(!is_reserved_windows_name("contact.txt"));
430        assert!(!is_reserved_windows_name("normal_file.pdf"));
431        assert!(!is_reserved_windows_name("auxiliary.txt"));
432        assert!(!is_reserved_windows_name("printer.jpg"));
433        assert!(!is_reserved_windows_name(""));
434    }
435
436    #[test]
437    fn test_resolve_destination_path() {
438        let base = Path::new("/tmp/lanflare_downloads");
439        let resolved = resolve_destination_path(base, "photo.jpg").unwrap();
440        assert_eq!(resolved, base.join("photo.jpg"));
441
442        // Traversal neutralized to basename
443        let resolved_traversal = resolve_destination_path(base, "../../etc/passwd").unwrap();
444        assert_eq!(resolved_traversal, base.join("passwd"));
445    }
446
447    #[test]
448    fn test_resolve_unique_destination_path() {
449        let temp_dir = std::env::temp_dir().join(format!("lanflare_unique_test_{}", uuid::Uuid::new_v4()));
450        std::fs::create_dir_all(&temp_dir).unwrap();
451
452        let file1 = temp_dir.join("report.pdf");
453        std::fs::write(&file1, b"first").unwrap();
454
455        let unique1 = resolve_unique_destination_path(&temp_dir, "report.pdf").unwrap();
456        assert_eq!(unique1, temp_dir.join("report (1).pdf"));
457
458        std::fs::write(&unique1, b"second").unwrap();
459
460        let unique2 = resolve_unique_destination_path(&temp_dir, "report.pdf").unwrap();
461        assert_eq!(unique2, temp_dir.join("report (2).pdf"));
462
463        let _ = std::fs::remove_dir_all(temp_dir);
464    }
465}