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::local_access::{LocalWorkspaceAccessBoundary, LocalWorkspaceAccessPolicy};
9use super::{
10    default_path_input, escape_control_chars_for_display, has_windows_path_prefix,
11    normalize_relative_path, pathbuf_to_workspace_path, validate_relative_pattern, CommandOutput,
12    CommandRequest, WorkspaceCommandRunner, WorkspaceDirEntry, WorkspaceError, WorkspaceFileSystem,
13    WorkspaceFileType, WorkspaceGit, WorkspaceGitBranch, WorkspaceGitCheckoutOutput,
14    WorkspaceGitCheckoutRequest, WorkspaceGitCommit, WorkspaceGitCreateBranchRequest,
15    WorkspaceGitCreateWorktreeRequest, WorkspaceGitDiffRequest, WorkspaceGitRemote,
16    WorkspaceGitRemoveWorktreeRequest, WorkspaceGitStash, WorkspaceGitStashProvider,
17    WorkspaceGitStashRequest, WorkspaceGitStatus, WorkspaceGitWorktree,
18    WorkspaceGitWorktreeMutation, WorkspaceGitWorktreeProvider, WorkspaceGlobRequest,
19    WorkspaceGlobResult, WorkspaceGrepOutcome, WorkspaceGrepRequest, WorkspaceGrepResult,
20    WorkspacePath, WorkspacePathResolver, WorkspaceResult, WorkspaceSearch, WorkspaceTextRange,
21    WorkspaceTextReader, WorkspaceWriteOutcome,
22};
23use anyhow::{anyhow, bail, Result};
24use async_trait::async_trait;
25use std::io::Read as _;
26use std::path::{Component, Path, PathBuf};
27use std::sync::atomic::{AtomicBool, Ordering};
28use std::sync::Arc;
29use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
30
31/// Local filesystem-backed workspace implementation.
32#[derive(Debug)]
33pub struct LocalWorkspaceBackend {
34    pub(super) root: PathBuf,
35    access_boundary: Option<LocalWorkspaceAccessBoundary>,
36}
37
38struct CancelGitWorkerOnDrop {
39    cancellation: Arc<AtomicBool>,
40    armed: bool,
41}
42
43impl CancelGitWorkerOnDrop {
44    fn new(cancellation: Arc<AtomicBool>) -> Self {
45        Self {
46            cancellation,
47            armed: true,
48        }
49    }
50
51    fn disarm(&mut self) {
52        self.armed = false;
53    }
54}
55
56impl Drop for CancelGitWorkerOnDrop {
57    fn drop(&mut self) {
58        if self.armed {
59            self.cancellation.store(true, Ordering::Release);
60        }
61    }
62}
63
64impl LocalWorkspaceBackend {
65    pub fn new(root: PathBuf) -> Self {
66        Self::new_with_access_policy(root, LocalWorkspaceAccessPolicy::Unrestricted)
67    }
68
69    pub fn new_with_access_policy(
70        root: PathBuf,
71        access_policy: LocalWorkspaceAccessPolicy,
72    ) -> Self {
73        let canonical = root.canonicalize();
74        let root = match canonical {
75            Ok(canonical) => canonical,
76            Err(e) => {
77                tracing::warn!(
78                    "LocalWorkspaceBackend: failed to canonicalize root '{}' at construction: {} \
79                     (path resolution will fail-closed at first use)",
80                    root.display(),
81                    e
82                );
83                root
84            }
85        };
86        let access_boundary = LocalWorkspaceAccessBoundary::for_policy(access_policy, &root);
87        Self {
88            root,
89            access_boundary,
90        }
91    }
92
93    fn local_path_for_read(&self, path: &WorkspacePath) -> Result<PathBuf> {
94        a3s_common::tools::resolve_path(&self.root, path.as_str()).map_err(|e| anyhow!("{}", e))
95    }
96
97    fn local_path_for_write(&self, path: &WorkspacePath) -> Result<PathBuf> {
98        let target = if path.is_root() {
99            self.root.clone()
100        } else {
101            self.root.join(path.as_str())
102        };
103
104        if let Some(parent) = target.parent() {
105            std::fs::create_dir_all(parent).map_err(|e| {
106                anyhow!(
107                    "Failed to create parent directories for {}: {}",
108                    target.display(),
109                    e
110                )
111            })?;
112        }
113
114        a3s_common::tools::resolve_path_for_write(&self.root, path.as_str())
115            .map_err(|e| anyhow!("{}", e))
116    }
117
118    fn ensure_access(
119        &self,
120        path: &WorkspacePath,
121        resolved: Option<&Path>,
122        metadata: Option<&std::fs::Metadata>,
123        operation: &'static str,
124    ) -> Result<()> {
125        match &self.access_boundary {
126            Some(boundary) => boundary.ensure_access(
127                &self.root,
128                Path::new(path.as_str()),
129                resolved,
130                metadata,
131                operation,
132            ),
133            None => Ok(()),
134        }
135    }
136
137    pub(super) fn ensure_search_base_allowed(&self, path: &WorkspacePath) -> Result<()> {
138        let resolved = self.local_path_for_read(path)?;
139        let metadata = std::fs::metadata(&resolved).ok();
140        self.ensure_access(path, Some(&resolved), metadata.as_ref(), "read")
141    }
142
143    pub(super) fn read_search_file(&self, path: &WorkspacePath) -> Option<String> {
144        let resolved = self.local_path_for_read(path).ok()?;
145        let mut file = std::fs::File::open(&resolved).ok()?;
146        let metadata = file.metadata().ok()?;
147        self.ensure_access(path, Some(&resolved), Some(&metadata), "read")
148            .ok()?;
149        let mut content = String::new();
150        file.read_to_string(&mut content).ok()?;
151        Some(content)
152    }
153
154    fn git_diff_path_allowed(&self, path: &Path) -> bool {
155        let Some(path_text) = path.to_str() else {
156            return false;
157        };
158        let Ok(workspace_path) = normalize_local_path(&self.root, path_text) else {
159            return false;
160        };
161        let candidate = self.root.join(path);
162        let resolved = candidate.canonicalize().ok();
163        let metadata = resolved
164            .as_deref()
165            .and_then(|resolved| std::fs::metadata(resolved).ok());
166        self.ensure_access(
167            &workspace_path,
168            resolved.as_deref(),
169            metadata.as_ref(),
170            "read",
171        )
172        .is_ok()
173    }
174}
175
176impl WorkspacePathResolver for LocalWorkspaceBackend {
177    fn normalize(&self, input: &str) -> Result<WorkspacePath> {
178        normalize_local_path(&self.root, input)
179    }
180}
181
182#[async_trait]
183impl WorkspaceFileSystem for LocalWorkspaceBackend {
184    async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String> {
185        let resolved = self.local_path_for_read(path)?;
186        let mut file = match tokio::fs::File::open(&resolved).await {
187            Ok(file) => file,
188            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
189                return Err(WorkspaceError::NotFound {
190                    path: resolved.display().to_string(),
191                })
192            }
193            Err(e) => {
194                return Err(WorkspaceError::Backend(anyhow!(
195                    "Failed to open file {}: {}",
196                    resolved.display(),
197                    e
198                )))
199            }
200        };
201        let metadata = file.metadata().await.map_err(|error| {
202            WorkspaceError::Backend(anyhow!(
203                "Failed to inspect file {}: {}",
204                resolved.display(),
205                error
206            ))
207        })?;
208        self.ensure_access(path, Some(&resolved), Some(&metadata), "read")?;
209
210        let mut content = String::new();
211        file.read_to_string(&mut content).await.map_err(|error| {
212            WorkspaceError::Backend(anyhow!(
213                "Failed to read file {}: {}",
214                resolved.display(),
215                error
216            ))
217        })?;
218        Ok(content)
219    }
220
221    async fn write_text(
222        &self,
223        path: &WorkspacePath,
224        content: &str,
225    ) -> WorkspaceResult<WorkspaceWriteOutcome> {
226        self.ensure_access(path, None, None, "write")?;
227        let resolved = self.local_path_for_write(path)?;
228        let mut file = tokio::fs::OpenOptions::new()
229            .create(true)
230            .truncate(false)
231            .write(true)
232            .open(&resolved)
233            .await
234            .map_err(|e| {
235                WorkspaceError::Backend(anyhow!(
236                    "Failed to open file {} for writing: {}",
237                    resolved.display(),
238                    e
239                ))
240            })?;
241        let metadata = file.metadata().await.map_err(|error| {
242            WorkspaceError::Backend(anyhow!(
243                "Failed to inspect file {} before writing: {}",
244                resolved.display(),
245                error
246            ))
247        })?;
248        self.ensure_access(path, Some(&resolved), Some(&metadata), "write")?;
249        file.set_len(0).await.map_err(|e| {
250            WorkspaceError::Backend(anyhow!(
251                "Failed to write file {}: {}",
252                resolved.display(),
253                e
254            ))
255        })?;
256        file.write_all(content.as_bytes()).await.map_err(|e| {
257            WorkspaceError::Backend(anyhow!(
258                "Failed to write file {}: {}",
259                resolved.display(),
260                e
261            ))
262        })?;
263        file.flush().await.map_err(|e| {
264            WorkspaceError::Backend(anyhow!(
265                "Failed to flush file {} after writing: {}",
266                resolved.display(),
267                e
268            ))
269        })?;
270
271        Ok(WorkspaceWriteOutcome {
272            bytes: content.len(),
273            lines: content.lines().count(),
274        })
275    }
276
277    async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>> {
278        let target = self.local_path_for_read(path)?;
279        if !target.exists() {
280            return Err(WorkspaceError::NotFound {
281                path: target.display().to_string(),
282            });
283        }
284        if !target.is_dir() {
285            return Err(WorkspaceError::InvalidArgument {
286                message: format!("Not a directory: {}", target.display()),
287            });
288        }
289
290        let mut dir = tokio::fs::read_dir(&target).await.map_err(|e| {
291            WorkspaceError::Backend(anyhow!(
292                "Failed to read directory {}: {}",
293                target.display(),
294                e
295            ))
296        })?;
297        let mut entries = Vec::new();
298
299        while let Some(entry) = dir
300            .next_entry()
301            .await
302            .map_err(|e| WorkspaceError::Backend(anyhow!("Failed to iterate directory: {}", e)))?
303        {
304            let name = entry.file_name().to_string_lossy().to_string();
305            let file_type = entry.file_type().await;
306            let metadata = entry.metadata().await;
307            let (kind, size) = match (&file_type, &metadata) {
308                (Ok(ft), Ok(m)) => {
309                    let kind = if ft.is_dir() {
310                        WorkspaceFileType::Directory
311                    } else if ft.is_symlink() {
312                        WorkspaceFileType::Symlink
313                    } else {
314                        WorkspaceFileType::File
315                    };
316                    (kind, m.len())
317                }
318                _ => (WorkspaceFileType::Unknown, 0),
319            };
320            entries.push(WorkspaceDirEntry { name, kind, size });
321        }
322
323        Ok(entries)
324    }
325}
326
327#[async_trait]
328impl WorkspaceTextReader for LocalWorkspaceBackend {
329    async fn read_text_range(
330        &self,
331        path: &WorkspacePath,
332        offset: usize,
333        limit: usize,
334    ) -> WorkspaceResult<WorkspaceTextRange> {
335        let resolved = self.local_path_for_read(path)?;
336        let file = tokio::fs::File::open(&resolved).await.map_err(|error| {
337            if error.kind() == std::io::ErrorKind::NotFound {
338                WorkspaceError::NotFound {
339                    path: resolved.display().to_string(),
340                }
341            } else {
342                WorkspaceError::Backend(anyhow!(
343                    "Failed to open file {}: {}",
344                    resolved.display(),
345                    error
346                ))
347            }
348        })?;
349        let metadata = file.metadata().await.map_err(|error| {
350            WorkspaceError::Backend(anyhow!(
351                "Failed to inspect file {}: {}",
352                resolved.display(),
353                error
354            ))
355        })?;
356        self.ensure_access(path, Some(&resolved), Some(&metadata), "read")?;
357        let mut lines = BufReader::new(file).lines();
358        let mut line_index = 0usize;
359        while line_index < offset {
360            match lines.next_line().await.map_err(|error| {
361                WorkspaceError::Backend(anyhow!(
362                    "Failed to read file {}: {}",
363                    resolved.display(),
364                    error
365                ))
366            })? {
367                Some(_) => line_index += 1,
368                None => {
369                    return Ok(WorkspaceTextRange {
370                        lines: Vec::new(),
371                        next_offset: None,
372                        eof: true,
373                        total_lines: Some(line_index),
374                    })
375                }
376            }
377        }
378
379        let mut selected = Vec::with_capacity(limit);
380        while selected.len() < limit {
381            match lines.next_line().await.map_err(|error| {
382                WorkspaceError::Backend(anyhow!(
383                    "Failed to read file {}: {}",
384                    resolved.display(),
385                    error
386                ))
387            })? {
388                Some(line) => selected.push(line),
389                None => {
390                    let total_lines = offset.saturating_add(selected.len());
391                    return Ok(WorkspaceTextRange {
392                        lines: selected,
393                        next_offset: None,
394                        eof: true,
395                        total_lines: Some(total_lines),
396                    });
397                }
398            }
399        }
400
401        let has_more = lines
402            .next_line()
403            .await
404            .map_err(|error| {
405                WorkspaceError::Backend(anyhow!(
406                    "Failed to read file {}: {}",
407                    resolved.display(),
408                    error
409                ))
410            })?
411            .is_some();
412        Ok(WorkspaceTextRange {
413            lines: selected,
414            next_offset: has_more.then_some(offset.saturating_add(limit)),
415            eof: !has_more,
416            total_lines: (!has_more).then_some(offset.saturating_add(limit)),
417        })
418    }
419}
420
421#[async_trait]
422impl WorkspaceSearch for LocalWorkspaceBackend {
423    async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult> {
424        validate_relative_pattern(&request.pattern, "glob pattern")?;
425        let base = self.local_path_for_read(&request.base)?;
426        let full_pattern = base.join(&request.pattern);
427        let full_pattern = full_pattern.to_string_lossy().replace('\\', "/");
428
429        let entries = glob::glob(&full_pattern)
430            .map_err(|e| anyhow!("Invalid glob pattern '{}': {}", request.pattern, e))?;
431
432        let mut matches = Vec::new();
433        for entry in entries {
434            match entry {
435                Ok(path) => {
436                    // On Windows, `canonicalize()` commonly gives the backend
437                    // root a verbatim `\\?\` prefix while `glob` returns a
438                    // regular drive path. Canonicalize each match before
439                    // stripping so equivalent paths use the same form.
440                    let normalized = path.canonicalize().unwrap_or(path);
441                    if let Ok(relative) = normalized.strip_prefix(&self.root) {
442                        matches.push(pathbuf_to_workspace_path(relative));
443                    }
444                }
445                Err(e) => tracing::warn!("Glob entry error: {}", e),
446            }
447        }
448
449        matches.sort_by(|a, b| a.as_str().cmp(b.as_str()));
450        Ok(WorkspaceGlobResult { matches })
451    }
452
453    async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult> {
454        Ok(self.grep_with_sources(request).await?.result)
455    }
456
457    async fn grep_with_sources(
458        &self,
459        request: WorkspaceGrepRequest,
460    ) -> Result<WorkspaceGrepOutcome> {
461        if let Some(ref glob) = request.glob {
462            validate_relative_pattern(glob, "grep glob filter")?;
463        }
464
465        let regex_pattern = if request.case_insensitive {
466            format!("(?i){}", request.pattern)
467        } else {
468            request.pattern.clone()
469        };
470        let regex = regex::Regex::new(&regex_pattern)
471            .map_err(|e| anyhow!("Invalid regex pattern '{}': {}", request.pattern, e))?;
472
473        let search_path = self.local_path_for_read(&request.base)?;
474        self.ensure_search_base_allowed(&request.base)?;
475        let mut builder = ignore::WalkBuilder::new(&search_path);
476        builder.hidden(false).git_ignore(true).git_global(true);
477
478        if let Some(ref glob_pat) = request.glob {
479            let mut types = ignore::types::TypesBuilder::new();
480            types.add("custom", glob_pat).ok();
481            types.select("custom");
482            if let Ok(built) = types.build() {
483                builder.types(built);
484            }
485        }
486
487        let mut output = String::new();
488        let mut match_count = 0;
489        let mut file_count = 0;
490        let mut total_size = 0;
491        let mut matched_paths = Vec::new();
492
493        for entry in builder.build().flatten() {
494            if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
495                continue;
496            }
497
498            let file_path = entry.path();
499            let workspace_path =
500                pathbuf_to_workspace_path(file_path.strip_prefix(&self.root).unwrap_or(file_path));
501            let Some(content) = self.read_search_file(&workspace_path) else {
502                continue;
503            };
504
505            let lines: Vec<&str> = content.lines().collect();
506            let mut file_matches = Vec::new();
507            for (line_idx, line) in lines.iter().enumerate() {
508                if regex.is_match(line) {
509                    file_matches.push(line_idx);
510                }
511            }
512
513            if file_matches.is_empty() {
514                continue;
515            }
516
517            file_count += 1;
518            let rel_path = workspace_path.as_str();
519            let display_path = escape_control_chars_for_display(rel_path);
520            let mut path_recorded = false;
521
522            for &match_idx in &file_matches {
523                if total_size > request.max_output_size {
524                    return Ok(WorkspaceGrepOutcome {
525                        result: WorkspaceGrepResult {
526                            output,
527                            match_count,
528                            file_count,
529                            truncated: true,
530                        },
531                        matched_paths: Some(matched_paths),
532                    });
533                }
534
535                if !path_recorded {
536                    matched_paths.push(workspace_path.clone());
537                    path_recorded = true;
538                }
539                match_count += 1;
540
541                let start = match_idx.saturating_sub(request.context_lines);
542                let end = (match_idx + request.context_lines + 1).min(lines.len());
543
544                for (i, line) in lines[start..end].iter().enumerate() {
545                    let abs_i = start + i;
546                    let prefix = if abs_i == match_idx { ">" } else { " " };
547                    let line = format!("{}{}:{}: {}\n", prefix, display_path, abs_i + 1, line);
548                    total_size += line.len();
549                    output.push_str(&line);
550                }
551
552                if request.context_lines > 0 {
553                    output.push_str("--\n");
554                    total_size += 3;
555                }
556            }
557        }
558
559        Ok(WorkspaceGrepOutcome {
560            result: WorkspaceGrepResult {
561                output,
562                match_count,
563                file_count,
564                truncated: false,
565            },
566            matched_paths: Some(matched_paths),
567        })
568    }
569}
570
571#[async_trait]
572impl WorkspaceGit for LocalWorkspaceBackend {
573    async fn is_repository(&self) -> Result<bool> {
574        self.run_blocking_git(|root| Ok(crate::git::is_git_repo(&root)))
575            .await
576    }
577
578    async fn status(&self) -> Result<WorkspaceGitStatus> {
579        self.run_blocking_git(|root| {
580            let status = crate::git::get_status(&root)?;
581            Ok(WorkspaceGitStatus {
582                branch: status.branch,
583                commit: status.commit,
584                is_worktree: status.is_worktree,
585                is_dirty: status.is_dirty,
586                dirty_count: status.dirty_count,
587            })
588        })
589        .await
590    }
591
592    async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
593        self.run_blocking_git(move |root| {
594            Ok(crate::git::get_log(&root, max_count)?
595                .into_iter()
596                .map(|commit| WorkspaceGitCommit {
597                    id: commit.id,
598                    message: commit.message,
599                    author: commit.author,
600                    date: commit.date,
601                })
602                .collect())
603        })
604        .await
605    }
606
607    async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
608        self.run_blocking_git(|root| {
609            Ok(crate::git::list_branches(&root)?
610                .into_iter()
611                .map(|branch| WorkspaceGitBranch {
612                    name: branch.name,
613                    is_current: branch.is_current,
614                })
615                .collect())
616        })
617        .await
618    }
619
620    async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
621        self.run_blocking_git(move |root| {
622            crate::git::create_branch(&root, &request.name, &request.base)
623        })
624        .await
625    }
626
627    async fn checkout(
628        &self,
629        request: WorkspaceGitCheckoutRequest,
630    ) -> Result<WorkspaceGitCheckoutOutput> {
631        let args = if request.force {
632            vec![
633                "checkout".to_string(),
634                "--force".to_string(),
635                request.refspec,
636            ]
637        } else {
638            vec!["checkout".to_string(), request.refspec]
639        };
640        let (success, stdout, stderr) = self.run_git_command(args).await?;
641        if !success {
642            bail!("{}", stderr.trim_end());
643        }
644        Ok(WorkspaceGitCheckoutOutput { stdout })
645    }
646
647    async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
648        let target = request.target;
649        if self.access_boundary.is_none() {
650            return self
651                .run_blocking_git(move |root| crate::git::get_diff(&root, target.as_deref()))
652                .await;
653        }
654
655        let target_for_paths = target.clone();
656        let paths = self
657            .run_blocking_git(move |root| {
658                crate::git::get_diff_paths(&root, target_for_paths.as_deref())
659            })
660            .await?;
661        let paths = paths
662            .into_iter()
663            .filter(|path| self.git_diff_path_allowed(path))
664            .collect::<Vec<_>>();
665        self.run_blocking_git(move |root| {
666            crate::git::get_diff_for_paths(&root, target.as_deref(), &paths)
667        })
668        .await
669    }
670
671    async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
672        let (success, stdout, stderr) = self
673            .run_git_command(vec!["remote".to_string(), "-v".to_string()])
674            .await?;
675        if !success {
676            bail!("{}", stderr.trim_end());
677        }
678
679        Ok(stdout.lines().filter_map(parse_git_remote_line).collect())
680    }
681}
682
683#[async_trait]
684impl WorkspaceGitStashProvider for LocalWorkspaceBackend {
685    async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
686        self.run_blocking_git(|root| {
687            Ok(crate::git::list_stashes(&root)?
688                .into_iter()
689                .map(|stash| WorkspaceGitStash {
690                    index: stash.index,
691                    message: stash.message,
692                })
693                .collect())
694        })
695        .await
696    }
697
698    async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
699        self.run_blocking_git(move |root| {
700            crate::git::stash(&root, request.message.as_deref(), request.include_untracked)
701        })
702        .await
703    }
704}
705
706#[async_trait]
707impl WorkspaceGitWorktreeProvider for LocalWorkspaceBackend {
708    async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>> {
709        self.run_blocking_git(|root| {
710            Ok(crate::git::list_worktrees(&root)?
711                .into_iter()
712                .map(|worktree| WorkspaceGitWorktree {
713                    path: worktree.path,
714                    branch: worktree.branch,
715                    is_bare: worktree.is_bare,
716                    is_detached: worktree.is_detached,
717                })
718                .collect())
719        })
720        .await
721    }
722
723    async fn create_worktree(
724        &self,
725        request: WorkspaceGitCreateWorktreeRequest,
726    ) -> Result<WorkspaceGitWorktreeMutation> {
727        let branch = request.branch;
728        let path = request
729            .path
730            .map(|path| {
731                let path = PathBuf::from(path);
732                if path.is_absolute() {
733                    path
734                } else {
735                    self.root.join(path)
736                }
737            })
738            .unwrap_or_else(|| default_local_worktree_path(&self.root, &branch));
739        let display_path = path.display().to_string();
740        let new_branch = request.new_branch;
741        let branch_for_git = branch.clone();
742
743        self.run_blocking_git(move |root| {
744            crate::git::create_worktree(&root, &branch_for_git, &path, new_branch)
745        })
746        .await?;
747
748        Ok(WorkspaceGitWorktreeMutation {
749            path: display_path,
750            branch: Some(branch),
751        })
752    }
753
754    async fn remove_worktree(
755        &self,
756        request: WorkspaceGitRemoveWorktreeRequest,
757    ) -> Result<WorkspaceGitWorktreeMutation> {
758        let path = PathBuf::from(request.path);
759        let display_path = path.display().to_string();
760        let force = request.force;
761
762        self.run_blocking_git(move |root| crate::git::remove_worktree(&root, &path, force))
763            .await?;
764
765        Ok(WorkspaceGitWorktreeMutation {
766            path: display_path,
767            branch: None,
768        })
769    }
770}
771
772#[async_trait]
773impl WorkspaceCommandRunner for LocalWorkspaceBackend {
774    async fn exec(&self, request: CommandRequest) -> Result<CommandOutput> {
775        #[cfg(windows)]
776        if let Some(output) =
777            crate::tools::builtin::bash::maybe_execute_simple_windows_http_command(&request.command)
778                .await
779        {
780            let exit_code = output
781                .metadata
782                .as_ref()
783                .and_then(|m| m.get("exit_code"))
784                .and_then(|v| v.as_i64())
785                .map(|v| v as i32)
786                .unwrap_or(if output.success { 0 } else { -1 });
787            return Ok(CommandOutput {
788                output: output.content,
789                exit_code,
790                timed_out: false,
791            });
792        }
793
794        let mut child = crate::tools::builtin::bash::spawn_shell(
795            &request.command,
796            &self.root,
797            request.env.as_deref(),
798        )
799        .map_err(|e| anyhow!("Failed to spawn shell: {}", e))?;
800
801        let output = crate::tools::process::read_process_output(
802            &mut child,
803            request.timeout_ms,
804            request.output_observer.as_deref(),
805        )
806        .await
807        .map_err(|error| anyhow!("Failed to capture shell output: {error}"))?;
808        let exit_code = output.status.and_then(|status| status.code()).unwrap_or(-1);
809
810        Ok(CommandOutput {
811            output: output.combined,
812            exit_code,
813            timed_out: output.timed_out,
814        })
815    }
816}
817
818impl LocalWorkspaceBackend {
819    async fn run_blocking_git<T, F>(&self, operation: F) -> Result<T>
820    where
821        T: Send + 'static,
822        F: FnOnce(PathBuf) -> Result<T> + Send + 'static,
823    {
824        let root = self.root.clone();
825        let cancellation = Arc::new(AtomicBool::new(false));
826        let worker_cancellation = Arc::clone(&cancellation);
827        let mut cancel_on_drop = CancelGitWorkerOnDrop::new(cancellation);
828        let joined = tokio::task::spawn_blocking(move || {
829            crate::git::with_git_cancellation(worker_cancellation, || operation(root))
830        })
831        .await;
832        cancel_on_drop.disarm();
833        joined.map_err(|e| anyhow!("Git worker failed: {}", e))?
834    }
835
836    async fn run_git_command(&self, args: Vec<String>) -> Result<(bool, String, String)> {
837        const GIT_COMMAND_TIMEOUT_MS: u64 = 30_000;
838
839        let executable = crate::git::trusted_git_executable(&self.root)?;
840        let mut command = tokio::process::Command::new(executable);
841        crate::git::configure_tokio_git_environment(&mut command, &self.root);
842        command
843            .args(&args)
844            .stdout(std::process::Stdio::piped())
845            .stderr(std::process::Stdio::piped())
846            .kill_on_drop(true);
847        crate::tools::process::configure_process_group(&mut command);
848        let mut child = command
849            .spawn()
850            .map_err(|e| anyhow!("Failed to execute git: {}", e))?;
851        let output =
852            crate::tools::process::read_process_output(&mut child, GIT_COMMAND_TIMEOUT_MS, None)
853                .await
854                .map_err(|e| anyhow!("Failed to wait for git: {}", e))?;
855        if output.timed_out {
856            bail!("Git command timed out after {GIT_COMMAND_TIMEOUT_MS}ms");
857        }
858        let success = output.status.is_some_and(|status| status.success());
859
860        Ok((success, output.stdout, output.stderr))
861    }
862}
863
864fn parse_git_remote_line(line: &str) -> Option<WorkspaceGitRemote> {
865    let mut parts = line.split_whitespace();
866    let name = parts.next()?;
867    let url = parts.next()?;
868    let direction = parts
869        .next()
870        .unwrap_or_default()
871        .trim_start_matches('(')
872        .trim_end_matches(')');
873
874    Some(WorkspaceGitRemote {
875        name: name.to_string(),
876        url: url.to_string(),
877        direction: direction.to_string(),
878    })
879}
880
881fn default_local_worktree_path(root: &Path, branch: &str) -> PathBuf {
882    let repo_name = root
883        .file_name()
884        .map(|name| name.to_string_lossy().to_string())
885        .unwrap_or_else(|| "repo".to_string());
886    root.parent()
887        .unwrap_or(root)
888        .join(format!("{repo_name}-{branch}"))
889}
890
891pub(super) fn normalize_local_path(root: &Path, input: &str) -> Result<WorkspacePath> {
892    let input = default_path_input(input);
893    let candidate = Path::new(input);
894
895    if candidate.is_absolute() {
896        let root = normalize_absolute_path(root)?;
897        let target = normalize_absolute_path(candidate)?;
898        if !target.starts_with(&root) {
899            bail!(
900                "Workspace boundary violation: path '{}' escapes workspace '{}'",
901                input,
902                root.display()
903            );
904        }
905        let relative = target
906            .strip_prefix(&root)
907            .map_err(|_| anyhow!("Failed to compute workspace-relative path"))?;
908        return Ok(pathbuf_to_workspace_path(relative));
909    }
910
911    if has_windows_path_prefix(input) {
912        bail!("Absolute paths are not supported by this workspace backend");
913    }
914
915    let normalized_input = input.replace('\\', "/");
916    let path = Path::new(&normalized_input);
917    if path.is_absolute() {
918        bail!("Absolute paths are not supported by this workspace backend");
919    }
920
921    let relative = normalize_relative_path(path)?;
922    Ok(pathbuf_to_workspace_path(&relative))
923}
924
925fn normalize_absolute_path(path: &Path) -> Result<PathBuf> {
926    let lexical = normalize_absolute_path_lexical(path)?;
927    if let Ok(canonical) = lexical.canonicalize() {
928        return Ok(canonical);
929    }
930
931    let mut current = lexical.as_path();
932    let mut suffix = Vec::new();
933    while !current.exists() {
934        let Some(file_name) = current.file_name() else {
935            return Ok(lexical);
936        };
937        suffix.push(file_name.to_os_string());
938        let Some(parent) = current.parent() else {
939            return Ok(lexical);
940        };
941        current = parent;
942    }
943
944    let mut normalized = current.canonicalize().unwrap_or_else(|_| {
945        normalize_absolute_path_lexical(current).unwrap_or_else(|_| current.into())
946    });
947    for part in suffix.iter().rev() {
948        normalized.push(part);
949    }
950    Ok(normalized)
951}
952
953fn normalize_absolute_path_lexical(path: &Path) -> Result<PathBuf> {
954    let mut out = PathBuf::new();
955    for component in path.components() {
956        match component {
957            Component::Prefix(prefix) => out.push(prefix.as_os_str()),
958            Component::RootDir => out.push(Path::new(std::path::MAIN_SEPARATOR_STR)),
959            Component::CurDir => {}
960            Component::Normal(part) => out.push(part),
961            Component::ParentDir => {
962                if !out.pop() {
963                    bail!("Invalid absolute path");
964                }
965            }
966        }
967    }
968    Ok(out)
969}
970
971#[cfg(test)]
972mod tests {
973    use super::super::WorkspaceServices;
974    use super::*;
975
976    #[tokio::test]
977    async fn local_backend_reads_writes_and_lists() {
978        let temp = tempfile::tempdir().unwrap();
979        let services = WorkspaceServices::local(temp.path());
980        let path = services.normalize_path("dir/file.txt").unwrap();
981
982        let written = services
983            .fs()
984            .write_text(&path, "hello\nworld\n")
985            .await
986            .unwrap();
987        assert_eq!(written.bytes, 12);
988        assert_eq!(written.lines, 2);
989
990        let content = services.fs().read_text(&path).await.unwrap();
991        assert_eq!(content, "hello\nworld\n");
992
993        let dir = services.normalize_path("dir").unwrap();
994        let entries = services.fs().list_dir(&dir).await.unwrap();
995        assert_eq!(entries.len(), 1);
996        assert_eq!(entries[0].name, "file.txt");
997    }
998
999    #[tokio::test]
1000    async fn local_backend_searches_glob_and_grep() {
1001        let temp = tempfile::tempdir().unwrap();
1002        let services = WorkspaceServices::local(temp.path());
1003        services
1004            .fs()
1005            .write_text(
1006                &services.normalize_path("src/main.rs").unwrap(),
1007                "fn main() {\n    println!(\"hello\");\n}\n",
1008            )
1009            .await
1010            .unwrap();
1011        services
1012            .fs()
1013            .write_text(
1014                &services.normalize_path("README.md").unwrap(),
1015                "hello from docs\n",
1016            )
1017            .await
1018            .unwrap();
1019
1020        let search = services.search().expect("local backend supports search");
1021        let glob = search
1022            .glob(WorkspaceGlobRequest {
1023                base: services.normalize_path("src").unwrap(),
1024                pattern: "*.rs".to_string(),
1025            })
1026            .await
1027            .unwrap();
1028        assert_eq!(glob.matches[0].as_str(), "src/main.rs");
1029
1030        let grep = search
1031            .grep(WorkspaceGrepRequest {
1032                base: WorkspacePath::root(),
1033                pattern: "hello".to_string(),
1034                glob: Some("**/*.rs".to_string()),
1035                context_lines: 0,
1036                case_insensitive: false,
1037                max_output_size: 1024,
1038            })
1039            .await
1040            .unwrap();
1041        assert_eq!(grep.match_count, 1);
1042        assert_eq!(grep.file_count, 1);
1043        assert!(grep.output.contains("src/main.rs:2"));
1044    }
1045
1046    fn credential_boundary_backend(root: &Path) -> LocalWorkspaceBackend {
1047        LocalWorkspaceBackend::new_with_access_policy(
1048            root.to_path_buf(),
1049            LocalWorkspaceAccessPolicy::CredentialBoundary,
1050        )
1051    }
1052
1053    #[tokio::test]
1054    async fn credential_boundary_denies_direct_secret_reads_and_writes() {
1055        let temp = tempfile::tempdir().unwrap();
1056        std::fs::create_dir_all(temp.path().join("apps/api")).unwrap();
1057        std::fs::write(temp.path().join("apps/api/.env.local"), "TOKEN=secret\n").unwrap();
1058        let backend = credential_boundary_backend(temp.path());
1059        let secret = backend.normalize("apps/api/.env.local").unwrap();
1060
1061        let read_error = backend
1062            .read_text(&secret)
1063            .await
1064            .expect_err("direct secret reads must be denied");
1065        assert!(read_error.to_string().contains("credential boundary"));
1066
1067        let range_error = backend
1068            .read_text_range(&secret, 0, 10)
1069            .await
1070            .expect_err("range reads must use the same boundary");
1071        assert!(range_error.to_string().contains("credential boundary"));
1072
1073        let write_error = backend
1074            .write_text(&secret, "TOKEN=overwritten\n")
1075            .await
1076            .expect_err("direct secret writes must be denied");
1077        assert!(write_error.to_string().contains("credential boundary"));
1078        assert_eq!(
1079            std::fs::read_to_string(temp.path().join("apps/api/.env.local")).unwrap(),
1080            "TOKEN=secret\n"
1081        );
1082
1083        let new_secret = backend.normalize(".env.generated").unwrap();
1084        backend
1085            .write_text(&new_secret, "TOKEN=new\n")
1086            .await
1087            .expect_err("creating a new env file must be denied");
1088        assert!(!temp.path().join(".env.generated").exists());
1089    }
1090
1091    #[tokio::test]
1092    async fn credential_boundary_filters_grep_and_rejects_explicit_secret_base() {
1093        let temp = tempfile::tempdir().unwrap();
1094        std::fs::write(temp.path().join(".env"), "BOUNDARY_TOKEN=secret\n").unwrap();
1095        std::fs::write(
1096            temp.path().join("README.md"),
1097            "BOUNDARY_TOKEN is configured externally\n",
1098        )
1099        .unwrap();
1100        let backend = credential_boundary_backend(temp.path());
1101
1102        let grep = backend
1103            .grep(WorkspaceGrepRequest {
1104                base: WorkspacePath::root(),
1105                pattern: "BOUNDARY_TOKEN".to_string(),
1106                glob: None,
1107                context_lines: 0,
1108                case_insensitive: false,
1109                max_output_size: 1024,
1110            })
1111            .await
1112            .unwrap();
1113        assert_eq!(grep.match_count, 1);
1114        assert_eq!(grep.file_count, 1);
1115        assert!(grep.output.contains("README.md"));
1116        assert!(!grep.output.contains("secret"));
1117        assert!(!grep.output.contains(".env"));
1118
1119        let error = backend
1120            .grep(WorkspaceGrepRequest {
1121                base: backend.normalize(".env").unwrap(),
1122                pattern: "secret".to_string(),
1123                glob: None,
1124                context_lines: 0,
1125                case_insensitive: false,
1126                max_output_size: 1024,
1127            })
1128            .await
1129            .expect_err("an explicit secret grep must fail closed");
1130        assert!(error.to_string().contains("credential boundary"));
1131    }
1132
1133    #[cfg(any(unix, windows))]
1134    #[tokio::test]
1135    async fn credential_boundary_denies_source_hardlinks_without_truncating_them() {
1136        let temp = tempfile::tempdir().unwrap();
1137        let source = temp.path().join("source.txt");
1138        let alias = temp.path().join("alias.txt");
1139        std::fs::write(&source, "linked secret\n").unwrap();
1140        std::fs::hard_link(&source, &alias).unwrap();
1141        let backend = credential_boundary_backend(temp.path());
1142        let alias_path = backend.normalize("alias.txt").unwrap();
1143
1144        backend
1145            .read_text(&alias_path)
1146            .await
1147            .expect_err("source-tree hardlink reads must be denied");
1148        backend
1149            .write_text(&alias_path, "overwritten\n")
1150            .await
1151            .expect_err("source-tree hardlink writes must be denied");
1152        assert_eq!(std::fs::read_to_string(&source).unwrap(), "linked secret\n");
1153    }
1154
1155    #[cfg(any(unix, windows))]
1156    #[tokio::test]
1157    async fn credential_boundary_allows_package_store_hardlinks_but_denies_secret_aliases() {
1158        let temp = tempfile::tempdir().unwrap();
1159        let package = temp.path().join("node_modules/pkg");
1160        std::fs::create_dir_all(&package).unwrap();
1161
1162        let package_source = package.join("source.js");
1163        let package_alias = package.join("alias.js");
1164        std::fs::write(&package_source, "export const value = 1;\n").unwrap();
1165        std::fs::hard_link(&package_source, &package_alias).unwrap();
1166
1167        let env = temp.path().join(".env");
1168        let env_alias = package.join("credential.txt");
1169        std::fs::write(&env, "TOKEN=secret\n").unwrap();
1170        std::fs::hard_link(&env, &env_alias).unwrap();
1171
1172        let backend = credential_boundary_backend(temp.path());
1173        let package_content = backend
1174            .read_text(&backend.normalize("node_modules/pkg/alias.js").unwrap())
1175            .await
1176            .expect("ordinary package-store hardlinks should remain readable");
1177        assert!(package_content.contains("value = 1"));
1178
1179        let error = backend
1180            .read_text(
1181                &backend
1182                    .normalize("node_modules/pkg/credential.txt")
1183                    .unwrap(),
1184            )
1185            .await
1186            .expect_err("a package-tree alias of a known credential must be denied");
1187        assert!(error.to_string().contains("credential boundary"));
1188    }
1189
1190    fn run_test_git(root: &Path, args: &[&str]) -> bool {
1191        std::process::Command::new("git")
1192            .arg("-C")
1193            .arg(root)
1194            .args([
1195                "-c",
1196                "user.name=A3S Test",
1197                "-c",
1198                "user.email=test@a3s.local",
1199            ])
1200            .args(args)
1201            .status()
1202            .is_ok_and(|status| status.success())
1203    }
1204
1205    #[cfg(any(unix, windows))]
1206    #[tokio::test]
1207    async fn credential_boundary_filters_git_diff_content_and_option_like_targets() {
1208        let temp = tempfile::tempdir().unwrap();
1209        if !run_test_git(temp.path(), &["init", "-q"]) {
1210            return;
1211        }
1212        std::fs::create_dir_all(temp.path().join("src")).unwrap();
1213        std::fs::write(temp.path().join(".env"), "TOKEN=old-secret\n").unwrap();
1214        std::fs::write(temp.path().join("src/lib.rs"), "pub const VALUE: u8 = 1;\n").unwrap();
1215        std::fs::write(temp.path().join("linked.txt"), "hardlink-old-secret\n").unwrap();
1216        std::fs::hard_link(
1217            temp.path().join("linked.txt"),
1218            temp.path().join("linked-alias.txt"),
1219        )
1220        .unwrap();
1221        assert!(run_test_git(temp.path(), &["add", "."]));
1222        assert!(run_test_git(temp.path(), &["commit", "-qm", "baseline"]));
1223
1224        std::fs::write(temp.path().join(".env"), "TOKEN=new-secret\n").unwrap();
1225        std::fs::write(temp.path().join("src/lib.rs"), "pub const VALUE: u8 = 2;\n").unwrap();
1226        std::fs::write(
1227            temp.path().join("linked-alias.txt"),
1228            "hardlink-new-secret\n",
1229        )
1230        .unwrap();
1231
1232        let backend = credential_boundary_backend(temp.path());
1233        let diff = backend
1234            .diff(WorkspaceGitDiffRequest { target: None })
1235            .await
1236            .unwrap();
1237        assert!(diff.contains("VALUE: u8 = 2"), "{diff}");
1238        for denied in [
1239            "old-secret",
1240            "new-secret",
1241            "hardlink-old-secret",
1242            "hardlink-new-secret",
1243            ".env",
1244            "linked.txt",
1245            "linked-alias.txt",
1246        ] {
1247            assert!(!diff.contains(denied), "{denied} leaked in {diff}");
1248        }
1249
1250        let output = temp.path().join("injected-diff-output");
1251        let error = backend
1252            .diff(WorkspaceGitDiffRequest {
1253                target: Some(format!("--output={}", output.display())),
1254            })
1255            .await
1256            .expect_err("an option-like target must be parsed only as a revision");
1257        assert!(error.to_string().contains("Git diff"));
1258        assert!(!output.exists());
1259    }
1260
1261    #[test]
1262    fn local_backend_rejects_absolute_paths_outside_workspace() {
1263        let temp = tempfile::tempdir().unwrap();
1264        let services = WorkspaceServices::local(temp.path());
1265        let outside = temp.path().parent().unwrap().join("secret.txt");
1266        let err = services
1267            .normalize_path(outside.to_str().unwrap())
1268            .expect_err("outside absolute path should be rejected");
1269        assert!(err.to_string().contains("escapes workspace"));
1270    }
1271
1272    #[test]
1273    fn local_backend_rejects_backslash_parent_escape() {
1274        let temp = tempfile::tempdir().unwrap();
1275        let services = WorkspaceServices::local(temp.path());
1276        let err = services
1277            .normalize_path(r"..\secret.txt")
1278            .expect_err("backslash parent traversal should be rejected");
1279        assert!(err.to_string().contains("escapes workspace"));
1280    }
1281
1282    #[test]
1283    fn local_backend_allows_absolute_paths_inside_workspace() {
1284        let temp = tempfile::tempdir().unwrap();
1285        let services = WorkspaceServices::local(temp.path());
1286        let absolute = temp.path().join("src/main.rs");
1287        let path = services
1288            .normalize_path(absolute.to_str().unwrap())
1289            .expect("absolute path inside workspace should normalize");
1290        assert_eq!(path.as_str(), "src/main.rs");
1291    }
1292}