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::DirectWriteGuard;
10use super::{
11    default_path_input, escape_control_chars_for_display, has_windows_path_prefix,
12    normalize_relative_path, pathbuf_to_workspace_path, validate_relative_pattern, CommandOutput,
13    CommandRequest, WorkspaceCommandRunner, WorkspaceDirEntry, WorkspaceError, WorkspaceFileSystem,
14    WorkspaceFileType, WorkspaceGit, WorkspaceGitBranch, WorkspaceGitCheckoutOutput,
15    WorkspaceGitCheckoutRequest, WorkspaceGitCommit, WorkspaceGitCreateBranchRequest,
16    WorkspaceGitCreateWorktreeRequest, WorkspaceGitDiffRequest, WorkspaceGitRemote,
17    WorkspaceGitRemoveWorktreeRequest, WorkspaceGitStash, WorkspaceGitStashProvider,
18    WorkspaceGitStashRequest, WorkspaceGitStatus, WorkspaceGitWorktree,
19    WorkspaceGitWorktreeMutation, WorkspaceGitWorktreeProvider, WorkspaceGlobRequest,
20    WorkspaceGlobResult, WorkspaceGrepOutcome, WorkspaceGrepRequest, WorkspaceGrepResult,
21    WorkspacePath, WorkspacePathResolver, WorkspaceResult, WorkspaceSearch, WorkspaceTextRange,
22    WorkspaceTextReader, WorkspaceWriteOutcome,
23};
24use crate::sandbox::native::hard_link_count_for_open_file;
25use anyhow::{anyhow, bail, Result};
26use async_trait::async_trait;
27use std::io::Read as _;
28use std::path::{Component, Path, PathBuf};
29use std::sync::atomic::{AtomicBool, Ordering};
30use std::sync::Arc;
31use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
32
33/// Local filesystem-backed workspace implementation.
34#[derive(Debug)]
35pub struct LocalWorkspaceBackend {
36    pub(super) root: PathBuf,
37    access_boundary: Option<LocalWorkspaceAccessBoundary>,
38}
39
40struct CancelGitWorkerOnDrop {
41    cancellation: Arc<AtomicBool>,
42    armed: bool,
43}
44
45impl CancelGitWorkerOnDrop {
46    fn new(cancellation: Arc<AtomicBool>) -> Self {
47        Self {
48            cancellation,
49            armed: true,
50        }
51    }
52
53    fn disarm(&mut self) {
54        self.armed = false;
55    }
56}
57
58impl Drop for CancelGitWorkerOnDrop {
59    fn drop(&mut self) {
60        if self.armed {
61            self.cancellation.store(true, Ordering::Release);
62        }
63    }
64}
65
66impl LocalWorkspaceBackend {
67    pub fn new(root: PathBuf) -> Self {
68        Self::new_with_access_policy(root, LocalWorkspaceAccessPolicy::Unrestricted)
69    }
70
71    pub fn new_with_access_policy(
72        root: PathBuf,
73        access_policy: LocalWorkspaceAccessPolicy,
74    ) -> Self {
75        Self::new_with_boundary(root, |root| {
76            LocalWorkspaceAccessBoundary::for_policy(access_policy, root)
77        })
78    }
79
80    pub(crate) fn new_with_source_egress_policy(root: PathBuf) -> Self {
81        Self::new_with_boundary(root, |_| {
82            Some(LocalWorkspaceAccessBoundary::for_source_egress())
83        })
84    }
85
86    fn new_with_boundary(
87        root: PathBuf,
88        boundary: impl FnOnce(&Path) -> Option<LocalWorkspaceAccessBoundary>,
89    ) -> Self {
90        let canonical = root.canonicalize();
91        let root = match canonical {
92            Ok(canonical) => canonical,
93            Err(e) => {
94                tracing::warn!(
95                    "LocalWorkspaceBackend: failed to canonicalize root '{}' at construction: {} \
96                     (path resolution will fail-closed at first use)",
97                    root.display(),
98                    e
99                );
100                root
101            }
102        };
103        let access_boundary = boundary(&root);
104        Self {
105            root,
106            access_boundary,
107        }
108    }
109
110    fn local_path_for_read(&self, path: &WorkspacePath) -> Result<PathBuf> {
111        a3s_common::tools::resolve_path(&self.root, path.as_str()).map_err(|e| anyhow!("{}", e))
112    }
113
114    fn local_path_for_write(&self, path: &WorkspacePath) -> Result<PathBuf> {
115        if path.is_root() {
116            bail!("write path must name a file");
117        }
118        // Refuse before create_dir_all. A symlink directory already inside the
119        // workspace would otherwise receive new parent directories outside it,
120        // and a symlink file would be opened and followed by the later write.
121        refuse_existing_symlink_on_write_path(&self.root, path.as_str())?;
122
123        let target = self.root.join(path.as_str());
124        if let Some(parent) = target.parent() {
125            std::fs::create_dir_all(parent).map_err(|e| {
126                anyhow!(
127                    "Failed to create parent directories for {}: {}",
128                    target.display(),
129                    e
130                )
131            })?;
132        }
133
134        a3s_common::tools::resolve_path_for_write(&self.root, path.as_str())
135            .map_err(|e| anyhow!("{}", e))
136    }
137
138    pub(crate) fn refuse_direct_write(&self, path: &WorkspacePath) -> Result<()> {
139        refuse_existing_symlink_on_write_path(&self.root, path.as_str())?;
140        let candidate = self.root.join(path.as_str());
141        let metadata = std::fs::metadata(&candidate).ok();
142        self.ensure_access(path, Some(&candidate), metadata.as_ref(), None, "write")
143    }
144
145    fn ensure_access(
146        &self,
147        path: &WorkspacePath,
148        resolved: Option<&Path>,
149        metadata: Option<&std::fs::Metadata>,
150        opened_hard_link_count: Option<u64>,
151        operation: &'static str,
152    ) -> Result<()> {
153        match &self.access_boundary {
154            Some(boundary) => boundary.ensure_access(
155                &self.root,
156                Path::new(path.as_str()),
157                resolved,
158                metadata,
159                opened_hard_link_count,
160                operation,
161            ),
162            None => Ok(()),
163        }
164    }
165
166    pub(super) fn ensure_search_base_allowed(&self, path: &WorkspacePath) -> Result<()> {
167        let resolved = self.local_path_for_read(path)?;
168        let metadata = std::fs::metadata(&resolved).ok();
169        self.ensure_access(path, Some(&resolved), metadata.as_ref(), None, "read")
170    }
171
172    pub(super) fn read_search_file(&self, path: &WorkspacePath) -> Option<String> {
173        let resolved = self.local_path_for_read(path).ok()?;
174        let mut file = std::fs::File::open(&resolved).ok()?;
175        let metadata = file.metadata().ok()?;
176        self.ensure_access(
177            path,
178            Some(&resolved),
179            Some(&metadata),
180            self.access_boundary
181                .as_ref()
182                .map(|_| hard_link_count_for_open_file(&file, &metadata)),
183            "read",
184        )
185        .ok()?;
186        let mut content = String::new();
187        file.read_to_string(&mut content).ok()?;
188        Some(content)
189    }
190
191    fn refuse_checkout_targets(&self, refspec: &str, force: bool) -> Result<()> {
192        let mut paths = crate::git::paths_changed_between(&self.root, "HEAD", refspec)?;
193        if force {
194            paths.extend(crate::git::get_diff_paths(&self.root, None)?);
195        }
196        paths.sort();
197        paths.dedup();
198        for path in paths {
199            self.refuse_checkout_path(&path)?;
200        }
201        Ok(())
202    }
203
204    fn refuse_worktree_checkout(
205        &self,
206        branch: &str,
207        new_branch: bool,
208        destination: &Path,
209    ) -> Result<()> {
210        let Some(destination) = resolved_workspace_destination(&self.root, destination) else {
211            return Ok(());
212        };
213        let revision = if new_branch { "HEAD" } else { branch };
214        let relative_destination = destination.strip_prefix(&self.root).unwrap_or(&destination);
215        for path in crate::git::tracked_tree_paths(&self.root, revision)? {
216            self.refuse_checkout_path(&relative_destination.join(path))?;
217        }
218        Ok(())
219    }
220
221    fn refuse_stash_targets(&self, include_untracked: bool) -> Result<()> {
222        let mut paths = crate::git::get_diff_paths(&self.root, None)?;
223        paths.extend(crate::git::staged_diff_paths(&self.root)?);
224        if include_untracked {
225            paths.extend(crate::git::untracked_paths(&self.root)?);
226        }
227        paths.sort();
228        paths.dedup();
229        for path in paths {
230            self.refuse_checkout_path(&path)?;
231        }
232        Ok(())
233    }
234
235    fn refuse_checkout_path(&self, path: &Path) -> Result<()> {
236        let path_text = path
237            .to_str()
238            .ok_or_else(|| anyhow!("checkout path is not utf-8"))?;
239        let workspace_path = normalize_local_path(&self.root, path_text)?;
240        let candidate = self.root.join(path);
241        let metadata = std::fs::metadata(&candidate).ok();
242        self.ensure_access(
243            &workspace_path,
244            Some(&candidate),
245            metadata.as_ref(),
246            None,
247            "write",
248        )
249    }
250
251    fn git_diff_path_allowed(&self, path: &Path) -> bool {
252        let Some(path_text) = path.to_str() else {
253            return false;
254        };
255        let Ok(workspace_path) = normalize_local_path(&self.root, path_text) else {
256            return false;
257        };
258        let candidate = self.root.join(path);
259        let resolved = candidate.canonicalize().ok();
260        let metadata = resolved
261            .as_deref()
262            .and_then(|resolved| std::fs::metadata(resolved).ok());
263        self.ensure_access(
264            &workspace_path,
265            resolved.as_deref(),
266            metadata.as_ref(),
267            None,
268            "read",
269        )
270        .is_ok()
271    }
272}
273
274impl DirectWriteGuard for LocalWorkspaceBackend {
275    fn refuse_direct_write(&self, path: &WorkspacePath) -> Result<()> {
276        LocalWorkspaceBackend::refuse_direct_write(self, path)
277    }
278}
279
280impl WorkspacePathResolver for LocalWorkspaceBackend {
281    fn normalize(&self, input: &str) -> Result<WorkspacePath> {
282        normalize_local_path(&self.root, input)
283    }
284}
285
286#[async_trait]
287impl WorkspaceFileSystem for LocalWorkspaceBackend {
288    async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String> {
289        let resolved = self.local_path_for_read(path)?;
290        let mut file = match tokio::fs::File::open(&resolved).await {
291            Ok(file) => file,
292            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
293                return Err(WorkspaceError::NotFound {
294                    path: resolved.display().to_string(),
295                })
296            }
297            Err(e) => {
298                return Err(WorkspaceError::Backend(anyhow!(
299                    "Failed to open file {}: {}",
300                    resolved.display(),
301                    e
302                )))
303            }
304        };
305        let metadata = file.metadata().await.map_err(|error| {
306            WorkspaceError::Backend(anyhow!(
307                "Failed to inspect file {}: {}",
308                resolved.display(),
309                error
310            ))
311        })?;
312        self.ensure_access(
313            path,
314            Some(&resolved),
315            Some(&metadata),
316            self.access_boundary
317                .as_ref()
318                .map(|_| hard_link_count_for_open_file(&file, &metadata)),
319            "read",
320        )?;
321
322        let mut content = String::new();
323        file.read_to_string(&mut content).await.map_err(|error| {
324            WorkspaceError::Backend(anyhow!(
325                "Failed to read file {}: {}",
326                resolved.display(),
327                error
328            ))
329        })?;
330        Ok(content)
331    }
332
333    async fn write_text(
334        &self,
335        path: &WorkspacePath,
336        content: &str,
337    ) -> WorkspaceResult<WorkspaceWriteOutcome> {
338        self.ensure_access(path, None, None, None, "write")?;
339        let resolved = self.local_path_for_write(path)?;
340        let mut file = tokio::fs::OpenOptions::new()
341            .create(true)
342            .truncate(false)
343            .write(true)
344            .open(&resolved)
345            .await
346            .map_err(|e| {
347                WorkspaceError::Backend(anyhow!(
348                    "Failed to open file {} for writing: {}",
349                    resolved.display(),
350                    e
351                ))
352            })?;
353        opened_write_stays_in_workspace(&file, &self.root).map_err(|error| {
354            WorkspaceError::Backend(anyhow!(
355                "Failed to write file {}: {}",
356                resolved.display(),
357                error
358            ))
359        })?;
360        let metadata = file.metadata().await.map_err(|error| {
361            WorkspaceError::Backend(anyhow!(
362                "Failed to inspect file {} before writing: {}",
363                resolved.display(),
364                error
365            ))
366        })?;
367        self.ensure_access(
368            path,
369            Some(&resolved),
370            Some(&metadata),
371            self.access_boundary
372                .as_ref()
373                .map(|_| hard_link_count_for_open_file(&file, &metadata)),
374            "write",
375        )?;
376        file.set_len(0).await.map_err(|e| {
377            WorkspaceError::Backend(anyhow!(
378                "Failed to write file {}: {}",
379                resolved.display(),
380                e
381            ))
382        })?;
383        file.write_all(content.as_bytes()).await.map_err(|e| {
384            WorkspaceError::Backend(anyhow!(
385                "Failed to write file {}: {}",
386                resolved.display(),
387                e
388            ))
389        })?;
390        file.flush().await.map_err(|e| {
391            WorkspaceError::Backend(anyhow!(
392                "Failed to flush file {} after writing: {}",
393                resolved.display(),
394                e
395            ))
396        })?;
397
398        Ok(WorkspaceWriteOutcome {
399            bytes: content.len(),
400            lines: content.lines().count(),
401        })
402    }
403
404    async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>> {
405        let target = self.local_path_for_read(path)?;
406        if !target.exists() {
407            return Err(WorkspaceError::NotFound {
408                path: target.display().to_string(),
409            });
410        }
411        if !target.is_dir() {
412            return Err(WorkspaceError::InvalidArgument {
413                message: format!("Not a directory: {}", target.display()),
414            });
415        }
416
417        let mut dir = tokio::fs::read_dir(&target).await.map_err(|e| {
418            WorkspaceError::Backend(anyhow!(
419                "Failed to read directory {}: {}",
420                target.display(),
421                e
422            ))
423        })?;
424        let mut entries = Vec::new();
425
426        while let Some(entry) = dir
427            .next_entry()
428            .await
429            .map_err(|e| WorkspaceError::Backend(anyhow!("Failed to iterate directory: {}", e)))?
430        {
431            let name = entry.file_name().to_string_lossy().to_string();
432            let file_type = entry.file_type().await;
433            let metadata = entry.metadata().await;
434            let (kind, size) = match (&file_type, &metadata) {
435                (Ok(ft), Ok(m)) => {
436                    let kind = if ft.is_dir() {
437                        WorkspaceFileType::Directory
438                    } else if ft.is_symlink() {
439                        WorkspaceFileType::Symlink
440                    } else {
441                        WorkspaceFileType::File
442                    };
443                    (kind, m.len())
444                }
445                _ => (WorkspaceFileType::Unknown, 0),
446            };
447            entries.push(WorkspaceDirEntry { name, kind, size });
448        }
449
450        Ok(entries)
451    }
452}
453
454#[async_trait]
455impl WorkspaceTextReader for LocalWorkspaceBackend {
456    async fn read_text_range(
457        &self,
458        path: &WorkspacePath,
459        offset: usize,
460        limit: usize,
461    ) -> WorkspaceResult<WorkspaceTextRange> {
462        let resolved = self.local_path_for_read(path)?;
463        let file = tokio::fs::File::open(&resolved).await.map_err(|error| {
464            if error.kind() == std::io::ErrorKind::NotFound {
465                WorkspaceError::NotFound {
466                    path: resolved.display().to_string(),
467                }
468            } else {
469                WorkspaceError::Backend(anyhow!(
470                    "Failed to open file {}: {}",
471                    resolved.display(),
472                    error
473                ))
474            }
475        })?;
476        let metadata = file.metadata().await.map_err(|error| {
477            WorkspaceError::Backend(anyhow!(
478                "Failed to inspect file {}: {}",
479                resolved.display(),
480                error
481            ))
482        })?;
483        self.ensure_access(
484            path,
485            Some(&resolved),
486            Some(&metadata),
487            self.access_boundary
488                .as_ref()
489                .map(|_| hard_link_count_for_open_file(&file, &metadata)),
490            "read",
491        )?;
492        let mut lines = BufReader::new(file).lines();
493        let mut line_index = 0usize;
494        while line_index < offset {
495            match lines.next_line().await.map_err(|error| {
496                WorkspaceError::Backend(anyhow!(
497                    "Failed to read file {}: {}",
498                    resolved.display(),
499                    error
500                ))
501            })? {
502                Some(_) => line_index += 1,
503                None => {
504                    return Ok(WorkspaceTextRange {
505                        lines: Vec::new(),
506                        next_offset: None,
507                        eof: true,
508                        total_lines: Some(line_index),
509                    })
510                }
511            }
512        }
513
514        let mut selected = Vec::with_capacity(limit);
515        while selected.len() < limit {
516            match lines.next_line().await.map_err(|error| {
517                WorkspaceError::Backend(anyhow!(
518                    "Failed to read file {}: {}",
519                    resolved.display(),
520                    error
521                ))
522            })? {
523                Some(line) => selected.push(line),
524                None => {
525                    let total_lines = offset.saturating_add(selected.len());
526                    return Ok(WorkspaceTextRange {
527                        lines: selected,
528                        next_offset: None,
529                        eof: true,
530                        total_lines: Some(total_lines),
531                    });
532                }
533            }
534        }
535
536        let has_more = lines
537            .next_line()
538            .await
539            .map_err(|error| {
540                WorkspaceError::Backend(anyhow!(
541                    "Failed to read file {}: {}",
542                    resolved.display(),
543                    error
544                ))
545            })?
546            .is_some();
547        Ok(WorkspaceTextRange {
548            lines: selected,
549            next_offset: has_more.then_some(offset.saturating_add(limit)),
550            eof: !has_more,
551            total_lines: (!has_more).then_some(offset.saturating_add(limit)),
552        })
553    }
554}
555
556#[async_trait]
557impl WorkspaceSearch for LocalWorkspaceBackend {
558    async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult> {
559        validate_relative_pattern(&request.pattern, "glob pattern")?;
560        let base = self.local_path_for_read(&request.base)?;
561        let full_pattern = base.join(&request.pattern);
562        let full_pattern = full_pattern.to_string_lossy().replace('\\', "/");
563
564        let entries = glob::glob(&full_pattern)
565            .map_err(|e| anyhow!("Invalid glob pattern '{}': {}", request.pattern, e))?;
566
567        let mut matches = Vec::new();
568        for entry in entries {
569            match entry {
570                Ok(path) => {
571                    // On Windows, `canonicalize()` commonly gives the backend
572                    // root a verbatim `\\?\` prefix while `glob` returns a
573                    // regular drive path. Canonicalize each match before
574                    // stripping so equivalent paths use the same form.
575                    let normalized = path.canonicalize().unwrap_or(path);
576                    if let Ok(relative) = normalized.strip_prefix(&self.root) {
577                        matches.push(pathbuf_to_workspace_path(relative));
578                    }
579                }
580                Err(e) => tracing::warn!("Glob entry error: {}", e),
581            }
582        }
583
584        matches.sort_by(|a, b| a.as_str().cmp(b.as_str()));
585        Ok(WorkspaceGlobResult { matches })
586    }
587
588    async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult> {
589        Ok(self.grep_with_sources(request).await?.result)
590    }
591
592    async fn grep_with_sources(
593        &self,
594        request: WorkspaceGrepRequest,
595    ) -> Result<WorkspaceGrepOutcome> {
596        if let Some(ref glob) = request.glob {
597            validate_relative_pattern(glob, "grep glob filter")?;
598        }
599
600        let regex_pattern = if request.case_insensitive {
601            format!("(?i){}", request.pattern)
602        } else {
603            request.pattern.clone()
604        };
605        let regex = regex::Regex::new(&regex_pattern)
606            .map_err(|e| anyhow!("Invalid regex pattern '{}': {}", request.pattern, e))?;
607
608        let search_path = self.local_path_for_read(&request.base)?;
609        self.ensure_search_base_allowed(&request.base)?;
610        let mut builder = ignore::WalkBuilder::new(&search_path);
611        builder
612            .hidden(false)
613            .git_ignore(true)
614            .git_global(true)
615            .follow_links(false);
616
617        if let Some(ref glob_pat) = request.glob {
618            let mut types = ignore::types::TypesBuilder::new();
619            types.add("custom", glob_pat).ok();
620            types.select("custom");
621            if let Ok(built) = types.build() {
622                builder.types(built);
623            }
624        }
625
626        let mut output = String::new();
627        let mut match_count = 0;
628        let mut file_count = 0;
629        let mut total_size = 0;
630        let mut matched_paths = Vec::new();
631        let metadata_only = request.max_output_size == 0;
632
633        for entry in builder.build().flatten() {
634            if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
635                continue;
636            }
637
638            let file_path = entry.path();
639            let workspace_path =
640                pathbuf_to_workspace_path(file_path.strip_prefix(&self.root).unwrap_or(file_path));
641            let Some(content) = self.read_search_file(&workspace_path) else {
642                continue;
643            };
644
645            let lines: Vec<&str> = content.lines().collect();
646            let mut file_matches = Vec::new();
647            for (line_idx, line) in lines.iter().enumerate() {
648                if regex.is_match(line) {
649                    file_matches.push(line_idx);
650                }
651            }
652
653            if file_matches.is_empty() {
654                continue;
655            }
656
657            file_count += 1;
658            let rel_path = workspace_path.as_str();
659            let display_path = escape_control_chars_for_display(rel_path);
660            let mut path_recorded = false;
661
662            for &match_idx in &file_matches {
663                if !metadata_only && total_size > request.max_output_size {
664                    return Ok(WorkspaceGrepOutcome {
665                        result: WorkspaceGrepResult {
666                            output,
667                            match_count,
668                            file_count,
669                            truncated: true,
670                        },
671                        matched_paths: Some(matched_paths),
672                    });
673                }
674
675                if !path_recorded {
676                    matched_paths.push(workspace_path.clone());
677                    path_recorded = true;
678                }
679                match_count += 1;
680                if metadata_only {
681                    continue;
682                }
683
684                let start = match_idx.saturating_sub(request.context_lines);
685                let end = (match_idx + request.context_lines + 1).min(lines.len());
686
687                for (i, line) in lines[start..end].iter().enumerate() {
688                    let abs_i = start + i;
689                    let prefix = if abs_i == match_idx { ">" } else { " " };
690                    let line = format!("{}{}:{}: {}\n", prefix, display_path, abs_i + 1, line);
691                    total_size += line.len();
692                    output.push_str(&line);
693                }
694
695                if request.context_lines > 0 {
696                    output.push_str("--\n");
697                    total_size += 3;
698                }
699            }
700        }
701
702        Ok(WorkspaceGrepOutcome {
703            result: WorkspaceGrepResult {
704                output,
705                match_count,
706                file_count,
707                truncated: false,
708            },
709            matched_paths: Some(matched_paths),
710        })
711    }
712}
713
714#[async_trait]
715impl WorkspaceGit for LocalWorkspaceBackend {
716    async fn is_repository(&self) -> Result<bool> {
717        self.run_blocking_git(|root| Ok(crate::git::is_git_repo(&root)))
718            .await
719    }
720
721    async fn status(&self) -> Result<WorkspaceGitStatus> {
722        self.run_blocking_git(|root| {
723            let status = crate::git::get_status(&root)?;
724            Ok(WorkspaceGitStatus {
725                branch: status.branch,
726                commit: status.commit,
727                is_worktree: status.is_worktree,
728                is_dirty: status.is_dirty,
729                dirty_count: status.dirty_count,
730            })
731        })
732        .await
733    }
734
735    async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
736        self.run_blocking_git(move |root| {
737            Ok(crate::git::get_log(&root, max_count)?
738                .into_iter()
739                .map(|commit| WorkspaceGitCommit {
740                    id: commit.id,
741                    message: commit.message,
742                    author: commit.author,
743                    date: commit.date,
744                })
745                .collect())
746        })
747        .await
748    }
749
750    async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
751        self.run_blocking_git(|root| {
752            Ok(crate::git::list_branches(&root)?
753                .into_iter()
754                .map(|branch| WorkspaceGitBranch {
755                    name: branch.name,
756                    is_current: branch.is_current,
757                })
758                .collect())
759        })
760        .await
761    }
762
763    async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
764        if self.access_boundary.is_some() {
765            self.refuse_checkout_targets(&request.base, false)?;
766        }
767        self.run_blocking_git(move |root| {
768            crate::git::create_branch(&root, &request.name, &request.base)
769        })
770        .await
771    }
772
773    async fn checkout(
774        &self,
775        request: WorkspaceGitCheckoutRequest,
776    ) -> Result<WorkspaceGitCheckoutOutput> {
777        crate::git::refuse_flag_like_git_revision(&request.refspec, "checkout ref")?;
778        if self.access_boundary.is_some() {
779            self.refuse_checkout_targets(&request.refspec, request.force)?;
780        }
781        let args = if request.force {
782            vec![
783                "checkout".to_string(),
784                "--force".to_string(),
785                request.refspec,
786            ]
787        } else {
788            vec!["checkout".to_string(), request.refspec]
789        };
790        let (success, stdout, stderr) = self.run_git_command(args).await?;
791        if !success {
792            bail!("{}", stderr.trim_end());
793        }
794        Ok(WorkspaceGitCheckoutOutput { stdout })
795    }
796
797    async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
798        let target = request.target;
799        if self.access_boundary.is_none() {
800            return self
801                .run_blocking_git(move |root| crate::git::get_diff(&root, target.as_deref()))
802                .await;
803        }
804
805        let target_for_paths = target.clone();
806        let paths = self
807            .run_blocking_git(move |root| {
808                crate::git::get_diff_paths(&root, target_for_paths.as_deref())
809            })
810            .await?;
811        let paths = paths
812            .into_iter()
813            .filter(|path| self.git_diff_path_allowed(path))
814            .collect::<Vec<_>>();
815        self.run_blocking_git(move |root| {
816            crate::git::get_diff_for_paths(&root, target.as_deref(), &paths)
817        })
818        .await
819    }
820
821    async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
822        let (success, stdout, stderr) = self
823            .run_git_command(vec!["remote".to_string(), "-v".to_string()])
824            .await?;
825        if !success {
826            bail!("{}", stderr.trim_end());
827        }
828
829        Ok(stdout.lines().filter_map(parse_git_remote_line).collect())
830    }
831}
832
833#[async_trait]
834impl WorkspaceGitStashProvider for LocalWorkspaceBackend {
835    async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
836        self.run_blocking_git(|root| {
837            Ok(crate::git::list_stashes(&root)?
838                .into_iter()
839                .map(|stash| WorkspaceGitStash {
840                    index: stash.index,
841                    message: stash.message,
842                })
843                .collect())
844        })
845        .await
846    }
847
848    async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
849        if self.access_boundary.is_some() {
850            self.refuse_stash_targets(request.include_untracked)?;
851        }
852        self.run_blocking_git(move |root| {
853            crate::git::stash(&root, request.message.as_deref(), request.include_untracked)
854        })
855        .await
856    }
857}
858
859#[async_trait]
860impl WorkspaceGitWorktreeProvider for LocalWorkspaceBackend {
861    async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>> {
862        self.run_blocking_git(|root| {
863            Ok(crate::git::list_worktrees(&root)?
864                .into_iter()
865                .map(|worktree| WorkspaceGitWorktree {
866                    path: worktree.path,
867                    branch: worktree.branch,
868                    is_bare: worktree.is_bare,
869                    is_detached: worktree.is_detached,
870                })
871                .collect())
872        })
873        .await
874    }
875
876    async fn create_worktree(
877        &self,
878        request: WorkspaceGitCreateWorktreeRequest,
879    ) -> Result<WorkspaceGitWorktreeMutation> {
880        let branch = request.branch;
881        let new_branch = request.new_branch;
882        if request.path.as_deref().is_some_and(|path| {
883            let path = Path::new(path);
884            !path.is_absolute()
885                && path
886                    .components()
887                    .any(|component| matches!(component, Component::ParentDir))
888        }) {
889            bail!("worktree path contains an unsupported component");
890        }
891        let path = request
892            .path
893            .map(|path| {
894                let path = PathBuf::from(path);
895                if path.is_absolute() {
896                    path
897                } else {
898                    self.root.join(path)
899                }
900            })
901            .unwrap_or_else(|| default_local_worktree_path(&self.root, &branch));
902        refuse_symlink_worktree_path(&self.root, &path)?;
903        refuse_symlink_escape(&self.root, &path)?;
904        if self.access_boundary.is_some() {
905            self.refuse_worktree_checkout(&branch, new_branch, &path)?;
906        }
907        let display_path = path.display().to_string();
908        let branch_for_git = branch.clone();
909
910        self.run_blocking_git(move |root| {
911            crate::git::create_worktree(&root, &branch_for_git, &path, new_branch)
912        })
913        .await?;
914
915        Ok(WorkspaceGitWorktreeMutation {
916            path: display_path,
917            branch: Some(branch),
918        })
919    }
920
921    async fn remove_worktree(
922        &self,
923        request: WorkspaceGitRemoveWorktreeRequest,
924    ) -> Result<WorkspaceGitWorktreeMutation> {
925        let path = PathBuf::from(request.path);
926        let display_path = path.display().to_string();
927        let force = request.force;
928        if self.access_boundary.is_some() {
929            if let Some(canonical) = canonicalize_inside_workspace(&self.root, &path) {
930                refuse_symlink_worktree_path(&self.root, &canonical)?;
931                let relative = canonical
932                    .strip_prefix(&self.root)
933                    .unwrap_or(canonical.as_path());
934                for file in crate::git::worktree_removable_paths(&canonical)? {
935                    self.refuse_checkout_path(&relative.join(file))?;
936                }
937            }
938        }
939
940        self.run_blocking_git(move |root| crate::git::remove_worktree(&root, &path, force))
941            .await?;
942
943        Ok(WorkspaceGitWorktreeMutation {
944            path: display_path,
945            branch: None,
946        })
947    }
948}
949
950#[async_trait]
951impl WorkspaceCommandRunner for LocalWorkspaceBackend {
952    async fn exec(&self, request: CommandRequest) -> Result<CommandOutput> {
953        #[cfg(windows)]
954        if let Some(output) =
955            crate::tools::builtin::bash::maybe_execute_simple_windows_http_command(&request.command)
956                .await
957        {
958            let exit_code = output
959                .metadata
960                .as_ref()
961                .and_then(|m| m.get("exit_code"))
962                .and_then(|v| v.as_i64())
963                .map(|v| v as i32)
964                .unwrap_or(if output.success { 0 } else { -1 });
965            return Ok(CommandOutput {
966                output: output.content,
967                exit_code,
968                timed_out: false,
969            });
970        }
971
972        let mut child = crate::tools::builtin::bash::spawn_shell(
973            &request.command,
974            &self.root,
975            request.env.as_deref(),
976        )
977        .map_err(|e| anyhow!("Failed to spawn shell: {}", e))?;
978
979        let output = crate::tools::process::read_process_output(
980            &mut child,
981            request.timeout_ms,
982            request.output_observer.as_deref(),
983        )
984        .await
985        .map_err(|error| anyhow!("Failed to capture shell output: {error}"))?;
986        let exit_code = output.status.and_then(|status| status.code()).unwrap_or(-1);
987
988        Ok(CommandOutput {
989            output: output.combined,
990            exit_code,
991            timed_out: output.timed_out,
992        })
993    }
994}
995
996impl LocalWorkspaceBackend {
997    async fn run_blocking_git<T, F>(&self, operation: F) -> Result<T>
998    where
999        T: Send + 'static,
1000        F: FnOnce(PathBuf) -> Result<T> + Send + 'static,
1001    {
1002        let root = self.root.clone();
1003        let cancellation = Arc::new(AtomicBool::new(false));
1004        let worker_cancellation = Arc::clone(&cancellation);
1005        let mut cancel_on_drop = CancelGitWorkerOnDrop::new(cancellation);
1006        let joined = tokio::task::spawn_blocking(move || {
1007            crate::git::with_git_cancellation(worker_cancellation, || operation(root))
1008        })
1009        .await;
1010        cancel_on_drop.disarm();
1011        joined.map_err(|e| anyhow!("Git worker failed: {}", e))?
1012    }
1013
1014    async fn run_git_command(&self, args: Vec<String>) -> Result<(bool, String, String)> {
1015        const GIT_COMMAND_TIMEOUT_MS: u64 = 30_000;
1016
1017        let executable = crate::git::trusted_git_executable(&self.root)?;
1018        let mut command = tokio::process::Command::new(executable);
1019        crate::git::configure_tokio_git_environment(&mut command, &self.root);
1020        command
1021            .args(&args)
1022            .stdout(std::process::Stdio::piped())
1023            .stderr(std::process::Stdio::piped())
1024            .kill_on_drop(true);
1025        crate::tools::process::configure_process_group(&mut command);
1026        let mut child = crate::tools::process::spawn_tokio_child(&mut command)
1027            .map_err(|e| anyhow!("Failed to execute git: {}", e))?;
1028        let output =
1029            crate::tools::process::read_process_output(&mut child, GIT_COMMAND_TIMEOUT_MS, None)
1030                .await
1031                .map_err(|e| anyhow!("Failed to wait for git: {}", e))?;
1032        if output.timed_out {
1033            bail!("Git command timed out after {GIT_COMMAND_TIMEOUT_MS}ms");
1034        }
1035        let success = output.status.is_some_and(|status| status.success());
1036
1037        Ok((success, output.stdout, output.stderr))
1038    }
1039}
1040
1041fn parse_git_remote_line(line: &str) -> Option<WorkspaceGitRemote> {
1042    let mut parts = line.split_whitespace();
1043    let name = parts.next()?;
1044    let url = parts.next()?;
1045    let direction = parts
1046        .next()
1047        .unwrap_or_default()
1048        .trim_start_matches('(')
1049        .trim_end_matches(')');
1050
1051    Some(WorkspaceGitRemote {
1052        name: name.to_string(),
1053        url: url.to_string(),
1054        direction: direction.to_string(),
1055    })
1056}
1057
1058/// A write must not follow a symlink that already exists on the requested path.
1059///
1060/// `create_dir_all` and `File::open` both follow directory and file symlinks.
1061/// Checking after either of those has already created or overwritten the
1062/// destination outside the workspace.
1063fn refuse_existing_symlink_on_write_path(root: &Path, relative: &str) -> Result<()> {
1064    let mut current = root
1065        .canonicalize()
1066        .map_err(|error| anyhow!("Failed to resolve local workspace root: {error}"))?;
1067    let relative = Path::new(relative);
1068    if relative.as_os_str().is_empty() {
1069        bail!("write path must name a file");
1070    }
1071    let components: Vec<_> = relative.components().collect();
1072    for (index, component) in components.iter().enumerate() {
1073        let Component::Normal(name) = component else {
1074            bail!("write path must stay inside the workspace");
1075        };
1076        current.push(name);
1077        let last = index + 1 == components.len();
1078        match std::fs::symlink_metadata(&current) {
1079            Ok(metadata) if metadata.file_type().is_symlink() => {
1080                bail!("write path crosses a symbolic link")
1081            }
1082            Ok(metadata) if !last && !metadata.is_dir() => {
1083                bail!("A write path parent component is not a directory")
1084            }
1085            Ok(_) => {}
1086            Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
1087            Err(error) => bail!("Failed to inspect write path: {error}"),
1088        }
1089    }
1090    Ok(())
1091}
1092
1093/// Last check before truncation. `/dev/fd` and `/proc/self/fd` are inconclusive
1094/// on some hosts; a resolved path that is neither of those and sits outside
1095/// the workspace is a followed link and must not be truncated.
1096fn opened_write_stays_in_workspace(file: &tokio::fs::File, workspace: &Path) -> Result<()> {
1097    let Some(canonical) = opened_file_canonical_path(file) else {
1098        return Ok(());
1099    };
1100    if canonical.starts_with("/dev") || canonical.starts_with("/proc") {
1101        return Ok(());
1102    }
1103    let root = workspace
1104        .canonicalize()
1105        .map_err(|error| anyhow!("Failed to resolve local workspace root: {error}"))?;
1106    if !canonical.starts_with(&root) {
1107        bail!("write path resolves outside the workspace");
1108    }
1109    Ok(())
1110}
1111
1112fn opened_file_canonical_path(file: &tokio::fs::File) -> Option<PathBuf> {
1113    #[cfg(unix)]
1114    {
1115        use std::os::unix::io::AsRawFd;
1116        let fd = file.as_raw_fd();
1117        for candidate in [format!("/dev/fd/{fd}"), format!("/proc/self/fd/{fd}")] {
1118            if let Ok(path) = std::fs::canonicalize(&candidate) {
1119                return Some(path);
1120            }
1121        }
1122        None
1123    }
1124    #[cfg(not(unix))]
1125    {
1126        let _ = file;
1127        None
1128    }
1129}
1130
1131fn default_local_worktree_path(root: &Path, branch: &str) -> PathBuf {
1132    let repo_name = root
1133        .file_name()
1134        .map(|name| name.to_string_lossy().to_string())
1135        .unwrap_or_else(|| "repo".to_string());
1136    root.parent()
1137        .unwrap_or(root)
1138        .join(format!("{repo_name}-{branch}"))
1139}
1140
1141fn resolved_workspace_destination(root: &Path, path: &Path) -> Option<PathBuf> {
1142    if path.starts_with(root) {
1143        return Some(path.to_path_buf());
1144    }
1145    let mut existing = path.to_path_buf();
1146    let mut missing = Vec::new();
1147    while !existing.exists() {
1148        let parent = existing.parent()?.to_path_buf();
1149        missing.push(existing.file_name()?.to_os_string());
1150        if parent == existing {
1151            return None;
1152        }
1153        existing = parent;
1154    }
1155    let canonical_existing = std::fs::canonicalize(&existing).ok()?;
1156    if !canonical_existing.starts_with(root) {
1157        return None;
1158    }
1159    let mut resolved = canonical_existing;
1160    for name in missing.into_iter().rev() {
1161        resolved.push(name);
1162    }
1163    resolved.starts_with(root).then_some(resolved)
1164}
1165
1166fn canonicalize_inside_workspace(root: &Path, path: &Path) -> Option<PathBuf> {
1167    let canonical = std::fs::canonicalize(path).ok()?;
1168    canonical.starts_with(root).then_some(canonical)
1169}
1170
1171fn refuse_symlink_escape(root: &Path, path: &Path) -> Result<()> {
1172    let mut cursor = PathBuf::new();
1173    for component in path.components() {
1174        cursor.push(component);
1175        let Ok(metadata) = std::fs::symlink_metadata(&cursor) else {
1176            break;
1177        };
1178        if !metadata.file_type().is_symlink() {
1179            continue;
1180        }
1181        let Ok(target) = std::fs::canonicalize(&cursor) else {
1182            bail!("refusing to follow a symbolic link in the worktree path");
1183        };
1184        if target.starts_with(root) || root.starts_with(&target) {
1185            continue;
1186        }
1187        bail!("refusing to follow a symbolic link in the worktree path");
1188    }
1189    Ok(())
1190}
1191
1192fn refuse_symlink_worktree_path(root: &Path, path: &Path) -> Result<()> {
1193    if path.components().any(|component| {
1194        matches!(
1195            component,
1196            Component::ParentDir | Component::RootDir | Component::Prefix(_)
1197        )
1198    }) && !path.is_absolute()
1199    {
1200        bail!("worktree path contains an unsupported component");
1201    }
1202    let candidate = if path.is_absolute() {
1203        path.to_path_buf()
1204    } else {
1205        root.join(path)
1206    };
1207    if path.is_absolute() && !candidate.starts_with(root) {
1208        return Ok(());
1209    }
1210    let relative = candidate.strip_prefix(root).unwrap_or(candidate.as_path());
1211    let mut current = root.to_path_buf();
1212    for component in relative.components() {
1213        let Component::Normal(name) = component else {
1214            bail!("worktree path contains an unsupported component");
1215        };
1216        current.push(name);
1217        match std::fs::symlink_metadata(&current) {
1218            Ok(metadata) if metadata.file_type().is_symlink() => {
1219                bail!("refusing to follow a symbolic link in the worktree path");
1220            }
1221            Ok(_) => {}
1222            Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
1223            Err(error) => bail!("failed to inspect worktree path: {error}"),
1224        }
1225    }
1226    Ok(())
1227}
1228
1229fn normalize_local_path(root: &Path, input: &str) -> Result<WorkspacePath> {
1230    let input = default_path_input(input);
1231    let candidate = Path::new(input);
1232
1233    if candidate.is_absolute() {
1234        let root = normalize_absolute_path(root)?;
1235        let target = normalize_absolute_path(candidate)?;
1236        if !target.starts_with(&root) {
1237            bail!(
1238                "Workspace boundary violation: path '{}' escapes workspace '{}'",
1239                input,
1240                root.display()
1241            );
1242        }
1243        let relative = target
1244            .strip_prefix(&root)
1245            .map_err(|_| anyhow!("Failed to compute workspace-relative path"))?;
1246        return Ok(pathbuf_to_workspace_path(relative));
1247    }
1248
1249    if has_windows_path_prefix(input) {
1250        bail!("Absolute paths are not supported by this workspace backend");
1251    }
1252
1253    let normalized_input = input.replace('\\', "/");
1254    let path = Path::new(&normalized_input);
1255    if path.is_absolute() {
1256        bail!("Absolute paths are not supported by this workspace backend");
1257    }
1258
1259    let relative = normalize_relative_path(path)?;
1260    Ok(pathbuf_to_workspace_path(&relative))
1261}
1262
1263fn normalize_absolute_path(path: &Path) -> Result<PathBuf> {
1264    let lexical = normalize_absolute_path_lexical(path)?;
1265    if let Ok(canonical) = lexical.canonicalize() {
1266        return Ok(canonical);
1267    }
1268
1269    let mut current = lexical.as_path();
1270    let mut suffix = Vec::new();
1271    while !current.exists() {
1272        let Some(file_name) = current.file_name() else {
1273            return Ok(lexical);
1274        };
1275        suffix.push(file_name.to_os_string());
1276        let Some(parent) = current.parent() else {
1277            return Ok(lexical);
1278        };
1279        current = parent;
1280    }
1281
1282    let mut normalized = current.canonicalize().unwrap_or_else(|_| {
1283        normalize_absolute_path_lexical(current).unwrap_or_else(|_| current.into())
1284    });
1285    for part in suffix.iter().rev() {
1286        normalized.push(part);
1287    }
1288    Ok(normalized)
1289}
1290
1291fn normalize_absolute_path_lexical(path: &Path) -> Result<PathBuf> {
1292    let mut out = PathBuf::new();
1293    for component in path.components() {
1294        match component {
1295            Component::Prefix(prefix) => out.push(prefix.as_os_str()),
1296            Component::RootDir => out.push(Path::new(std::path::MAIN_SEPARATOR_STR)),
1297            Component::CurDir => {}
1298            Component::Normal(part) => out.push(part),
1299            Component::ParentDir => {
1300                if !out.pop() {
1301                    bail!("Invalid absolute path");
1302                }
1303            }
1304        }
1305    }
1306    Ok(out)
1307}
1308
1309#[cfg(test)]
1310mod tests {
1311    use super::super::WorkspaceServices;
1312    use super::*;
1313
1314    #[tokio::test]
1315    async fn local_backend_reads_writes_and_lists() {
1316        let temp = tempfile::tempdir().unwrap();
1317        let services = WorkspaceServices::local(temp.path());
1318        let path = services.normalize_path("dir/file.txt").unwrap();
1319
1320        let written = services
1321            .fs()
1322            .write_text(&path, "hello\nworld\n")
1323            .await
1324            .unwrap();
1325        assert_eq!(written.bytes, 12);
1326        assert_eq!(written.lines, 2);
1327
1328        let content = services.fs().read_text(&path).await.unwrap();
1329        assert_eq!(content, "hello\nworld\n");
1330
1331        let dir = services.normalize_path("dir").unwrap();
1332        let entries = services.fs().list_dir(&dir).await.unwrap();
1333        assert_eq!(entries.len(), 1);
1334        assert_eq!(entries[0].name, "file.txt");
1335    }
1336
1337    #[tokio::test]
1338    async fn local_backend_searches_glob_and_grep() {
1339        let temp = tempfile::tempdir().unwrap();
1340        let services = WorkspaceServices::local(temp.path());
1341        services
1342            .fs()
1343            .write_text(
1344                &services.normalize_path("src/main.rs").unwrap(),
1345                "fn main() {\n    println!(\"hello\");\n}\n",
1346            )
1347            .await
1348            .unwrap();
1349        services
1350            .fs()
1351            .write_text(
1352                &services.normalize_path("README.md").unwrap(),
1353                "hello from docs\n",
1354            )
1355            .await
1356            .unwrap();
1357
1358        let search = services.search().expect("local backend supports search");
1359        let glob = search
1360            .glob(WorkspaceGlobRequest {
1361                base: services.normalize_path("src").unwrap(),
1362                pattern: "*.rs".to_string(),
1363            })
1364            .await
1365            .unwrap();
1366        assert_eq!(glob.matches[0].as_str(), "src/main.rs");
1367
1368        let grep = search
1369            .grep(WorkspaceGrepRequest {
1370                base: WorkspacePath::root(),
1371                pattern: "hello".to_string(),
1372                glob: Some("**/*.rs".to_string()),
1373                context_lines: 0,
1374                case_insensitive: false,
1375                max_output_size: 1024,
1376            })
1377            .await
1378            .unwrap();
1379        assert_eq!(grep.match_count, 1);
1380        assert_eq!(grep.file_count, 1);
1381        assert!(grep.output.contains("src/main.rs:2"));
1382    }
1383
1384    fn credential_boundary_backend(root: &Path) -> LocalWorkspaceBackend {
1385        LocalWorkspaceBackend::new_with_access_policy(
1386            root.to_path_buf(),
1387            LocalWorkspaceAccessPolicy::CredentialBoundary,
1388        )
1389    }
1390
1391    #[tokio::test]
1392    async fn credential_boundary_denies_direct_secret_reads_and_writes() {
1393        let temp = tempfile::tempdir().unwrap();
1394        std::fs::create_dir_all(temp.path().join("apps/api")).unwrap();
1395        std::fs::write(temp.path().join("apps/api/.env.local"), "TOKEN=secret\n").unwrap();
1396        let backend = credential_boundary_backend(temp.path());
1397        let secret = backend.normalize("apps/api/.env.local").unwrap();
1398
1399        let read_error = backend
1400            .read_text(&secret)
1401            .await
1402            .expect_err("direct secret reads must be denied");
1403        assert!(read_error.to_string().contains("credential boundary"));
1404
1405        let range_error = backend
1406            .read_text_range(&secret, 0, 10)
1407            .await
1408            .expect_err("range reads must use the same boundary");
1409        assert!(range_error.to_string().contains("credential boundary"));
1410
1411        let write_error = backend
1412            .write_text(&secret, "TOKEN=overwritten\n")
1413            .await
1414            .expect_err("direct secret writes must be denied");
1415        assert!(write_error.to_string().contains("credential boundary"));
1416        assert_eq!(
1417            std::fs::read_to_string(temp.path().join("apps/api/.env.local")).unwrap(),
1418            "TOKEN=secret\n"
1419        );
1420
1421        let new_secret = backend.normalize(".env.generated").unwrap();
1422        backend
1423            .write_text(&new_secret, "TOKEN=new\n")
1424            .await
1425            .expect_err("creating a new env file must be denied");
1426        assert!(!temp.path().join(".env.generated").exists());
1427    }
1428
1429    #[tokio::test]
1430    async fn credential_boundary_filters_grep_and_rejects_explicit_secret_base() {
1431        let temp = tempfile::tempdir().unwrap();
1432        std::fs::write(temp.path().join(".env"), "BOUNDARY_TOKEN=secret\n").unwrap();
1433        std::fs::write(
1434            temp.path().join("README.md"),
1435            "BOUNDARY_TOKEN is configured externally\n",
1436        )
1437        .unwrap();
1438        let backend = credential_boundary_backend(temp.path());
1439
1440        let grep = backend
1441            .grep(WorkspaceGrepRequest {
1442                base: WorkspacePath::root(),
1443                pattern: "BOUNDARY_TOKEN".to_string(),
1444                glob: None,
1445                context_lines: 0,
1446                case_insensitive: false,
1447                max_output_size: 1024,
1448            })
1449            .await
1450            .unwrap();
1451        assert_eq!(grep.match_count, 1);
1452        assert_eq!(grep.file_count, 1);
1453        assert!(grep.output.contains("README.md"));
1454        assert!(!grep.output.contains("secret"));
1455        assert!(!grep.output.contains(".env"));
1456
1457        let error = backend
1458            .grep(WorkspaceGrepRequest {
1459                base: backend.normalize(".env").unwrap(),
1460                pattern: "secret".to_string(),
1461                glob: None,
1462                context_lines: 0,
1463                case_insensitive: false,
1464                max_output_size: 1024,
1465            })
1466            .await
1467            .expect_err("an explicit secret grep must fail closed");
1468        assert!(error.to_string().contains("credential boundary"));
1469    }
1470
1471    #[cfg(any(unix, windows))]
1472    #[tokio::test]
1473    async fn credential_boundary_denies_source_hardlinks_without_truncating_them() {
1474        let temp = tempfile::tempdir().unwrap();
1475        let source = temp.path().join("source.txt");
1476        let alias = temp.path().join("alias.txt");
1477        std::fs::write(&source, "linked secret\n").unwrap();
1478        std::fs::hard_link(&source, &alias).unwrap();
1479        let backend = credential_boundary_backend(temp.path());
1480        let alias_path = backend.normalize("alias.txt").unwrap();
1481
1482        backend
1483            .read_text(&alias_path)
1484            .await
1485            .expect_err("source-tree hardlink reads must be denied");
1486        backend
1487            .write_text(&alias_path, "overwritten\n")
1488            .await
1489            .expect_err("source-tree hardlink writes must be denied");
1490        assert_eq!(std::fs::read_to_string(&source).unwrap(), "linked secret\n");
1491    }
1492
1493    #[cfg(any(unix, windows))]
1494    #[tokio::test]
1495    async fn source_egress_boundary_denies_control_paths_and_every_hardlink() {
1496        let temp = tempfile::tempdir().unwrap();
1497        std::fs::create_dir_all(temp.path().join("src")).unwrap();
1498        std::fs::create_dir_all(temp.path().join(".a3s")).unwrap();
1499        let source = temp.path().join("source.txt");
1500        let alias = temp.path().join("src/apparently-safe.txt");
1501        std::fs::write(&source, "linked source\n").unwrap();
1502        std::fs::hard_link(&source, &alias).unwrap();
1503        std::fs::write(temp.path().join(".a3s/config.acl"), "secret = true\n").unwrap();
1504        std::fs::write(temp.path().join("src/safe.rs"), "pub fn safe() {}\n").unwrap();
1505        let backend =
1506            LocalWorkspaceBackend::new_with_source_egress_policy(temp.path().to_path_buf());
1507
1508        backend
1509            .read_text(&backend.normalize("src/apparently-safe.txt").unwrap())
1510            .await
1511            .expect_err("source egress must reject every multi-link file");
1512        backend
1513            .read_text(&backend.normalize(".a3s/config.acl").unwrap())
1514            .await
1515            .expect_err("source egress must reject control paths at read time");
1516        assert_eq!(
1517            backend
1518                .read_text(&backend.normalize("src/safe.rs").unwrap())
1519                .await
1520                .unwrap(),
1521            "pub fn safe() {}\n"
1522        );
1523    }
1524
1525    #[cfg(any(unix, windows))]
1526    #[tokio::test]
1527    async fn credential_boundary_allows_package_store_hardlinks_but_denies_secret_aliases() {
1528        let temp = tempfile::tempdir().unwrap();
1529        let package = temp.path().join("node_modules/pkg");
1530        std::fs::create_dir_all(&package).unwrap();
1531
1532        let package_source = package.join("source.js");
1533        let package_alias = package.join("alias.js");
1534        std::fs::write(&package_source, "export const value = 1;\n").unwrap();
1535        std::fs::hard_link(&package_source, &package_alias).unwrap();
1536
1537        let env = temp.path().join(".env");
1538        let env_alias = package.join("credential.txt");
1539        std::fs::write(&env, "TOKEN=secret\n").unwrap();
1540        std::fs::hard_link(&env, &env_alias).unwrap();
1541
1542        let backend = credential_boundary_backend(temp.path());
1543        let package_content = backend
1544            .read_text(&backend.normalize("node_modules/pkg/alias.js").unwrap())
1545            .await
1546            .expect("ordinary package-store hardlinks should remain readable");
1547        assert!(package_content.contains("value = 1"));
1548
1549        let error = backend
1550            .read_text(
1551                &backend
1552                    .normalize("node_modules/pkg/credential.txt")
1553                    .unwrap(),
1554            )
1555            .await
1556            .expect_err("a package-tree alias of a known credential must be denied");
1557        assert!(error.to_string().contains("credential boundary"));
1558    }
1559
1560    fn run_test_git(root: &Path, args: &[&str]) -> bool {
1561        let mut command = std::process::Command::new("git");
1562        command
1563            .arg("-C")
1564            .arg(root)
1565            .args([
1566                "-c",
1567                "user.name=A3S Test",
1568                "-c",
1569                "user.email=test@a3s.local",
1570            ])
1571            .args(args);
1572        let started = crate::tools::process::status_std_child(&mut command)
1573            .is_ok_and(|status| status.success());
1574        if started && args.first() == Some(&"init") {
1575            // Do not inherit the runner's `core.autocrlf`. A Windows checkout
1576            // that rewrites `\n` to `\r\n` dirties ordinary files and makes a
1577            // preserved credential file look overwritten.
1578            let _ = run_test_git(root, &["config", "core.autocrlf", "false"]);
1579            let _ = run_test_git(root, &["config", "core.eol", "lf"]);
1580        }
1581        started
1582    }
1583
1584    #[cfg(any(unix, windows))]
1585    #[tokio::test]
1586    async fn credential_boundary_filters_git_diff_content_and_option_like_targets() {
1587        let temp = tempfile::tempdir().unwrap();
1588        if !run_test_git(temp.path(), &["init", "-q"]) {
1589            return;
1590        }
1591        std::fs::create_dir_all(temp.path().join("src")).unwrap();
1592        std::fs::write(temp.path().join(".env"), "TOKEN=old-secret\n").unwrap();
1593        std::fs::write(temp.path().join("src/lib.rs"), "pub const VALUE: u8 = 1;\n").unwrap();
1594        std::fs::write(temp.path().join("linked.txt"), "hardlink-old-secret\n").unwrap();
1595        std::fs::hard_link(
1596            temp.path().join("linked.txt"),
1597            temp.path().join("linked-alias.txt"),
1598        )
1599        .unwrap();
1600        assert!(run_test_git(temp.path(), &["add", "."]));
1601        assert!(run_test_git(temp.path(), &["commit", "-qm", "baseline"]));
1602
1603        std::fs::write(temp.path().join(".env"), "TOKEN=new-secret\n").unwrap();
1604        std::fs::write(temp.path().join("src/lib.rs"), "pub const VALUE: u8 = 2;\n").unwrap();
1605        std::fs::write(
1606            temp.path().join("linked-alias.txt"),
1607            "hardlink-new-secret\n",
1608        )
1609        .unwrap();
1610
1611        let backend = credential_boundary_backend(temp.path());
1612        let diff = backend
1613            .diff(WorkspaceGitDiffRequest { target: None })
1614            .await
1615            .unwrap();
1616        assert!(diff.contains("VALUE: u8 = 2"), "{diff}");
1617        for denied in [
1618            "old-secret",
1619            "new-secret",
1620            "hardlink-old-secret",
1621            "hardlink-new-secret",
1622            ".env",
1623            "linked.txt",
1624            "linked-alias.txt",
1625        ] {
1626            assert!(!diff.contains(denied), "{denied} leaked in {diff}");
1627        }
1628
1629        let output = temp.path().join("injected-diff-output");
1630        let error = backend
1631            .diff(WorkspaceGitDiffRequest {
1632                target: Some(format!("--output={}", output.display())),
1633            })
1634            .await
1635            .expect_err("an option-like target must be parsed only as a revision");
1636        assert!(error.to_string().contains("Git diff"));
1637        assert!(!output.exists());
1638    }
1639
1640    #[tokio::test]
1641    async fn credential_boundary_checkout_does_not_overwrite_a_credential_file() {
1642        let temp = tempfile::tempdir().unwrap();
1643        assert!(run_test_git(temp.path(), &["init", "-q"]));
1644        std::fs::create_dir_all(temp.path().join("src")).unwrap();
1645        std::fs::write(temp.path().join(".env"), "TOKEN=checkout-secret-4e17\n").unwrap();
1646        std::fs::write(temp.path().join("src/lib.rs"), "pub const VALUE: u8 = 1;\n").unwrap();
1647        assert!(run_test_git(temp.path(), &["add", "."]));
1648        assert!(run_test_git(temp.path(), &["commit", "-qm", "old"]));
1649        assert!(run_test_git(temp.path(), &["branch", "old"]));
1650        std::fs::write(temp.path().join(".env"), "TOKEN=checkout-secret-new\n").unwrap();
1651        std::fs::write(temp.path().join("src/lib.rs"), "pub const VALUE: u8 = 2;\n").unwrap();
1652        assert!(run_test_git(temp.path(), &["add", "."]));
1653        assert!(run_test_git(temp.path(), &["commit", "-qm", "new"]));
1654        let new_branch = if run_test_git(temp.path(), &["rev-parse", "--verify", "main"]) {
1655            "main"
1656        } else {
1657            "master"
1658        };
1659        assert!(run_test_git(temp.path(), &["checkout", "-q", "old"]));
1660
1661        let backend = credential_boundary_backend(temp.path());
1662        let error = backend
1663            .checkout(WorkspaceGitCheckoutRequest {
1664                refspec: new_branch.to_string(),
1665                force: false,
1666            })
1667            .await
1668            .expect_err("checkout must not apply a credential path");
1669        assert!(error.to_string().contains("credential boundary"), "{error}");
1670        assert_eq!(
1671            std::fs::read_to_string(temp.path().join(".env")).unwrap(),
1672            "TOKEN=checkout-secret-4e17\n"
1673        );
1674        assert_eq!(
1675            std::fs::read_to_string(temp.path().join("src/lib.rs")).unwrap(),
1676            "pub const VALUE: u8 = 1;\n"
1677        );
1678    }
1679
1680    #[tokio::test]
1681    async fn credential_boundary_checkout_still_updates_an_ordinary_file() {
1682        let temp = tempfile::tempdir().unwrap();
1683        assert!(run_test_git(temp.path(), &["init", "-q"]));
1684        std::fs::write(temp.path().join("README.md"), "old\n").unwrap();
1685        assert!(run_test_git(temp.path(), &["add", "."]));
1686        assert!(run_test_git(temp.path(), &["commit", "-qm", "old"]));
1687        assert!(run_test_git(temp.path(), &["branch", "old"]));
1688        std::fs::write(temp.path().join("README.md"), "new\n").unwrap();
1689        assert!(run_test_git(temp.path(), &["add", "."]));
1690        assert!(run_test_git(temp.path(), &["commit", "-qm", "new"]));
1691        let new_branch = if run_test_git(temp.path(), &["rev-parse", "--verify", "main"]) {
1692            "main"
1693        } else {
1694            "master"
1695        };
1696        assert!(run_test_git(temp.path(), &["checkout", "-q", "old"]));
1697
1698        let backend = credential_boundary_backend(temp.path());
1699        backend
1700            .checkout(WorkspaceGitCheckoutRequest {
1701                refspec: new_branch.to_string(),
1702                force: false,
1703            })
1704            .await
1705            .expect("ordinary checkout");
1706        assert_eq!(
1707            std::fs::read_to_string(temp.path().join("README.md")).unwrap(),
1708            "new\n"
1709        );
1710    }
1711
1712    #[tokio::test]
1713    async fn credential_boundary_force_checkout_does_not_reset_a_dirty_credential_file() {
1714        let temp = tempfile::tempdir().unwrap();
1715        assert!(run_test_git(temp.path(), &["init", "-q"]));
1716        std::fs::write(temp.path().join(".env"), "TOKEN=committed\n").unwrap();
1717        assert!(run_test_git(temp.path(), &["add", "."]));
1718        assert!(run_test_git(temp.path(), &["commit", "-qm", "base"]));
1719        std::fs::write(temp.path().join(".env"), "TOKEN=dirty-checkout-7c41\n").unwrap();
1720
1721        let backend = credential_boundary_backend(temp.path());
1722        let error = backend
1723            .checkout(WorkspaceGitCheckoutRequest {
1724                refspec: "HEAD".to_string(),
1725                force: true,
1726            })
1727            .await
1728            .expect_err("force checkout must not reset a credential file");
1729        assert!(error.to_string().contains("credential boundary"), "{error}");
1730        assert_eq!(
1731            std::fs::read_to_string(temp.path().join(".env")).unwrap(),
1732            "TOKEN=dirty-checkout-7c41\n"
1733        );
1734    }
1735
1736    #[tokio::test]
1737    async fn credential_boundary_stash_does_not_reset_a_dirty_credential_file() {
1738        let temp = tempfile::tempdir().unwrap();
1739        assert!(run_test_git(temp.path(), &["init", "-q"]));
1740        std::fs::write(temp.path().join(".env"), "TOKEN=committed\n").unwrap();
1741        std::fs::write(temp.path().join("README.md"), "old\n").unwrap();
1742        assert!(run_test_git(temp.path(), &["add", "."]));
1743        assert!(run_test_git(temp.path(), &["commit", "-qm", "base"]));
1744        std::fs::write(temp.path().join(".env"), "TOKEN=dirty-stash-7c41\n").unwrap();
1745        std::fs::write(temp.path().join("README.md"), "new\n").unwrap();
1746
1747        let backend = credential_boundary_backend(temp.path());
1748        let error = backend
1749            .stash(WorkspaceGitStashRequest {
1750                message: Some("should-not-land".to_string()),
1751                include_untracked: false,
1752            })
1753            .await
1754            .expect_err("stash must not reset a credential file");
1755        assert!(error.to_string().contains("credential boundary"), "{error}");
1756        assert_eq!(
1757            std::fs::read_to_string(temp.path().join(".env")).unwrap(),
1758            "TOKEN=dirty-stash-7c41\n"
1759        );
1760        assert_eq!(
1761            std::fs::read_to_string(temp.path().join("README.md")).unwrap(),
1762            "new\n"
1763        );
1764        assert!(backend.list_stashes().await.unwrap().is_empty());
1765    }
1766
1767    #[tokio::test]
1768    async fn credential_boundary_stash_does_not_remove_an_untracked_credential_file() {
1769        let temp = tempfile::tempdir().unwrap();
1770        assert!(run_test_git(temp.path(), &["init", "-q"]));
1771        std::fs::write(temp.path().join("README.md"), "old\n").unwrap();
1772        assert!(run_test_git(temp.path(), &["add", "."]));
1773        assert!(run_test_git(temp.path(), &["commit", "-qm", "base"]));
1774        std::fs::write(temp.path().join(".env"), "TOKEN=untracked-stash-91aa\n").unwrap();
1775        std::fs::write(temp.path().join("notes.txt"), "keep\n").unwrap();
1776
1777        let backend = credential_boundary_backend(temp.path());
1778        let error = backend
1779            .stash(WorkspaceGitStashRequest {
1780                message: Some("should-not-land".to_string()),
1781                include_untracked: true,
1782            })
1783            .await
1784            .expect_err("stash -u must not remove an untracked credential file");
1785        assert!(error.to_string().contains("credential boundary"), "{error}");
1786        assert_eq!(
1787            std::fs::read_to_string(temp.path().join(".env")).unwrap(),
1788            "TOKEN=untracked-stash-91aa\n"
1789        );
1790        assert_eq!(
1791            std::fs::read_to_string(temp.path().join("notes.txt")).unwrap(),
1792            "keep\n"
1793        );
1794        assert!(backend.list_stashes().await.unwrap().is_empty());
1795    }
1796
1797    #[tokio::test]
1798    async fn credential_boundary_stash_still_saves_an_ordinary_file() {
1799        let temp = tempfile::tempdir().unwrap();
1800        assert!(run_test_git(temp.path(), &["init", "-q"]));
1801        std::fs::write(temp.path().join("README.md"), "old\n").unwrap();
1802        assert!(run_test_git(temp.path(), &["add", "."]));
1803        assert!(run_test_git(temp.path(), &["commit", "-qm", "base"]));
1804        std::fs::write(temp.path().join("README.md"), "new\n").unwrap();
1805
1806        let backend = credential_boundary_backend(temp.path());
1807        backend
1808            .stash(WorkspaceGitStashRequest {
1809                message: Some("ordinary".to_string()),
1810                include_untracked: false,
1811            })
1812            .await
1813            .expect("an ordinary dirty file must still be stashable");
1814        assert_eq!(
1815            std::fs::read_to_string(temp.path().join("README.md")).unwrap(),
1816            "old\n"
1817        );
1818        let stashes = backend.list_stashes().await.unwrap();
1819        assert!(
1820            stashes
1821                .iter()
1822                .any(|stash| stash.message.contains("ordinary")),
1823            "{stashes:?}"
1824        );
1825    }
1826
1827    #[tokio::test]
1828    async fn create_branch_does_not_treat_an_option_like_base_as_a_git_flag() {
1829        let temp = tempfile::tempdir().unwrap();
1830        assert!(run_test_git(temp.path(), &["init", "-q"]));
1831        std::fs::write(temp.path().join(".env"), "TOKEN=committed\n").unwrap();
1832        std::fs::write(temp.path().join("README.md"), "old\n").unwrap();
1833        assert!(run_test_git(temp.path(), &["add", "."]));
1834        assert!(run_test_git(temp.path(), &["commit", "-qm", "base"]));
1835        assert!(run_test_git(temp.path(), &["branch", "-M", "main"]));
1836        std::fs::write(temp.path().join(".env"), "TOKEN=dirty-branch-4e18\n").unwrap();
1837        std::fs::write(temp.path().join("README.md"), "new\n").unwrap();
1838
1839        let backend = LocalWorkspaceBackend::new(temp.path().to_path_buf());
1840        let error = backend
1841            .create_branch(WorkspaceGitCreateBranchRequest {
1842                name: "feature".to_string(),
1843                base: "-f".to_string(),
1844            })
1845            .await
1846            .expect_err("an option-like base must not force-discard the worktree");
1847        assert!(error.to_string().contains("create branch"), "{error}");
1848        assert_eq!(
1849            std::fs::read_to_string(temp.path().join(".env")).unwrap(),
1850            "TOKEN=dirty-branch-4e18\n"
1851        );
1852        assert_eq!(
1853            std::fs::read_to_string(temp.path().join("README.md")).unwrap(),
1854            "new\n"
1855        );
1856        assert_eq!(
1857            std::fs::read_to_string(temp.path().join(".git/HEAD")).unwrap(),
1858            "ref: refs/heads/main\n"
1859        );
1860    }
1861
1862    #[tokio::test]
1863    async fn credential_boundary_create_branch_does_not_rewrite_a_credential_file() {
1864        let temp = tempfile::tempdir().unwrap();
1865        assert!(run_test_git(temp.path(), &["init", "-q"]));
1866        std::fs::write(temp.path().join(".env"), "TOKEN=branch-secret-4e18\n").unwrap();
1867        std::fs::write(temp.path().join("README.md"), "one\n").unwrap();
1868        assert!(run_test_git(temp.path(), &["add", "."]));
1869        assert!(run_test_git(temp.path(), &["commit", "-qm", "main"]));
1870        assert!(run_test_git(temp.path(), &["branch", "-M", "main"]));
1871        assert!(run_test_git(
1872            temp.path(),
1873            &["checkout", "-q", "-b", "other"]
1874        ));
1875        std::fs::write(temp.path().join(".env"), "TOKEN=other\n").unwrap();
1876        std::fs::write(temp.path().join("README.md"), "two\n").unwrap();
1877        assert!(run_test_git(temp.path(), &["add", "."]));
1878        assert!(run_test_git(temp.path(), &["commit", "-qm", "other"]));
1879        assert!(run_test_git(temp.path(), &["checkout", "-q", "main"]));
1880
1881        let backend = credential_boundary_backend(temp.path());
1882        let error = backend
1883            .create_branch(WorkspaceGitCreateBranchRequest {
1884                name: "feature".to_string(),
1885                base: "other".to_string(),
1886            })
1887            .await
1888            .expect_err("creating a branch must not check out a credential path");
1889        assert!(error.to_string().contains("credential boundary"), "{error}");
1890        assert_eq!(
1891            std::fs::read_to_string(temp.path().join(".env")).unwrap(),
1892            "TOKEN=branch-secret-4e18\n"
1893        );
1894        assert_eq!(
1895            std::fs::read_to_string(temp.path().join("README.md")).unwrap(),
1896            "one\n"
1897        );
1898        assert_eq!(
1899            std::fs::read_to_string(temp.path().join(".git/HEAD")).unwrap(),
1900            "ref: refs/heads/main\n"
1901        );
1902    }
1903
1904    #[tokio::test]
1905    async fn credential_boundary_create_branch_still_updates_an_ordinary_file() {
1906        let temp = tempfile::tempdir().unwrap();
1907        assert!(run_test_git(temp.path(), &["init", "-q"]));
1908        std::fs::write(temp.path().join("README.md"), "one\n").unwrap();
1909        assert!(run_test_git(temp.path(), &["add", "."]));
1910        assert!(run_test_git(temp.path(), &["commit", "-qm", "main"]));
1911        assert!(run_test_git(temp.path(), &["branch", "-M", "main"]));
1912        assert!(run_test_git(
1913            temp.path(),
1914            &["checkout", "-q", "-b", "other"]
1915        ));
1916        std::fs::write(temp.path().join("README.md"), "two\n").unwrap();
1917        assert!(run_test_git(temp.path(), &["add", "."]));
1918        assert!(run_test_git(temp.path(), &["commit", "-qm", "other"]));
1919        assert!(run_test_git(temp.path(), &["checkout", "-q", "main"]));
1920
1921        let backend = credential_boundary_backend(temp.path());
1922        backend
1923            .create_branch(WorkspaceGitCreateBranchRequest {
1924                name: "feature".to_string(),
1925                base: "other".to_string(),
1926            })
1927            .await
1928            .expect("a branch that only changes an ordinary file must still be created");
1929        assert_eq!(
1930            std::fs::read_to_string(temp.path().join("README.md")).unwrap(),
1931            "two\n"
1932        );
1933        assert_eq!(
1934            std::fs::read_to_string(temp.path().join(".git/HEAD")).unwrap(),
1935            "ref: refs/heads/feature\n"
1936        );
1937    }
1938
1939    #[tokio::test]
1940    async fn checkout_does_not_treat_an_option_like_ref_as_a_git_flag() {
1941        let temp = tempfile::tempdir().unwrap();
1942        assert!(run_test_git(temp.path(), &["init", "-q"]));
1943        std::fs::write(temp.path().join("README.md"), "old\n").unwrap();
1944        assert!(run_test_git(temp.path(), &["add", "."]));
1945        assert!(run_test_git(temp.path(), &["commit", "-qm", "base"]));
1946        let output = temp.path().join("injected-checkout");
1947        let backend = LocalWorkspaceBackend::new(temp.path().to_path_buf());
1948        let error = backend
1949            .checkout(WorkspaceGitCheckoutRequest {
1950                refspec: format!("--output={}", output.display()),
1951                force: true,
1952            })
1953            .await
1954            .expect_err("an option-like ref must not become a Git flag");
1955        assert!(
1956            error.to_string().contains("option")
1957                || error.to_string().contains("checkout")
1958                || error.to_string().contains("Git"),
1959            "{error}"
1960        );
1961        assert!(!output.exists());
1962    }
1963
1964    #[test]
1965    fn local_backend_rejects_absolute_paths_outside_workspace() {
1966        let temp = tempfile::tempdir().unwrap();
1967        let services = WorkspaceServices::local(temp.path());
1968        let outside = temp.path().parent().unwrap().join("secret.txt");
1969        let err = services
1970            .normalize_path(outside.to_str().unwrap())
1971            .expect_err("outside absolute path should be rejected");
1972        assert!(err.to_string().contains("escapes workspace"));
1973    }
1974
1975    #[test]
1976    fn local_backend_rejects_backslash_parent_escape() {
1977        let temp = tempfile::tempdir().unwrap();
1978        let services = WorkspaceServices::local(temp.path());
1979        let err = services
1980            .normalize_path(r"..\secret.txt")
1981            .expect_err("backslash parent traversal should be rejected");
1982        assert!(err.to_string().contains("escapes workspace"));
1983    }
1984
1985    #[test]
1986    fn local_backend_allows_absolute_paths_inside_workspace() {
1987        let temp = tempfile::tempdir().unwrap();
1988        let services = WorkspaceServices::local(temp.path());
1989        let absolute = temp.path().join("src/main.rs");
1990        let path = services
1991            .normalize_path(absolute.to_str().unwrap())
1992            .expect("absolute path inside workspace should normalize");
1993        assert_eq!(path.as_str(), "src/main.rs");
1994    }
1995
1996    #[cfg(unix)]
1997    #[tokio::test]
1998    async fn write_text_does_not_follow_a_symlink_file_out_of_the_workspace() {
1999        use std::os::unix::fs::symlink;
2000
2001        let workspace = tempfile::tempdir().unwrap();
2002        let outside = tempfile::tempdir().unwrap();
2003        let outside_file = outside.path().join("secret.txt");
2004        std::fs::write(&outside_file, "outside-token-4c91").unwrap();
2005        symlink(&outside_file, workspace.path().join("guest.txt")).unwrap();
2006
2007        let services = WorkspaceServices::local(workspace.path());
2008        let path = services.normalize_path("guest.txt").unwrap();
2009        let error = services
2010            .fs()
2011            .write_text(&path, "written-through-link")
2012            .await
2013            .expect_err("a symlink destination must not be written");
2014        assert!(error.to_string().contains("symbolic link"), "{error}");
2015        assert_eq!(
2016            std::fs::read_to_string(&outside_file).unwrap(),
2017            "outside-token-4c91"
2018        );
2019    }
2020
2021    #[cfg(unix)]
2022    #[tokio::test]
2023    async fn write_text_does_not_create_directories_through_a_symlink() {
2024        use std::os::unix::fs::symlink;
2025
2026        let workspace = tempfile::tempdir().unwrap();
2027        let outside = tempfile::tempdir().unwrap();
2028        symlink(outside.path(), workspace.path().join("escape")).unwrap();
2029
2030        let services = WorkspaceServices::local(workspace.path());
2031        let path = services.normalize_path("escape/nested/new.txt").unwrap();
2032        let error = services
2033            .fs()
2034            .write_text(&path, "created-outside")
2035            .await
2036            .expect_err("a symlink parent must not receive new directories");
2037        assert!(error.to_string().contains("symbolic link"), "{error}");
2038        assert!(!outside.path().join("nested").exists());
2039    }
2040
2041    #[cfg(unix)]
2042    #[tokio::test]
2043    async fn read_text_does_not_return_bytes_through_a_symlink() {
2044        use std::os::unix::fs::symlink;
2045
2046        let workspace = tempfile::tempdir().unwrap();
2047        let outside = tempfile::tempdir().unwrap();
2048        let outside_file = outside.path().join("secret.txt");
2049        std::fs::write(&outside_file, "outside-token-b81e").unwrap();
2050        symlink(&outside_file, workspace.path().join("guest.txt")).unwrap();
2051
2052        let services = WorkspaceServices::local(workspace.path());
2053        let path = services.normalize_path("guest.txt").unwrap();
2054        let error = services
2055            .fs()
2056            .read_text(&path)
2057            .await
2058            .expect_err("a symlink read must not return outside bytes");
2059        assert!(!error.to_string().contains("outside-token-b81e"), "{error}");
2060    }
2061
2062    #[cfg(unix)]
2063    #[tokio::test]
2064    async fn list_dir_does_not_follow_a_symlink_directory() {
2065        use std::os::unix::fs::symlink;
2066
2067        let workspace = tempfile::tempdir().unwrap();
2068        let outside = tempfile::tempdir().unwrap();
2069        std::fs::write(outside.path().join("hidden.txt"), "outside-token-c44a").unwrap();
2070        symlink(outside.path(), workspace.path().join("escape")).unwrap();
2071
2072        let services = WorkspaceServices::local(workspace.path());
2073        let path = services.normalize_path("escape").unwrap();
2074        let error = services
2075            .fs()
2076            .list_dir(&path)
2077            .await
2078            .expect_err("a symlink directory must not be listed");
2079        let rendered = error.to_string();
2080        assert!(!rendered.contains("hidden.txt"), "{rendered}");
2081        assert!(!rendered.contains("outside-token-c44a"), "{rendered}");
2082    }
2083
2084    #[cfg(unix)]
2085    #[tokio::test]
2086    async fn grep_and_glob_do_not_surface_a_symlink_target() {
2087        use std::os::unix::fs::symlink;
2088
2089        let workspace = tempfile::tempdir().unwrap();
2090        let outside = tempfile::tempdir().unwrap();
2091        std::fs::write(outside.path().join("hidden.txt"), "outside-token-e90d").unwrap();
2092        symlink(outside.path(), workspace.path().join("escape")).unwrap();
2093        std::fs::write(workspace.path().join("local.txt"), "local-only\n").unwrap();
2094
2095        let services = WorkspaceServices::local(workspace.path());
2096        let search = services.search().expect("local backend supports search");
2097        let grep = search
2098            .grep(WorkspaceGrepRequest {
2099                base: WorkspacePath::root(),
2100                pattern: "outside-token-e90d".to_string(),
2101                glob: None,
2102                context_lines: 0,
2103                case_insensitive: false,
2104                max_output_size: 4096,
2105            })
2106            .await
2107            .unwrap();
2108        assert_eq!(grep.match_count, 0, "{}", grep.output);
2109        assert!(!grep.output.contains("outside-token-e90d"));
2110
2111        let glob = search
2112            .glob(WorkspaceGlobRequest {
2113                base: WorkspacePath::root(),
2114                pattern: "**/*".to_string(),
2115            })
2116            .await
2117            .unwrap();
2118        assert!(
2119            glob.matches
2120                .iter()
2121                .all(|path| !path.as_str().contains("hidden.txt")),
2122            "{:?}",
2123            glob.matches
2124        );
2125    }
2126
2127    #[cfg(unix)]
2128    #[tokio::test]
2129    async fn create_worktree_does_not_follow_a_symlink_directory() {
2130        use std::os::unix::fs::symlink;
2131
2132        let workspace = tempfile::tempdir().unwrap();
2133        let outside = tempfile::tempdir().unwrap();
2134        std::fs::write(
2135            outside.path().join("keep.txt"),
2136            "outside-worktree-token-6a41",
2137        )
2138        .unwrap();
2139        symlink(outside.path(), workspace.path().join("escape")).unwrap();
2140        init_worktree_repo(workspace.path());
2141
2142        let backend = LocalWorkspaceBackend::new(workspace.path().to_path_buf());
2143        let error = backend
2144            .create_worktree(WorkspaceGitCreateWorktreeRequest {
2145                branch: "feature".to_string(),
2146                path: Some("escape/wt".to_string()),
2147                new_branch: true,
2148            })
2149            .await
2150            .expect_err("worktree create must not follow a symlink out of the workspace");
2151        assert!(error.to_string().contains("symbolic link"), "{error}");
2152        assert!(!outside.path().join("wt").exists());
2153        assert_eq!(
2154            std::fs::read_to_string(outside.path().join("keep.txt")).unwrap(),
2155            "outside-worktree-token-6a41"
2156        );
2157
2158        let absolute = workspace.path().join("escape/wt-abs");
2159        let error = backend
2160            .create_worktree(WorkspaceGitCreateWorktreeRequest {
2161                branch: "feature-abs".to_string(),
2162                path: Some(absolute.display().to_string()),
2163                new_branch: true,
2164            })
2165            .await
2166            .expect_err("an absolute path must not follow a symlink out of the workspace");
2167        assert!(error.to_string().contains("symbolic link"), "{error}");
2168        assert!(!outside.path().join("wt-abs").exists());
2169        assert_eq!(
2170            std::fs::read_to_string(outside.path().join("keep.txt")).unwrap(),
2171            "outside-worktree-token-6a41"
2172        );
2173
2174        let sibling = tempfile::tempdir().unwrap();
2175        let error = backend
2176            .create_worktree(WorkspaceGitCreateWorktreeRequest {
2177                branch: "sibling".to_string(),
2178                path: Some(format!(
2179                    "../{}",
2180                    sibling.path().file_name().unwrap().to_string_lossy()
2181                )),
2182                new_branch: true,
2183            })
2184            .await
2185            .expect_err("a relative parent path must not select a sibling worktree");
2186        assert!(
2187            error.to_string().contains("unsupported component"),
2188            "{error}"
2189        );
2190        assert!(std::fs::read_dir(sibling.path()).unwrap().next().is_none());
2191    }
2192
2193    #[tokio::test]
2194    async fn create_worktree_still_creates_an_ordinary_worktree() {
2195        let workspace = tempfile::tempdir().unwrap();
2196        init_worktree_repo(workspace.path());
2197        let backend = LocalWorkspaceBackend::new(workspace.path().to_path_buf());
2198        let created = backend
2199            .create_worktree(WorkspaceGitCreateWorktreeRequest {
2200                branch: "feature".to_string(),
2201                path: Some("wt".to_string()),
2202                new_branch: true,
2203            })
2204            .await
2205            .expect("an ordinary worktree path must still be created");
2206        assert!(created.path.ends_with("wt"), "{}", created.path);
2207        assert!(workspace.path().join("wt/README.md").is_file());
2208        backend
2209            .remove_worktree(WorkspaceGitRemoveWorktreeRequest {
2210                path: workspace.path().join("wt").display().to_string(),
2211                force: true,
2212            })
2213            .await
2214            .expect("the ordinary worktree must still be removable");
2215    }
2216
2217    #[tokio::test]
2218    async fn credential_boundary_create_worktree_does_not_check_out_a_credential_file() {
2219        let workspace = tempfile::tempdir().unwrap();
2220        init_worktree_repo(workspace.path());
2221        std::fs::write(
2222            workspace.path().join(".env"),
2223            "TOKEN=worktree-secret-2c91\n",
2224        )
2225        .unwrap();
2226        assert!(run_test_git(workspace.path(), &["add", ".env"]));
2227        assert!(run_test_git(workspace.path(), &["commit", "-qm", "secret"]));
2228
2229        let backend = credential_boundary_backend(workspace.path());
2230        let error = backend
2231            .create_worktree(WorkspaceGitCreateWorktreeRequest {
2232                branch: "feature".to_string(),
2233                path: Some("wt".to_string()),
2234                new_branch: true,
2235            })
2236            .await
2237            .expect_err("worktree create must not check out a credential file");
2238        assert!(error.to_string().contains("credential boundary"), "{error}");
2239        assert!(!workspace.path().join("wt").exists());
2240        assert_eq!(
2241            std::fs::read_to_string(workspace.path().join(".env")).unwrap(),
2242            "TOKEN=worktree-secret-2c91\n"
2243        );
2244
2245        assert!(run_test_git(
2246            workspace.path(),
2247            &["checkout", "-q", "-b", "other"]
2248        ));
2249        std::fs::write(workspace.path().join(".env"), "TOKEN=other-worktree-8f12\n").unwrap();
2250        assert!(run_test_git(workspace.path(), &["add", ".env"]));
2251        assert!(run_test_git(workspace.path(), &["commit", "-qm", "other"]));
2252        assert!(run_test_git(workspace.path(), &["checkout", "-q", "main"]));
2253        let error = backend
2254            .create_worktree(WorkspaceGitCreateWorktreeRequest {
2255                branch: "other".to_string(),
2256                path: Some("wt2".to_string()),
2257                new_branch: false,
2258            })
2259            .await
2260            .expect_err("checking out an existing branch must not write a credential file");
2261        assert!(error.to_string().contains("credential boundary"), "{error}");
2262        assert!(!workspace.path().join("wt2").exists());
2263
2264        let alias = workspace.path().join("wt-alias");
2265        let error = backend
2266            .create_worktree(WorkspaceGitCreateWorktreeRequest {
2267                branch: "alias-feature".to_string(),
2268                path: Some(alias.display().to_string()),
2269                new_branch: true,
2270            })
2271            .await
2272            .expect_err("a non-canonical absolute path must not skip the credential check");
2273        assert!(error.to_string().contains("credential boundary"), "{error}");
2274        assert!(!alias.exists());
2275        assert!(!workspace
2276            .path()
2277            .canonicalize()
2278            .unwrap()
2279            .join("wt-alias")
2280            .exists());
2281    }
2282
2283    #[tokio::test]
2284    async fn credential_boundary_create_worktree_still_creates_an_ordinary_tree() {
2285        let workspace = tempfile::tempdir().unwrap();
2286        init_worktree_repo(workspace.path());
2287        let backend = credential_boundary_backend(workspace.path());
2288        backend
2289            .create_worktree(WorkspaceGitCreateWorktreeRequest {
2290                branch: "feature".to_string(),
2291                path: Some("wt".to_string()),
2292                new_branch: true,
2293            })
2294            .await
2295            .expect("a tree without credential files must still create a worktree");
2296        assert_eq!(
2297            std::fs::read_to_string(workspace.path().join("wt/README.md")).unwrap(),
2298            "v1\n"
2299        );
2300        assert!(!workspace.path().join("wt/.env").exists());
2301        backend
2302            .remove_worktree(WorkspaceGitRemoveWorktreeRequest {
2303                path: workspace.path().join("wt").display().to_string(),
2304                force: true,
2305            })
2306            .await
2307            .expect("an ordinary worktree must still be removable");
2308        assert!(!workspace.path().join("wt").exists());
2309    }
2310
2311    #[tokio::test]
2312    async fn credential_boundary_remove_worktree_does_not_delete_a_credential_file() {
2313        let workspace = tempfile::tempdir().unwrap();
2314        init_worktree_repo(workspace.path());
2315        std::fs::write(workspace.path().join(".env"), "TOKEN=remove-wt-6b20\n").unwrap();
2316        assert!(run_test_git(workspace.path(), &["add", ".env"]));
2317        assert!(run_test_git(workspace.path(), &["commit", "-qm", "secret"]));
2318        assert!(run_test_git(
2319            workspace.path(),
2320            &["worktree", "add", "wt", "-b", "feature"]
2321        ));
2322
2323        let backend = credential_boundary_backend(workspace.path());
2324        let error = backend
2325            .remove_worktree(WorkspaceGitRemoveWorktreeRequest {
2326                path: workspace.path().join("wt").display().to_string(),
2327                force: true,
2328            })
2329            .await
2330            .expect_err("worktree remove must not delete a credential file");
2331        assert!(error.to_string().contains("credential boundary"), "{error}");
2332        assert_eq!(
2333            std::fs::read_to_string(workspace.path().join("wt/.env")).unwrap(),
2334            "TOKEN=remove-wt-6b20\n"
2335        );
2336        assert_eq!(
2337            std::fs::read_to_string(workspace.path().join(".env")).unwrap(),
2338            "TOKEN=remove-wt-6b20\n"
2339        );
2340    }
2341
2342    fn init_worktree_repo(root: &Path) {
2343        // Name the branch explicitly. `git init` follows the runner's
2344        // `init.defaultBranch`, which is not always `main`.
2345        assert!(run_test_git(root, &["init", "-b", "main"]));
2346        assert!(run_test_git(
2347            root,
2348            &["config", "user.email", "a3s@example.com"]
2349        ));
2350        assert!(run_test_git(root, &["config", "user.name", "a3s"]));
2351        std::fs::write(root.join("README.md"), "v1\n").unwrap();
2352        assert!(run_test_git(root, &["add", "README.md"]));
2353        assert!(run_test_git(root, &["commit", "-m", "one"]));
2354    }
2355}