pub fn safe_component(name: &str) -> bool {
if name.is_empty() || name == "." || name == ".." {
return false;
}
if name.contains('\0') || name.contains('/') {
return false;
}
#[cfg(windows)]
if name.contains('\\') {
return false;
}
let mut comps = std::path::Path::new(name).components();
matches!(
(comps.next(), comps.next()),
(Some(std::path::Component::Normal(_)), None)
)
}
pub fn sanitize_name(s: &str) -> String {
let needs = s
.bytes()
.any(|b| b < 0x20 || b == 0x7f || (0x80..=0x9f).contains(&b) || b == b'\\');
if !needs {
return s.to_string();
}
let mut out = String::with_capacity(s.len() + 8);
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
c if (c as u32) < 0x20 || c as u32 == 0x7f || (0x80..=0x9f).contains(&(c as u32)) => {
use std::fmt::Write as _;
let _ = write!(out, "\\x{:02x}", c as u32);
}
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_component_accepts_normal_names() {
for n in ["file", "a.txt", "weird name", "résumé"] {
assert!(safe_component(n), "{n:?} should be a safe component");
}
#[cfg(not(windows))]
assert!(safe_component("A:ROSE Includes"));
#[cfg(windows)]
assert!(!safe_component("A:ROSE Includes"));
}
#[test]
fn safe_component_rejects_traversal_and_separators() {
for n in [
"",
".",
"..",
"/",
"/etc/passwd",
"../x",
"a/b",
"a/../b",
"with\0nul",
"./x",
] {
assert!(!safe_component(n), "{n:?} should be rejected");
}
}
#[test]
fn sanitize_passes_benign_names() {
for n in ["file.txt", "A:ROSE Includes", "café", "a b c"] {
assert_eq!(sanitize_name(n), n, "{n:?} should pass through");
}
}
#[test]
fn sanitize_escapes_control_bytes() {
assert_eq!(sanitize_name("a\x1b[31mb"), "a\\x1b[31mb");
assert_eq!(sanitize_name("x\x07y"), "x\\x07y");
assert_eq!(sanitize_name("a\nb"), "a\\x0ab");
assert_eq!(sanitize_name("a\x7fb"), "a\\x7fb");
assert_eq!(sanitize_name("a\\b"), "a\\\\b");
}
}