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