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}