const RESERVED_NAMES: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
pub(super) const MAX_BYTES: usize = 255;
pub fn safe_name(proposed: &str) -> String {
let last_chunk = proposed.rsplit(['/', '\\']).next().unwrap_or(proposed);
let sanitized: String =
last_chunk.chars().filter(|&c| c != ':' && !c.is_control()).collect();
let sanitized = sanitized.trim();
let sanitized = sanitized.trim_end_matches(['.', ' ']);
if sanitized.is_empty() {
return "file".to_string();
}
let sanitized = avoids_reserved_names(sanitized);
let truncated = fits_length(&sanitized);
let truncated = truncated.trim_end_matches(['.', ' ']);
if truncated.is_empty() { "file".to_string() } else { truncated.to_string() }
}
fn avoids_reserved_names(name: &str) -> String {
let (stem, extension) = match name.split_once('.') {
Some((stem, rest)) => (stem, Some(rest)),
None => (name, None),
};
let is_reserved = RESERVED_NAMES.iter().any(|r| r.eq_ignore_ascii_case(stem));
if !is_reserved {
return name.to_string();
}
match extension {
Some(extension) => format!("{stem}_.{extension}"),
None => format!("{stem}_"),
}
}
fn fits_length(name: &str) -> String {
if name.len() <= MAX_BYTES {
return name.to_string();
}
let extension = name.rsplit_once('.').map(|(_, e)| format!(".{e}")).unwrap_or_default();
let extension = if extension.len() < 16 { extension } else { String::new() };
let keep_bytes = MAX_BYTES - extension.len();
let mut stem = String::new();
for c in name.chars() {
if stem.len() + c.len_utf8() > keep_bytes {
break;
}
stem.push(c);
}
format!("{stem}{extension}")
}
#[cfg(test)]
mod tests {
use super::safe_name;
#[test]
fn only_one_path_component_survives() {
let table = [
("report.pdf", "report.pdf"),
("../../etc/passwd", "passwd"),
("/etc/passwd", "passwd"),
("a/b/c.txt", "c.txt"),
(r"C:\Windows\system32\cmd.exe", "cmd.exe"),
("..", "file"),
(".", "file"),
("", "file"),
(" ", "file"),
("a\0b.txt", "ab.txt"),
("a\nb.txt", "ab.txt"),
("..", ".."),
("CON", "CON_"),
("con.txt", "con_.txt"),
("LPT1.log", "LPT1_.log"),
("name.", "name"),
("name ", "name"),
];
for (input, expected) in table {
assert_eq!(safe_name(input), expected, "input: {input:?}");
}
}
#[test]
fn never_exceeds_255_bytes_no_matter_how_long() {
let long_input = "가".repeat(300);
let actual = safe_name(&long_input);
assert!(actual.len() <= 255, "{} bytes", actual.len());
assert!(actual.chars().all(|c| c == '가'));
}
#[test]
fn extension_survives_truncation() {
let long_input = format!("{}.tar.gz", "a".repeat(300));
let actual = safe_name(&long_input);
assert!(actual.len() <= 255);
assert!(actual.ends_with(".gz"), "actual: {actual}");
}
#[test]
fn extension_dropped_for_length_and_cut_lands_on_dot_strips_again() {
let long_input = format!("{}.{}", "a".repeat(254), "b".repeat(20));
let actual = safe_name(&long_input);
assert_eq!(actual, "a".repeat(254), "actual: {actual}");
assert!(!actual.ends_with('.'), "actual: {actual}");
}
#[test]
fn no_extension_and_cut_lands_on_space_strips_again() {
let long_input = format!("{} {}", "a".repeat(254), "b".repeat(20));
let actual = safe_name(&long_input);
assert_eq!(actual, "a".repeat(254), "actual: {actual}");
assert!(!actual.ends_with(' '), "actual: {actual}");
}
#[test]
fn falls_back_to_default_name_when_truncation_leaves_nothing() {
let long_input = format!("{}z.{}", ".".repeat(260), "b".repeat(20));
let actual = safe_name(&long_input);
assert_eq!(actual, "file", "actual: {actual}");
}
}