#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NameOptions {
pub max_bytes: usize,
pub fallback: &'static str,
}
impl Default for NameOptions {
fn default() -> Self {
Self {
max_bytes: 200,
fallback: "download",
}
}
}
pub fn is_windows_reserved_name(name: &str) -> bool {
let stem = name.split('.').next().unwrap_or(name).trim_end_matches(' ');
let upper = stem.to_uppercase();
matches!(
upper.as_str(),
"CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$"
) || device_with_number(&upper, "COM")
|| device_with_number(&upper, "LPT")
}
fn device_with_number(upper: &str, prefix: &str) -> bool {
let Some(rest) = upper.strip_prefix(prefix) else {
return false;
};
let mut chars = rest.chars();
matches!(
(chars.next(), chars.next()),
(Some('1'..='9' | '\u{b9}' | '\u{b2}' | '\u{b3}'), None)
)
}
pub fn is_unsafe_windows_component(name: &str) -> bool {
name.is_empty()
|| name == "."
|| name == ".."
|| name.ends_with(['.', ' '])
|| name.chars().any(|c| {
c < ' '
|| c == '\u{7f}'
|| matches!(c, '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|')
})
|| is_windows_reserved_name(name)
}
fn is_stripped(c: char) -> bool {
c.is_control()
|| matches!(
c,
'\u{ad}'
| '\u{61c}'
| '\u{180e}'
| '\u{200b}'..='\u{200f}'
| '\u{2028}'..='\u{202e}'
| '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{206f}'
| '\u{feff}'
| '\u{fff9}'..='\u{fffb}'
)
}
fn is_replaced(c: char) -> bool {
matches!(c, '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|')
}
pub fn sanitize_file_name(raw: &str) -> String {
sanitize_file_name_with(raw, &NameOptions::default())
}
pub fn sanitize_file_name_with(raw: &str, options: &NameOptions) -> String {
let max = options.max_bytes.clamp(8, 240);
match shape(raw, max) {
Some(name) => name,
None => shape(options.fallback, max).unwrap_or_else(|| "download".to_string()),
}
}
fn shape(raw: &str, max: usize) -> Option<String> {
let last = raw
.rsplit(['/', '\\'])
.find(|s| !s.is_empty())
.unwrap_or("");
let cleaned: String = last
.chars()
.filter(|c| !is_stripped(*c))
.map(|c| if is_replaced(c) { '_' } else { c })
.collect();
let trimmed = cleaned
.trim_start_matches(|c: char| c == '.' || c.is_whitespace())
.trim_end_matches(|c: char| c == '.' || c.is_whitespace());
if trimmed.is_empty() {
return None;
}
let settle = |name: String| -> String {
name.trim_end_matches(|c: char| c == '.' || c.is_whitespace())
.to_string()
};
let mut name = settle(truncate_keeping_extension(trimmed, max));
if name.is_empty() {
return None;
}
if is_windows_reserved_name(&name) {
name.insert(0, '_');
name = settle(truncate_keeping_extension(&name, max));
}
Some(name)
}
fn truncate_keeping_extension(name: &str, max: usize) -> String {
if name.len() <= max {
return name.to_string();
}
let split = name
.rfind('.')
.filter(|i| *i > 0 && name.len() - *i <= 24usize.min(max / 2));
let (stem, ext) = match split {
Some(i) => name.split_at(i),
None => (name, ""),
};
let budget = max - ext.len();
let mut end = stem.len().min(budget);
while !stem.is_char_boundary(end) {
end -= 1;
}
format!("{}{}", &stem[..end], ext)
}
pub fn numbered_candidates(name: &str, count: usize) -> Vec<String> {
let split = name.rfind('.').filter(|i| *i > 0);
let (stem, ext) = match split {
Some(i) => name.split_at(i),
None => (name, ""),
};
(0..count)
.map(|n| {
if n == 0 {
name.to_string()
} else {
format!("{stem} ({n}){ext}")
}
})
.collect()
}
#[cfg(feature = "fs")]
pub const MAX_UNIQUE_TRIES: usize = 10_000;
#[cfg(feature = "fs")]
pub fn create_unique_file(
dir: &std::path::Path,
name: &str,
) -> crate::error::Result<(std::path::PathBuf, std::fs::File)> {
use crate::error::FsError;
if name.contains('\0') || name.contains(['/', '\\']) || is_unsafe_windows_component(name) {
return Err(FsError::BadName(name.to_string()));
}
crate::atomic::reject_reserved_name(name)?;
let mut last = dir.join(name);
for candidate in numbered_candidates(name, MAX_UNIQUE_TRIES) {
let path = dir.join(&candidate);
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(file) => return Ok((path, file)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => last = path,
Err(_) if std::fs::symlink_metadata(&path).is_ok() => last = path,
Err(e) => return Err(FsError::io(&path, e)),
}
}
Err(FsError::AlreadyExists(last))
}
#[cfg(feature = "fs")]
pub fn create_download_file(
dir: &std::path::Path,
raw_name: &str,
) -> crate::error::Result<(std::path::PathBuf, std::fs::File)> {
create_unique_file(dir, &sanitize_file_name(raw_name))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn directory_parts_are_dropped_for_both_separators() {
assert_eq!(sanitize_file_name("../../etc/passwd"), "passwd");
assert_eq!(
sanitize_file_name(r"C:\Windows\System32\cmd.exe"),
"cmd.exe"
);
assert_eq!(sanitize_file_name("a/b\\c/d.txt"), "d.txt");
assert_eq!(sanitize_file_name("/"), "download");
assert_eq!(sanitize_file_name("dir/"), "dir");
assert_eq!(sanitize_file_name(r"\\server\share\f.bin"), "f.bin");
}
#[test]
fn dot_names_and_hidden_files_are_not_produced() {
for raw in [".", "..", "...", " . ", ".. ..", ""] {
assert_eq!(sanitize_file_name(raw), "download", "{raw:?}");
}
assert_eq!(sanitize_file_name(".bashrc"), "bashrc");
assert_eq!(sanitize_file_name("..hidden.txt"), "hidden.txt");
}
#[test]
fn trailing_dots_and_spaces_are_dropped() {
assert_eq!(sanitize_file_name("report.pdf "), "report.pdf");
assert_eq!(sanitize_file_name("report.pdf. . "), "report.pdf");
assert_eq!(sanitize_file_name("a b.txt"), "a b.txt");
}
#[test]
fn invalid_characters_become_underscores() {
assert_eq!(
sanitize_file_name("a:b*c?d\"e<f>g|h.txt"),
"a_b_c_d_e_f_g_h.txt"
);
assert_eq!(sanitize_file_name("file.txt:evil.exe"), "file.txt_evil.exe");
}
#[test]
fn controls_nul_and_bidi_overrides_are_removed() {
assert_eq!(sanitize_file_name("a\0b.txt"), "ab.txt");
assert_eq!(sanitize_file_name("a\u{1}\u{1f}\u{7f}\u{85}b"), "ab");
assert_eq!(
sanitize_file_name("invoice\u{202e}fdp.exe"),
"invoicefdp.exe"
);
for c in [
'\u{200e}', '\u{200f}', '\u{202a}', '\u{202b}', '\u{202c}', '\u{202d}', '\u{2066}',
'\u{2067}', '\u{2068}', '\u{2069}', '\u{061c}', '\u{200b}', '\u{feff}', '\u{2028}',
'\u{2029}',
] {
assert_eq!(
sanitize_file_name(&format!("a{c}b")),
"ab",
"U+{:04X}",
c as u32
);
}
assert_eq!(sanitize_file_name("\u{202e}\u{200b}"), "download");
}
#[test]
fn reserved_windows_names_get_a_prefix() {
for (raw, want) in [
("CON", "_CON"),
("nul.txt", "_nul.txt"),
("Aux.tar.gz", "_Aux.tar.gz"),
("com1", "_com1"),
("COM9.log", "_COM9.log"),
("lpt3.txt", "_lpt3.txt"),
("con .txt", "_con .txt"),
("CONIN$", "_CONIN$"),
("COM\u{b9}", "_COM\u{b9}"),
] {
assert_eq!(sanitize_file_name(raw), want, "{raw:?}");
}
for raw in [
"console.txt",
"com10",
"com0",
"com",
"lpt",
"auxiliary",
"null",
] {
assert_eq!(sanitize_file_name(raw), raw, "{raw:?}");
}
}
#[test]
fn predicate_for_unsafe_windows_components() {
for bad in [
"", ".", "..", "a.", "a ", "a:b", "a\\b", "a/b", "a*", "a?", "a\"", "a<", "a>", "a|",
"con", "NUL.txt", "a\u{1}",
] {
assert!(is_unsafe_windows_component(bad), "{bad:?}");
}
for ok in ["a", "a.txt", "a b", ".hidden", "console", "com10", "é"] {
assert!(!is_unsafe_windows_component(ok), "{ok:?}");
}
}
#[test]
fn long_names_are_cut_on_a_boundary_and_keep_the_extension() {
let long = format!("{}.pdf", "a".repeat(500));
let got = sanitize_file_name(&long);
assert_eq!(got.len(), 200);
assert!(got.ends_with(".pdf"));
let wide = format!("{}.txt", "\u{e9}".repeat(300));
let got = sanitize_file_name(&wide);
assert!(got.len() <= 200 && got.ends_with(".txt"));
assert!(got.is_char_boundary(got.len() - 4));
let bare = "b".repeat(300);
assert_eq!(sanitize_file_name(&bare).len(), 200);
let long_ext = format!("x.{}", "e".repeat(300));
assert!(sanitize_file_name(&long_ext).len() <= 200);
let options = NameOptions {
max_bytes: 12,
fallback: "download",
};
assert_eq!(
sanitize_file_name_with("abcdefghijklmnop.txt", &options),
"abcdefgh.txt"
);
}
#[test]
fn a_cut_that_leaves_a_reserved_name_is_caught() {
let options = NameOptions {
max_bytes: 8,
fallback: "download",
};
let got = sanitize_file_name_with("COM1abcd.txt", &options);
assert!(!is_windows_reserved_name(&got), "{got:?}");
assert!(
got.len() <= 8 && got.ends_with(".txt") || got.starts_with('_'),
"{got:?}"
);
}
#[test]
fn a_cut_that_exposes_a_trailing_dot_is_settled() {
let options = NameOptions {
max_bytes: 8,
fallback: "download",
};
let got = sanitize_file_name_with("abcdefg.hijklmnopqrstuvwxyzabcdefgh", &options);
assert!(!got.ends_with('.') && !got.is_empty(), "{got:?}");
}
#[test]
fn the_fallback_is_used_and_is_itself_sanitised() {
let options = NameOptions {
max_bytes: 200,
fallback: "../x/CON",
};
assert_eq!(sanitize_file_name_with("..", &options), "_CON");
let bad = NameOptions {
max_bytes: 200,
fallback: "",
};
assert_eq!(sanitize_file_name_with("..", &bad), "download");
}
#[test]
fn no_result_ever_holds_a_separator_or_dots_only() {
for raw in [
"a/../b",
"..\\..\\x",
"%2e%2e/x",
"....//",
"x\u{2215}y",
"a\u{ff0f}b",
] {
let got = sanitize_file_name(raw);
assert!(!got.contains(['/', '\\', '\0']), "{raw:?} -> {got:?}");
assert!(got != "." && got != "..", "{raw:?} -> {got:?}");
assert!(!is_unsafe_windows_component(&got), "{raw:?} -> {got:?}");
}
}
#[test]
fn candidates_number_before_the_extension() {
assert_eq!(
numbered_candidates("report.pdf", 3),
["report.pdf", "report (1).pdf", "report (2).pdf"]
);
assert_eq!(numbered_candidates("noext", 2), ["noext", "noext (1)"]);
assert_eq!(
numbered_candidates("a.tar.gz", 2),
["a.tar.gz", "a.tar (1).gz"]
);
assert_eq!(numbered_candidates("x", 0), Vec::<String>::new());
assert_eq!(numbered_candidates(".rc", 2), [".rc", ".rc (1)"]);
}
#[cfg(feature = "fs")]
mod fs_tests {
use super::*;
use crate::error::FsError;
#[test]
fn first_free_name_is_taken_exclusively() {
let dir = tempfile::tempdir().unwrap();
let (a, _fa) = create_unique_file(dir.path(), "f.txt").unwrap();
let (b, _fb) = create_unique_file(dir.path(), "f.txt").unwrap();
let (c, _fc) = create_unique_file(dir.path(), "f.txt").unwrap();
assert_eq!(a, dir.path().join("f.txt"));
assert_eq!(b, dir.path().join("f (1).txt"));
assert_eq!(c, dir.path().join("f (2).txt"));
}
#[test]
fn an_existing_directory_or_file_is_skipped_and_never_written() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("d")).unwrap();
std::fs::write(dir.path().join("d (1)"), b"keep").unwrap();
let (p, _f) = create_unique_file(dir.path(), "d").unwrap();
assert_eq!(p, dir.path().join("d (2)"));
assert_eq!(std::fs::read(dir.path().join("d (1)")).unwrap(), b"keep");
}
#[cfg(unix)]
#[test]
fn a_symlink_at_the_name_is_skipped_not_followed() {
let dir = tempfile::tempdir().unwrap();
let outside = dir.path().join("outside");
std::fs::write(&outside, b"secret").unwrap();
std::os::unix::fs::symlink(&outside, dir.path().join("f")).unwrap();
let (p, _f) = create_unique_file(dir.path(), "f").unwrap();
assert_eq!(p, dir.path().join("f (1)"));
assert_eq!(std::fs::read(&outside).unwrap(), b"secret");
}
#[test]
fn an_unsafe_name_is_refused_before_anything_is_created() {
let dir = tempfile::tempdir().unwrap();
for bad in [
"../x", "a/b", "a\\b", "..", "", "NUL", "a\0b", "x:y", "trail.",
] {
let err = create_unique_file(dir.path(), bad).unwrap_err();
assert!(matches!(err, FsError::BadName(_)), "{bad:?}: {err:?}");
}
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[test]
fn a_temp_shaped_name_is_refused() {
let dir = tempfile::tempdir().unwrap();
assert!(create_unique_file(dir.path(), ".x.rktmp-1-1-0").is_err());
}
#[test]
fn create_download_file_sanitises_first() {
let dir = tempfile::tempdir().unwrap();
let (p, _f) = create_download_file(dir.path(), "../../etc/CON.txt").unwrap();
assert_eq!(p, dir.path().join("_CON.txt"));
assert_eq!(p.parent().unwrap(), dir.path());
}
}
}