use std::ffi::OsStr;
use std::path::{Component, Path, PathBuf};
#[must_use]
pub fn safe_join(base: &Path, input: &str) -> Option<PathBuf> {
if input.contains('\0') {
return None;
}
let mut stack: Vec<&OsStr> = Vec::new();
for component in Path::new(input).components() {
match component {
Component::CurDir => {},
Component::Normal(segment) => stack.push(segment),
Component::ParentDir => {
stack.pop()?;
},
Component::RootDir | Component::Prefix(_) => return None,
}
}
let mut resolved = base.to_path_buf();
for segment in stack {
resolved.push(segment);
}
Some(resolved)
}
#[must_use]
pub fn is_safe_relative_path(input: &str) -> bool {
safe_join(Path::new(""), input).is_some()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn is_safe_relative_path_accepts_plain_subpaths() {
assert!(is_safe_relative_path("css/custom.css"));
assert!(is_safe_relative_path("js/theme.js"));
assert!(is_safe_relative_path("fonts/roboto/roboto-regular.ttf"));
assert!(is_safe_relative_path("."));
assert!(is_safe_relative_path(""));
}
#[test]
fn is_safe_relative_path_rejects_traversal_and_absolute() {
assert!(!is_safe_relative_path("../secret"));
assert!(!is_safe_relative_path("css/../../etc/passwd"));
assert!(!is_safe_relative_path("/etc/passwd"));
assert!(!is_safe_relative_path("a\0b"));
}
#[test]
fn safe_join_confines_to_base() {
let base = Path::new("/srv/data");
assert_eq!(
safe_join(base, "2026/001/a.json"),
Some(PathBuf::from("/srv/data/2026/001/a.json"))
);
assert_eq!(safe_join(base, "2026/.."), Some(PathBuf::from("/srv/data")));
assert_eq!(safe_join(base, "../escape"), None);
assert_eq!(safe_join(base, "/etc/passwd"), None);
}
}