use std::path::{Component, Path, PathBuf};
fn normalize_lexically(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}
pub fn resolve_in_workspace(path: &str) -> Result<PathBuf, String> {
let root = std::env::current_dir()
.and_then(|d| d.canonicalize())
.map_err(|e| format!("Failed to resolve workspace root: {}", e))?;
resolve_under(&root, path, "workspace")
}
pub fn resolve_under(root: &Path, path: &str, label: &str) -> Result<PathBuf, String> {
let root = root
.canonicalize()
.map_err(|e| format!("Failed to resolve {} root {}: {}", label, root.display(), e))?;
let requested = Path::new(path);
let joined = if requested.is_absolute() {
requested.to_path_buf()
} else {
root.join(requested)
};
let candidate = normalize_lexically(&joined);
if !candidate.starts_with(&root) {
return Err(format!(
"Path is outside the {} {}: {}",
label,
root.display(),
path
));
}
let anchor = nearest_existing_ancestor(&candidate)
.ok_or_else(|| format!("Path is outside the {}: {}", label, path))?;
let real_anchor = anchor
.canonicalize()
.map_err(|e| format!("Failed to resolve {}: {}", anchor.display(), e))?;
if !real_anchor.starts_with(&root) {
return Err(format!(
"Path escapes the {} via a symlink: {}",
label, path
));
}
Ok(candidate)
}
fn nearest_existing_ancestor(path: &Path) -> Option<&Path> {
let mut current = path;
loop {
if current.exists() {
return Some(current);
}
current = current.parent()?;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn root() -> PathBuf {
std::env::current_dir().unwrap().canonicalize().unwrap()
}
#[test]
fn accepts_relative_path_inside_workspace() {
let resolved = resolve_in_workspace("src/main.rs").unwrap();
assert_eq!(resolved, root().join("src/main.rs"));
}
#[test]
fn accepts_absolute_path_inside_workspace() {
let inside = root().join("Cargo.toml");
let resolved = resolve_in_workspace(inside.to_str().unwrap()).unwrap();
assert_eq!(resolved, inside);
}
#[test]
fn normalizes_interior_parent_segments() {
let resolved = resolve_in_workspace("src/../Cargo.toml").unwrap();
assert_eq!(resolved, root().join("Cargo.toml"));
}
#[test]
fn rejects_new_file_behind_symlinked_parent() {
let link = root().join("target/tmp-escape-link");
std::fs::create_dir_all(root().join("target")).unwrap();
let _ = std::fs::remove_file(&link);
#[cfg(unix)]
std::os::unix::fs::symlink("/tmp", &link).unwrap();
let result = resolve_in_workspace("target/tmp-escape-link/pwned.txt");
std::fs::remove_file(&link).unwrap();
let err = result.expect_err("a new file behind a symlinked parent must be rejected");
assert!(
err.contains("symlink"),
"expected the symlink guard to reject it, got: {}",
err
);
}
#[test]
fn rejects_traversal_out_of_workspace() {
for path in [
"../../../../etc/passwd",
"src/../../escape.txt",
"/etc/passwd",
"/home/other/.ssh/authorized_keys",
] {
assert!(
resolve_in_workspace(path).is_err(),
"should have rejected {}",
path
);
}
}
}