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        let metadata_only = request.max_output_size == 0;
493
494        for entry in builder.build().flatten() {
495            if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
496                continue;
497            }
498
499            let file_path = entry.path();
500            let workspace_path =
501                pathbuf_to_workspace_path(file_path.strip_prefix(&self.root).unwrap_or(file_path));
502            let Some(content) = self.read_search_file(&workspace_path) else {
503                continue;
504            };
505
506            let lines: Vec<&str> = content.lines().collect();
507            let mut file_matches = Vec::new();
508            for (line_idx, line) in lines.iter().enumerate() {
509                if regex.is_match(line) {
510                    file_matches.push(line_idx);
511                }
512            }
513
514            if file_matches.is_empty() {
515                continue;
516            }
517
518            file_count += 1;
519            let rel_path = workspace_path.as_str();
520            let display_path = escape_control_chars_for_display(rel_path);
521            let mut path_recorded = false;
522
523            for &match_idx in &file_matches {
524                if !metadata_only && total_size > request.max_output_size {
525                    return Ok(WorkspaceGrepOutcome {
526                        result: WorkspaceGrepResult {
527                            output,
528                            match_count,
529                            file_count,
530                            truncated: true,
531                        },
532                        matched_paths: Some(matched_paths),
533                    });
534                }
535
536                if !path_recorded {
537                    matched_paths.push(workspace_path.clone());
538                    path_recorded = true;
539                }
540                match_count += 1;
541                if metadata_only {
542                    continue;
543                }
544
545                let start = match_idx.saturating_sub(request.context_lines);
546                let end = (match_idx + request.context_lines + 1).min(lines.len());
547
548                for (i, line) in lines[start..end].iter().enumerate() {
549                    let abs_i = start + i;
550                    let prefix = if abs_i == match_idx { ">" } else { " " };
551                    let line = format!("{}{}:{}: {}\n", prefix, display_path, abs_i + 1, line);
552                    total_size += line.len();
553                    output.push_str(&line);
554                }
555
556                if request.context_lines > 0 {
557                    output.push_str("--\n");
558                    total_size += 3;
559                }
560            }
561        }
562
563        Ok(WorkspaceGrepOutcome {
564            result: WorkspaceGrepResult {
565                output,
566                match_count,
567                file_count,
568                truncated: false,
569            },
570            matched_paths: Some(matched_paths),
571        })
572    }
573}
574
575#[async_trait]
576impl WorkspaceGit for LocalWorkspaceBackend {
577    async fn is_repository(&self) -> Result<bool> {
578        self.run_blocking_git(|root| Ok(crate::git::is_git_repo(&root)))
579            .await
580    }
581
582    async fn status(&self) -> Result<WorkspaceGitStatus> {
583        self.run_blocking_git(|root| {
584            let status = crate::git::get_status(&root)?;
585            Ok(WorkspaceGitStatus {
586                branch: status.branch,
587                commit: status.commit,
588                is_worktree: status.is_worktree,
589                is_dirty: status.is_dirty,
590                dirty_count: status.dirty_count,
591            })
592        })
593        .await
594    }
595
596    async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
597        self.run_blocking_git(move |root| {
598            Ok(crate::git::get_log(&root, max_count)?
599                .into_iter()
600                .map(|commit| WorkspaceGitCommit {
601                    id: commit.id,
602                    message: commit.message,
603                    author: commit.author,
604                    date: commit.date,
605                })
606                .collect())
607        })
608        .await
609    }
610
611    async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
612        self.run_blocking_git(|root| {
613            Ok(crate::git::list_branches(&root)?
614                .into_iter()
615                .map(|branch| WorkspaceGitBranch {
616                    name: branch.name,
617                    is_current: branch.is_current,
618                })
619                .collect())
620        })
621        .await
622    }
623
624    async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
625        self.run_blocking_git(move |root| {
626            crate::git::create_branch(&root, &request.name, &request.base)
627        })
628        .await
629    }
630
631    async fn checkout(
632        &self,
633        request: WorkspaceGitCheckoutRequest,
634    ) -> Result<WorkspaceGitCheckoutOutput> {
635        let args = if request.force {
636            vec![
637                "checkout".to_string(),
638                "--force".to_string(),
639                request.refspec,
640            ]
641        } else {
642            vec!["checkout".to_string(), request.refspec]
643        };
644        let (success, stdout, stderr) = self.run_git_command(args).await?;
645        if !success {
646            bail!("{}", stderr.trim_end());
647        }
648        Ok(WorkspaceGitCheckoutOutput { stdout })
649    }
650
651    async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
652        let target = request.target;
653        if self.access_boundary.is_none() {
654            return self
655                .run_blocking_git(move |root| crate::git::get_diff(&root, target.as_deref()))
656                .await;
657        }
658
659        let target_for_paths = target.clone();
660        let paths = self
661            .run_blocking_git(move |root| {
662                crate::git::get_diff_paths(&root, target_for_paths.as_deref())
663            })
664            .await?;
665        let paths = paths
666            .into_iter()
667            .filter(|path| self.git_diff_path_allowed(path))
668            .collect::<Vec<_>>();
669        self.run_blocking_git(move |root| {
670            crate::git::get_diff_for_paths(&root, target.as_deref(), &paths)
671        })
672        .await
673    }
674
675    async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
676        let (success, stdout, stderr) = self
677            .run_git_command(vec!["remote".to_string(), "-v".to_string()])
678            .await?;
679        if !success {
680            bail!("{}", stderr.trim_end());
681        }
682
683        Ok(stdout.lines().filter_map(parse_git_remote_line).collect())
684    }
685}
686
687#[async_trait]
688impl WorkspaceGitStashProvider for LocalWorkspaceBackend {
689    async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
690        self.run_blocking_git(|root| {
691            Ok(crate::git::list_stashes(&root)?
692                .into_iter()
693                .map(|stash| WorkspaceGitStash {
694                    index: stash.index,
695                    message: stash.message,
696                })
697                .collect())
698        })
699        .await
700    }
701
702    async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
703        self.run_blocking_git(move |root| {
704            crate::git::stash(&root, request.message.as_deref(), request.include_untracked)
705        })
706        .await
707    }
708}
709
710#[async_trait]
711impl WorkspaceGitWorktreeProvider for LocalWorkspaceBackend {
712    async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>> {
713        self.run_blocking_git(|root| {
714            Ok(crate::git::list_worktrees(&root)?
715                .into_iter()
716                .map(|worktree| WorkspaceGitWorktree {
717                    path: worktree.path,
718                    branch: worktree.branch,
719                    is_bare: worktree.is_bare,
720                    is_detached: worktree.is_detached,
721                })
722                .collect())
723        })
724        .await
725    }
726
727    async fn create_worktree(
728        &self,
729        request: WorkspaceGitCreateWorktreeRequest,
730    ) -> Result<WorkspaceGitWorktreeMutation> {
731        let branch = request.branch;
732        let path = request
733            .path
734            .map(|path| {
735                let path = PathBuf::from(path);
736                if path.is_absolute() {
737                    path
738                } else {
739                    self.root.join(path)
740                }
741            })
742            .unwrap_or_else(|| default_local_worktree_path(&self.root, &branch));
743        let display_path = path.display().to_string();
744        let new_branch = request.new_branch;
745        let branch_for_git = branch.clone();
746
747        self.run_blocking_git(move |root| {
748            crate::git::create_worktree(&root, &branch_for_git, &path, new_branch)
749        })
750        .await?;
751
752        Ok(WorkspaceGitWorktreeMutation {
753            path: display_path,
754            branch: Some(branch),
755        })
756    }
757
758    async fn remove_worktree(
759        &self,
760        request: WorkspaceGitRemoveWorktreeRequest,
761    ) -> Result<WorkspaceGitWorktreeMutation> {
762        let path = PathBuf::from(request.path);
763        let display_path = path.display().to_string();
764        let force = request.force;
765
766        self.run_blocking_git(move |root| crate::git::remove_worktree(&root, &path, force))
767            .await?;
768
769        Ok(WorkspaceGitWorktreeMutation {
770            path: display_path,
771            branch: None,
772        })
773    }
774}
775
776#[async_trait]
777impl WorkspaceCommandRunner for LocalWorkspaceBackend {
778    async fn exec(&self, request: CommandRequest) -> Result<CommandOutput> {
779        #[cfg(windows)]
780        if let Some(output) =
781            crate::tools::builtin::bash::maybe_execute_simple_windows_http_command(&request.command)
782                .await
783        {
784            let exit_code = output
785                .metadata
786                .as_ref()
787                .and_then(|m| m.get("exit_code"))
788                .and_then(|v| v.as_i64())
789                .map(|v| v as i32)
790                .unwrap_or(if output.success { 0 } else { -1 });
791            return Ok(CommandOutput {
792                output: output.content,
793                exit_code,
794                timed_out: false,
795            });
796        }
797
798        let mut child = crate::tools::builtin::bash::spawn_shell(
799            &request.command,
800            &self.root,
801            request.env.as_deref(),
802        )
803        .map_err(|e| anyhow!("Failed to spawn shell: {}", e))?;
804
805        let output = crate::tools::process::read_process_output(
806            &mut child,
807            request.timeout_ms,
808            request.output_observer.as_deref(),
809        )
810        .await
811        .map_err(|error| anyhow!("Failed to capture shell output: {error}"))?;
812        let exit_code = output.status.and_then(|status| status.code()).unwrap_or(-1);
813
814        Ok(CommandOutput {
815            output: output.combined,
816            exit_code,
817            timed_out: output.timed_out,
818        })
819    }
820}
821
822impl LocalWorkspaceBackend {
823    async fn run_blocking_git<T, F>(&self, operation: F) -> Result<T>
824    where
825        T: Send + 'static,
826        F: FnOnce(PathBuf) -> Result<T> + Send + 'static,
827    {
828        let root = self.root.clone();
829        let cancellation = Arc::new(AtomicBool::new(false));
830        let worker_cancellation = Arc::clone(&cancellation);
831        let mut cancel_on_drop = CancelGitWorkerOnDrop::new(cancellation);
832        let joined = tokio::task::spawn_blocking(move || {
833            crate::git::with_git_cancellation(worker_cancellation, || operation(root))
834        })
835        .await;
836        cancel_on_drop.disarm();
837        joined.map_err(|e| anyhow!("Git worker failed: {}", e))?
838    }
839
840    async fn run_git_command(&self, args: Vec<String>) -> Result<(bool, String, String)> {
841        const GIT_COMMAND_TIMEOUT_MS: u64 = 30_000;
842
843        let executable = crate::git::trusted_git_executable(&self.root)?;
844        let mut command = tokio::process::Command::new(executable);
845        crate::git::configure_tokio_git_environment(&mut command, &self.root);
846        command
847            .args(&args)
848            .stdout(std::process::Stdio::piped())
849            .stderr(std::process::Stdio::piped())
850            .kill_on_drop(true);
851        crate::tools::process::configure_process_group(&mut command);
852        let mut child = command
853            .spawn()
854            .map_err(|e| anyhow!("Failed to execute git: {}", e))?;
855        let output =
856            crate::tools::process::read_process_output(&mut child, GIT_COMMAND_TIMEOUT_MS, None)
857                .await
858                .map_err(|e| anyhow!("Failed to wait for git: {}", e))?;
859        if output.timed_out {
860            bail!("Git command timed out after {GIT_COMMAND_TIMEOUT_MS}ms");
861        }
862        let success = output.status.is_some_and(|status| status.success());
863
864        Ok((success, output.stdout, output.stderr))
865    }
866}
867
868fn parse_git_remote_line(line: &str) -> Option<WorkspaceGitRemote> {
869    let mut parts = line.split_whitespace();
870    let name = parts.next()?;
871    let url = parts.next()?;
872    let direction = parts
873        .next()
874        .unwrap_or_default()
875        .trim_start_matches('(')
876        .trim_end_matches(')');
877
878    Some(WorkspaceGitRemote {
879        name: name.to_string(),
880        url: url.to_string(),
881        direction: direction.to_string(),
882    })
883}
884
885fn default_local_worktree_path(root: &Path, branch: &str) -> PathBuf {
886    let repo_name = root
887        .file_name()
888        .map(|name| name.to_string_lossy().to_string())
889        .unwrap_or_else(|| "repo".to_string());
890    root.parent()
891        .unwrap_or(root)
892        .join(format!("{repo_name}-{branch}"))
893}
894
895pub(super) fn normalize_local_path(root: &Path, input: &str) -> Result<WorkspacePath> {
896    let input = default_path_input(input);
897    let candidate = Path::new(input);
898
899    if candidate.is_absolute() {
900        let root = normalize_absolute_path(root)?;
901        let target = normalize_absolute_path(candidate)?;
902        if !target.starts_with(&root) {
903            bail!(
904                "Workspace boundary violation: path '{}' escapes workspace '{}'",
905                input,
906                root.display()
907            );
908        }
909        let relative = target
910            .strip_prefix(&root)
911            .map_err(|_| anyhow!("Failed to compute workspace-relative path"))?;
912        return Ok(pathbuf_to_workspace_path(relative));
913    }
914
915    if has_windows_path_prefix(input) {
916        bail!("Absolute paths are not supported by this workspace backend");
917    }
918
919    let normalized_input = input.replace('\\', "/");
920    let path = Path::new(&normalized_input);
921    if path.is_absolute() {
922        bail!("Absolute paths are not supported by this workspace backend");
923    }
924
925    let relative = normalize_relative_path(path)?;
926    Ok(pathbuf_to_workspace_path(&relative))
927}
928
929fn normalize_absolute_path(path: &Path) -> Result<PathBuf> {
930    let lexical = normalize_absolute_path_lexical(path)?;
931    if let Ok(canonical) = lexical.canonicalize() {
932        return Ok(canonical);
933    }
934
935    let mut current = lexical.as_path();
936    let mut suffix = Vec::new();
937    while !current.exists() {
938        let Some(file_name) = current.file_name() else {
939            return Ok(lexical);
940        };
941        suffix.push(file_name.to_os_string());
942        let Some(parent) = current.parent() else {
943            return Ok(lexical);
944        };
945        current = parent;
946    }
947
948    let mut normalized = current.canonicalize().unwrap_or_else(|_| {
949        normalize_absolute_path_lexical(current).unwrap_or_else(|_| current.into())
950    });
951    for part in suffix.iter().rev() {
952        normalized.push(part);
953    }
954    Ok(normalized)
955}
956
957fn normalize_absolute_path_lexical(path: &Path) -> Result<PathBuf> {
958    let mut out = PathBuf::new();
959    for component in path.components() {
960        match component {
961            Component::Prefix(prefix) => out.push(prefix.as_os_str()),
962            Component::RootDir => out.push(Path::new(std::path::MAIN_SEPARATOR_STR)),
963            Component::CurDir => {}
964            Component::Normal(part) => out.push(part),
965            Component::ParentDir => {
966                if !out.pop() {
967                    bail!("Invalid absolute path");
968                }
969            }
970        }
971    }
972    Ok(out)
973}
974
975#[cfg(test)]
976mod tests {
977    use super::super::WorkspaceServices;
978    use super::*;
979
980    #[tokio::test]
981    async fn local_backend_reads_writes_and_lists() {
982        let temp = tempfile::tempdir().unwrap();
983        let services = WorkspaceServices::local(temp.path());
984        let path = services.normalize_path("dir/file.txt").unwrap();
985
986        let written = services
987            .fs()
988            .write_text(&path, "hello\nworld\n")
989            .await
990            .unwrap();
991        assert_eq!(written.bytes, 12);
992        assert_eq!(written.lines, 2);
993
994        let content = services.fs().read_text(&path).await.unwrap();
995        assert_eq!(content, "hello\nworld\n");
996
997        let dir = services.normalize_path("dir").unwrap();
998        let entries = services.fs().list_dir(&dir).await.unwrap();
999        assert_eq!(entries.len(), 1);
1000        assert_eq!(entries[0].name, "file.txt");
1001    }
1002
1003    #[tokio::test]
1004    async fn local_backend_searches_glob_and_grep() {
1005        let temp = tempfile::tempdir().unwrap();
1006        let services = WorkspaceServices::local(temp.path());
1007        services
1008            .fs()
1009            .write_text(
1010                &services.normalize_path("src/main.rs").unwrap(),
1011                "fn main() {\n    println!(\"hello\");\n}\n",
1012            )
1013            .await
1014            .unwrap();
1015        services
1016            .fs()
1017            .write_text(
1018                &services.normalize_path("README.md").unwrap(),
1019                "hello from docs\n",
1020            )
1021            .await
1022            .unwrap();
1023
1024        let search = services.search().expect("local backend supports search");
1025        let glob = search
1026            .glob(WorkspaceGlobRequest {
1027                base: services.normalize_path("src").unwrap(),
1028                pattern: "*.rs".to_string(),
1029            })
1030            .await
1031            .unwrap();
1032        assert_eq!(glob.matches[0].as_str(), "src/main.rs");
1033
1034        let grep = search
1035            .grep(WorkspaceGrepRequest {
1036                base: WorkspacePath::root(),
1037                pattern: "hello".to_string(),
1038                glob: Some("**/*.rs".to_string()),
1039                context_lines: 0,
1040                case_insensitive: false,
1041                max_output_size: 1024,
1042            })
1043            .await
1044            .unwrap();
1045        assert_eq!(grep.match_count, 1);
1046        assert_eq!(grep.file_count, 1);
1047        assert!(grep.output.contains("src/main.rs:2"));
1048    }
1049
1050    fn credential_boundary_backend(root: &Path) -> LocalWorkspaceBackend {
1051        LocalWorkspaceBackend::new_with_access_policy(
1052            root.to_path_buf(),
1053            LocalWorkspaceAccessPolicy::CredentialBoundary,
1054        )
1055    }
1056
1057    #[tokio::test]
1058    async fn credential_boundary_denies_direct_secret_reads_and_writes() {
1059        let temp = tempfile::tempdir().unwrap();
1060        std::fs::create_dir_all(temp.path().join("apps/api")).unwrap();
1061        std::fs::write(temp.path().join("apps/api/.env.local"), "TOKEN=secret\n").unwrap();
1062        let backend = credential_boundary_backend(temp.path());
1063        let secret = backend.normalize("apps/api/.env.local").unwrap();
1064
1065        let read_error = backend
1066            .read_text(&secret)
1067            .await
1068            .expect_err("direct secret reads must be denied");
1069        assert!(read_error.to_string().contains("credential boundary"));
1070
1071        let range_error = backend
1072            .read_text_range(&secret, 0, 10)
1073            .await
1074            .expect_err("range reads must use the same boundary");
1075        assert!(range_error.to_string().contains("credential boundary"));
1076
1077        let write_error = backend
1078            .write_text(&secret, "TOKEN=overwritten\n")
1079            .await
1080            .expect_err("direct secret writes must be denied");
1081        assert!(write_error.to_string().contains("credential boundary"));
1082        assert_eq!(
1083            std::fs::read_to_string(temp.path().join("apps/api/.env.local")).unwrap(),
1084            "TOKEN=secret\n"
1085        );
1086
1087        let new_secret = backend.normalize(".env.generated").unwrap();
1088        backend
1089            .write_text(&new_secret, "TOKEN=new\n")
1090            .await
1091            .expect_err("creating a new env file must be denied");
1092        assert!(!temp.path().join(".env.generated").exists());
1093    }
1094
1095    #[tokio::test]
1096    async fn credential_boundary_filters_grep_and_rejects_explicit_secret_base() {
1097        let temp = tempfile::tempdir().unwrap();
1098        std::fs::write(temp.path().join(".env"), "BOUNDARY_TOKEN=secret\n").unwrap();
1099        std::fs::write(
1100            temp.path().join("README.md"),
1101            "BOUNDARY_TOKEN is configured externally\n",
1102        )
1103        .unwrap();
1104        let backend = credential_boundary_backend(temp.path());
1105
1106        let grep = backend
1107            .grep(WorkspaceGrepRequest {
1108                base: WorkspacePath::root(),
1109                pattern: "BOUNDARY_TOKEN".to_string(),
1110                glob: None,
1111                context_lines: 0,
1112                case_insensitive: false,
1113                max_output_size: 1024,
1114            })
1115            .await
1116            .unwrap();
1117        assert_eq!(grep.match_count, 1);
1118        assert_eq!(grep.file_count, 1);
1119        assert!(grep.output.contains("README.md"));
1120        assert!(!grep.output.contains("secret"));
1121        assert!(!grep.output.contains(".env"));
1122
1123        let error = backend
1124            .grep(WorkspaceGrepRequest {
1125                base: backend.normalize(".env").unwrap(),
1126                pattern: "secret".to_string(),
1127                glob: None,
1128                context_lines: 0,
1129                case_insensitive: false,
1130                max_output_size: 1024,
1131            })
1132            .await
1133            .expect_err("an explicit secret grep must fail closed");
1134        assert!(error.to_string().contains("credential boundary"));
1135    }
1136
1137    #[cfg(any(unix, windows))]
1138    #[tokio::test]
1139    async fn credential_boundary_denies_source_hardlinks_without_truncating_them() {
1140        let temp = tempfile::tempdir().unwrap();
1141        let source = temp.path().join("source.txt");
1142        let alias = temp.path().join("alias.txt");
1143        std::fs::write(&source, "linked secret\n").unwrap();
1144        std::fs::hard_link(&source, &alias).unwrap();
1145        let backend = credential_boundary_backend(temp.path());
1146        let alias_path = backend.normalize("alias.txt").unwrap();
1147
1148        backend
1149            .read_text(&alias_path)
1150            .await
1151            .expect_err("source-tree hardlink reads must be denied");
1152        backend
1153            .write_text(&alias_path, "overwritten\n")
1154            .await
1155            .expect_err("source-tree hardlink writes must be denied");
1156        assert_eq!(std::fs::read_to_string(&source).unwrap(), "linked secret\n");
1157    }
1158
1159    #[cfg(any(unix, windows))]
1160    #[tokio::test]
1161    async fn credential_boundary_allows_package_store_hardlinks_but_denies_secret_aliases() {
1162        let temp = tempfile::tempdir().unwrap();
1163        let package = temp.path().join("node_modules/pkg");
1164        std::fs::create_dir_all(&package).unwrap();
1165
1166        let package_source = package.join("source.js");
1167        let package_alias = package.join("alias.js");
1168        std::fs::write(&package_source, "export const value = 1;\n").unwrap();
1169        std::fs::hard_link(&package_source, &package_alias).unwrap();
1170
1171        let env = temp.path().join(".env");
1172        let env_alias = package.join("credential.txt");
1173        std::fs::write(&env, "TOKEN=secret\n").unwrap();
1174        std::fs::hard_link(&env, &env_alias).unwrap();
1175
1176        let backend = credential_boundary_backend(temp.path());
1177        let package_content = backend
1178            .read_text(&backend.normalize("node_modules/pkg/alias.js").unwrap())
1179            .await
1180            .expect("ordinary package-store hardlinks should remain readable");
1181        assert!(package_content.contains("value = 1"));
1182
1183        let error = backend
1184            .read_text(
1185                &backend
1186                    .normalize("node_modules/pkg/credential.txt")
1187                    .unwrap(),
1188            )
1189            .await
1190            .expect_err("a package-tree alias of a known credential must be denied");
1191        assert!(error.to_string().contains("credential boundary"));
1192    }
1193
1194    fn run_test_git(root: &Path, args: &[&str]) -> bool {
1195        std::process::Command::new("git")
1196            .arg("-C")
1197            .arg(root)
1198            .args([
1199                "-c",
1200                "user.name=A3S Test",
1201                "-c",
1202                "user.email=test@a3s.local",
1203            ])
1204            .args(args)
1205            .status()
1206            .is_ok_and(|status| status.success())
1207    }
1208
1209    #[cfg(any(unix, windows))]
1210    #[tokio::test]
1211    async fn credential_boundary_filters_git_diff_content_and_option_like_targets() {
1212        let temp = tempfile::tempdir().unwrap();
1213        if !run_test_git(temp.path(), &["init", "-q"]) {
1214            return;
1215        }
1216        std::fs::create_dir_all(temp.path().join("src")).unwrap();
1217        std::fs::write(temp.path().join(".env"), "TOKEN=old-secret\n").unwrap();
1218        std::fs::write(temp.path().join("src/lib.rs"), "pub const VALUE: u8 = 1;\n").unwrap();
1219        std::fs::write(temp.path().join("linked.txt"), "hardlink-old-secret\n").unwrap();
1220        std::fs::hard_link(
1221            temp.path().join("linked.txt"),
1222            temp.path().join("linked-alias.txt"),
1223        )
1224        .unwrap();
1225        assert!(run_test_git(temp.path(), &["add", "."]));
1226        assert!(run_test_git(temp.path(), &["commit", "-qm", "baseline"]));
1227
1228        std::fs::write(temp.path().join(".env"), "TOKEN=new-secret\n").unwrap();
1229        std::fs::write(temp.path().join("src/lib.rs"), "pub const VALUE: u8 = 2;\n").unwrap();
1230        std::fs::write(
1231            temp.path().join("linked-alias.txt"),
1232            "hardlink-new-secret\n",
1233        )
1234        .unwrap();
1235
1236        let backend = credential_boundary_backend(temp.path());
1237        let diff = backend
1238            .diff(WorkspaceGitDiffRequest { target: None })
1239            .await
1240            .unwrap();
1241        assert!(diff.contains("VALUE: u8 = 2"), "{diff}");
1242        for denied in [
1243            "old-secret",
1244            "new-secret",
1245            "hardlink-old-secret",
1246            "hardlink-new-secret",
1247            ".env",
1248            "linked.txt",
1249            "linked-alias.txt",
1250        ] {
1251            assert!(!diff.contains(denied), "{denied} leaked in {diff}");
1252        }
1253
1254        let output = temp.path().join("injected-diff-output");
1255        let error = backend
1256            .diff(WorkspaceGitDiffRequest {
1257                target: Some(format!("--output={}", output.display())),
1258            })
1259            .await
1260            .expect_err("an option-like target must be parsed only as a revision");
1261        assert!(error.to_string().contains("Git diff"));
1262        assert!(!output.exists());
1263    }
1264
1265    #[test]
1266    fn local_backend_rejects_absolute_paths_outside_workspace() {
1267        let temp = tempfile::tempdir().unwrap();
1268        let services = WorkspaceServices::local(temp.path());
1269        let outside = temp.path().parent().unwrap().join("secret.txt");
1270        let err = services
1271            .normalize_path(outside.to_str().unwrap())
1272            .expect_err("outside absolute path should be rejected");
1273        assert!(err.to_string().contains("escapes workspace"));
1274    }
1275
1276    #[test]
1277    fn local_backend_rejects_backslash_parent_escape() {
1278        let temp = tempfile::tempdir().unwrap();
1279        let services = WorkspaceServices::local(temp.path());
1280        let err = services
1281            .normalize_path(r"..\secret.txt")
1282            .expect_err("backslash parent traversal should be rejected");
1283        assert!(err.to_string().contains("escapes workspace"));
1284    }
1285
1286    #[test]
1287    fn local_backend_allows_absolute_paths_inside_workspace() {
1288        let temp = tempfile::tempdir().unwrap();
1289        let services = WorkspaceServices::local(temp.path());
1290        let absolute = temp.path().join("src/main.rs");
1291        let path = services
1292            .normalize_path(absolute.to_str().unwrap())
1293            .expect("absolute path inside workspace should normalize");
1294        assert_eq!(path.as_str(), "src/main.rs");
1295    }
1296}