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(_)
)
})
}
pub fn rel_path_names_git_dir(raw: &str) -> bool {
raw.split(['/', '\\'])
.any(|segment| segment.trim().eq_ignore_ascii_case(".git"))
}
pub fn real_path_within(root: &Path, path: &Path) -> bool {
let Ok(real_root) = std::fs::canonicalize(root) else {
return false;
};
let mut probe = path;
loop {
match std::fs::canonicalize(probe) {
Ok(real) => return real.starts_with(&real_root),
Err(_) if probe.symlink_metadata().is_ok() => return false,
Err(_) => match probe.parent() {
Some(parent) => probe = parent,
None => return false,
},
}
}
}
#[cfg(test)]
mod tests {
use super::{contained_rel_path, real_path_within, rel_path_escapes_root, rel_path_names_git_dir};
#[cfg(unix)]
#[test]
fn a_symlink_out_of_the_root_is_outside() {
let base = std::env::temp_dir().join(format!("zf-real-path-{}", std::process::id()));
let root = base.join("root");
let outside = base.join("outside");
std::fs::create_dir_all(root.join("static")).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(outside.join("secret"), "x").unwrap();
std::os::unix::fs::symlink(&outside, root.join("static/out")).unwrap();
std::os::unix::fs::symlink(base.join("missing"), root.join("dangling")).unwrap();
std::fs::write(root.join("static/ok.txt"), "ok").unwrap();
assert!(real_path_within(&root, &root.join("static/ok.txt")));
assert!(real_path_within(&root, &root.join("static/new/file.txt")));
assert!(!real_path_within(&root, &root.join("static/out/secret")));
assert!(!real_path_within(&root, &root.join("static/out/new.txt")));
assert!(!real_path_within(&root, &root.join("dangling")));
assert!(!real_path_within(&root, &root.join("dangling/x")));
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn the_git_directory_is_named_in_any_case_at_any_depth() {
for raw in [".git", ".git/config", "a/.git/hooks/pre-commit", ".GIT/config", "a\\.Git\\x"] {
assert!(rel_path_names_git_dir(raw), "{raw}");
}
for raw in [".github/workflows/ci.yml", ".gitignore", "docs/git.md", "a.git/b"] {
assert!(!rel_path_names_git_dir(raw), "{raw}");
}
}
#[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/"));
}
}