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