Skip to main content

rightkit_fs/
names.rs

1//! File names that come from somewhere else: sanitising a download name and picking a free one.
2//!
3//! A name taken from a URL, a `Content-Disposition` header or a mail attachment is attacker
4//! controlled text. [`sanitize_file_name`] turns it into one safe path component that is valid on
5//! Windows, macOS and Linux alike (the rules are the strictest of the three, so a file saved on one
6//! platform can be copied to another): no directory part, no control or bidirectional-override
7//! characters, no reserved Windows device names, no trailing dot or space, no leading dot, bounded
8//! length with the extension kept. It never returns an empty name.
9//!
10//! [`numbered_candidates`] lists `name.ext`, `name (1).ext`, `name (2).ext`, ...; with feature `fs`,
11//! [`create_unique_file`] creates the first free one exclusively (`create_new`, so two processes
12//! never get the same file) and [`create_download_file`] sanitises first.
13//!
14//! The pure functions here need no feature and behave the same on every platform, so a name
15//! sanitised on macOS is identical to the one Windows would produce.
16
17/// How [`sanitize_file_name_with`] shapes a name.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub struct NameOptions {
20    /// Longest result in UTF-8 bytes, clamped to `8..=240`. The default, 200, leaves room for a
21    /// ` (9999)` collision suffix inside the 255-byte component limit of the common filesystems.
22    pub max_bytes: usize,
23    /// Returned when nothing usable is left. Must itself be a safe name; it is sanitised too, and
24    /// `"download"` stands in if it sanitises to nothing.
25    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
37/// True for a name Windows treats as a device, whatever its extension or letter case: `CON`, `PRN`,
38/// `AUX`, `NUL`, `COM1`-`COM9`, `LPT1`-`LPT9` (also with the superscript digits `¹ ² ³` that
39/// Windows maps to the same devices), `CONIN$` and `CONOUT$`. The part before the first `.` decides:
40/// `nul.txt` and `NUL.tar.gz` are reserved, and so is `con ` (trailing spaces are dropped).
41pub 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
62/// True when `name` cannot be used as one path component on Windows: empty, `.` or `..`, a reserved
63/// device name ([`is_windows_reserved_name`]), a trailing dot or space (Windows drops it, so the name
64/// would address a different entry), or any of `\ / : * ? " < > |` or a control character (a `:`
65/// names an NTFS alternate data stream). Used by the containment checks for names on Windows.
66pub 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
79/// Characters removed outright: C0 and C1 controls, the Unicode line and paragraph separators,
80/// zero-width and invisible formatting characters, and every bidirectional override, embedding and
81/// isolate (the classic `invoice\u{202e}fdp.exe` spoof).
82fn 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
98/// Characters that cannot appear in a Windows file name, plus the path separators. Replaced by `_`.
99fn is_replaced(c: char) -> bool {
100    matches!(c, '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|')
101}
102
103/// [`sanitize_file_name_with`] with [`NameOptions::default`].
104pub fn sanitize_file_name(raw: &str) -> String {
105    sanitize_file_name_with(raw, &NameOptions::default())
106}
107
108/// Turn `raw` into one safe, portable file name.
109///
110/// In order: only the last non-empty segment between `/` and `\` is kept (a name that is only
111/// separators has none); control, invisible and bidirectional-control characters are removed; `: * ? " < > |` become
112/// `_`; leading dots and whitespace are dropped (no hidden file, no `.` or `..`); trailing dots and
113/// whitespace are dropped (Windows would drop them silently); a reserved Windows device name
114/// ([`is_windows_reserved_name`]) gets a `_` prefix; the result is cut to `options.max_bytes` at a
115/// character boundary, keeping the extension (up to 24 bytes of it) when there is one; and an empty
116/// result is the fallback name. The result is never empty, never `.` or `..`, and contains no
117/// separator, so joining it to a directory cannot leave that directory.
118pub 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        // A cut can expose a trailing dot or space.
144        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    // After the cut, so a cut that leaves exactly `COM1.txt` is still caught. The prefix makes the
152    // stem start with `_`, which no reserved name does, and a second cut keeps that prefix.
153    if is_windows_reserved_name(&name) {
154        name.insert(0, '_');
155        name = settle(truncate_keeping_extension(&name, max));
156    }
157    Some(name)
158}
159
160/// Cut `name` to at most `max` bytes at a character boundary. When it has an extension of at most 24
161/// bytes (the part after the last `.`, with the dot), the stem is shortened and the extension kept.
162fn truncate_keeping_extension(name: &str, max: usize) -> String {
163    if name.len() <= max {
164        return name.to_string();
165    }
166    // An extension is kept only when it is short and leaves at least half the budget to the stem.
167    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
182/// `name`, then `stem (1).ext`, `stem (2).ext`, ... up to `count` names in all (the first is
183/// `name` itself). The number goes before the last extension; a name with no extension, or a
184/// leading-dot name, takes it at the end. `name` should already be safe ([`sanitize_file_name`]).
185/// A `count` of zero gives an empty list.
186pub 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/// The most names [`create_unique_file`] tries before giving up with `AlreadyExists`.
204#[cfg(feature = "fs")]
205pub const MAX_UNIQUE_TRIES: usize = 10_000;
206
207/// Exclusively create the first free name of [`numbered_candidates`]`(name, MAX_UNIQUE_TRIES)` in
208/// `dir` and return its path and the open file (write-only).
209///
210/// `create_new` is the atomic claim, so two processes or threads asking for the same name get
211/// different files, and a name that exists as a file, directory or symlink (a link is never
212/// followed) is skipped. The file is created with the platform's default permissions (for a download
213/// the user will open; use [`crate::create_private_new`] for a secret). `name` must be a single safe
214/// component: anything with a separator, `..`, NUL or an unsafe Windows name is refused as
215/// [`crate::FsError::BadName`], so a caller that skipped [`sanitize_file_name`] cannot write outside
216/// `dir`. Every other error ends the search. When every candidate exists the result is
217/// [`crate::FsError::AlreadyExists`].
218#[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            // Windows refuses `create_new` on an existing directory with access denied, not
239            // "already exists". A name that is taken by anything (a directory, a link) is skipped.
240            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/// [`sanitize_file_name`] then [`create_unique_file`]: the one call for "save this download under
248/// the name it asked for, or the next free one".
249#[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        // An NTFS alternate data stream name cannot be smuggled in.
297        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        // `invoice` + RLO + `fdp.exe` would render as `invoiceexe.pdf`.
305        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        // Names that merely start like a device are untouched.
340        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        // A multi-byte character straddling the cut is dropped whole.
373        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        // No extension: a plain cut. A huge "extension" is not preserved.
378        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        // `COM1abcd.txt` cut to 8 bytes keeping `.txt` is `COM1.txt`: a device name.
399        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}