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 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 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
189pub 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 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 assert!(matches!(
259 validate_filename(r"C:\Windows\System32"),
260 Err(PathSanitizeError::AlternateDataStream)
261 ));
262
263 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 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 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 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 assert!(matches!(
309 validate_filename(""),
310 Err(PathSanitizeError::EmptyFilename)
311 ));
312 assert!(matches!(
313 validate_filename(" "),
314 Err(PathSanitizeError::EmptyFilename)
315 ));
316
317 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 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 assert_eq!(
362 sanitize_filename("file<name>?with*bad|chars.txt").unwrap(),
363 "file_name__with_bad_chars.txt"
364 );
365
366 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 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 assert!(matches!(
392 sanitize_filename("foo.txt:hidden"),
393 Err(PathSanitizeError::AlternateDataStream)
394 ));
395
396 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 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 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 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}