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