Skip to main content

a3s_code_core/workspace/
local.rs

1//! Local filesystem-backed workspace implementation.
2//!
3//! [`LocalWorkspaceBackend`] preserves the historical "agent runs on the host
4//! filesystem" behavior. It implements every workspace capability trait so
5//! local sessions get the full tool surface (read, write, edit, patch, ls,
6//! bash, grep, glob, git, git_stash, git_worktree).
7
8use super::{
9    default_path_input, has_windows_path_prefix, normalize_relative_path,
10    pathbuf_to_workspace_path, validate_relative_pattern, CommandOutput, CommandRequest,
11    WorkspaceCommandRunner, WorkspaceDirEntry, WorkspaceFileSystem, WorkspaceFileType,
12    WorkspaceGit, WorkspaceGitBranch, WorkspaceGitCheckoutOutput, WorkspaceGitCheckoutRequest,
13    WorkspaceGitCommit, WorkspaceGitCreateBranchRequest, WorkspaceGitCreateWorktreeRequest,
14    WorkspaceGitDiffRequest, WorkspaceGitRemote, WorkspaceGitRemoveWorktreeRequest,
15    WorkspaceGitStash, WorkspaceGitStashProvider, WorkspaceGitStashRequest, WorkspaceGitStatus,
16    WorkspaceGitWorktree, WorkspaceGitWorktreeMutation, WorkspaceGitWorktreeProvider,
17    WorkspaceGlobRequest, WorkspaceGlobResult, WorkspaceGrepRequest, WorkspaceGrepResult,
18    WorkspacePath, WorkspacePathResolver, WorkspaceSearch, WorkspaceWriteOutcome,
19};
20use anyhow::{anyhow, bail, Result};
21use async_trait::async_trait;
22use std::path::{Component, Path, PathBuf};
23
24/// Local filesystem-backed workspace implementation.
25#[derive(Debug)]
26pub struct LocalWorkspaceBackend {
27    pub(super) root: PathBuf,
28}
29
30impl LocalWorkspaceBackend {
31    pub fn new(root: PathBuf) -> Self {
32        let canonical = root.canonicalize();
33        let root = match canonical {
34            Ok(canonical) => canonical,
35            Err(e) => {
36                tracing::warn!(
37                    "LocalWorkspaceBackend: failed to canonicalize root '{}' at construction: {} \
38                     (path resolution will fail-closed at first use)",
39                    root.display(),
40                    e
41                );
42                root
43            }
44        };
45        Self { root }
46    }
47
48    fn local_path_for_read(&self, path: &WorkspacePath) -> Result<PathBuf> {
49        a3s_common::tools::resolve_path(&self.root, path.as_str()).map_err(|e| anyhow!("{}", e))
50    }
51
52    fn local_path_for_write(&self, path: &WorkspacePath) -> Result<PathBuf> {
53        let target = if path.is_root() {
54            self.root.clone()
55        } else {
56            self.root.join(path.as_str())
57        };
58
59        if let Some(parent) = target.parent() {
60            std::fs::create_dir_all(parent).map_err(|e| {
61                anyhow!(
62                    "Failed to create parent directories for {}: {}",
63                    target.display(),
64                    e
65                )
66            })?;
67        }
68
69        a3s_common::tools::resolve_path_for_write(&self.root, path.as_str())
70            .map_err(|e| anyhow!("{}", e))
71    }
72}
73
74impl WorkspacePathResolver for LocalWorkspaceBackend {
75    fn normalize(&self, input: &str) -> Result<WorkspacePath> {
76        normalize_local_path(&self.root, input)
77    }
78}
79
80#[async_trait]
81impl WorkspaceFileSystem for LocalWorkspaceBackend {
82    async fn read_text(&self, path: &WorkspacePath) -> Result<String> {
83        let resolved = self.local_path_for_read(path)?;
84        tokio::fs::read_to_string(&resolved)
85            .await
86            .map_err(|e| anyhow!("Failed to read file {}: {}", resolved.display(), e))
87    }
88
89    async fn write_text(
90        &self,
91        path: &WorkspacePath,
92        content: &str,
93    ) -> Result<WorkspaceWriteOutcome> {
94        let resolved = self.local_path_for_write(path)?;
95        tokio::fs::write(&resolved, content)
96            .await
97            .map_err(|e| anyhow!("Failed to write file {}: {}", resolved.display(), e))?;
98
99        Ok(WorkspaceWriteOutcome {
100            bytes: content.len(),
101            lines: content.lines().count(),
102        })
103    }
104
105    async fn list_dir(&self, path: &WorkspacePath) -> Result<Vec<WorkspaceDirEntry>> {
106        let target = self.local_path_for_read(path)?;
107        if !target.exists() {
108            bail!("Directory not found: {}", target.display());
109        }
110        if !target.is_dir() {
111            bail!("Not a directory: {}", target.display());
112        }
113
114        let mut dir = tokio::fs::read_dir(&target)
115            .await
116            .map_err(|e| anyhow!("Failed to read directory {}: {}", target.display(), e))?;
117        let mut entries = Vec::new();
118
119        while let Some(entry) = dir.next_entry().await? {
120            let name = entry.file_name().to_string_lossy().to_string();
121            let file_type = entry.file_type().await;
122            let metadata = entry.metadata().await;
123            let (kind, size) = match (&file_type, &metadata) {
124                (Ok(ft), Ok(m)) => {
125                    let kind = if ft.is_dir() {
126                        WorkspaceFileType::Directory
127                    } else if ft.is_symlink() {
128                        WorkspaceFileType::Symlink
129                    } else {
130                        WorkspaceFileType::File
131                    };
132                    (kind, m.len())
133                }
134                _ => (WorkspaceFileType::Unknown, 0),
135            };
136            entries.push(WorkspaceDirEntry { name, kind, size });
137        }
138
139        Ok(entries)
140    }
141}
142
143#[async_trait]
144impl WorkspaceSearch for LocalWorkspaceBackend {
145    async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult> {
146        validate_relative_pattern(&request.pattern, "glob pattern")?;
147        let base = self.local_path_for_read(&request.base)?;
148        let full_pattern = base.join(&request.pattern);
149        let full_pattern = full_pattern.to_string_lossy().replace('\\', "/");
150
151        let entries = glob::glob(&full_pattern)
152            .map_err(|e| anyhow!("Invalid glob pattern '{}': {}", request.pattern, e))?;
153
154        let mut matches = Vec::new();
155        for entry in entries {
156            match entry {
157                Ok(path) => {
158                    if let Ok(relative) = path.strip_prefix(&self.root) {
159                        matches.push(pathbuf_to_workspace_path(relative));
160                    }
161                }
162                Err(e) => tracing::warn!("Glob entry error: {}", e),
163            }
164        }
165
166        matches.sort_by(|a, b| a.as_str().cmp(b.as_str()));
167        Ok(WorkspaceGlobResult { matches })
168    }
169
170    async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult> {
171        if let Some(ref glob) = request.glob {
172            validate_relative_pattern(glob, "grep glob filter")?;
173        }
174
175        let regex_pattern = if request.case_insensitive {
176            format!("(?i){}", request.pattern)
177        } else {
178            request.pattern.clone()
179        };
180        let regex = regex::Regex::new(&regex_pattern)
181            .map_err(|e| anyhow!("Invalid regex pattern '{}': {}", request.pattern, e))?;
182
183        let search_path = self.local_path_for_read(&request.base)?;
184        let mut builder = ignore::WalkBuilder::new(&search_path);
185        builder.hidden(false).git_ignore(true).git_global(true);
186
187        if let Some(ref glob_pat) = request.glob {
188            let mut types = ignore::types::TypesBuilder::new();
189            types.add("custom", glob_pat).ok();
190            types.select("custom");
191            if let Ok(built) = types.build() {
192                builder.types(built);
193            }
194        }
195
196        let mut output = String::new();
197        let mut match_count = 0;
198        let mut file_count = 0;
199        let mut total_size = 0;
200
201        for entry in builder.build().flatten() {
202            if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
203                continue;
204            }
205
206            let file_path = entry.path();
207            let content = match std::fs::read_to_string(file_path) {
208                Ok(content) => content,
209                Err(_) => continue,
210            };
211
212            let lines: Vec<&str> = content.lines().collect();
213            let mut file_matches = Vec::new();
214            for (line_idx, line) in lines.iter().enumerate() {
215                if regex.is_match(line) {
216                    file_matches.push(line_idx);
217                }
218            }
219
220            if file_matches.is_empty() {
221                continue;
222            }
223
224            file_count += 1;
225            let rel_path = file_path
226                .strip_prefix(&self.root)
227                .unwrap_or(file_path)
228                .to_string_lossy()
229                .replace('\\', "/");
230
231            for &match_idx in &file_matches {
232                if total_size > request.max_output_size {
233                    return Ok(WorkspaceGrepResult {
234                        output,
235                        match_count,
236                        file_count,
237                        truncated: true,
238                    });
239                }
240
241                match_count += 1;
242
243                let start = match_idx.saturating_sub(request.context_lines);
244                let end = (match_idx + request.context_lines + 1).min(lines.len());
245
246                for (i, line) in lines[start..end].iter().enumerate() {
247                    let abs_i = start + i;
248                    let prefix = if abs_i == match_idx { ">" } else { " " };
249                    let line = format!("{}{}:{}: {}\n", prefix, rel_path, abs_i + 1, line);
250                    total_size += line.len();
251                    output.push_str(&line);
252                }
253
254                if request.context_lines > 0 {
255                    output.push_str("--\n");
256                    total_size += 3;
257                }
258            }
259        }
260
261        Ok(WorkspaceGrepResult {
262            output,
263            match_count,
264            file_count,
265            truncated: false,
266        })
267    }
268}
269
270#[async_trait]
271impl WorkspaceGit for LocalWorkspaceBackend {
272    async fn is_repository(&self) -> Result<bool> {
273        self.run_blocking_git(|root| Ok(crate::git::is_git_repo(&root)))
274            .await
275    }
276
277    async fn status(&self) -> Result<WorkspaceGitStatus> {
278        self.run_blocking_git(|root| {
279            let status = crate::git::get_status(&root)?;
280            Ok(WorkspaceGitStatus {
281                branch: status.branch,
282                commit: status.commit,
283                is_worktree: status.is_worktree,
284                is_dirty: status.is_dirty,
285                dirty_count: status.dirty_count,
286            })
287        })
288        .await
289    }
290
291    async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
292        self.run_blocking_git(move |root| {
293            Ok(crate::git::get_log(&root, max_count)?
294                .into_iter()
295                .map(|commit| WorkspaceGitCommit {
296                    id: commit.id,
297                    message: commit.message,
298                    author: commit.author,
299                    date: commit.date,
300                })
301                .collect())
302        })
303        .await
304    }
305
306    async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
307        self.run_blocking_git(|root| {
308            Ok(crate::git::list_branches(&root)?
309                .into_iter()
310                .map(|branch| WorkspaceGitBranch {
311                    name: branch.name,
312                    is_current: branch.is_current,
313                })
314                .collect())
315        })
316        .await
317    }
318
319    async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
320        self.run_blocking_git(move |root| {
321            crate::git::create_branch(&root, &request.name, &request.base)
322        })
323        .await
324    }
325
326    async fn checkout(
327        &self,
328        request: WorkspaceGitCheckoutRequest,
329    ) -> Result<WorkspaceGitCheckoutOutput> {
330        let args = if request.force {
331            vec![
332                "checkout".to_string(),
333                "--force".to_string(),
334                request.refspec,
335            ]
336        } else {
337            vec!["checkout".to_string(), request.refspec]
338        };
339        let (success, stdout, stderr) = self.run_git_command(args).await?;
340        if !success {
341            bail!("{}", stderr.trim_end());
342        }
343        Ok(WorkspaceGitCheckoutOutput { stdout })
344    }
345
346    async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
347        self.run_blocking_git(move |root| crate::git::get_diff(&root, request.target.as_deref()))
348            .await
349    }
350
351    async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
352        let (success, stdout, stderr) = self
353            .run_git_command(vec!["remote".to_string(), "-v".to_string()])
354            .await?;
355        if !success {
356            bail!("{}", stderr.trim_end());
357        }
358
359        Ok(stdout.lines().filter_map(parse_git_remote_line).collect())
360    }
361}
362
363#[async_trait]
364impl WorkspaceGitStashProvider for LocalWorkspaceBackend {
365    async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
366        self.run_blocking_git(|root| {
367            Ok(crate::git::list_stashes(&root)?
368                .into_iter()
369                .map(|stash| WorkspaceGitStash {
370                    index: stash.index,
371                    message: stash.message,
372                })
373                .collect())
374        })
375        .await
376    }
377
378    async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
379        self.run_blocking_git(move |root| {
380            crate::git::stash(&root, request.message.as_deref(), request.include_untracked)
381        })
382        .await
383    }
384}
385
386#[async_trait]
387impl WorkspaceGitWorktreeProvider for LocalWorkspaceBackend {
388    async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>> {
389        self.run_blocking_git(|root| {
390            Ok(crate::git::list_worktrees(&root)?
391                .into_iter()
392                .map(|worktree| WorkspaceGitWorktree {
393                    path: worktree.path,
394                    branch: worktree.branch,
395                    is_bare: worktree.is_bare,
396                    is_detached: worktree.is_detached,
397                })
398                .collect())
399        })
400        .await
401    }
402
403    async fn create_worktree(
404        &self,
405        request: WorkspaceGitCreateWorktreeRequest,
406    ) -> Result<WorkspaceGitWorktreeMutation> {
407        let branch = request.branch;
408        let path = request
409            .path
410            .map(|path| {
411                let path = PathBuf::from(path);
412                if path.is_absolute() {
413                    path
414                } else {
415                    self.root.join(path)
416                }
417            })
418            .unwrap_or_else(|| default_local_worktree_path(&self.root, &branch));
419        let display_path = path.display().to_string();
420        let new_branch = request.new_branch;
421        let branch_for_git = branch.clone();
422
423        self.run_blocking_git(move |root| {
424            crate::git::create_worktree(&root, &branch_for_git, &path, new_branch)
425        })
426        .await?;
427
428        Ok(WorkspaceGitWorktreeMutation {
429            path: display_path,
430            branch: Some(branch),
431        })
432    }
433
434    async fn remove_worktree(
435        &self,
436        request: WorkspaceGitRemoveWorktreeRequest,
437    ) -> Result<WorkspaceGitWorktreeMutation> {
438        let path = PathBuf::from(request.path);
439        let display_path = path.display().to_string();
440        let force = request.force;
441
442        self.run_blocking_git(move |root| crate::git::remove_worktree(&root, &path, force))
443            .await?;
444
445        Ok(WorkspaceGitWorktreeMutation {
446            path: display_path,
447            branch: None,
448        })
449    }
450}
451
452#[async_trait]
453impl WorkspaceCommandRunner for LocalWorkspaceBackend {
454    async fn exec(&self, request: CommandRequest) -> Result<CommandOutput> {
455        #[cfg(windows)]
456        if let Some(output) =
457            crate::tools::builtin::bash::maybe_execute_simple_windows_http_command(&request.command)
458                .await
459        {
460            let exit_code = output
461                .metadata
462                .as_ref()
463                .and_then(|m| m.get("exit_code"))
464                .and_then(|v| v.as_i64())
465                .map(|v| v as i32)
466                .unwrap_or(if output.success { 0 } else { -1 });
467            return Ok(CommandOutput {
468                output: output.content,
469                exit_code,
470                timed_out: false,
471            });
472        }
473
474        let timeout_secs = request.timeout_ms / 1000;
475        let mut child = crate::tools::builtin::bash::spawn_shell(
476            &request.command,
477            &self.root,
478            request.env.as_deref(),
479        )
480        .map_err(|e| anyhow!("Failed to spawn shell: {}", e))?;
481
482        let (output, timed_out) = crate::tools::process::read_process_output(
483            &mut child,
484            timeout_secs,
485            request.output_observer.as_deref(),
486        )
487        .await;
488
489        if timed_out {
490            return Ok(CommandOutput {
491                output,
492                exit_code: -1,
493                timed_out: true,
494            });
495        }
496
497        let status = child
498            .wait()
499            .await
500            .map_err(|e| anyhow!("Failed to wait for shell: {}", e))?;
501        let exit_code = status.code().unwrap_or(-1);
502
503        Ok(CommandOutput {
504            output,
505            exit_code,
506            timed_out: false,
507        })
508    }
509}
510
511impl LocalWorkspaceBackend {
512    async fn run_blocking_git<T, F>(&self, operation: F) -> Result<T>
513    where
514        T: Send + 'static,
515        F: FnOnce(PathBuf) -> Result<T> + Send + 'static,
516    {
517        let root = self.root.clone();
518        tokio::task::spawn_blocking(move || operation(root))
519            .await
520            .map_err(|e| anyhow!("Git worker failed: {}", e))?
521    }
522
523    async fn run_git_command(&self, args: Vec<String>) -> Result<(bool, String, String)> {
524        tokio::task::spawn_blocking(crate::git::ensure_git_installed)
525            .await
526            .map_err(|e| anyhow!("Git worker failed: {}", e))??;
527
528        let output = tokio::process::Command::new("git")
529            .arg("-C")
530            .arg(self.root.as_os_str())
531            .args(&args)
532            .output()
533            .await
534            .map_err(|e| anyhow!("Failed to execute git: {}", e))?;
535
536        Ok((
537            output.status.success(),
538            String::from_utf8_lossy(&output.stdout).to_string(),
539            String::from_utf8_lossy(&output.stderr).to_string(),
540        ))
541    }
542}
543
544fn parse_git_remote_line(line: &str) -> Option<WorkspaceGitRemote> {
545    let mut parts = line.split_whitespace();
546    let name = parts.next()?;
547    let url = parts.next()?;
548    let direction = parts
549        .next()
550        .unwrap_or_default()
551        .trim_start_matches('(')
552        .trim_end_matches(')');
553
554    Some(WorkspaceGitRemote {
555        name: name.to_string(),
556        url: url.to_string(),
557        direction: direction.to_string(),
558    })
559}
560
561fn default_local_worktree_path(root: &Path, branch: &str) -> PathBuf {
562    let repo_name = root
563        .file_name()
564        .map(|name| name.to_string_lossy().to_string())
565        .unwrap_or_else(|| "repo".to_string());
566    root.parent()
567        .unwrap_or(root)
568        .join(format!("{repo_name}-{branch}"))
569}
570
571pub(super) fn normalize_local_path(root: &Path, input: &str) -> Result<WorkspacePath> {
572    let input = default_path_input(input);
573    let candidate = Path::new(input);
574
575    if candidate.is_absolute() {
576        let root = normalize_absolute_path(root)?;
577        let target = normalize_absolute_path(candidate)?;
578        if !target.starts_with(&root) {
579            bail!(
580                "Workspace boundary violation: path '{}' escapes workspace '{}'",
581                input,
582                root.display()
583            );
584        }
585        let relative = target
586            .strip_prefix(&root)
587            .map_err(|_| anyhow!("Failed to compute workspace-relative path"))?;
588        return Ok(pathbuf_to_workspace_path(relative));
589    }
590
591    if has_windows_path_prefix(input) {
592        bail!("Absolute paths are not supported by this workspace backend");
593    }
594
595    let normalized_input = input.replace('\\', "/");
596    let path = Path::new(&normalized_input);
597    if path.is_absolute() {
598        bail!("Absolute paths are not supported by this workspace backend");
599    }
600
601    let relative = normalize_relative_path(path)?;
602    Ok(pathbuf_to_workspace_path(&relative))
603}
604
605fn normalize_absolute_path(path: &Path) -> Result<PathBuf> {
606    let lexical = normalize_absolute_path_lexical(path)?;
607    if let Ok(canonical) = lexical.canonicalize() {
608        return Ok(canonical);
609    }
610
611    let mut current = lexical.as_path();
612    let mut suffix = Vec::new();
613    while !current.exists() {
614        let Some(file_name) = current.file_name() else {
615            return Ok(lexical);
616        };
617        suffix.push(file_name.to_os_string());
618        let Some(parent) = current.parent() else {
619            return Ok(lexical);
620        };
621        current = parent;
622    }
623
624    let mut normalized = current.canonicalize().unwrap_or_else(|_| {
625        normalize_absolute_path_lexical(current).unwrap_or_else(|_| current.into())
626    });
627    for part in suffix.iter().rev() {
628        normalized.push(part);
629    }
630    Ok(normalized)
631}
632
633fn normalize_absolute_path_lexical(path: &Path) -> Result<PathBuf> {
634    let mut out = PathBuf::new();
635    for component in path.components() {
636        match component {
637            Component::Prefix(prefix) => out.push(prefix.as_os_str()),
638            Component::RootDir => out.push(Path::new(std::path::MAIN_SEPARATOR_STR)),
639            Component::CurDir => {}
640            Component::Normal(part) => out.push(part),
641            Component::ParentDir => {
642                if !out.pop() {
643                    bail!("Invalid absolute path");
644                }
645            }
646        }
647    }
648    Ok(out)
649}
650
651#[cfg(test)]
652mod tests {
653    use super::super::WorkspaceServices;
654    use super::*;
655
656    #[tokio::test]
657    async fn local_backend_reads_writes_and_lists() {
658        let temp = tempfile::tempdir().unwrap();
659        let services = WorkspaceServices::local(temp.path());
660        let path = services.normalize_path("dir/file.txt").unwrap();
661
662        let written = services
663            .fs()
664            .write_text(&path, "hello\nworld\n")
665            .await
666            .unwrap();
667        assert_eq!(written.bytes, 12);
668        assert_eq!(written.lines, 2);
669
670        let content = services.fs().read_text(&path).await.unwrap();
671        assert_eq!(content, "hello\nworld\n");
672
673        let dir = services.normalize_path("dir").unwrap();
674        let entries = services.fs().list_dir(&dir).await.unwrap();
675        assert_eq!(entries.len(), 1);
676        assert_eq!(entries[0].name, "file.txt");
677    }
678
679    #[tokio::test]
680    async fn local_backend_searches_glob_and_grep() {
681        let temp = tempfile::tempdir().unwrap();
682        let services = WorkspaceServices::local(temp.path());
683        services
684            .fs()
685            .write_text(
686                &services.normalize_path("src/main.rs").unwrap(),
687                "fn main() {\n    println!(\"hello\");\n}\n",
688            )
689            .await
690            .unwrap();
691        services
692            .fs()
693            .write_text(
694                &services.normalize_path("README.md").unwrap(),
695                "hello from docs\n",
696            )
697            .await
698            .unwrap();
699
700        let search = services.search().expect("local backend supports search");
701        let glob = search
702            .glob(WorkspaceGlobRequest {
703                base: services.normalize_path("src").unwrap(),
704                pattern: "*.rs".to_string(),
705            })
706            .await
707            .unwrap();
708        assert_eq!(glob.matches[0].as_str(), "src/main.rs");
709
710        let grep = search
711            .grep(WorkspaceGrepRequest {
712                base: WorkspacePath::root(),
713                pattern: "hello".to_string(),
714                glob: Some("**/*.rs".to_string()),
715                context_lines: 0,
716                case_insensitive: false,
717                max_output_size: 1024,
718            })
719            .await
720            .unwrap();
721        assert_eq!(grep.match_count, 1);
722        assert_eq!(grep.file_count, 1);
723        assert!(grep.output.contains("src/main.rs:2"));
724    }
725
726    #[test]
727    fn local_backend_rejects_absolute_paths_outside_workspace() {
728        let temp = tempfile::tempdir().unwrap();
729        let services = WorkspaceServices::local(temp.path());
730        let outside = temp.path().parent().unwrap().join("secret.txt");
731        let err = services
732            .normalize_path(outside.to_str().unwrap())
733            .expect_err("outside absolute path should be rejected");
734        assert!(err.to_string().contains("escapes workspace"));
735    }
736
737    #[test]
738    fn local_backend_rejects_backslash_parent_escape() {
739        let temp = tempfile::tempdir().unwrap();
740        let services = WorkspaceServices::local(temp.path());
741        let err = services
742            .normalize_path(r"..\secret.txt")
743            .expect_err("backslash parent traversal should be rejected");
744        assert!(err.to_string().contains("escapes workspace"));
745    }
746
747    #[test]
748    fn local_backend_allows_absolute_paths_inside_workspace() {
749        let temp = tempfile::tempdir().unwrap();
750        let services = WorkspaceServices::local(temp.path());
751        let absolute = temp.path().join("src/main.rs");
752        let path = services
753            .normalize_path(absolute.to_str().unwrap())
754            .expect("absolute path inside workspace should normalize");
755        assert_eq!(path.as_str(), "src/main.rs");
756    }
757}