1use std::path::{Component, Path, PathBuf};
22
23#[derive(Debug, Clone)]
26pub struct FilesystemRoot {
27 root: PathBuf,
28}
29
30impl FilesystemRoot {
31 pub fn new(root: impl Into<PathBuf>) -> Result<Self, String> {
35 let root = root.into();
36 let canonical = std::fs::canonicalize(&root)
37 .map_err(|e| format!("sandbox root {}: {}", root.display(), e))?;
38 Ok(Self { root: canonical })
39 }
40
41 pub fn root(&self) -> &Path {
43 &self.root
44 }
45
46 pub fn resolve(&self, input: &str) -> Result<PathBuf, String> {
55 if input.is_empty() {
56 return Err("empty path".into());
57 }
58 let raw = Path::new(input);
59
60 for comp in raw.components() {
64 if matches!(comp, Component::ParentDir) {
65 return Err(format!("path contains '..': {}", input));
66 }
67 }
68
69 let joined: PathBuf = if raw.is_absolute() {
73 raw.to_path_buf()
74 } else {
75 self.root.join(raw)
76 };
77
78 if let Ok(canonical) = std::fs::canonicalize(&joined) {
80 if !canonical.starts_with(&self.root) {
81 return Err(format!(
82 "path escapes sandbox root: {} not under {}",
83 canonical.display(),
84 self.root.display()
85 ));
86 }
87 return Ok(canonical);
88 }
89
90 let parent = joined
93 .parent()
94 .ok_or_else(|| format!("path has no parent: {}", input))?;
95 let file_name = joined
96 .file_name()
97 .ok_or_else(|| format!("path has no filename: {}", input))?;
98
99 let parent_canonical = std::fs::canonicalize(parent).map_err(|e| {
100 format!(
101 "sandbox parent {} of {}: {}",
102 parent.display(),
103 input,
104 e
105 )
106 })?;
107
108 if !parent_canonical.starts_with(&self.root) {
109 return Err(format!(
110 "path escapes sandbox root: {} not under {}",
111 parent_canonical.display(),
112 self.root.display()
113 ));
114 }
115
116 Ok(parent_canonical.join(file_name))
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use std::fs;
124
125 fn tmpdir() -> PathBuf {
126 let d = std::env::temp_dir().join(format!(
127 "agnt-sandbox-{}-{}",
128 std::process::id(),
129 std::time::SystemTime::now()
130 .duration_since(std::time::UNIX_EPOCH)
131 .map(|d| d.as_nanos())
132 .unwrap_or(0)
133 ));
134 fs::create_dir_all(&d).unwrap();
135 d
136 }
137
138 #[test]
139 fn resolves_relative_under_root() {
140 let dir = tmpdir();
141 let sandbox = FilesystemRoot::new(&dir).unwrap();
142 fs::write(dir.join("a.txt"), "x").unwrap();
143 let resolved = sandbox.resolve("a.txt").unwrap();
144 assert!(resolved.starts_with(sandbox.root()));
145 }
146
147 #[test]
148 fn rejects_parent_escape() {
149 let dir = tmpdir();
150 let sandbox = FilesystemRoot::new(&dir).unwrap();
151 let err = sandbox.resolve("../etc/shadow").unwrap_err();
152 assert!(err.contains(".."), "expected .. rejection, got {}", err);
153 }
154
155 #[test]
156 fn rejects_absolute_outside_root() {
157 let dir = tmpdir();
158 let sandbox = FilesystemRoot::new(&dir).unwrap();
159 let err = sandbox.resolve("/etc/passwd").unwrap_err();
160 assert!(err.contains("sandbox") || err.contains("escape"));
161 }
162
163 #[test]
164 fn allows_new_file_under_root() {
165 let dir = tmpdir();
166 let sandbox = FilesystemRoot::new(&dir).unwrap();
167 let resolved = sandbox.resolve("new.txt").unwrap();
168 assert!(resolved.starts_with(sandbox.root()));
169 }
170
171 #[test]
172 fn rejects_symlink_escape() {
173 #[cfg(unix)]
174 {
175 let dir = tmpdir();
176 let outside = tmpdir();
177 fs::write(outside.join("secret.txt"), "pw").unwrap();
178 std::os::unix::fs::symlink(&outside, dir.join("link")).unwrap();
179 let sandbox = FilesystemRoot::new(&dir).unwrap();
180 let err = sandbox.resolve("link/secret.txt").unwrap_err();
181 assert!(err.contains("escape") || err.contains("sandbox"));
182 }
183 }
184}