use std::path::{Path, PathBuf};
pub(crate) fn resolve_real(path: &Path) -> Option<PathBuf> {
let normalized = crate::tools::normalize(path)?;
let mut existing = normalized.clone();
let mut tail: Vec<std::ffi::OsString> = Vec::new();
loop {
if existing.exists() {
break;
}
let name = existing.file_name().map(|n| n.to_os_string())?;
tail.push(name);
if !existing.pop() {
return None;
}
}
let real_existing = std::fs::canonicalize(&existing).ok()?;
let mut real = real_existing;
for name in tail.into_iter().rev() {
real.push(name);
}
Some(real)
}
pub(crate) fn contained(root: &Path, path: &Path) -> bool {
let Ok(real_root) = std::fs::canonicalize(root) else {
return false;
};
let Some(real) = resolve_real(path) else {
return false;
};
real.starts_with(&real_root)
}
pub(crate) fn normalized_project_rel(root: &Path, path: &Path) -> Option<String> {
let normalized_root = crate::tools::normalize(root)?;
let normalized_path = crate::tools::normalize(path)?;
let rel = normalized_path.strip_prefix(&normalized_root).ok()?;
Some(rel.to_string_lossy().replace('\\', "/"))
}
pub(crate) fn resolved_project_rel(root: &Path, path: &Path) -> Option<String> {
let real_root = std::fs::canonicalize(root).ok()?;
let real = resolve_real(path)?;
let rel = real.strip_prefix(&real_root).ok()?;
Some(rel.to_string_lossy().replace('\\', "/"))
}
pub(crate) fn reject_unsafe_rel_path(rel: &str) -> Option<String> {
if rel.is_empty() {
return Some("refused: escapes the project root (empty path)".to_string());
}
let p = Path::new(rel);
if p.is_absolute() {
return Some("refused: escapes the project root (absolute path)".to_string());
}
let unsafe_component = p.components().any(|c| {
matches!(
c,
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_)
)
});
if unsafe_component {
return Some("refused: escapes the project root (unsafe path component)".to_string());
}
None
}
pub(crate) fn is_protected(rel: &str, extra_globs: &[String]) -> bool {
if rel == ".git" || rel.starts_with(".git/") {
return true;
}
extra_globs
.iter()
.any(|g| crate::config::glob_match(g, rel))
}
pub(crate) enum PathForMatching {
Inside {
lexical_rel: String,
resolved_rel: String,
},
Outside,
Unsafe(&'static str),
}
pub(crate) fn resolve_for_matching(root: &Path, raw: &str) -> PathForMatching {
let candidate = if Path::new(raw).is_absolute() {
PathBuf::from(raw)
} else {
root.join(raw)
};
let Some(normalized_root) = crate::tools::normalize(root) else {
return PathForMatching::Unsafe("root does not resolve");
};
let Some(normalized_candidate) = crate::tools::normalize(&candidate) else {
return PathForMatching::Unsafe("path does not resolve");
};
let lexical_rel = match normalized_candidate.strip_prefix(&normalized_root) {
Ok(rel) => rel.to_string_lossy().replace('\\', "/"),
Err(_) => return PathForMatching::Outside,
};
let Some(real_root) = resolve_real(root) else {
return PathForMatching::Unsafe("root symlink resolution failed");
};
let Some(real_candidate) = resolve_real(&candidate) else {
return PathForMatching::Unsafe("path symlink resolution failed");
};
let resolved_rel = match real_candidate.strip_prefix(&real_root) {
Ok(rel) => rel.to_string_lossy().replace('\\', "/"),
Err(_) => {
return PathForMatching::Unsafe("resolves outside root (symlink escape)");
}
};
PathForMatching::Inside {
lexical_rel,
resolved_rel,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"supercode-safepath-test-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn resolve_for_matching_collapses_dot_dot_traversal_lexically() {
let root = tmp("traversal");
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::write(root.join(".git").join("config"), "real").unwrap();
match resolve_for_matching(&root, "x/../.git/config") {
PathForMatching::Inside {
lexical_rel,
resolved_rel,
} => {
assert_eq!(lexical_rel, ".git/config");
assert_eq!(resolved_rel, ".git/config");
}
_ => panic!("expected Inside"),
}
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn resolve_for_matching_clean_relative_path_round_trips() {
let root = tmp("clean");
std::fs::create_dir_all(root.join("src")).unwrap();
match resolve_for_matching(&root, "src/main.rs") {
PathForMatching::Inside {
lexical_rel,
resolved_rel,
} => {
assert_eq!(lexical_rel, "src/main.rs");
assert_eq!(resolved_rel, "src/main.rs");
}
_ => panic!("expected Inside"),
}
std::fs::remove_dir_all(&root).ok();
}
#[test]
#[cfg(unix)]
fn resolve_for_matching_symlink_escape_into_dot_git_is_unsafe() {
let root = tmp("symlink");
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::write(root.join(".git").join("config"), "real").unwrap();
std::os::unix::fs::symlink(root.join(".git"), root.join("foo")).unwrap();
match resolve_for_matching(&root, "foo/config") {
PathForMatching::Inside {
lexical_rel,
resolved_rel,
} => {
assert_eq!(lexical_rel, "foo/config");
assert_eq!(
resolved_rel, ".git/config",
"the RESOLVED form must reveal the real symlink target"
);
}
_ => panic!("expected Inside with a divergent resolved_rel"),
}
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn resolve_for_matching_absolute_path_outside_root_is_outside() {
let root = tmp("outside-root");
let elsewhere = tmp("outside-elsewhere");
let outside_path = elsewhere.join("scratch.txt");
assert!(matches!(
resolve_for_matching(&root, outside_path.to_str().unwrap()),
PathForMatching::Outside
));
std::fs::remove_dir_all(&root).ok();
std::fs::remove_dir_all(&elsewhere).ok();
}
#[test]
fn contained_rejects_symlink_escape() {
let root = tmp("contained-symlink-root");
let outside = tmp("contained-symlink-outside");
std::fs::write(outside.join("secret.txt"), "s").unwrap();
#[cfg(unix)]
{
std::os::unix::fs::symlink(outside.join("secret.txt"), root.join("link.txt")).unwrap();
assert!(!contained(&root, &root.join("link.txt")));
}
std::fs::remove_dir_all(&root).ok();
std::fs::remove_dir_all(&outside).ok();
}
#[test]
fn contained_accepts_a_brand_new_file_inside_the_root() {
let root = tmp("contained-newfile");
assert!(contained(&root, &root.join("does_not_exist_yet.txt")));
assert!(contained(&root, &root.join("nested/dir/new.txt")));
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn is_protected_hard_floor_covers_dot_git_regardless_of_extra_globs() {
assert!(is_protected(".git", &[]));
assert!(is_protected(".git/config", &[]));
assert!(!is_protected(".gitignore", &[]));
assert!(!is_protected("src/main.rs", &[]));
}
#[test]
fn reject_unsafe_rel_path_catches_absolute_and_traversal() {
assert!(reject_unsafe_rel_path("../etc/passwd").is_some());
assert!(reject_unsafe_rel_path("/etc/passwd").is_some());
assert!(reject_unsafe_rel_path("").is_some());
assert!(reject_unsafe_rel_path("src/main.rs").is_none());
}
}