use std::path::{Component, Path, PathBuf};
pub fn normalize_lexical(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for comp in path.components() {
match comp {
Component::Prefix(_) | Component::RootDir => out.push(comp.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
match out.components().next_back() {
Some(Component::Normal(_)) => {
out.pop();
}
Some(Component::RootDir) | Some(Component::Prefix(_)) => {
}
_ => out.push(".."),
}
}
Component::Normal(seg) => out.push(seg),
}
}
out
}
fn expanduser(path: &Path) -> PathBuf {
let s = match path.to_str() {
Some(s) => s,
None => return path.to_path_buf(),
};
if s == "~" {
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home);
}
return path.to_path_buf();
}
if let Some(rest) = s.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
return Path::new(&home).join(rest);
}
}
path.to_path_buf()
}
pub fn resolve_xdg_first(xdg_value: Option<&str>, subpath: &[&str], fallback: &Path) -> PathBuf {
let trimmed = xdg_value.map(str::trim).filter(|s| !s.is_empty());
let base: PathBuf = match trimmed {
Some(raw) if Path::new(raw).is_absolute() => {
let mut p = PathBuf::from(raw);
for seg in subpath {
p.push(seg);
}
p
}
_ => fallback.to_path_buf(),
};
normalize_lexical(&expanduser(&base))
}
pub fn is_within(path: &Path, parent: &Path) -> bool {
let path = normalize_lexical(path);
let parent = normalize_lexical(parent);
path.strip_prefix(&parent).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_lexical_folds_dot_dotdot() {
assert_eq!(
normalize_lexical(Path::new("/a/b/../c")),
PathBuf::from("/a/c")
);
assert_eq!(
normalize_lexical(Path::new("/a/./b/")),
PathBuf::from("/a/b")
);
assert_eq!(
normalize_lexical(Path::new("/a/../../b")),
PathBuf::from("/b")
); assert_eq!(
normalize_lexical(Path::new("a/b/../c")),
PathBuf::from("a/c")
);
}
#[test]
fn test_is_within_basic() {
assert!(is_within(Path::new("/base/sub/file"), Path::new("/base")));
assert!(is_within(Path::new("/base"), Path::new("/base"))); assert!(is_within(Path::new("/base/./sub"), Path::new("/base")));
}
#[test]
fn test_is_within_rejects_traversal_and_escape() {
assert!(!is_within(Path::new("/base/../secret"), Path::new("/base")));
assert!(!is_within(
Path::new("/base/sub/../../etc"),
Path::new("/base")
));
assert!(!is_within(Path::new("/etc/passwd"), Path::new("/base")));
assert!(!is_within(Path::new("/base2/x"), Path::new("/base")));
}
#[test]
fn test_resolve_xdg_first_prefers_absolute_xdg() {
let fallback = Path::new("/home/u/.local/share/a2c");
let r = resolve_xdg_first(Some("/xdg/data"), &["a2c", "skills"], fallback);
assert_eq!(r, PathBuf::from("/xdg/data/a2c/skills"));
}
#[test]
fn test_resolve_xdg_first_falls_back() {
let fallback = Path::new("/home/u/.local/share/a2c");
assert_eq!(resolve_xdg_first(None, &["x"], fallback), fallback);
assert_eq!(resolve_xdg_first(Some(" "), &["x"], fallback), fallback);
assert_eq!(
resolve_xdg_first(Some("relative/dir"), &["x"], fallback),
fallback
);
}
#[test]
fn test_expanduser_branches() {
assert_eq!(expanduser(Path::new("/abs/x")), PathBuf::from("/abs/x"));
assert_eq!(expanduser(Path::new("rel/x")), PathBuf::from("rel/x"));
assert_eq!(expanduser(Path::new("/x/~/y")), PathBuf::from("/x/~/y"));
if let Ok(home) = std::env::var("HOME") {
assert_eq!(expanduser(Path::new("~")), PathBuf::from(&home));
assert_eq!(
expanduser(Path::new("~/sub/dir")),
Path::new(&home).join("sub/dir")
);
}
}
}