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::native::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 = crate::tools::process::spawn_tokio_with_native_gate(&mut command)
904            .map_err(|e| anyhow!("Failed to execute git: {}", e))?;
905        let output =
906            crate::tools::process::read_process_output(&mut child, GIT_COMMAND_TIMEOUT_MS, None)
907                .await
908                .map_err(|e| anyhow!("Failed to wait for git: {}", e))?;
909        if output.timed_out {
910            bail!("Git command timed out after {GIT_COMMAND_TIMEOUT_MS}ms");
911        }
912        let success = output.status.is_some_and(|status| status.success());
913
914        Ok((success, output.stdout, output.stderr))
915    }
916}
917
918fn parse_git_remote_line(line: &str) -> Option<WorkspaceGitRemote> {
919    let mut parts = line.split_whitespace();
920    let name = parts.next()?;
921    let url = parts.next()?;
922    let direction = parts
923        .next()
924        .unwrap_or_default()
925        .trim_start_matches('(')
926        .trim_end_matches(')');
927
928    Some(WorkspaceGitRemote {
929        name: name.to_string(),
930        url: url.to_string(),
931        direction: direction.to_string(),
932    })
933}
934
935fn default_local_worktree_path(root: &Path, branch: &str) -> PathBuf {
936    let repo_name = root
937        .file_name()
938        .map(|name| name.to_string_lossy().to_string())
939        .unwrap_or_else(|| "repo".to_string());
940    root.parent()
941        .unwrap_or(root)
942        .join(format!("{repo_name}-{branch}"))
943}
944
945pub(super) fn normalize_local_path(root: &Path, input: &str) -> Result<WorkspacePath> {
946    let input = default_path_input(input);
947    let candidate = Path::new(input);
948
949    if candidate.is_absolute() {
950        let root = normalize_absolute_path(root)?;
951        let target = normalize_absolute_path(candidate)?;
952        if !target.starts_with(&root) {
953            bail!(
954                "Workspace boundary violation: path '{}' escapes workspace '{}'",
955                input,
956                root.display()
957            );
958        }
959        let relative = target
960            .strip_prefix(&root)
961            .map_err(|_| anyhow!("Failed to compute workspace-relative path"))?;
962        return Ok(pathbuf_to_workspace_path(relative));
963    }
964
965    if has_windows_path_prefix(input) {
966        bail!("Absolute paths are not supported by this workspace backend");
967    }
968
969    let normalized_input = input.replace('\\', "/");
970    let path = Path::new(&normalized_input);
971    if path.is_absolute() {
972        bail!("Absolute paths are not supported by this workspace backend");
973    }
974
975    let relative = normalize_relative_path(path)?;
976    Ok(pathbuf_to_workspace_path(&relative))
977}
978
979fn normalize_absolute_path(path: &Path) -> Result<PathBuf> {
980    let lexical = normalize_absolute_path_lexical(path)?;
981    if let Ok(canonical) = lexical.canonicalize() {
982        return Ok(canonical);
983    }
984
985    let mut current = lexical.as_path();
986    let mut suffix = Vec::new();
987    while !current.exists() {
988        let Some(file_name) = current.file_name() else {
989            return Ok(lexical);
990        };
991        suffix.push(file_name.to_os_string());
992        let Some(parent) = current.parent() else {
993            return Ok(lexical);
994        };
995        current = parent;
996    }
997
998    let mut normalized = current.canonicalize().unwrap_or_else(|_| {
999        normalize_absolute_path_lexical(current).unwrap_or_else(|_| current.into())
1000    });
1001    for part in suffix.iter().rev() {
1002        normalized.push(part);
1003    }
1004    Ok(normalized)
1005}
1006
1007fn normalize_absolute_path_lexical(path: &Path) -> Result<PathBuf> {
1008    let mut out = PathBuf::new();
1009    for component in path.components() {
1010        match component {
1011            Component::Prefix(prefix) => out.push(prefix.as_os_str()),
1012            Component::RootDir => out.push(Path::new(std::path::MAIN_SEPARATOR_STR)),
1013            Component::CurDir => {}
1014            Component::Normal(part) => out.push(part),
1015            Component::ParentDir => {
1016                if !out.pop() {
1017                    bail!("Invalid absolute path");
1018                }
1019            }
1020        }
1021    }
1022    Ok(out)
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027    use super::super::WorkspaceServices;
1028    use super::*;
1029
1030    #[tokio::test]
1031    async fn local_backend_reads_writes_and_lists() {
1032        let temp = tempfile::tempdir().unwrap();
1033        let services = WorkspaceServices::local(temp.path());
1034        let path = services.normalize_path("dir/file.txt").unwrap();
1035
1036        let written = services
1037            .fs()
1038            .write_text(&path, "hello\nworld\n")
1039            .await
1040            .unwrap();
1041        assert_eq!(written.bytes, 12);
1042        assert_eq!(written.lines, 2);
1043
1044        let content = services.fs().read_text(&path).await.unwrap();
1045        assert_eq!(content, "hello\nworld\n");
1046
1047        let dir = services.normalize_path("dir").unwrap();
1048        let entries = services.fs().list_dir(&dir).await.unwrap();
1049        assert_eq!(entries.len(), 1);
1050        assert_eq!(entries[0].name, "file.txt");
1051    }
1052
1053    #[tokio::test]
1054    async fn local_backend_searches_glob_and_grep() {
1055        let temp = tempfile::tempdir().unwrap();
1056        let services = WorkspaceServices::local(temp.path());
1057        services
1058            .fs()
1059            .write_text(
1060                &services.normalize_path("src/main.rs").unwrap(),
1061                "fn main() {\n    println!(\"hello\");\n}\n",
1062            )
1063            .await
1064            .unwrap();
1065        services
1066            .fs()
1067            .write_text(
1068                &services.normalize_path("README.md").unwrap(),
1069                "hello from docs\n",
1070            )
1071            .await
1072            .unwrap();
1073
1074        let search = services.search().expect("local backend supports search");
1075        let glob = search
1076            .glob(WorkspaceGlobRequest {
1077                base: services.normalize_path("src").unwrap(),
1078                pattern: "*.rs".to_string(),
1079            })
1080            .await
1081            .unwrap();
1082        assert_eq!(glob.matches[0].as_str(), "src/main.rs");
1083
1084        let grep = search
1085            .grep(WorkspaceGrepRequest {
1086                base: WorkspacePath::root(),
1087                pattern: "hello".to_string(),
1088                glob: Some("**/*.rs".to_string()),
1089                context_lines: 0,
1090                case_insensitive: false,
1091                max_output_size: 1024,
1092            })
1093            .await
1094            .unwrap();
1095        assert_eq!(grep.match_count, 1);
1096        assert_eq!(grep.file_count, 1);
1097        assert!(grep.output.contains("src/main.rs:2"));
1098    }
1099
1100    fn credential_boundary_backend(root: &Path) -> LocalWorkspaceBackend {
1101        LocalWorkspaceBackend::new_with_access_policy(
1102            root.to_path_buf(),
1103            LocalWorkspaceAccessPolicy::CredentialBoundary,
1104        )
1105    }
1106
1107    #[tokio::test]
1108    async fn credential_boundary_denies_direct_secret_reads_and_writes() {
1109        let temp = tempfile::tempdir().unwrap();
1110        std::fs::create_dir_all(temp.path().join("apps/api")).unwrap();
1111        std::fs::write(temp.path().join("apps/api/.env.local"), "TOKEN=secret\n").unwrap();
1112        let backend = credential_boundary_backend(temp.path());
1113        let secret = backend.normalize("apps/api/.env.local").unwrap();
1114
1115        let read_error = backend
1116            .read_text(&secret)
1117            .await
1118            .expect_err("direct secret reads must be denied");
1119        assert!(read_error.to_string().contains("credential boundary"));
1120
1121        let range_error = backend
1122            .read_text_range(&secret, 0, 10)
1123            .await
1124            .expect_err("range reads must use the same boundary");
1125        assert!(range_error.to_string().contains("credential boundary"));
1126
1127        let write_error = backend
1128            .write_text(&secret, "TOKEN=overwritten\n")
1129            .await
1130            .expect_err("direct secret writes must be denied");
1131        assert!(write_error.to_string().contains("credential boundary"));
1132        assert_eq!(
1133            std::fs::read_to_string(temp.path().join("apps/api/.env.local")).unwrap(),
1134            "TOKEN=secret\n"
1135        );
1136
1137        let new_secret = backend.normalize(".env.generated").unwrap();
1138        backend
1139            .write_text(&new_secret, "TOKEN=new\n")
1140            .await
1141            .expect_err("creating a new env file must be denied");
1142        assert!(!temp.path().join(".env.generated").exists());
1143    }
1144
1145    #[tokio::test]
1146    async fn credential_boundary_filters_grep_and_rejects_explicit_secret_base() {
1147        let temp = tempfile::tempdir().unwrap();
1148        std::fs::write(temp.path().join(".env"), "BOUNDARY_TOKEN=secret\n").unwrap();
1149        std::fs::write(
1150            temp.path().join("README.md"),
1151            "BOUNDARY_TOKEN is configured externally\n",
1152        )
1153        .unwrap();
1154        let backend = credential_boundary_backend(temp.path());
1155
1156        let grep = backend
1157            .grep(WorkspaceGrepRequest {
1158                base: WorkspacePath::root(),
1159                pattern: "BOUNDARY_TOKEN".to_string(),
1160                glob: None,
1161                context_lines: 0,
1162                case_insensitive: false,
1163                max_output_size: 1024,
1164            })
1165            .await
1166            .unwrap();
1167        assert_eq!(grep.match_count, 1);
1168        assert_eq!(grep.file_count, 1);
1169        assert!(grep.output.contains("README.md"));
1170        assert!(!grep.output.contains("secret"));
1171        assert!(!grep.output.contains(".env"));
1172
1173        let error = backend
1174            .grep(WorkspaceGrepRequest {
1175                base: backend.normalize(".env").unwrap(),
1176                pattern: "secret".to_string(),
1177                glob: None,
1178                context_lines: 0,
1179                case_insensitive: false,
1180                max_output_size: 1024,
1181            })
1182            .await
1183            .expect_err("an explicit secret grep must fail closed");
1184        assert!(error.to_string().contains("credential boundary"));
1185    }
1186
1187    #[cfg(any(unix, windows))]
1188    #[tokio::test]
1189    async fn credential_boundary_denies_source_hardlinks_without_truncating_them() {
1190        let temp = tempfile::tempdir().unwrap();
1191        let source = temp.path().join("source.txt");
1192        let alias = temp.path().join("alias.txt");
1193        std::fs::write(&source, "linked secret\n").unwrap();
1194        std::fs::hard_link(&source, &alias).unwrap();
1195        let backend = credential_boundary_backend(temp.path());
1196        let alias_path = backend.normalize("alias.txt").unwrap();
1197
1198        backend
1199            .read_text(&alias_path)
1200            .await
1201            .expect_err("source-tree hardlink reads must be denied");
1202        backend
1203            .write_text(&alias_path, "overwritten\n")
1204            .await
1205            .expect_err("source-tree hardlink writes must be denied");
1206        assert_eq!(std::fs::read_to_string(&source).unwrap(), "linked secret\n");
1207    }
1208
1209    #[cfg(any(unix, windows))]
1210    #[tokio::test]
1211    async fn source_egress_boundary_denies_control_paths_and_every_hardlink() {
1212        let temp = tempfile::tempdir().unwrap();
1213        std::fs::create_dir_all(temp.path().join("src")).unwrap();
1214        std::fs::create_dir_all(temp.path().join(".a3s")).unwrap();
1215        let source = temp.path().join("source.txt");
1216        let alias = temp.path().join("src/apparently-safe.txt");
1217        std::fs::write(&source, "linked source\n").unwrap();
1218        std::fs::hard_link(&source, &alias).unwrap();
1219        std::fs::write(temp.path().join(".a3s/config.acl"), "secret = true\n").unwrap();
1220        std::fs::write(temp.path().join("src/safe.rs"), "pub fn safe() {}\n").unwrap();
1221        let backend =
1222            LocalWorkspaceBackend::new_with_source_egress_policy(temp.path().to_path_buf());
1223
1224        backend
1225            .read_text(&backend.normalize("src/apparently-safe.txt").unwrap())
1226            .await
1227            .expect_err("source egress must reject every multi-link file");
1228        backend
1229            .read_text(&backend.normalize(".a3s/config.acl").unwrap())
1230            .await
1231            .expect_err("source egress must reject control paths at read time");
1232        assert_eq!(
1233            backend
1234                .read_text(&backend.normalize("src/safe.rs").unwrap())
1235                .await
1236                .unwrap(),
1237            "pub fn safe() {}\n"
1238        );
1239    }
1240
1241    #[cfg(any(unix, windows))]
1242    #[tokio::test]
1243    async fn credential_boundary_allows_package_store_hardlinks_but_denies_secret_aliases() {
1244        let temp = tempfile::tempdir().unwrap();
1245        let package = temp.path().join("node_modules/pkg");
1246        std::fs::create_dir_all(&package).unwrap();
1247
1248        let package_source = package.join("source.js");
1249        let package_alias = package.join("alias.js");
1250        std::fs::write(&package_source, "export const value = 1;\n").unwrap();
1251        std::fs::hard_link(&package_source, &package_alias).unwrap();
1252
1253        let env = temp.path().join(".env");
1254        let env_alias = package.join("credential.txt");
1255        std::fs::write(&env, "TOKEN=secret\n").unwrap();
1256        std::fs::hard_link(&env, &env_alias).unwrap();
1257
1258        let backend = credential_boundary_backend(temp.path());
1259        let package_content = backend
1260            .read_text(&backend.normalize("node_modules/pkg/alias.js").unwrap())
1261            .await
1262            .expect("ordinary package-store hardlinks should remain readable");
1263        assert!(package_content.contains("value = 1"));
1264
1265        let error = backend
1266            .read_text(
1267                &backend
1268                    .normalize("node_modules/pkg/credential.txt")
1269                    .unwrap(),
1270            )
1271            .await
1272            .expect_err("a package-tree alias of a known credential must be denied");
1273        assert!(error.to_string().contains("credential boundary"));
1274    }
1275
1276    fn run_test_git(root: &Path, args: &[&str]) -> bool {
1277        let mut command = std::process::Command::new("git");
1278        command
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        crate::tools::process::status_std_with_native_gate(&mut command)
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}