use std::path::{Component, Path, PathBuf};
use crate::error::AppError;
pub fn validate_relative_subpath(p: &Path) -> Result<(), AppError> {
for component in p.components() {
let reason = match component {
Component::Normal(_) | Component::CurDir => continue,
Component::ParentDir => "parent traversal `..` is not allowed",
Component::RootDir => "absolute paths are not allowed",
Component::Prefix(_) => "Windows path prefix is not allowed",
};
return Err(AppError::Config(format!(
"rejected path `{}`: {reason}",
p.display()
)));
}
Ok(())
}
pub fn safe_join(base: &Path, untrusted: impl AsRef<Path>) -> Result<PathBuf, AppError> {
let untrusted = untrusted.as_ref();
validate_relative_subpath(untrusted)?;
Ok(base.join(untrusted))
}
pub fn normalize_lexical(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for comp in p.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() {
out.push(Component::ParentDir);
}
}
other => out.push(other.as_os_str()),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn rejected(p: &str) {
assert!(
validate_relative_subpath(Path::new(p)).is_err(),
"expected `{p}` to be rejected"
);
}
fn accepted(p: &str) {
assert!(
validate_relative_subpath(Path::new(p)).is_ok(),
"expected `{p}` to be accepted"
);
}
#[test]
fn rejects_absolute_unix() {
rejected("/etc/passwd");
rejected("/");
rejected("/home/user/.ssh");
}
#[test]
fn rejects_parent_traversal_at_start() {
rejected("..");
rejected("../escape");
rejected("../../etc/passwd");
}
#[test]
fn rejects_parent_traversal_in_middle() {
rejected("foo/../bar");
rejected("a/b/../../etc");
}
#[test]
fn rejects_parent_traversal_at_end() {
rejected("foo/..");
}
#[test]
fn accepts_empty_path() {
accepted("");
}
#[test]
fn accepts_curdir_only() {
accepted(".");
}
#[test]
fn accepts_simple_relative() {
accepted("foo");
accepted("foo/bar");
accepted("foo/bar/baz");
}
#[test]
fn accepts_dotfile() {
accepted(".claude/skills/foo");
accepted(".skills.toml");
}
#[test]
fn accepts_curdir_interleaved() {
accepted("./foo");
accepted("foo/./bar");
}
#[test]
fn safe_join_combines_when_safe() {
let joined = safe_join(Path::new("/tmp/base"), "subdir/file").unwrap();
assert_eq!(joined, Path::new("/tmp/base/subdir/file"));
}
#[test]
fn safe_join_rejects_absolute_rhs() {
assert!(safe_join(Path::new("/tmp/base"), "/etc/passwd").is_err());
}
#[test]
fn safe_join_rejects_parent_traversal() {
assert!(safe_join(Path::new("/tmp/base"), "../escape").is_err());
assert!(safe_join(Path::new("/tmp/base"), "ok/../escape").is_err());
}
#[test]
fn safe_join_with_empty_returns_base() {
let joined = safe_join(Path::new("/tmp/base"), "").unwrap();
assert_eq!(joined, Path::new("/tmp/base"));
}
#[test]
fn normalize_collapses_curdir() {
assert_eq!(
normalize_lexical(Path::new("./foo/./bar")),
PathBuf::from("foo/bar")
);
}
#[test]
fn normalize_resolves_parent_internally() {
assert_eq!(
normalize_lexical(Path::new("foo/bar/../baz")),
PathBuf::from("foo/baz")
);
}
#[test]
fn normalize_preserves_absolute_root() {
assert_eq!(
normalize_lexical(Path::new("/tmp/foo/../bar")),
PathBuf::from("/tmp/bar")
);
}
#[test]
fn normalize_keeps_excess_parents() {
assert_eq!(
normalize_lexical(Path::new("../escape")),
PathBuf::from("../escape")
);
}
}