use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone)]
pub struct FilesystemRoot {
root: PathBuf,
}
impl FilesystemRoot {
pub fn new(root: impl Into<PathBuf>) -> Result<Self, String> {
let root = root.into();
let canonical = std::fs::canonicalize(&root)
.map_err(|e| format!("sandbox root {}: {}", root.display(), e))?;
Ok(Self { root: canonical })
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn resolve(&self, input: &str) -> Result<PathBuf, String> {
if input.is_empty() {
return Err("empty path".into());
}
let raw = Path::new(input);
for comp in raw.components() {
if matches!(comp, Component::ParentDir) {
return Err(format!("path contains '..': {}", input));
}
}
let joined: PathBuf = if raw.is_absolute() {
raw.to_path_buf()
} else {
self.root.join(raw)
};
if let Ok(canonical) = std::fs::canonicalize(&joined) {
if !canonical.starts_with(&self.root) {
return Err(format!(
"path escapes sandbox root: {} not under {}",
canonical.display(),
self.root.display()
));
}
return Ok(canonical);
}
let parent = joined
.parent()
.ok_or_else(|| format!("path has no parent: {}", input))?;
let file_name = joined
.file_name()
.ok_or_else(|| format!("path has no filename: {}", input))?;
let parent_canonical = std::fs::canonicalize(parent).map_err(|e| {
format!(
"sandbox parent {} of {}: {}",
parent.display(),
input,
e
)
})?;
if !parent_canonical.starts_with(&self.root) {
return Err(format!(
"path escapes sandbox root: {} not under {}",
parent_canonical.display(),
self.root.display()
));
}
Ok(parent_canonical.join(file_name))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn tmpdir() -> PathBuf {
let d = std::env::temp_dir().join(format!(
"agnt-sandbox-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn resolves_relative_under_root() {
let dir = tmpdir();
let sandbox = FilesystemRoot::new(&dir).unwrap();
fs::write(dir.join("a.txt"), "x").unwrap();
let resolved = sandbox.resolve("a.txt").unwrap();
assert!(resolved.starts_with(sandbox.root()));
}
#[test]
fn rejects_parent_escape() {
let dir = tmpdir();
let sandbox = FilesystemRoot::new(&dir).unwrap();
let err = sandbox.resolve("../etc/shadow").unwrap_err();
assert!(err.contains(".."), "expected .. rejection, got {}", err);
}
#[test]
fn rejects_absolute_outside_root() {
let dir = tmpdir();
let sandbox = FilesystemRoot::new(&dir).unwrap();
let err = sandbox.resolve("/etc/passwd").unwrap_err();
assert!(err.contains("sandbox") || err.contains("escape"));
}
#[test]
fn allows_new_file_under_root() {
let dir = tmpdir();
let sandbox = FilesystemRoot::new(&dir).unwrap();
let resolved = sandbox.resolve("new.txt").unwrap();
assert!(resolved.starts_with(sandbox.root()));
}
#[test]
fn rejects_symlink_escape() {
#[cfg(unix)]
{
let dir = tmpdir();
let outside = tmpdir();
fs::write(outside.join("secret.txt"), "pw").unwrap();
std::os::unix::fs::symlink(&outside, dir.join("link")).unwrap();
let sandbox = FilesystemRoot::new(&dir).unwrap();
let err = sandbox.resolve("link/secret.txt").unwrap_err();
assert!(err.contains("escape") || err.contains("sandbox"));
}
}
}