1#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub struct NameOptions {
20 pub max_bytes: usize,
23 pub fallback: &'static str,
26}
27
28impl Default for NameOptions {
29 fn default() -> Self {
30 Self {
31 max_bytes: 200,
32 fallback: "download",
33 }
34 }
35}
36
37pub fn is_windows_reserved_name(name: &str) -> bool {
42 let stem = name.split('.').next().unwrap_or(name).trim_end_matches(' ');
43 let upper = stem.to_uppercase();
44 matches!(
45 upper.as_str(),
46 "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$"
47 ) || device_with_number(&upper, "COM")
48 || device_with_number(&upper, "LPT")
49}
50
51fn device_with_number(upper: &str, prefix: &str) -> bool {
52 let Some(rest) = upper.strip_prefix(prefix) else {
53 return false;
54 };
55 let mut chars = rest.chars();
56 matches!(
57 (chars.next(), chars.next()),
58 (Some('1'..='9' | '\u{b9}' | '\u{b2}' | '\u{b3}'), None)
59 )
60}
61
62pub fn is_unsafe_windows_component(name: &str) -> bool {
67 name.is_empty()
68 || name == "."
69 || name == ".."
70 || name.ends_with(['.', ' '])
71 || name.chars().any(|c| {
72 c < ' '
73 || c == '\u{7f}'
74 || matches!(c, '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|')
75 })
76 || is_windows_reserved_name(name)
77}
78
79fn is_stripped(c: char) -> bool {
83 c.is_control()
84 || matches!(
85 c,
86 '\u{ad}'
87 | '\u{61c}'
88 | '\u{180e}'
89 | '\u{200b}'..='\u{200f}'
90 | '\u{2028}'..='\u{202e}'
91 | '\u{2060}'..='\u{2064}'
92 | '\u{2066}'..='\u{206f}'
93 | '\u{feff}'
94 | '\u{fff9}'..='\u{fffb}'
95 )
96}
97
98fn is_replaced(c: char) -> bool {
100 matches!(c, '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|')
101}
102
103pub fn sanitize_file_name(raw: &str) -> String {
105 sanitize_file_name_with(raw, &NameOptions::default())
106}
107
108pub fn sanitize_file_name_with(raw: &str, options: &NameOptions) -> String {
119 let max = options.max_bytes.clamp(8, 240);
120 match shape(raw, max) {
121 Some(name) => name,
122 None => shape(options.fallback, max).unwrap_or_else(|| "download".to_string()),
123 }
124}
125
126fn shape(raw: &str, max: usize) -> Option<String> {
127 let last = raw
128 .rsplit(['/', '\\'])
129 .find(|s| !s.is_empty())
130 .unwrap_or("");
131 let cleaned: String = last
132 .chars()
133 .filter(|c| !is_stripped(*c))
134 .map(|c| if is_replaced(c) { '_' } else { c })
135 .collect();
136 let trimmed = cleaned
137 .trim_start_matches(|c: char| c == '.' || c.is_whitespace())
138 .trim_end_matches(|c: char| c == '.' || c.is_whitespace());
139 if trimmed.is_empty() {
140 return None;
141 }
142 let settle = |name: String| -> String {
143 name.trim_end_matches(|c: char| c == '.' || c.is_whitespace())
145 .to_string()
146 };
147 let mut name = settle(truncate_keeping_extension(trimmed, max));
148 if name.is_empty() {
149 return None;
150 }
151 if is_windows_reserved_name(&name) {
154 name.insert(0, '_');
155 name = settle(truncate_keeping_extension(&name, max));
156 }
157 Some(name)
158}
159
160fn truncate_keeping_extension(name: &str, max: usize) -> String {
163 if name.len() <= max {
164 return name.to_string();
165 }
166 let split = name
168 .rfind('.')
169 .filter(|i| *i > 0 && name.len() - *i <= 24usize.min(max / 2));
170 let (stem, ext) = match split {
171 Some(i) => name.split_at(i),
172 None => (name, ""),
173 };
174 let budget = max - ext.len();
175 let mut end = stem.len().min(budget);
176 while !stem.is_char_boundary(end) {
177 end -= 1;
178 }
179 format!("{}{}", &stem[..end], ext)
180}
181
182pub fn numbered_candidates(name: &str, count: usize) -> Vec<String> {
187 let split = name.rfind('.').filter(|i| *i > 0);
188 let (stem, ext) = match split {
189 Some(i) => name.split_at(i),
190 None => (name, ""),
191 };
192 (0..count)
193 .map(|n| {
194 if n == 0 {
195 name.to_string()
196 } else {
197 format!("{stem} ({n}){ext}")
198 }
199 })
200 .collect()
201}
202
203#[cfg(feature = "fs")]
205pub const MAX_UNIQUE_TRIES: usize = 10_000;
206
207#[cfg(feature = "fs")]
219pub fn create_unique_file(
220 dir: &std::path::Path,
221 name: &str,
222) -> crate::error::Result<(std::path::PathBuf, std::fs::File)> {
223 use crate::error::FsError;
224 if name.contains('\0') || name.contains(['/', '\\']) || is_unsafe_windows_component(name) {
225 return Err(FsError::BadName(name.to_string()));
226 }
227 crate::atomic::reject_reserved_name(name)?;
228 let mut last = dir.join(name);
229 for candidate in numbered_candidates(name, MAX_UNIQUE_TRIES) {
230 let path = dir.join(&candidate);
231 match std::fs::OpenOptions::new()
232 .write(true)
233 .create_new(true)
234 .open(&path)
235 {
236 Ok(file) => return Ok((path, file)),
237 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => last = path,
238 Err(_) if std::fs::symlink_metadata(&path).is_ok() => last = path,
241 Err(e) => return Err(FsError::io(&path, e)),
242 }
243 }
244 Err(FsError::AlreadyExists(last))
245}
246
247#[cfg(feature = "fs")]
250pub fn create_download_file(
251 dir: &std::path::Path,
252 raw_name: &str,
253) -> crate::error::Result<(std::path::PathBuf, std::fs::File)> {
254 create_unique_file(dir, &sanitize_file_name(raw_name))
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn directory_parts_are_dropped_for_both_separators() {
263 assert_eq!(sanitize_file_name("../../etc/passwd"), "passwd");
264 assert_eq!(
265 sanitize_file_name(r"C:\Windows\System32\cmd.exe"),
266 "cmd.exe"
267 );
268 assert_eq!(sanitize_file_name("a/b\\c/d.txt"), "d.txt");
269 assert_eq!(sanitize_file_name("/"), "download");
270 assert_eq!(sanitize_file_name("dir/"), "dir");
271 assert_eq!(sanitize_file_name(r"\\server\share\f.bin"), "f.bin");
272 }
273
274 #[test]
275 fn dot_names_and_hidden_files_are_not_produced() {
276 for raw in [".", "..", "...", " . ", ".. ..", ""] {
277 assert_eq!(sanitize_file_name(raw), "download", "{raw:?}");
278 }
279 assert_eq!(sanitize_file_name(".bashrc"), "bashrc");
280 assert_eq!(sanitize_file_name("..hidden.txt"), "hidden.txt");
281 }
282
283 #[test]
284 fn trailing_dots_and_spaces_are_dropped() {
285 assert_eq!(sanitize_file_name("report.pdf "), "report.pdf");
286 assert_eq!(sanitize_file_name("report.pdf. . "), "report.pdf");
287 assert_eq!(sanitize_file_name("a b.txt"), "a b.txt");
288 }
289
290 #[test]
291 fn invalid_characters_become_underscores() {
292 assert_eq!(
293 sanitize_file_name("a:b*c?d\"e<f>g|h.txt"),
294 "a_b_c_d_e_f_g_h.txt"
295 );
296 assert_eq!(sanitize_file_name("file.txt:evil.exe"), "file.txt_evil.exe");
298 }
299
300 #[test]
301 fn controls_nul_and_bidi_overrides_are_removed() {
302 assert_eq!(sanitize_file_name("a\0b.txt"), "ab.txt");
303 assert_eq!(sanitize_file_name("a\u{1}\u{1f}\u{7f}\u{85}b"), "ab");
304 assert_eq!(
306 sanitize_file_name("invoice\u{202e}fdp.exe"),
307 "invoicefdp.exe"
308 );
309 for c in [
310 '\u{200e}', '\u{200f}', '\u{202a}', '\u{202b}', '\u{202c}', '\u{202d}', '\u{2066}',
311 '\u{2067}', '\u{2068}', '\u{2069}', '\u{061c}', '\u{200b}', '\u{feff}', '\u{2028}',
312 '\u{2029}',
313 ] {
314 assert_eq!(
315 sanitize_file_name(&format!("a{c}b")),
316 "ab",
317 "U+{:04X}",
318 c as u32
319 );
320 }
321 assert_eq!(sanitize_file_name("\u{202e}\u{200b}"), "download");
322 }
323
324 #[test]
325 fn reserved_windows_names_get_a_prefix() {
326 for (raw, want) in [
327 ("CON", "_CON"),
328 ("nul.txt", "_nul.txt"),
329 ("Aux.tar.gz", "_Aux.tar.gz"),
330 ("com1", "_com1"),
331 ("COM9.log", "_COM9.log"),
332 ("lpt3.txt", "_lpt3.txt"),
333 ("con .txt", "_con .txt"),
334 ("CONIN$", "_CONIN$"),
335 ("COM\u{b9}", "_COM\u{b9}"),
336 ] {
337 assert_eq!(sanitize_file_name(raw), want, "{raw:?}");
338 }
339 for raw in [
341 "console.txt",
342 "com10",
343 "com0",
344 "com",
345 "lpt",
346 "auxiliary",
347 "null",
348 ] {
349 assert_eq!(sanitize_file_name(raw), raw, "{raw:?}");
350 }
351 }
352
353 #[test]
354 fn predicate_for_unsafe_windows_components() {
355 for bad in [
356 "", ".", "..", "a.", "a ", "a:b", "a\\b", "a/b", "a*", "a?", "a\"", "a<", "a>", "a|",
357 "con", "NUL.txt", "a\u{1}",
358 ] {
359 assert!(is_unsafe_windows_component(bad), "{bad:?}");
360 }
361 for ok in ["a", "a.txt", "a b", ".hidden", "console", "com10", "é"] {
362 assert!(!is_unsafe_windows_component(ok), "{ok:?}");
363 }
364 }
365
366 #[test]
367 fn long_names_are_cut_on_a_boundary_and_keep_the_extension() {
368 let long = format!("{}.pdf", "a".repeat(500));
369 let got = sanitize_file_name(&long);
370 assert_eq!(got.len(), 200);
371 assert!(got.ends_with(".pdf"));
372 let wide = format!("{}.txt", "\u{e9}".repeat(300));
374 let got = sanitize_file_name(&wide);
375 assert!(got.len() <= 200 && got.ends_with(".txt"));
376 assert!(got.is_char_boundary(got.len() - 4));
377 let bare = "b".repeat(300);
379 assert_eq!(sanitize_file_name(&bare).len(), 200);
380 let long_ext = format!("x.{}", "e".repeat(300));
381 assert!(sanitize_file_name(&long_ext).len() <= 200);
382 let options = NameOptions {
383 max_bytes: 12,
384 fallback: "download",
385 };
386 assert_eq!(
387 sanitize_file_name_with("abcdefghijklmnop.txt", &options),
388 "abcdefgh.txt"
389 );
390 }
391
392 #[test]
393 fn a_cut_that_leaves_a_reserved_name_is_caught() {
394 let options = NameOptions {
395 max_bytes: 8,
396 fallback: "download",
397 };
398 let got = sanitize_file_name_with("COM1abcd.txt", &options);
400 assert!(!is_windows_reserved_name(&got), "{got:?}");
401 assert!(
402 got.len() <= 8 && got.ends_with(".txt") || got.starts_with('_'),
403 "{got:?}"
404 );
405 }
406
407 #[test]
408 fn a_cut_that_exposes_a_trailing_dot_is_settled() {
409 let options = NameOptions {
410 max_bytes: 8,
411 fallback: "download",
412 };
413 let got = sanitize_file_name_with("abcdefg.hijklmnopqrstuvwxyzabcdefgh", &options);
414 assert!(!got.ends_with('.') && !got.is_empty(), "{got:?}");
415 }
416
417 #[test]
418 fn the_fallback_is_used_and_is_itself_sanitised() {
419 let options = NameOptions {
420 max_bytes: 200,
421 fallback: "../x/CON",
422 };
423 assert_eq!(sanitize_file_name_with("..", &options), "_CON");
424 let bad = NameOptions {
425 max_bytes: 200,
426 fallback: "",
427 };
428 assert_eq!(sanitize_file_name_with("..", &bad), "download");
429 }
430
431 #[test]
432 fn no_result_ever_holds_a_separator_or_dots_only() {
433 for raw in [
434 "a/../b",
435 "..\\..\\x",
436 "%2e%2e/x",
437 "....//",
438 "x\u{2215}y",
439 "a\u{ff0f}b",
440 ] {
441 let got = sanitize_file_name(raw);
442 assert!(!got.contains(['/', '\\', '\0']), "{raw:?} -> {got:?}");
443 assert!(got != "." && got != "..", "{raw:?} -> {got:?}");
444 assert!(!is_unsafe_windows_component(&got), "{raw:?} -> {got:?}");
445 }
446 }
447
448 #[test]
449 fn candidates_number_before_the_extension() {
450 assert_eq!(
451 numbered_candidates("report.pdf", 3),
452 ["report.pdf", "report (1).pdf", "report (2).pdf"]
453 );
454 assert_eq!(numbered_candidates("noext", 2), ["noext", "noext (1)"]);
455 assert_eq!(
456 numbered_candidates("a.tar.gz", 2),
457 ["a.tar.gz", "a.tar (1).gz"]
458 );
459 assert_eq!(numbered_candidates("x", 0), Vec::<String>::new());
460 assert_eq!(numbered_candidates(".rc", 2), [".rc", ".rc (1)"]);
461 }
462
463 #[cfg(feature = "fs")]
464 mod fs_tests {
465 use super::*;
466 use crate::error::FsError;
467
468 #[test]
469 fn first_free_name_is_taken_exclusively() {
470 let dir = tempfile::tempdir().unwrap();
471 let (a, _fa) = create_unique_file(dir.path(), "f.txt").unwrap();
472 let (b, _fb) = create_unique_file(dir.path(), "f.txt").unwrap();
473 let (c, _fc) = create_unique_file(dir.path(), "f.txt").unwrap();
474 assert_eq!(a, dir.path().join("f.txt"));
475 assert_eq!(b, dir.path().join("f (1).txt"));
476 assert_eq!(c, dir.path().join("f (2).txt"));
477 }
478
479 #[test]
480 fn an_existing_directory_or_file_is_skipped_and_never_written() {
481 let dir = tempfile::tempdir().unwrap();
482 std::fs::create_dir(dir.path().join("d")).unwrap();
483 std::fs::write(dir.path().join("d (1)"), b"keep").unwrap();
484 let (p, _f) = create_unique_file(dir.path(), "d").unwrap();
485 assert_eq!(p, dir.path().join("d (2)"));
486 assert_eq!(std::fs::read(dir.path().join("d (1)")).unwrap(), b"keep");
487 }
488
489 #[cfg(unix)]
490 #[test]
491 fn a_symlink_at_the_name_is_skipped_not_followed() {
492 let dir = tempfile::tempdir().unwrap();
493 let outside = dir.path().join("outside");
494 std::fs::write(&outside, b"secret").unwrap();
495 std::os::unix::fs::symlink(&outside, dir.path().join("f")).unwrap();
496 let (p, _f) = create_unique_file(dir.path(), "f").unwrap();
497 assert_eq!(p, dir.path().join("f (1)"));
498 assert_eq!(std::fs::read(&outside).unwrap(), b"secret");
499 }
500
501 #[test]
502 fn an_unsafe_name_is_refused_before_anything_is_created() {
503 let dir = tempfile::tempdir().unwrap();
504 for bad in [
505 "../x", "a/b", "a\\b", "..", "", "NUL", "a\0b", "x:y", "trail.",
506 ] {
507 let err = create_unique_file(dir.path(), bad).unwrap_err();
508 assert!(matches!(err, FsError::BadName(_)), "{bad:?}: {err:?}");
509 }
510 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
511 }
512
513 #[test]
514 fn a_temp_shaped_name_is_refused() {
515 let dir = tempfile::tempdir().unwrap();
516 assert!(create_unique_file(dir.path(), ".x.rktmp-1-1-0").is_err());
517 }
518
519 #[test]
520 fn create_download_file_sanitises_first() {
521 let dir = tempfile::tempdir().unwrap();
522 let (p, _f) = create_download_file(dir.path(), "../../etc/CON.txt").unwrap();
523 assert_eq!(p, dir.path().join("_CON.txt"));
524 assert_eq!(p.parent().unwrap(), dir.path());
525 }
526 }
527}