Skip to main content

roder_tools/
workspace.rs

1use std::path::{Component, Path, PathBuf};
2
3use anyhow::{Context, bail};
4use roder_api::tools::ToolExecutionContext;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
7pub enum ToolPathScope {
8    /// Resolve relative paths from the workspace root, but allow absolute paths and
9    /// `..` segments to address files outside the workspace.
10    #[default]
11    Global,
12    /// Require every resolved path to stay under the workspace root.
13    Workspace,
14}
15
16impl ToolPathScope {
17    pub fn parse(value: &str) -> Option<Self> {
18        match value.trim().to_ascii_lowercase().as_str() {
19            "global" | "all" | "unrestricted" | "filesystem" | "fs" => Some(Self::Global),
20            "workspace" | "workspace-only" | "cwd" | "repo" | "root" => Some(Self::Workspace),
21            _ => None,
22        }
23    }
24
25    pub(crate) fn allows_external_paths(self) -> bool {
26        matches!(self, Self::Global)
27    }
28}
29
30fn strip_matching_quotes(input: &str) -> &str {
31    let mut chars = input.chars();
32    let Some(first) = chars.next() else {
33        return input;
34    };
35    let Some(last) = input.chars().last() else {
36        return input;
37    };
38    if input.len() >= 2 && matches!(first, '\'' | '"' | '`') && first == last {
39        &input[first.len_utf8()..input.len() - last.len_utf8()]
40    } else {
41        input
42    }
43}
44
45fn is_workspace_root_alias(input: &str) -> bool {
46    let compact = input
47        .chars()
48        .filter(|ch| !ch.is_ascii_whitespace())
49        .map(|ch| if ch == '\\' { '/' } else { ch })
50        .collect::<String>();
51    compact.starts_with('.') && compact[1..].chars().all(|ch| ch == '/')
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57
58    #[test]
59    fn workdir_resolution_accepts_common_workspace_root_spellings() {
60        let root = temp_workspace("roder-workdir-root");
61        std::fs::create_dir_all(&root).unwrap();
62        let workspace = Workspace::new(root.clone()).unwrap();
63        let canonical = root.canonicalize().unwrap();
64
65        for value in [
66            "", " ", ".", "./", " . ", " . /", "'.'", "\"./\"", "` . / `",
67        ] {
68            assert_eq!(
69                workspace.resolve_existing_workdir(value).unwrap(),
70                canonical
71            );
72        }
73
74        let _ = std::fs::remove_dir_all(root);
75    }
76
77    #[test]
78    fn remote_workspace_resolves_paths_lexically_without_local_disk() {
79        let workspace = Workspace::remote(
80            PathBuf::from("/sandbox/workspace"),
81            ToolPathScope::Workspace,
82        )
83        .unwrap();
84
85        // None of these paths exist locally; resolution must not touch local disk.
86        assert_eq!(
87            workspace.resolve_existing("src/main.rs").unwrap(),
88            PathBuf::from("/sandbox/workspace/src/main.rs")
89        );
90        assert_eq!(
91            workspace.resolve_for_write("src/../notes.txt").unwrap(),
92            PathBuf::from("/sandbox/workspace/notes.txt")
93        );
94        assert_eq!(
95            workspace.resolve_existing_workdir("./").unwrap(),
96            PathBuf::from("/sandbox/workspace")
97        );
98
99        let escape = workspace.resolve_for_write("../outside.txt").unwrap_err();
100        assert!(escape.to_string().contains("outside workspace"));
101        let home = workspace.resolve_existing("~/secrets").unwrap_err();
102        assert!(home.to_string().contains("not supported"));
103    }
104
105    #[test]
106    fn remote_workspace_requires_an_absolute_root() {
107        let error =
108            Workspace::remote(PathBuf::from("workspace"), ToolPathScope::Workspace).unwrap_err();
109        assert!(error.to_string().contains("absolute"));
110    }
111
112    #[test]
113    fn remote_read_roots_widen_reads_but_not_writes_or_workdir() {
114        let workspace = Workspace::remote_with_read_roots(
115            PathBuf::from("/var/workspace/session"),
116            ToolPathScope::Workspace,
117            vec![PathBuf::from("/var/workspace/skills")],
118        )
119        .unwrap();
120
121        // A read under a declared read root resolves.
122        assert_eq!(
123            workspace
124                .resolve_existing("/var/workspace/skills/global/x/SKILL.md")
125                .unwrap(),
126            PathBuf::from("/var/workspace/skills/global/x/SKILL.md")
127        );
128        // Reads under the primary root still resolve.
129        assert_eq!(
130            workspace.resolve_existing("notes.md").unwrap(),
131            PathBuf::from("/var/workspace/session/notes.md")
132        );
133
134        // A read outside every declared root is rejected.
135        let undeclared = workspace
136            .resolve_existing("/var/workspace/documents/a.md")
137            .unwrap_err();
138        assert!(undeclared.to_string().contains("outside workspace"));
139
140        // Writes stay confined to the primary root even under a read root.
141        let write_escape = workspace
142            .resolve_for_write("/var/workspace/skills/global/x/out.md")
143            .unwrap_err();
144        assert!(write_escape.to_string().contains("outside workspace"));
145
146        // The working directory stays confined to the primary root.
147        let workdir_escape = workspace
148            .resolve_existing_workdir("/var/workspace/skills/global")
149            .unwrap_err();
150        assert!(workdir_escape.to_string().contains("outside workspace"));
151    }
152
153    #[test]
154    fn remote_read_roots_must_be_absolute() {
155        let error = Workspace::remote_with_read_roots(
156            PathBuf::from("/var/workspace/session"),
157            ToolPathScope::Workspace,
158            vec![PathBuf::from("skills")],
159        )
160        .unwrap_err();
161        assert!(error.to_string().contains("absolute"));
162    }
163
164    #[test]
165    fn workdir_resolution_still_accepts_normal_relative_directories() {
166        let root = temp_workspace("roder-workdir-subdir");
167        let subdir = root.join("crates").join("roder-tools");
168        std::fs::create_dir_all(&subdir).unwrap();
169        let workspace = Workspace::new(root.clone()).unwrap();
170
171        assert_eq!(
172            workspace
173                .resolve_existing_workdir("'crates/roder-tools'")
174                .unwrap(),
175            subdir.canonicalize().unwrap()
176        );
177
178        let _ = std::fs::remove_dir_all(root);
179    }
180
181    fn temp_workspace(prefix: &str) -> PathBuf {
182        let nanos = std::time::SystemTime::now()
183            .duration_since(std::time::UNIX_EPOCH)
184            .unwrap()
185            .as_nanos();
186        std::env::temp_dir().join(format!("{prefix}-{nanos}"))
187    }
188}
189
190pub(crate) fn expand_home(input: &str) -> anyhow::Result<PathBuf> {
191    if input == "~" {
192        return home_dir();
193    }
194
195    if let Some(rest) = input.strip_prefix("~/") {
196        let home = home_dir()?;
197        return Ok(home.join(rest));
198    }
199
200    Ok(PathBuf::from(input))
201}
202
203fn home_dir() -> anyhow::Result<PathBuf> {
204    dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory is not available"))
205}
206
207#[derive(Debug, Clone)]
208pub(crate) struct Workspace {
209    root: PathBuf,
210    path_scope: ToolPathScope,
211    /**
212     * Extra absolute roots that file reads may resolve under, beyond `root`.
213     * Writes and the working directory stay confined to `root`; only the
214     * read path (`resolve_existing`) consults these. Populated for remote
215     * workspaces that expose read-only mounts outside the writable root.
216     */
217    read_roots: Vec<PathBuf>,
218    /**
219     * Remote workspaces scope paths on a runner filesystem: resolution is
220     * purely lexical (no canonicalize/existence checks against local disk)
221     * and existence errors surface from the runner backend instead.
222     */
223    remote: bool,
224}
225
226impl Workspace {
227    #[cfg(test)]
228    pub(crate) fn new(root: PathBuf) -> anyhow::Result<Self> {
229        Self::new_with_scope(root, ToolPathScope::default())
230    }
231
232    pub(crate) fn new_with_scope(root: PathBuf, path_scope: ToolPathScope) -> anyhow::Result<Self> {
233        let root = if root.as_os_str().is_empty() {
234            std::env::current_dir()?
235        } else {
236            root
237        };
238        let root = root
239            .canonicalize()
240            .with_context(|| format!("workspace root does not exist: {}", root.display()))?;
241        Ok(Self {
242            root,
243            path_scope,
244            read_roots: Vec::new(),
245            remote: false,
246        })
247    }
248
249    #[cfg(test)]
250    pub(crate) fn remote(root: PathBuf, path_scope: ToolPathScope) -> anyhow::Result<Self> {
251        Self::remote_with_read_roots(root, path_scope, Vec::new())
252    }
253
254    pub(crate) fn remote_with_read_roots(
255        root: PathBuf,
256        path_scope: ToolPathScope,
257        read_roots: Vec<PathBuf>,
258    ) -> anyhow::Result<Self> {
259        if !root.is_absolute() {
260            bail!(
261                "remote workspace root must be an absolute runner path: {}",
262                root.display()
263            );
264        }
265        for read_root in &read_roots {
266            if !read_root.is_absolute() {
267                bail!(
268                    "remote workspace read root must be an absolute runner path: {}",
269                    read_root.display()
270                );
271            }
272        }
273        Ok(Self {
274            root,
275            path_scope,
276            read_roots,
277            remote: true,
278        })
279    }
280
281    pub(crate) fn root(&self) -> &Path {
282        &self.root
283    }
284
285    pub(crate) fn path_scope(&self) -> ToolPathScope {
286        self.path_scope
287    }
288
289    pub(crate) fn from_context_or_fallback(
290        ctx: &ToolExecutionContext,
291        fallback: &Workspace,
292    ) -> anyhow::Result<Self> {
293        if let Some(remote) = ctx.handles.remote_workspace.as_ref() {
294            return Self::remote_with_read_roots(
295                remote.root.clone(),
296                fallback.path_scope,
297                remote.read_roots.clone(),
298            );
299        }
300        let Some(handle) = ctx.handles.workspace.as_ref() else {
301            return Ok(fallback.clone());
302        };
303        let Some(root) = handle.workspace_root() else {
304            return Ok(fallback.clone());
305        };
306        Self::new_with_scope(root, fallback.path_scope)
307    }
308
309    /// Like `from_context_or_fallback` but rejects remote workspaces for tools that only run locally.
310    pub(crate) fn local_from_context_or_fallback(
311        ctx: &ToolExecutionContext,
312        fallback: &Workspace,
313        tool: &str,
314    ) -> anyhow::Result<Self> {
315        if ctx.handles.remote_workspace.is_some() {
316            bail!("{tool} is not supported on a remote runner workspace");
317        }
318        Self::from_context_or_fallback(ctx, fallback)
319    }
320
321    pub(crate) fn resolve_existing(&self, input: &str) -> anyhow::Result<PathBuf> {
322        let candidate = self.candidate(input)?;
323        if self.remote {
324            let normalized = self.normalize(candidate)?;
325            self.ensure_readable(&normalized)?;
326            return Ok(normalized);
327        }
328        let canonical = candidate
329            .canonicalize()
330            .with_context(|| format!("path does not exist: {input}"))?;
331        self.ensure_readable(&canonical)?;
332        Ok(canonical)
333    }
334
335    pub(crate) fn resolve_existing_workdir(&self, input: &str) -> anyhow::Result<PathBuf> {
336        let trimmed = strip_matching_quotes(input.trim()).trim();
337        if trimmed.is_empty() || is_workspace_root_alias(trimmed) {
338            return Ok(self.root.clone());
339        }
340        let resolved = self.resolve_existing(trimmed)?;
341        // The working directory is where writes land; keep it under the
342        // primary root even though read roots widen `resolve_existing`.
343        self.ensure_allowed(&resolved)?;
344        Ok(resolved)
345    }
346
347    pub(crate) fn resolve_for_write(&self, input: &str) -> anyhow::Result<PathBuf> {
348        let candidate = self.normalize(self.candidate(input)?)?;
349        self.ensure_allowed(&candidate)?;
350        Ok(candidate)
351    }
352
353    pub(crate) fn display(&self, path: &Path) -> String {
354        path.strip_prefix(&self.root)
355            .unwrap_or(path)
356            .to_string_lossy()
357            .replace('\\', "/")
358    }
359
360    fn candidate(&self, input: &str) -> anyhow::Result<PathBuf> {
361        let trimmed = input.trim();
362        if trimmed.is_empty() {
363            bail!("path is required");
364        }
365        // `~` would expand to the local home, not the runner's; reject it.
366        if self.remote && (trimmed == "~" || trimmed.starts_with("~/")) {
367            bail!("home-relative paths are not supported on a remote runner workspace: {trimmed}");
368        }
369        let path = expand_home(trimmed)?;
370        if path.is_absolute() {
371            Ok(path)
372        } else {
373            Ok(self.root.join(path))
374        }
375    }
376
377    fn ensure_allowed(&self, path: &Path) -> anyhow::Result<()> {
378        if self.path_scope.allows_external_paths() || path.starts_with(&self.root) {
379            return Ok(());
380        }
381        bail!(
382            "path {} is outside workspace {}",
383            path.display(),
384            self.root.display()
385        );
386    }
387
388    /**
389     * Read access check: the primary root, any declared read root, or an
390     * unrestricted scope. Reads may reach declared read roots even though
391     * writes (`ensure_allowed`) stay confined to the primary root.
392     */
393    fn ensure_readable(&self, path: &Path) -> anyhow::Result<()> {
394        if self.path_scope.allows_external_paths()
395            || path.starts_with(&self.root)
396            || self.read_roots.iter().any(|root| path.starts_with(root))
397        {
398            return Ok(());
399        }
400        bail!(
401            "path {} is outside workspace {}",
402            path.display(),
403            self.root.display()
404        );
405    }
406
407    fn normalize(&self, path: PathBuf) -> anyhow::Result<PathBuf> {
408        let mut normalized = PathBuf::new();
409        for component in path.components() {
410            match component {
411                Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
412                Component::RootDir => normalized.push(component.as_os_str()),
413                Component::CurDir => {}
414                Component::Normal(part) => normalized.push(part),
415                Component::ParentDir => {
416                    if !normalized.pop() {
417                        bail!("path escapes filesystem root");
418                    }
419                }
420            }
421        }
422        Ok(normalized)
423    }
424}