use std::fmt;
use std::path::{Component, Path, PathBuf};
const ILLEGAL_FILENAME_CHARS: &[char] = &[':', '?', '*', '"', '>', '<', '|'];
const WINDOWS_RESERVED_NAMES: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
"COM8", "COM9", "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
const MAX_COMPONENT_BYTES: usize = 255;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathSafetyError {
Empty,
NotRelative,
Traversal,
Separator,
IllegalCharacter(char),
ReservedName,
TrailingDotOrSpace,
TooLong,
}
impl fmt::Display for PathSafetyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PathSafetyError::Empty => write!(f, "path is empty"),
PathSafetyError::NotRelative => {
write!(f, "path must be relative (no leading '/' or drive prefix)")
}
PathSafetyError::Traversal => {
write!(f, "path escapes its directory via '..'")
}
PathSafetyError::Separator => {
write!(f, "name must not contain a path separator ('/' or '\\')")
}
PathSafetyError::IllegalCharacter(c) => {
write!(f, "name contains {c:?}, which is not portable in filenames")
}
PathSafetyError::ReservedName => {
write!(f, "name is reserved for a device on Windows")
}
PathSafetyError::TrailingDotOrSpace => {
write!(f, "name must not end with '.' or a space")
}
PathSafetyError::TooLong => {
write!(f, "name exceeds {MAX_COMPONENT_BYTES} bytes")
}
}
}
}
impl std::error::Error for PathSafetyError {}
pub fn validate_component(name: &str) -> Result<(), PathSafetyError> {
if name.is_empty() {
return Err(PathSafetyError::Empty);
}
if name.contains('/') || name.contains('\\') {
return Err(PathSafetyError::Separator);
}
if name == "." || name == ".." {
return Err(PathSafetyError::Traversal);
}
if let Some(c) = name
.chars()
.find(|c| c.is_control() || ILLEGAL_FILENAME_CHARS.contains(c))
{
return Err(PathSafetyError::IllegalCharacter(c));
}
let stem = name.split('.').next().unwrap_or(name);
if WINDOWS_RESERVED_NAMES
.iter()
.any(|r| r.eq_ignore_ascii_case(stem))
{
return Err(PathSafetyError::ReservedName);
}
if name.ends_with(['.', ' ']) {
return Err(PathSafetyError::TrailingDotOrSpace);
}
if name.len() > MAX_COMPONENT_BYTES {
return Err(PathSafetyError::TooLong);
}
let mut components = Path::new(name).components();
match (components.next(), components.next()) {
(Some(Component::Normal(_)), None) => Ok(()),
_ => Err(PathSafetyError::NotRelative),
}
}
#[must_use]
pub fn is_safe_component(name: &str) -> bool {
validate_component(name).is_ok()
}
pub fn safe_join(base: &Path, name: &str) -> Result<PathBuf, PathSafetyError> {
validate_component(name)?;
Ok(base.join(name))
}
pub fn confine(root: &Path, candidate: &Path) -> Result<PathBuf, PathSafetyError> {
if candidate.as_os_str().is_empty() {
return Err(PathSafetyError::Empty);
}
if candidate.is_absolute() {
return Err(PathSafetyError::NotRelative);
}
let mut relative = PathBuf::new();
let mut depth: usize = 0;
for component in candidate.components() {
match component {
Component::Normal(segment) => {
relative.push(segment);
depth = depth.saturating_add(1);
}
Component::CurDir => {}
Component::ParentDir => {
if depth == 0 {
return Err(PathSafetyError::Traversal);
}
relative.pop();
depth = depth.saturating_sub(1);
}
Component::RootDir | Component::Prefix(_) => {
return Err(PathSafetyError::NotRelative);
}
}
}
Ok(root.join(relative))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_plain_names() {
assert!(is_safe_component("wrapped_key"));
assert!(is_safe_component("root_ca_cert.pem"));
assert!(is_safe_component("share-1"));
assert!(is_safe_component("résumé"));
assert!(is_safe_component("semi;colon.js"));
assert!(is_safe_component("singlequote'.js"));
assert!(is_safe_component("plus+.js"));
assert!(is_safe_component(" space at front"));
assert!(is_safe_component(".period"));
}
#[test]
fn rejects_separators_and_traversal_in_components() {
assert_eq!(
validate_component("../etc/passwd"),
Err(PathSafetyError::Separator)
);
assert_eq!(validate_component("a/b"), Err(PathSafetyError::Separator));
assert_eq!(validate_component("a\\b"), Err(PathSafetyError::Separator));
assert_eq!(validate_component(".."), Err(PathSafetyError::Traversal));
assert_eq!(validate_component("."), Err(PathSafetyError::Traversal));
assert_eq!(validate_component(""), Err(PathSafetyError::Empty));
}
#[test]
fn rejects_absolute_component() {
assert_eq!(validate_component("/abs"), Err(PathSafetyError::Separator));
}
#[test]
fn rejects_windows_illegal_characters() {
assert_eq!(
validate_component("key.pem:hidden"),
Err(PathSafetyError::IllegalCharacter(':'))
);
assert_eq!(
validate_component("C:"),
Err(PathSafetyError::IllegalCharacter(':'))
);
for name in [
"col:on.js",
"star*.js",
"question?.js",
"quote\".js",
"brack<e>ts.js",
"p|pes.js",
] {
assert!(
matches!(
validate_component(name),
Err(PathSafetyError::IllegalCharacter(_))
),
"{name:?} should be rejected"
);
}
}
#[test]
fn rejects_control_characters() {
assert_eq!(
validate_component("hello\u{0000}world"),
Err(PathSafetyError::IllegalCharacter('\u{0000}'))
);
assert_eq!(
validate_component("hello\nworld"),
Err(PathSafetyError::IllegalCharacter('\n'))
);
assert_eq!(
validate_component("key\u{001b}[2K.pem"),
Err(PathSafetyError::IllegalCharacter('\u{001b}'))
);
assert_eq!(
validate_component("a\u{0085}b"),
Err(PathSafetyError::IllegalCharacter('\u{0085}'))
);
}
#[test]
fn rejects_windows_reserved_device_names() {
for name in ["CON", "con", "NUL", "nul.txt", "LPT9.asdf", "COM1.tar.gz"] {
assert_eq!(
validate_component(name),
Err(PathSafetyError::ReservedName),
"{name:?} should be rejected"
);
}
assert!(is_safe_component("CONSOLE"));
assert!(is_safe_component("null.txt"));
assert!(is_safe_component("com.example"));
assert!(is_safe_component("communication"));
}
#[test]
fn rejects_trailing_dot_or_space() {
assert_eq!(
validate_component("period."),
Err(PathSafetyError::TrailingDotOrSpace)
);
assert_eq!(
validate_component("space at end "),
Err(PathSafetyError::TrailingDotOrSpace)
);
assert_eq!(
validate_component("foobar..."),
Err(PathSafetyError::TrailingDotOrSpace)
);
}
#[test]
fn rejects_overlong_names() {
let long = "a".repeat(300);
assert_eq!(validate_component(&long), Err(PathSafetyError::TooLong));
let max = "a".repeat(255);
assert!(is_safe_component(&max));
}
#[test]
fn safe_join_confines_to_base() {
let base = Path::new("/runs/c/artifacts");
assert_eq!(
safe_join(base, "key.pem").unwrap(),
PathBuf::from("/runs/c/artifacts/key.pem")
);
assert!(safe_join(base, "../../etc/passwd").is_err());
assert!(safe_join(base, "/etc/passwd").is_err());
}
#[test]
fn confine_allows_subpaths() {
let root = Path::new("/ceremony");
assert_eq!(
confine(root, Path::new("keys/root.pem")).unwrap(),
PathBuf::from("/ceremony/keys/root.pem")
);
assert_eq!(
confine(root, Path::new("keys/../keys/root.pem")).unwrap(),
PathBuf::from("/ceremony/keys/root.pem")
);
assert_eq!(
confine(root, Path::new("./root.pem")).unwrap(),
PathBuf::from("/ceremony/root.pem")
);
}
#[test]
fn confine_normalizes_like_path_clean() {
let root = Path::new("/ceremony");
assert_eq!(
confine(root, Path::new("path//to///thing")).unwrap(),
PathBuf::from("/ceremony/path/to/thing")
);
assert_eq!(
confine(root, Path::new("./test/./path")).unwrap(),
PathBuf::from("/ceremony/test/path")
);
assert_eq!(
confine(root, Path::new("test/path/.")).unwrap(),
PathBuf::from("/ceremony/test/path")
);
assert_eq!(
confine(root, Path::new("test/path/")).unwrap(),
PathBuf::from("/ceremony/test/path")
);
assert_eq!(
confine(root, Path::new("test/path/../../")).unwrap(),
PathBuf::from("/ceremony")
);
assert_eq!(
confine(root, Path::new(".")).unwrap(),
PathBuf::from("/ceremony")
);
}
#[test]
fn confine_rejects_escapes_absolute_and_empty() {
let root = Path::new("/ceremony");
assert_eq!(
confine(root, Path::new("../secret")),
Err(PathSafetyError::Traversal)
);
assert_eq!(
confine(root, Path::new("keys/../../secret")),
Err(PathSafetyError::Traversal)
);
assert_eq!(
confine(root, Path::new("../ceremony/keys")),
Err(PathSafetyError::Traversal)
);
assert_eq!(
confine(root, Path::new("/etc/passwd")),
Err(PathSafetyError::NotRelative)
);
assert_eq!(confine(root, Path::new("")), Err(PathSafetyError::Empty));
}
}