use std::path::{Component, Path};
pub fn contained_rel_path(raw: &str) -> String {
let unified = raw.trim().replace('\\', "/");
Path::new(&unified)
.components()
.filter_map(|component| match component {
Component::Normal(segment) => {
let text = segment.to_string_lossy();
let text = text.trim();
(!text.is_empty()).then(|| text.to_string())
}
Component::CurDir
| Component::ParentDir
| Component::RootDir
| Component::Prefix(_) => None,
})
.collect::<Vec<_>>()
.join("/")
}
pub fn rel_path_escapes_root(raw: &str) -> bool {
let unified = raw.trim().replace('\\', "/");
if unified.contains('\0') {
return true;
}
Path::new(&unified).components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
}
#[cfg(test)]
mod tests {
use super::{contained_rel_path, rel_path_escapes_root};
#[test]
fn traversal_cannot_survive_normalization() {
for raw in [
"../../../../tmp/evil",
"billing/../../../../tmp/evil",
"/etc/passwd",
"..\\..\\tmp\\evil",
"./././../secret",
] {
let cleaned = contained_rel_path(raw);
assert!(
!cleaned.contains(".."),
"'{raw}' normalized to '{cleaned}', which still traverses"
);
assert!(
!cleaned.starts_with('/'),
"'{raw}' normalized to '{cleaned}', which is absolute"
);
}
}
#[test]
fn ordinary_paths_are_left_alone() {
assert_eq!(
contained_rel_path("pipelines/api/foo.zf.json"),
"pipelines/api/foo.zf.json"
);
assert_eq!(
contained_rel_path("./pipelines/foo.zf.json"),
"pipelines/foo.zf.json"
);
assert_eq!(
contained_rel_path("/pipelines//foo.zf.json"),
"pipelines/foo.zf.json"
);
assert_eq!(contained_rel_path(" spaced/name.txt "), "spaced/name.txt");
assert_eq!(contained_rel_path(""), "");
assert_eq!(contained_rel_path("../.."), "");
}
#[test]
fn a_joined_normalized_path_stays_under_its_root() {
let root = std::path::Path::new("/srv/zebflow/users/alice/shop/repo");
let joined = root.join(contained_rel_path(
"../../../bob/private/repo/src/steal.zf.json",
));
assert!(joined.starts_with(root));
assert!(!joined.to_string_lossy().contains(".."));
}
#[test]
fn escape_detection_matches_normalization() {
assert!(rel_path_escapes_root("../up"));
assert!(rel_path_escapes_root("/absolute"));
assert!(rel_path_escapes_root("a/../../b"));
assert!(rel_path_escapes_root("a\0b"));
assert!(!rel_path_escapes_root("a/b/c.txt"));
assert!(!rel_path_escapes_root("./a/b"));
assert!(!rel_path_escapes_root("a//b/"));
}
}