1use std::collections::HashSet;
2use std::path::{Component, Path, PathBuf};
3
4use crate::error::{AgentLoopError, Result};
5use crate::mount_fs::WORKSPACE_MOUNT;
6
7pub const PRIMARY_WORKSPACE_ROOT_NAME: &str = "workspace";
8pub const ADDITIONAL_ROOTS_MOUNT: &str = "/workspace/roots";
9
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct WorkspaceRoot {
12 pub name: String,
13 pub path: PathBuf,
14}
15
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct WorkspaceRootSet {
18 pub primary: WorkspaceRoot,
19 pub additional: Vec<WorkspaceRoot>,
20}
21
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct ResolvedPath {
24 pub root_name: String,
25 pub relative: RelPath,
26}
27
28#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
29pub struct RelPath(String);
30
31impl RelPath {
32 pub fn as_relative(&self) -> &str {
33 &self.0
34 }
35
36 pub fn to_session_path(&self) -> String {
37 if self.0.is_empty() {
38 "/".to_string()
39 } else {
40 format!("/{}", self.0)
41 }
42 }
43
44 fn from_path(relative: &Path) -> Result<Self> {
45 let mut segments = Vec::new();
46 for component in relative.components() {
47 match component {
48 Component::CurDir => {}
49 Component::Normal(segment) => {
50 let segment = segment.to_str().ok_or_else(|| {
51 AgentLoopError::tool(format!(
52 "non-UTF-8 path component: {}",
53 relative.display()
54 ))
55 })?;
56 segments.push(segment.to_string());
57 }
58 Component::ParentDir => {
59 return Err(AgentLoopError::tool(format!(
60 "path traversal rejected: {}",
61 relative.display()
62 )));
63 }
64 Component::RootDir | Component::Prefix(_) => {
65 return Err(AgentLoopError::tool(format!(
66 "absolute path component rejected: {}",
67 relative.display()
68 )));
69 }
70 }
71 }
72 Ok(Self(segments.join("/")))
73 }
74
75 fn from_str(input: &str) -> Result<Self> {
76 let mut segments = Vec::new();
77 for part in input.split('/') {
78 match part {
79 "" | "." => {}
80 ".." => {
81 return Err(AgentLoopError::tool(format!(
82 "path traversal rejected: {input}"
83 )));
84 }
85 segment => segments.push(segment.to_string()),
86 }
87 }
88 Ok(Self(segments.join("/")))
89 }
90}
91
92impl WorkspaceRootSet {
93 pub fn from_primary(path: impl Into<PathBuf>) -> Result<Self> {
94 Self::new(path, Vec::<(String, PathBuf)>::new())
95 }
96
97 pub fn new<I, P>(primary: impl Into<PathBuf>, additional: I) -> Result<Self>
98 where
99 I: IntoIterator<Item = (String, P)>,
100 P: Into<PathBuf>,
101 {
102 let primary = WorkspaceRoot {
103 name: PRIMARY_WORKSPACE_ROOT_NAME.to_string(),
104 path: canonicalize_root(primary.into())?,
105 };
106 let mut additional_roots = Vec::new();
107 let mut names = HashSet::new();
108 for (name, path) in additional {
109 validate_additional_name(&name)?;
110 if !names.insert(name.clone()) {
111 return Err(AgentLoopError::config(format!(
112 "duplicate workspace root name: {name}"
113 )));
114 }
115 additional_roots.push(WorkspaceRoot {
116 name,
117 path: canonicalize_root(path.into())?,
118 });
119 }
120
121 let root_set = Self {
122 primary,
123 additional: additional_roots,
124 };
125 root_set.reject_overlaps()?;
126 Ok(root_set)
127 }
128
129 pub fn parse_vfs_path(&self, input: &str) -> Result<ResolvedPath> {
130 let trimmed = input.trim();
131 let candidate = Path::new(trimmed);
132 if candidate.is_absolute() && !trimmed.starts_with("/workspace") {
133 if let Some((root, relative)) = self.resolve_host_path(candidate)? {
134 return Ok(ResolvedPath {
135 root_name: root.name.clone(),
136 relative,
137 });
138 }
139 return Err(AgentLoopError::tool(format!(
140 "host path is outside registered workspace roots: {trimmed}"
141 )));
142 }
143
144 let session = if trimmed == WORKSPACE_MOUNT || trimmed == "workspace" {
145 "/".to_string()
146 } else if let Some(rest) = trimmed.strip_prefix("/workspace/") {
147 format!("/{rest}")
148 } else if trimmed.starts_with('/') {
149 trimmed.to_string()
150 } else {
151 format!("/{trimmed}")
152 };
153
154 if session == "/" || !session.starts_with("/roots/") {
155 return Ok(ResolvedPath {
156 root_name: self.primary.name.clone(),
157 relative: RelPath::from_str(&session)?,
158 });
159 }
160
161 let rest = session.strip_prefix("/roots/").unwrap_or_default();
162 let (name, relative) = rest.split_once('/').unwrap_or((rest, ""));
163 let root = self
164 .additional
165 .iter()
166 .find(|root| root.name == name)
167 .ok_or_else(|| AgentLoopError::tool(format!("unknown workspace root: {name}")))?;
168 Ok(ResolvedPath {
169 root_name: root.name.clone(),
170 relative: RelPath::from_str(relative)?,
171 })
172 }
173
174 pub fn parse_host_scope(&self, root: &str, relative: Option<&str>) -> Result<PathBuf> {
175 let workspace_root = self.root_by_name(root)?;
176 let rel = RelPath::from_str(relative.unwrap_or(""))?;
177 let candidate = if rel.as_relative().is_empty() {
178 workspace_root.path.clone()
179 } else {
180 workspace_root.path.join(rel.as_relative())
181 };
182 let resolved = canonicalize_existing_or_parent(&candidate)?;
183 if resolved != workspace_root.path && !resolved.starts_with(&workspace_root.path) {
184 return Err(AgentLoopError::tool(format!(
185 "path escapes workspace root: {}",
186 candidate.display()
187 )));
188 }
189 Ok(resolved)
190 }
191
192 pub fn primary_host_root(&self) -> &Path {
193 &self.primary.path
194 }
195
196 pub fn set_primary_host_root(&mut self, path: PathBuf) -> Result<()> {
197 let mut updated = self.clone();
199 updated.primary.path = canonicalize_root(path)?;
200 updated.reject_overlaps()?;
201 *self = updated;
202 Ok(())
203 }
204
205 pub fn spawn_cwd(&self) -> Result<PathBuf> {
206 canonicalize_root(self.primary.path.clone())
207 }
208
209 pub fn contains_host_path(&self, path: &Path) -> bool {
210 let Ok(canonical) = canonicalize_existing_or_parent(path) else {
211 return false;
212 };
213 self.all_roots()
214 .any(|root| canonical == root.path || canonical.starts_with(&root.path))
215 }
216
217 pub fn additional_mount_point(root_name: &str) -> String {
218 format!("{ADDITIONAL_ROOTS_MOUNT}/{root_name}")
219 }
220
221 fn all_roots(&self) -> impl Iterator<Item = &WorkspaceRoot> {
222 std::iter::once(&self.primary).chain(self.additional.iter())
223 }
224
225 fn root_by_name(&self, name: &str) -> Result<&WorkspaceRoot> {
226 if name == self.primary.name {
227 return Ok(&self.primary);
228 }
229 self.additional
230 .iter()
231 .find(|root| root.name == name)
232 .ok_or_else(|| AgentLoopError::tool(format!("unknown workspace root: {name}")))
233 }
234
235 fn resolve_host_path(&self, path: &Path) -> Result<Option<(&WorkspaceRoot, RelPath)>> {
236 let canonical = canonicalize_existing_or_parent(path)?;
237 for root in self.all_roots() {
238 if let Ok(relative) = canonical.strip_prefix(&root.path) {
239 return Ok(Some((root, RelPath::from_path(relative)?)));
240 }
241 }
242 Ok(None)
243 }
244
245 fn reject_overlaps(&self) -> Result<()> {
246 let roots: Vec<&WorkspaceRoot> = self.all_roots().collect();
247 for (idx, left) in roots.iter().enumerate() {
248 for right in roots.iter().skip(idx + 1) {
249 if left.path == right.path
250 || left.path.starts_with(&right.path)
251 || right.path.starts_with(&left.path)
252 {
253 return Err(AgentLoopError::config(format!(
254 "workspace roots must not overlap: {} ({}) and {} ({})",
255 left.name,
256 left.path.display(),
257 right.name,
258 right.path.display()
259 )));
260 }
261 }
262 }
263 Ok(())
264 }
265}
266
267fn validate_additional_name(name: &str) -> Result<()> {
268 if name.is_empty()
269 || name == "."
270 || name == ".."
271 || name == PRIMARY_WORKSPACE_ROOT_NAME
272 || name == "roots"
273 || name.contains('/')
274 || name.contains('\\')
275 {
276 return Err(AgentLoopError::config(format!(
277 "invalid workspace root name: {name}"
278 )));
279 }
280 Ok(())
281}
282
283fn canonicalize_root(root: PathBuf) -> Result<PathBuf> {
284 let canonical = std::fs::canonicalize(&root).map_err(|e| {
285 AgentLoopError::config(format!(
286 "failed to canonicalize workspace root {}: {e}",
287 root.display()
288 ))
289 })?;
290 if !canonical.is_dir() {
291 return Err(AgentLoopError::config(format!(
292 "workspace root is not a directory: {}",
293 canonical.display()
294 )));
295 }
296 Ok(canonical)
297}
298
299fn canonicalize_existing_or_parent(path: &Path) -> Result<PathBuf> {
300 match std::fs::canonicalize(path) {
301 Ok(path) => Ok(path),
302 Err(_) => {
303 let parent = path.parent().ok_or_else(|| {
304 AgentLoopError::tool(format!("path has no parent: {}", path.display()))
305 })?;
306 let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
307 AgentLoopError::tool(format!(
308 "failed to canonicalize parent {}: {e}",
309 parent.display()
310 ))
311 })?;
312 let name = path.file_name().ok_or_else(|| {
313 AgentLoopError::tool(format!("path has no file name: {}", path.display()))
314 })?;
315 Ok(canonical_parent.join(name))
316 }
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use tempfile::TempDir;
324
325 fn roots() -> (WorkspaceRootSet, TempDir, TempDir) {
326 let primary = TempDir::new().unwrap();
327 let backend = TempDir::new().unwrap();
328 let set = WorkspaceRootSet::new(
329 primary.path(),
330 [("backend".to_string(), backend.path().to_path_buf())],
331 )
332 .unwrap();
333 (set, primary, backend)
334 }
335
336 #[test]
337 fn canonicalizes_and_rejects_overlapping_roots() {
338 let primary = TempDir::new().unwrap();
339 let nested = primary.path().join("nested");
340 std::fs::create_dir(&nested).unwrap();
341
342 for (first, second) in [
343 (primary.path(), nested.as_path()),
344 (nested.as_path(), primary.path()),
345 (primary.path(), primary.path()),
346 ] {
347 let err = WorkspaceRootSet::new(first, [("other".to_string(), second)]).unwrap_err();
348 assert!(err.to_string().contains("must not overlap"));
349 }
350 let canonical = WorkspaceRootSet::from_primary(primary.path().join("nested/..")).unwrap();
351 assert_eq!(
352 canonical.primary.path,
353 std::fs::canonicalize(primary.path()).unwrap()
354 );
355 }
356
357 #[test]
358 fn rejects_duplicate_names() {
359 let primary = TempDir::new().unwrap();
360 let a = TempDir::new().unwrap();
361 let b = TempDir::new().unwrap();
362
363 let err = WorkspaceRootSet::new(
364 primary.path(),
365 [
366 ("backend".to_string(), a.path().to_path_buf()),
367 ("backend".to_string(), b.path().to_path_buf()),
368 ],
369 )
370 .unwrap_err();
371 assert!(err.to_string().contains("duplicate workspace root name"));
372 }
373
374 #[test]
375 fn parses_primary_aliases_and_rejects_traversal() {
376 let (set, _primary, _backend) = roots();
377
378 assert_eq!(
379 set.parse_vfs_path("/workspace/src/lib.rs").unwrap(),
380 ResolvedPath {
381 root_name: "workspace".to_string(),
382 relative: RelPath("src/lib.rs".to_string())
383 }
384 );
385 assert_eq!(
386 set.parse_vfs_path("workspace").unwrap().relative,
387 RelPath::default()
388 );
389 assert!(set.parse_vfs_path("../outside").is_err());
390 }
391
392 #[test]
393 fn parses_additional_root_mounts_only() {
394 let (set, _primary, _backend) = roots();
395
396 assert_eq!(
397 set.parse_vfs_path("/workspace/roots/backend/src/lib.rs")
398 .unwrap(),
399 ResolvedPath {
400 root_name: "backend".to_string(),
401 relative: RelPath("src/lib.rs".to_string())
402 }
403 );
404 assert!(set.parse_vfs_path("/workspace/roots/missing/file").is_err());
405 }
406
407 #[test]
408 fn repoints_primary_without_touching_additional() {
409 let (mut set, _primary, backend) = roots();
410 let next = TempDir::new().unwrap();
411 set.set_primary_host_root(next.path().to_path_buf())
412 .unwrap();
413
414 assert_eq!(
415 set.spawn_cwd().unwrap(),
416 std::fs::canonicalize(next.path()).unwrap()
417 );
418 assert_eq!(
419 set.parse_host_scope("backend", Some("Cargo.toml")).unwrap(),
420 std::fs::canonicalize(backend.path())
421 .unwrap()
422 .join("Cargo.toml")
423 );
424 }
425
426 #[test]
427 fn rejected_primary_repoint_preserves_registered_roots() {
428 let (mut set, _primary, backend) = roots();
429 let original = set.clone();
430 let error = set
431 .set_primary_host_root(backend.path().to_path_buf())
432 .unwrap_err();
433 assert!(error.to_string().contains("must not overlap"));
434 assert_eq!(set, original);
435 assert_eq!(set.spawn_cwd().unwrap(), original.primary.path);
436 }
437
438 #[cfg(unix)]
439 #[test]
440 fn host_scope_rejects_symlink_escape() {
441 let primary = TempDir::new().unwrap();
442 let outside = TempDir::new().unwrap();
443 std::os::unix::fs::symlink(outside.path(), primary.path().join("outside-link")).unwrap();
444 let set = WorkspaceRootSet::from_primary(primary.path()).unwrap();
445
446 let err = set
447 .parse_host_scope("workspace", Some("outside-link/secret.txt"))
448 .unwrap_err();
449
450 assert!(err.to_string().contains("path escapes workspace root"));
451 }
452}