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 #[default]
11 Global,
12 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 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 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 assert_eq!(
130 workspace.resolve_existing("notes.md").unwrap(),
131 PathBuf::from("/var/workspace/session/notes.md")
132 );
133
134 let undeclared = workspace
136 .resolve_existing("/var/workspace/documents/a.md")
137 .unwrap_err();
138 assert!(undeclared.to_string().contains("outside workspace"));
139
140 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 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 read_roots: Vec<PathBuf>,
218 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 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 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 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 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}