use std::fmt;
pub const MAX_ID_LEN: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathSafetyError {
Empty,
TooLong { len: usize, max: usize },
InvalidChar { ch: char, position: usize },
LeadingDot,
}
impl fmt::Display for PathSafetyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PathSafetyError::Empty => write!(f, "identifier is empty"),
PathSafetyError::TooLong { len, max } => {
write!(f, "identifier is {len} bytes (max {max})")
}
PathSafetyError::InvalidChar { ch, position } => write!(
f,
"identifier contains invalid character {ch:?} at position {position} \
(only A-Z, a-z, 0-9, '.', '_', '-' are allowed)"
),
PathSafetyError::LeadingDot => write!(
f,
"identifier may not start with '.' (would be a hidden file \
or parent-directory reference)"
),
}
}
}
impl std::error::Error for PathSafetyError {}
pub fn safe_id(input: &str) -> Result<&str, PathSafetyError> {
if input.is_empty() {
return Err(PathSafetyError::Empty);
}
if input.len() > MAX_ID_LEN {
return Err(PathSafetyError::TooLong {
len: input.len(),
max: MAX_ID_LEN,
});
}
if input.starts_with('.') {
return Err(PathSafetyError::LeadingDot);
}
for (position, ch) in input.chars().enumerate() {
let ok = ch.is_ascii_alphanumeric() || ch == '.' || ch == '_' || ch == '-';
if !ok {
return Err(PathSafetyError::InvalidChar { ch, position });
}
}
Ok(input)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_simple_alphanumeric() {
assert!(safe_id("foo").is_ok());
assert!(safe_id("MyModel123").is_ok());
assert!(safe_id("session-2026-05-17_v2").is_ok());
assert!(safe_id("a.b.c").is_ok());
}
#[test]
fn rejects_empty() {
assert_eq!(safe_id(""), Err(PathSafetyError::Empty));
}
#[test]
fn rejects_path_separators() {
assert!(matches!(
safe_id("foo/bar"),
Err(PathSafetyError::InvalidChar { ch: '/', .. })
));
assert!(matches!(
safe_id("foo\\bar"),
Err(PathSafetyError::InvalidChar { ch: '\\', .. })
));
}
#[test]
fn rejects_parent_directory_traversal() {
assert_eq!(safe_id("."), Err(PathSafetyError::LeadingDot));
assert_eq!(safe_id(".."), Err(PathSafetyError::LeadingDot));
assert_eq!(safe_id(".env"), Err(PathSafetyError::LeadingDot));
assert!(matches!(
safe_id("../../etc/passwd"),
Err(PathSafetyError::LeadingDot)
));
}
#[test]
fn rejects_null_byte() {
assert!(matches!(
safe_id("foo\0bar"),
Err(PathSafetyError::InvalidChar { ch: '\0', .. })
));
}
#[test]
fn rejects_whitespace_and_specials() {
assert!(matches!(
safe_id("foo bar"),
Err(PathSafetyError::InvalidChar { ch: ' ', .. })
));
assert!(matches!(
safe_id("foo;rm -rf /"),
Err(PathSafetyError::InvalidChar { .. })
));
assert!(matches!(
safe_id("foo$bar"),
Err(PathSafetyError::InvalidChar { ch: '$', .. })
));
}
#[test]
fn rejects_non_ascii() {
assert!(matches!(
safe_id("café"),
Err(PathSafetyError::InvalidChar { .. })
));
assert!(matches!(
safe_id("foo\u{FF0F}bar"),
Err(PathSafetyError::InvalidChar { .. })
));
}
#[test]
fn rejects_too_long() {
let too_long = "a".repeat(MAX_ID_LEN + 1);
assert_eq!(
safe_id(&too_long),
Err(PathSafetyError::TooLong {
len: MAX_ID_LEN + 1,
max: MAX_ID_LEN
})
);
}
#[test]
fn boundary_max_len() {
let at_max = "a".repeat(MAX_ID_LEN);
assert!(safe_id(&at_max).is_ok());
}
}