use crate::types::ToolError;
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, Default)]
pub struct PathSandbox {
roots: Vec<PathBuf>,
}
impl PathSandbox {
pub fn new(roots: Vec<String>) -> Self {
let roots = roots
.iter()
.map(|r| {
let p = PathBuf::from(r);
std::fs::canonicalize(&p).unwrap_or_else(|_| normalize_lexically(&p))
})
.collect();
Self { roots }
}
pub fn io_path(&self, path: &str) -> Result<PathBuf, ToolError> {
let resolved = self.check(path)?;
Ok(if self.is_unrestricted() {
PathBuf::from(path)
} else {
resolved
})
}
pub fn is_unrestricted(&self) -> bool {
self.roots.is_empty()
}
pub fn check(&self, path: &str) -> Result<PathBuf, ToolError> {
let resolved = resolve(Path::new(path), 0);
if self.is_unrestricted() {
return Ok(resolved.unwrap_or_else(|| PathBuf::from(path)));
}
let Some(resolved) = resolved else {
return Err(ToolError::Failed(format!(
"path '{path}' could not be resolved (symlink loop?)"
)));
};
if self.roots.iter().any(|root| resolved.starts_with(root)) {
Ok(resolved)
} else {
Err(ToolError::Failed(format!(
"path '{path}' is outside the allowed directories"
)))
}
}
}
const MAX_SYMLINK_HOPS: u8 = 40;
fn resolve(path: &Path, hops: u8) -> Option<PathBuf> {
let anchored;
let path = if path.is_relative() {
anchored = std::env::current_dir().ok()?.join(path);
anchored.as_path()
} else {
path
};
if let Ok(c) = std::fs::canonicalize(path) {
return Some(c);
}
let mut trailing: Vec<std::ffi::OsString> = Vec::new();
let mut climbs = false;
let mut current = path;
loop {
if std::fs::symlink_metadata(current).is_ok_and(|m| m.file_type().is_symlink()) {
if hops >= MAX_SYMLINK_HOPS {
return None;
}
let target = std::fs::read_link(current).ok()?;
let mut next = match current.parent() {
Some(parent) if target.is_relative() => parent.join(target),
_ => target,
};
for part in trailing.iter().rev() {
next.push(part);
}
return resolve(&next, hops + 1);
}
match current.parent() {
Some(parent) => {
if let Some(last) = current.components().next_back() {
climbs |= last == Component::ParentDir;
trailing.push(last.as_os_str().to_owned());
}
if let Ok(base) = std::fs::canonicalize(parent) {
let mut out = base;
for part in trailing.iter().rev() {
out.push(part);
}
let out = normalize_lexically(&out);
return if climbs {
resolve(&out, hops + 1)
} else {
Some(out)
};
}
current = parent;
}
None => return Some(normalize_lexically(path)),
}
}
}
fn normalize_lexically(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
if out.as_os_str().is_empty() {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
} else if out.is_relative() {
std::env::current_dir()
.map(|cwd| cwd.join(&out))
.unwrap_or(out)
} else {
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn empty_sandbox_allows_anything() {
let s = PathSandbox::default();
assert!(s.is_unrestricted());
assert!(s.check("/etc/passwd").is_ok());
}
#[test]
fn inside_allowed_root_passes_outside_is_rejected() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("ok.txt"), "x").unwrap();
let s = PathSandbox::new(vec![tmp.path().to_string_lossy().to_string()]);
assert!(s.check(tmp.path().join("ok.txt").to_str().unwrap()).is_ok());
assert!(s.check("/etc/passwd").is_err());
}
#[test]
fn traversal_out_of_the_root_is_rejected() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("workspace");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(tmp.path().join("secret.txt"), "s").unwrap();
let s = PathSandbox::new(vec![root.to_string_lossy().to_string()]);
let escape = root.join("../secret.txt");
assert!(
s.check(escape.to_str().unwrap()).is_err(),
"`..` must not escape the sandbox"
);
}
#[test]
fn traversal_through_a_missing_directory_is_rejected() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("workspace");
std::fs::create_dir_all(&root).unwrap();
let s = PathSandbox::new(vec![root.to_string_lossy().to_string()]);
for escape in ["x/../../escaped.txt", "x/y/../../../escaped.txt"] {
let p = root.join(escape);
assert!(
s.check(p.to_str().unwrap()).is_err(),
"{escape} must not escape the sandbox"
);
}
let inside = root.join("x/../ok.txt");
assert!(s.check(inside.to_str().unwrap()).is_ok());
}
#[cfg(unix)]
#[test]
fn traversal_through_a_missing_directory_into_a_symlink_is_rejected() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("workspace");
std::fs::create_dir_all(&root).unwrap();
let outside = tmp.path().join("outside");
std::fs::create_dir_all(&outside).unwrap();
std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
let s = PathSandbox::new(vec![root.to_string_lossy().to_string()]);
let p = root.join("new/../link/x.txt");
assert!(
s.check(p.to_str().unwrap()).is_err(),
"a `..` that lands on a symlink out of the root must be rejected"
);
}
#[cfg(unix)]
#[test]
fn a_dangling_symlink_out_of_the_root_is_rejected() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("workspace");
std::fs::create_dir_all(&root).unwrap();
std::os::unix::fs::symlink(tmp.path().join("pwned.txt"), root.join("link")).unwrap();
std::os::unix::fs::symlink("../elsewhere/x", root.join("rel")).unwrap();
let s = PathSandbox::new(vec![root.to_string_lossy().to_string()]);
for name in ["link", "rel", "rel/deeper.txt"] {
assert!(
s.check(root.join(name).to_str().unwrap()).is_err(),
"{name} must not reach outside the root"
);
}
}
#[cfg(unix)]
#[test]
fn a_dangling_symlink_inside_the_root_resolves_to_its_target() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("workspace");
std::fs::create_dir_all(&root).unwrap();
std::os::unix::fs::symlink("later.txt", root.join("link")).unwrap();
let s = PathSandbox::new(vec![root.to_string_lossy().to_string()]);
let io = s.io_path(root.join("link").to_str().unwrap()).unwrap();
assert_eq!(io, std::fs::canonicalize(&root).unwrap().join("later.txt"));
}
#[cfg(unix)]
#[test]
fn a_symlink_loop_is_refused() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("workspace");
std::fs::create_dir_all(&root).unwrap();
std::os::unix::fs::symlink("b", root.join("a")).unwrap();
std::os::unix::fs::symlink("a", root.join("b")).unwrap();
let s = PathSandbox::new(vec![root.to_string_lossy().to_string()]);
let err = s.check(root.join("a").to_str().unwrap()).unwrap_err();
assert!(err.to_string().contains("could not be resolved"), "{err}");
}
#[test]
fn io_path_is_the_checked_path_only_when_restricted() {
assert_eq!(
PathSandbox::default().io_path("rel/a.txt").unwrap(),
PathBuf::from("rel/a.txt")
);
let tmp = TempDir::new().unwrap();
let s = PathSandbox::new(vec![tmp.path().to_string_lossy().to_string()]);
let p = tmp.path().join("a/../b.txt");
assert_eq!(
s.io_path(p.to_str().unwrap()).unwrap(),
std::fs::canonicalize(tmp.path()).unwrap().join("b.txt")
);
}
#[test]
fn nonexistent_file_under_allowed_root_is_permitted() {
let tmp = TempDir::new().unwrap();
let s = PathSandbox::new(vec![tmp.path().to_string_lossy().to_string()]);
let new_file = tmp.path().join("nested/deep/new.txt");
assert!(s.check(new_file.to_str().unwrap()).is_ok());
}
#[test]
fn nonexistent_path_outside_root_is_rejected() {
let tmp = TempDir::new().unwrap();
let s = PathSandbox::new(vec![tmp.path().join("ws").to_string_lossy().to_string()]);
assert!(s.check("/nonexistent-elsewhere/x.txt").is_err());
}
#[cfg(unix)]
#[test]
fn symlink_out_of_the_root_is_rejected() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("workspace");
std::fs::create_dir_all(&root).unwrap();
let outside = tmp.path().join("outside");
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(outside.join("secret.txt"), "s").unwrap();
std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
let s = PathSandbox::new(vec![root.to_string_lossy().to_string()]);
assert!(
s.check(root.join("link/secret.txt").to_str().unwrap())
.is_err(),
"a symlink pointing out of the sandbox must not grant access"
);
}
#[test]
fn error_does_not_disclose_the_allowed_roots() {
let s = PathSandbox::new(vec!["/srv/secret-workspace-name".to_string()]);
let err = s.check("/etc/passwd").unwrap_err().to_string();
assert!(
!err.contains("secret-workspace-name"),
"leaked roots: {err}"
);
}
}