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}