adk_devtools/
workspace.rs1use std::collections::HashSet;
4use std::path::{Component, Path, PathBuf};
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7
8use tokio::io::AsyncWriteExt;
9
10use crate::error::DevToolError;
11
12#[derive(Clone)]
25pub struct Workspace {
26 root: PathBuf,
27 writable: bool,
28 allow_bash: bool,
29 bash_timeout: Duration,
30 max_output_bytes: usize,
31 read_tracker: Arc<Mutex<HashSet<PathBuf>>>,
32 inherit_env: bool,
34 env_allowlist: Vec<String>,
36}
37
38pub const DEFAULT_ENV_ALLOWLIST: &[&str] =
44 &["PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "TERM", "USER", "SHELL"];
45
46impl Workspace {
47 pub fn new(root: impl Into<PathBuf>) -> Self {
51 let root = root.into();
52 let root = std::fs::canonicalize(&root).unwrap_or(root);
53 Self {
54 root,
55 writable: true,
56 allow_bash: true,
57 bash_timeout: Duration::from_secs(120),
58 max_output_bytes: 1_048_576,
59 read_tracker: Arc::new(Mutex::new(HashSet::new())),
60 inherit_env: false,
61 env_allowlist: DEFAULT_ENV_ALLOWLIST.iter().map(|k| (*k).to_string()).collect(),
62 }
63 }
64
65 pub fn read_only(root: impl Into<PathBuf>) -> Self {
68 let mut ws = Self::new(root);
69 ws.writable = false;
70 ws.allow_bash = false;
71 ws
72 }
73
74 pub fn writable(mut self, yes: bool) -> Self {
76 self.writable = yes;
77 self
78 }
79
80 pub fn allow_bash(mut self, yes: bool) -> Self {
82 self.allow_bash = yes;
83 self
84 }
85
86 pub fn bash_timeout(mut self, timeout: Duration) -> Self {
88 self.bash_timeout = timeout;
89 self
90 }
91
92 pub fn max_output_bytes(mut self, bytes: usize) -> Self {
94 self.max_output_bytes = bytes;
95 self
96 }
97
98 #[must_use]
105 pub fn inherit_env(mut self, yes: bool) -> Self {
106 self.inherit_env = yes;
107 self
108 }
109
110 #[must_use]
115 pub fn env_allowlist<I, S>(mut self, keys: I) -> Self
116 where
117 I: IntoIterator<Item = S>,
118 S: Into<String>,
119 {
120 self.env_allowlist = keys.into_iter().map(Into::into).collect();
121 self
122 }
123
124 pub fn inherits_env(&self) -> bool {
126 self.inherit_env
127 }
128
129 pub fn bash_env(&self) -> Vec<(String, String)> {
134 if self.inherit_env {
135 return Vec::new();
136 }
137 self.env_allowlist
138 .iter()
139 .filter_map(|key| std::env::var(key).ok().map(|value| (key.clone(), value)))
140 .collect()
141 }
142
143 pub fn root(&self) -> &Path {
145 &self.root
146 }
147
148 pub fn is_writable(&self) -> bool {
150 self.writable
151 }
152
153 pub fn bash_allowed(&self) -> bool {
155 self.allow_bash
156 }
157
158 pub fn bash_timeout_value(&self) -> Duration {
160 self.bash_timeout
161 }
162
163 pub fn max_output(&self) -> usize {
165 self.max_output_bytes
166 }
167
168 pub fn resolve(&self, path: &str) -> Result<PathBuf, DevToolError> {
171 let requested = Path::new(path);
172 let joined = if requested.is_absolute() {
173 requested.to_path_buf()
174 } else {
175 self.root.join(requested)
176 };
177 let normalized = normalize(&joined);
178 if !normalized.starts_with(&self.root) {
179 return Err(DevToolError::PathEscape(path.to_string()));
180 }
181 self.reject_symlink_escape(&normalized, path)?;
184 Ok(normalized)
185 }
186
187 fn reject_symlink_escape(
203 &self,
204 normalized: &Path,
205 requested: &str,
206 ) -> Result<(), DevToolError> {
207 let mut existing = normalized;
208 loop {
209 match std::fs::canonicalize(existing) {
210 Ok(canonical) => {
211 if !canonical.starts_with(&self.root) {
212 return Err(DevToolError::PathEscape(requested.to_string()));
213 }
214 return Ok(());
215 }
216 Err(_) => {
217 if std::fs::symlink_metadata(existing).is_ok() {
220 return Err(DevToolError::PathEscape(requested.to_string()));
221 }
222 match existing.parent() {
225 Some(parent) if parent.starts_with(&self.root) => existing = parent,
226 _ => return Ok(()),
227 }
228 }
229 }
230 }
231 }
232
233 pub(crate) async fn write_contained(
242 &self,
243 resolved: &Path,
244 contents: &[u8],
245 ) -> Result<(), DevToolError> {
246 let escape = || DevToolError::PathEscape(self.display(resolved));
247 let is_symlink = tokio::fs::symlink_metadata(resolved)
248 .await
249 .is_ok_and(|meta| meta.file_type().is_symlink());
250 let target = if is_symlink {
251 tokio::fs::canonicalize(resolved).await.map_err(|_| escape())?
252 } else {
253 let (Some(parent), Some(name)) = (resolved.parent(), resolved.file_name()) else {
254 return Err(escape());
255 };
256 tokio::fs::canonicalize(parent).await?.join(name)
257 };
258 if !target.starts_with(&self.root) {
259 return Err(escape());
260 }
261
262 let mut options = tokio::fs::OpenOptions::new();
263 options.write(true).create(true).truncate(true);
264 #[cfg(unix)]
265 options.custom_flags(libc::O_NOFOLLOW);
266 let mut file = options.open(&target).await?;
267 file.write_all(contents).await?;
268 file.flush().await?;
269 Ok(())
270 }
271
272 pub fn display(&self, path: &Path) -> String {
274 path.strip_prefix(&self.root).unwrap_or(path).display().to_string()
275 }
276
277 pub(crate) fn mark_read(&self, path: &Path) {
279 if let Ok(mut set) = self.read_tracker.lock() {
280 set.insert(path.to_path_buf());
281 }
282 }
283
284 pub(crate) fn was_read(&self, path: &Path) -> bool {
286 self.read_tracker.lock().map(|set| set.contains(path)).unwrap_or(false)
287 }
288}
289
290fn normalize(path: &Path) -> PathBuf {
293 let mut out = PathBuf::new();
294 for component in path.components() {
295 match component {
296 Component::ParentDir => {
297 out.pop();
298 }
299 Component::CurDir => {}
300 other => out.push(other.as_os_str()),
301 }
302 }
303 out
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn rejects_escape() {
312 let dir = tempfile::tempdir().unwrap();
313 let ws = Workspace::new(dir.path());
314 assert!(ws.resolve("../etc/passwd").is_err());
315 assert!(ws.resolve("ok/file.rs").is_ok());
316 }
317
318 #[cfg(unix)]
319 #[tokio::test]
320 async fn a_symlink_planted_after_resolve_is_not_followed() {
321 let temp = tempfile::tempdir().unwrap();
322 let root = temp.path().join("workspace");
323 let outside = temp.path().join("outside");
324 std::fs::create_dir_all(&root).unwrap();
325 std::fs::create_dir_all(&outside).unwrap();
326 std::fs::write(outside.join("existing.txt"), "host").unwrap();
327 let ws = Workspace::new(&root);
328
329 for (name, target) in
330 [("dangling", outside.join("planted.txt")), ("existing", outside.join("existing.txt"))]
331 {
332 let resolved = ws.resolve(name).expect("the path does not exist yet");
333 std::os::unix::fs::symlink(&target, root.join(name)).unwrap();
334
335 let result = ws.write_contained(&resolved, b"payload").await;
336
337 assert!(
338 matches!(result, Err(DevToolError::PathEscape(_))),
339 "a {name} link planted after resolve was followed: {result:?}"
340 );
341 }
342 assert!(!outside.join("planted.txt").exists());
343 assert_eq!(std::fs::read_to_string(outside.join("existing.txt")).unwrap(), "host");
344 }
345
346 #[test]
347 fn read_tracking() {
348 let dir = tempfile::tempdir().unwrap();
349 let ws = Workspace::new(dir.path());
350 let p = ws.resolve("a.txt").unwrap();
351 assert!(!ws.was_read(&p));
352 ws.mark_read(&p);
353 assert!(ws.was_read(&p));
354 }
355}