#[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))
}