Skip to main content

everruns_core/
workspace_roots.rs

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        // A rejected reconfiguration must leave the registered roots usable.
198        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}