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
493 for entry in builder.build().flatten() {
494 if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
495 continue;
496 }
497
498 let file_path = entry.path();
499 let workspace_path =
500 pathbuf_to_workspace_path(file_path.strip_prefix(&self.root).unwrap_or(file_path));
501 let Some(content) = self.read_search_file(&workspace_path) else {
502 continue;
503 };
504
505 let lines: Vec<&str> = content.lines().collect();
506 let mut file_matches = Vec::new();
507 for (line_idx, line) in lines.iter().enumerate() {
508 if regex.is_match(line) {
509 file_matches.push(line_idx);
510 }
511 }
512
513 if file_matches.is_empty() {
514 continue;
515 }
516
517 file_count += 1;
518 let rel_path = workspace_path.as_str();
519 let display_path = escape_control_chars_for_display(rel_path);
520 let mut path_recorded = false;
521
522 for &match_idx in &file_matches {
523 if total_size > request.max_output_size {
524 return Ok(WorkspaceGrepOutcome {
525 result: WorkspaceGrepResult {
526 output,
527 match_count,
528 file_count,
529 truncated: true,
530 },
531 matched_paths: Some(matched_paths),
532 });
533 }
534
535 if !path_recorded {
536 matched_paths.push(workspace_path.clone());
537 path_recorded = true;
538 }
539 match_count += 1;
540
541 let start = match_idx.saturating_sub(request.context_lines);
542 let end = (match_idx + request.context_lines + 1).min(lines.len());
543
544 for (i, line) in lines[start..end].iter().enumerate() {
545 let abs_i = start + i;
546 let prefix = if abs_i == match_idx { ">" } else { " " };
547 let line = format!("{}{}:{}: {}\n", prefix, display_path, abs_i + 1, line);
548 total_size += line.len();
549 output.push_str(&line);
550 }
551
552 if request.context_lines > 0 {
553 output.push_str("--\n");
554 total_size += 3;
555 }
556 }
557 }
558
559 Ok(WorkspaceGrepOutcome {
560 result: WorkspaceGrepResult {
561 output,
562 match_count,
563 file_count,
564 truncated: false,
565 },
566 matched_paths: Some(matched_paths),
567 })
568 }
569}
570
571#[async_trait]
572impl WorkspaceGit for LocalWorkspaceBackend {
573 async fn is_repository(&self) -> Result<bool> {
574 self.run_blocking_git(|root| Ok(crate::git::is_git_repo(&root)))
575 .await
576 }
577
578 async fn status(&self) -> Result<WorkspaceGitStatus> {
579 self.run_blocking_git(|root| {
580 let status = crate::git::get_status(&root)?;
581 Ok(WorkspaceGitStatus {
582 branch: status.branch,
583 commit: status.commit,
584 is_worktree: status.is_worktree,
585 is_dirty: status.is_dirty,
586 dirty_count: status.dirty_count,
587 })
588 })
589 .await
590 }
591
592 async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
593 self.run_blocking_git(move |root| {
594 Ok(crate::git::get_log(&root, max_count)?
595 .into_iter()
596 .map(|commit| WorkspaceGitCommit {
597 id: commit.id,
598 message: commit.message,
599 author: commit.author,
600 date: commit.date,
601 })
602 .collect())
603 })
604 .await
605 }
606
607 async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
608 self.run_blocking_git(|root| {
609 Ok(crate::git::list_branches(&root)?
610 .into_iter()
611 .map(|branch| WorkspaceGitBranch {
612 name: branch.name,
613 is_current: branch.is_current,
614 })
615 .collect())
616 })
617 .await
618 }
619
620 async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
621 self.run_blocking_git(move |root| {
622 crate::git::create_branch(&root, &request.name, &request.base)
623 })
624 .await
625 }
626
627 async fn checkout(
628 &self,
629 request: WorkspaceGitCheckoutRequest,
630 ) -> Result<WorkspaceGitCheckoutOutput> {
631 let args = if request.force {
632 vec![
633 "checkout".to_string(),
634 "--force".to_string(),
635 request.refspec,
636 ]
637 } else {
638 vec!["checkout".to_string(), request.refspec]
639 };
640 let (success, stdout, stderr) = self.run_git_command(args).await?;
641 if !success {
642 bail!("{}", stderr.trim_end());
643 }
644 Ok(WorkspaceGitCheckoutOutput { stdout })
645 }
646
647 async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
648 let target = request.target;
649 if self.access_boundary.is_none() {
650 return self
651 .run_blocking_git(move |root| crate::git::get_diff(&root, target.as_deref()))
652 .await;
653 }
654
655 let target_for_paths = target.clone();
656 let paths = self
657 .run_blocking_git(move |root| {
658 crate::git::get_diff_paths(&root, target_for_paths.as_deref())
659 })
660 .await?;
661 let paths = paths
662 .into_iter()
663 .filter(|path| self.git_diff_path_allowed(path))
664 .collect::<Vec<_>>();
665 self.run_blocking_git(move |root| {
666 crate::git::get_diff_for_paths(&root, target.as_deref(), &paths)
667 })
668 .await
669 }
670
671 async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
672 let (success, stdout, stderr) = self
673 .run_git_command(vec!["remote".to_string(), "-v".to_string()])
674 .await?;
675 if !success {
676 bail!("{}", stderr.trim_end());
677 }
678
679 Ok(stdout.lines().filter_map(parse_git_remote_line).collect())
680 }
681}
682
683#[async_trait]
684impl WorkspaceGitStashProvider for LocalWorkspaceBackend {
685 async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
686 self.run_blocking_git(|root| {
687 Ok(crate::git::list_stashes(&root)?
688 .into_iter()
689 .map(|stash| WorkspaceGitStash {
690 index: stash.index,
691 message: stash.message,
692 })
693 .collect())
694 })
695 .await
696 }
697
698 async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
699 self.run_blocking_git(move |root| {
700 crate::git::stash(&root, request.message.as_deref(), request.include_untracked)
701 })
702 .await
703 }
704}
705
706#[async_trait]
707impl WorkspaceGitWorktreeProvider for LocalWorkspaceBackend {
708 async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>> {
709 self.run_blocking_git(|root| {
710 Ok(crate::git::list_worktrees(&root)?
711 .into_iter()
712 .map(|worktree| WorkspaceGitWorktree {
713 path: worktree.path,
714 branch: worktree.branch,
715 is_bare: worktree.is_bare,
716 is_detached: worktree.is_detached,
717 })
718 .collect())
719 })
720 .await
721 }
722
723 async fn create_worktree(
724 &self,
725 request: WorkspaceGitCreateWorktreeRequest,
726 ) -> Result<WorkspaceGitWorktreeMutation> {
727 let branch = request.branch;
728 let path = request
729 .path
730 .map(|path| {
731 let path = PathBuf::from(path);
732 if path.is_absolute() {
733 path
734 } else {
735 self.root.join(path)
736 }
737 })
738 .unwrap_or_else(|| default_local_worktree_path(&self.root, &branch));
739 let display_path = path.display().to_string();
740 let new_branch = request.new_branch;
741 let branch_for_git = branch.clone();
742
743 self.run_blocking_git(move |root| {
744 crate::git::create_worktree(&root, &branch_for_git, &path, new_branch)
745 })
746 .await?;
747
748 Ok(WorkspaceGitWorktreeMutation {
749 path: display_path,
750 branch: Some(branch),
751 })
752 }
753
754 async fn remove_worktree(
755 &self,
756 request: WorkspaceGitRemoveWorktreeRequest,
757 ) -> Result<WorkspaceGitWorktreeMutation> {
758 let path = PathBuf::from(request.path);
759 let display_path = path.display().to_string();
760 let force = request.force;
761
762 self.run_blocking_git(move |root| crate::git::remove_worktree(&root, &path, force))
763 .await?;
764
765 Ok(WorkspaceGitWorktreeMutation {
766 path: display_path,
767 branch: None,
768 })
769 }
770}
771
772#[async_trait]
773impl WorkspaceCommandRunner for LocalWorkspaceBackend {
774 async fn exec(&self, request: CommandRequest) -> Result<CommandOutput> {
775 #[cfg(windows)]
776 if let Some(output) =
777 crate::tools::builtin::bash::maybe_execute_simple_windows_http_command(&request.command)
778 .await
779 {
780 let exit_code = output
781 .metadata
782 .as_ref()
783 .and_then(|m| m.get("exit_code"))
784 .and_then(|v| v.as_i64())
785 .map(|v| v as i32)
786 .unwrap_or(if output.success { 0 } else { -1 });
787 return Ok(CommandOutput {
788 output: output.content,
789 exit_code,
790 timed_out: false,
791 });
792 }
793
794 let mut child = crate::tools::builtin::bash::spawn_shell(
795 &request.command,
796 &self.root,
797 request.env.as_deref(),
798 )
799 .map_err(|e| anyhow!("Failed to spawn shell: {}", e))?;
800
801 let output = crate::tools::process::read_process_output(
802 &mut child,
803 request.timeout_ms,
804 request.output_observer.as_deref(),
805 )
806 .await
807 .map_err(|error| anyhow!("Failed to capture shell output: {error}"))?;
808 let exit_code = output.status.and_then(|status| status.code()).unwrap_or(-1);
809
810 Ok(CommandOutput {
811 output: output.combined,
812 exit_code,
813 timed_out: output.timed_out,
814 })
815 }
816}
817
818impl LocalWorkspaceBackend {
819 async fn run_blocking_git<T, F>(&self, operation: F) -> Result<T>
820 where
821 T: Send + 'static,
822 F: FnOnce(PathBuf) -> Result<T> + Send + 'static,
823 {
824 let root = self.root.clone();
825 let cancellation = Arc::new(AtomicBool::new(false));
826 let worker_cancellation = Arc::clone(&cancellation);
827 let mut cancel_on_drop = CancelGitWorkerOnDrop::new(cancellation);
828 let joined = tokio::task::spawn_blocking(move || {
829 crate::git::with_git_cancellation(worker_cancellation, || operation(root))
830 })
831 .await;
832 cancel_on_drop.disarm();
833 joined.map_err(|e| anyhow!("Git worker failed: {}", e))?
834 }
835
836 async fn run_git_command(&self, args: Vec<String>) -> Result<(bool, String, String)> {
837 const GIT_COMMAND_TIMEOUT_MS: u64 = 30_000;
838
839 let executable = crate::git::trusted_git_executable(&self.root)?;
840 let mut command = tokio::process::Command::new(executable);
841 crate::git::configure_tokio_git_environment(&mut command, &self.root);
842 command
843 .args(&args)
844 .stdout(std::process::Stdio::piped())
845 .stderr(std::process::Stdio::piped())
846 .kill_on_drop(true);
847 crate::tools::process::configure_process_group(&mut command);
848 let mut child = command
849 .spawn()
850 .map_err(|e| anyhow!("Failed to execute git: {}", e))?;
851 let output =
852 crate::tools::process::read_process_output(&mut child, GIT_COMMAND_TIMEOUT_MS, None)
853 .await
854 .map_err(|e| anyhow!("Failed to wait for git: {}", e))?;
855 if output.timed_out {
856 bail!("Git command timed out after {GIT_COMMAND_TIMEOUT_MS}ms");
857 }
858 let success = output.status.is_some_and(|status| status.success());
859
860 Ok((success, output.stdout, output.stderr))
861 }
862}
863
864fn parse_git_remote_line(line: &str) -> Option<WorkspaceGitRemote> {
865 let mut parts = line.split_whitespace();
866 let name = parts.next()?;
867 let url = parts.next()?;
868 let direction = parts
869 .next()
870 .unwrap_or_default()
871 .trim_start_matches('(')
872 .trim_end_matches(')');
873
874 Some(WorkspaceGitRemote {
875 name: name.to_string(),
876 url: url.to_string(),
877 direction: direction.to_string(),
878 })
879}
880
881fn default_local_worktree_path(root: &Path, branch: &str) -> PathBuf {
882 let repo_name = root
883 .file_name()
884 .map(|name| name.to_string_lossy().to_string())
885 .unwrap_or_else(|| "repo".to_string());
886 root.parent()
887 .unwrap_or(root)
888 .join(format!("{repo_name}-{branch}"))
889}
890
891pub(super) fn normalize_local_path(root: &Path, input: &str) -> Result<WorkspacePath> {
892 let input = default_path_input(input);
893 let candidate = Path::new(input);
894
895 if candidate.is_absolute() {
896 let root = normalize_absolute_path(root)?;
897 let target = normalize_absolute_path(candidate)?;
898 if !target.starts_with(&root) {
899 bail!(
900 "Workspace boundary violation: path '{}' escapes workspace '{}'",
901 input,
902 root.display()
903 );
904 }
905 let relative = target
906 .strip_prefix(&root)
907 .map_err(|_| anyhow!("Failed to compute workspace-relative path"))?;
908 return Ok(pathbuf_to_workspace_path(relative));
909 }
910
911 if has_windows_path_prefix(input) {
912 bail!("Absolute paths are not supported by this workspace backend");
913 }
914
915 let normalized_input = input.replace('\\', "/");
916 let path = Path::new(&normalized_input);
917 if path.is_absolute() {
918 bail!("Absolute paths are not supported by this workspace backend");
919 }
920
921 let relative = normalize_relative_path(path)?;
922 Ok(pathbuf_to_workspace_path(&relative))
923}
924
925fn normalize_absolute_path(path: &Path) -> Result<PathBuf> {
926 let lexical = normalize_absolute_path_lexical(path)?;
927 if let Ok(canonical) = lexical.canonicalize() {
928 return Ok(canonical);
929 }
930
931 let mut current = lexical.as_path();
932 let mut suffix = Vec::new();
933 while !current.exists() {
934 let Some(file_name) = current.file_name() else {
935 return Ok(lexical);
936 };
937 suffix.push(file_name.to_os_string());
938 let Some(parent) = current.parent() else {
939 return Ok(lexical);
940 };
941 current = parent;
942 }
943
944 let mut normalized = current.canonicalize().unwrap_or_else(|_| {
945 normalize_absolute_path_lexical(current).unwrap_or_else(|_| current.into())
946 });
947 for part in suffix.iter().rev() {
948 normalized.push(part);
949 }
950 Ok(normalized)
951}
952
953fn normalize_absolute_path_lexical(path: &Path) -> Result<PathBuf> {
954 let mut out = PathBuf::new();
955 for component in path.components() {
956 match component {
957 Component::Prefix(prefix) => out.push(prefix.as_os_str()),
958 Component::RootDir => out.push(Path::new(std::path::MAIN_SEPARATOR_STR)),
959 Component::CurDir => {}
960 Component::Normal(part) => out.push(part),
961 Component::ParentDir => {
962 if !out.pop() {
963 bail!("Invalid absolute path");
964 }
965 }
966 }
967 }
968 Ok(out)
969}
970
971#[cfg(test)]
972mod tests {
973 use super::super::WorkspaceServices;
974 use super::*;
975
976 #[tokio::test]
977 async fn local_backend_reads_writes_and_lists() {
978 let temp = tempfile::tempdir().unwrap();
979 let services = WorkspaceServices::local(temp.path());
980 let path = services.normalize_path("dir/file.txt").unwrap();
981
982 let written = services
983 .fs()
984 .write_text(&path, "hello\nworld\n")
985 .await
986 .unwrap();
987 assert_eq!(written.bytes, 12);
988 assert_eq!(written.lines, 2);
989
990 let content = services.fs().read_text(&path).await.unwrap();
991 assert_eq!(content, "hello\nworld\n");
992
993 let dir = services.normalize_path("dir").unwrap();
994 let entries = services.fs().list_dir(&dir).await.unwrap();
995 assert_eq!(entries.len(), 1);
996 assert_eq!(entries[0].name, "file.txt");
997 }
998
999 #[tokio::test]
1000 async fn local_backend_searches_glob_and_grep() {
1001 let temp = tempfile::tempdir().unwrap();
1002 let services = WorkspaceServices::local(temp.path());
1003 services
1004 .fs()
1005 .write_text(
1006 &services.normalize_path("src/main.rs").unwrap(),
1007 "fn main() {\n println!(\"hello\");\n}\n",
1008 )
1009 .await
1010 .unwrap();
1011 services
1012 .fs()
1013 .write_text(
1014 &services.normalize_path("README.md").unwrap(),
1015 "hello from docs\n",
1016 )
1017 .await
1018 .unwrap();
1019
1020 let search = services.search().expect("local backend supports search");
1021 let glob = search
1022 .glob(WorkspaceGlobRequest {
1023 base: services.normalize_path("src").unwrap(),
1024 pattern: "*.rs".to_string(),
1025 })
1026 .await
1027 .unwrap();
1028 assert_eq!(glob.matches[0].as_str(), "src/main.rs");
1029
1030 let grep = search
1031 .grep(WorkspaceGrepRequest {
1032 base: WorkspacePath::root(),
1033 pattern: "hello".to_string(),
1034 glob: Some("**/*.rs".to_string()),
1035 context_lines: 0,
1036 case_insensitive: false,
1037 max_output_size: 1024,
1038 })
1039 .await
1040 .unwrap();
1041 assert_eq!(grep.match_count, 1);
1042 assert_eq!(grep.file_count, 1);
1043 assert!(grep.output.contains("src/main.rs:2"));
1044 }
1045
1046 fn credential_boundary_backend(root: &Path) -> LocalWorkspaceBackend {
1047 LocalWorkspaceBackend::new_with_access_policy(
1048 root.to_path_buf(),
1049 LocalWorkspaceAccessPolicy::CredentialBoundary,
1050 )
1051 }
1052
1053 #[tokio::test]
1054 async fn credential_boundary_denies_direct_secret_reads_and_writes() {
1055 let temp = tempfile::tempdir().unwrap();
1056 std::fs::create_dir_all(temp.path().join("apps/api")).unwrap();
1057 std::fs::write(temp.path().join("apps/api/.env.local"), "TOKEN=secret\n").unwrap();
1058 let backend = credential_boundary_backend(temp.path());
1059 let secret = backend.normalize("apps/api/.env.local").unwrap();
1060
1061 let read_error = backend
1062 .read_text(&secret)
1063 .await
1064 .expect_err("direct secret reads must be denied");
1065 assert!(read_error.to_string().contains("credential boundary"));
1066
1067 let range_error = backend
1068 .read_text_range(&secret, 0, 10)
1069 .await
1070 .expect_err("range reads must use the same boundary");
1071 assert!(range_error.to_string().contains("credential boundary"));
1072
1073 let write_error = backend
1074 .write_text(&secret, "TOKEN=overwritten\n")
1075 .await
1076 .expect_err("direct secret writes must be denied");
1077 assert!(write_error.to_string().contains("credential boundary"));
1078 assert_eq!(
1079 std::fs::read_to_string(temp.path().join("apps/api/.env.local")).unwrap(),
1080 "TOKEN=secret\n"
1081 );
1082
1083 let new_secret = backend.normalize(".env.generated").unwrap();
1084 backend
1085 .write_text(&new_secret, "TOKEN=new\n")
1086 .await
1087 .expect_err("creating a new env file must be denied");
1088 assert!(!temp.path().join(".env.generated").exists());
1089 }
1090
1091 #[tokio::test]
1092 async fn credential_boundary_filters_grep_and_rejects_explicit_secret_base() {
1093 let temp = tempfile::tempdir().unwrap();
1094 std::fs::write(temp.path().join(".env"), "BOUNDARY_TOKEN=secret\n").unwrap();
1095 std::fs::write(
1096 temp.path().join("README.md"),
1097 "BOUNDARY_TOKEN is configured externally\n",
1098 )
1099 .unwrap();
1100 let backend = credential_boundary_backend(temp.path());
1101
1102 let grep = backend
1103 .grep(WorkspaceGrepRequest {
1104 base: WorkspacePath::root(),
1105 pattern: "BOUNDARY_TOKEN".to_string(),
1106 glob: None,
1107 context_lines: 0,
1108 case_insensitive: false,
1109 max_output_size: 1024,
1110 })
1111 .await
1112 .unwrap();
1113 assert_eq!(grep.match_count, 1);
1114 assert_eq!(grep.file_count, 1);
1115 assert!(grep.output.contains("README.md"));
1116 assert!(!grep.output.contains("secret"));
1117 assert!(!grep.output.contains(".env"));
1118
1119 let error = backend
1120 .grep(WorkspaceGrepRequest {
1121 base: backend.normalize(".env").unwrap(),
1122 pattern: "secret".to_string(),
1123 glob: None,
1124 context_lines: 0,
1125 case_insensitive: false,
1126 max_output_size: 1024,
1127 })
1128 .await
1129 .expect_err("an explicit secret grep must fail closed");
1130 assert!(error.to_string().contains("credential boundary"));
1131 }
1132
1133 #[cfg(any(unix, windows))]
1134 #[tokio::test]
1135 async fn credential_boundary_denies_source_hardlinks_without_truncating_them() {
1136 let temp = tempfile::tempdir().unwrap();
1137 let source = temp.path().join("source.txt");
1138 let alias = temp.path().join("alias.txt");
1139 std::fs::write(&source, "linked secret\n").unwrap();
1140 std::fs::hard_link(&source, &alias).unwrap();
1141 let backend = credential_boundary_backend(temp.path());
1142 let alias_path = backend.normalize("alias.txt").unwrap();
1143
1144 backend
1145 .read_text(&alias_path)
1146 .await
1147 .expect_err("source-tree hardlink reads must be denied");
1148 backend
1149 .write_text(&alias_path, "overwritten\n")
1150 .await
1151 .expect_err("source-tree hardlink writes must be denied");
1152 assert_eq!(std::fs::read_to_string(&source).unwrap(), "linked secret\n");
1153 }
1154
1155 #[cfg(any(unix, windows))]
1156 #[tokio::test]
1157 async fn credential_boundary_allows_package_store_hardlinks_but_denies_secret_aliases() {
1158 let temp = tempfile::tempdir().unwrap();
1159 let package = temp.path().join("node_modules/pkg");
1160 std::fs::create_dir_all(&package).unwrap();
1161
1162 let package_source = package.join("source.js");
1163 let package_alias = package.join("alias.js");
1164 std::fs::write(&package_source, "export const value = 1;\n").unwrap();
1165 std::fs::hard_link(&package_source, &package_alias).unwrap();
1166
1167 let env = temp.path().join(".env");
1168 let env_alias = package.join("credential.txt");
1169 std::fs::write(&env, "TOKEN=secret\n").unwrap();
1170 std::fs::hard_link(&env, &env_alias).unwrap();
1171
1172 let backend = credential_boundary_backend(temp.path());
1173 let package_content = backend
1174 .read_text(&backend.normalize("node_modules/pkg/alias.js").unwrap())
1175 .await
1176 .expect("ordinary package-store hardlinks should remain readable");
1177 assert!(package_content.contains("value = 1"));
1178
1179 let error = backend
1180 .read_text(
1181 &backend
1182 .normalize("node_modules/pkg/credential.txt")
1183 .unwrap(),
1184 )
1185 .await
1186 .expect_err("a package-tree alias of a known credential must be denied");
1187 assert!(error.to_string().contains("credential boundary"));
1188 }
1189
1190 fn run_test_git(root: &Path, args: &[&str]) -> bool {
1191 std::process::Command::new("git")
1192 .arg("-C")
1193 .arg(root)
1194 .args([
1195 "-c",
1196 "user.name=A3S Test",
1197 "-c",
1198 "user.email=test@a3s.local",
1199 ])
1200 .args(args)
1201 .status()
1202 .is_ok_and(|status| status.success())
1203 }
1204
1205 #[cfg(any(unix, windows))]
1206 #[tokio::test]
1207 async fn credential_boundary_filters_git_diff_content_and_option_like_targets() {
1208 let temp = tempfile::tempdir().unwrap();
1209 if !run_test_git(temp.path(), &["init", "-q"]) {
1210 return;
1211 }
1212 std::fs::create_dir_all(temp.path().join("src")).unwrap();
1213 std::fs::write(temp.path().join(".env"), "TOKEN=old-secret\n").unwrap();
1214 std::fs::write(temp.path().join("src/lib.rs"), "pub const VALUE: u8 = 1;\n").unwrap();
1215 std::fs::write(temp.path().join("linked.txt"), "hardlink-old-secret\n").unwrap();
1216 std::fs::hard_link(
1217 temp.path().join("linked.txt"),
1218 temp.path().join("linked-alias.txt"),
1219 )
1220 .unwrap();
1221 assert!(run_test_git(temp.path(), &["add", "."]));
1222 assert!(run_test_git(temp.path(), &["commit", "-qm", "baseline"]));
1223
1224 std::fs::write(temp.path().join(".env"), "TOKEN=new-secret\n").unwrap();
1225 std::fs::write(temp.path().join("src/lib.rs"), "pub const VALUE: u8 = 2;\n").unwrap();
1226 std::fs::write(
1227 temp.path().join("linked-alias.txt"),
1228 "hardlink-new-secret\n",
1229 )
1230 .unwrap();
1231
1232 let backend = credential_boundary_backend(temp.path());
1233 let diff = backend
1234 .diff(WorkspaceGitDiffRequest { target: None })
1235 .await
1236 .unwrap();
1237 assert!(diff.contains("VALUE: u8 = 2"), "{diff}");
1238 for denied in [
1239 "old-secret",
1240 "new-secret",
1241 "hardlink-old-secret",
1242 "hardlink-new-secret",
1243 ".env",
1244 "linked.txt",
1245 "linked-alias.txt",
1246 ] {
1247 assert!(!diff.contains(denied), "{denied} leaked in {diff}");
1248 }
1249
1250 let output = temp.path().join("injected-diff-output");
1251 let error = backend
1252 .diff(WorkspaceGitDiffRequest {
1253 target: Some(format!("--output={}", output.display())),
1254 })
1255 .await
1256 .expect_err("an option-like target must be parsed only as a revision");
1257 assert!(error.to_string().contains("Git diff"));
1258 assert!(!output.exists());
1259 }
1260
1261 #[test]
1262 fn local_backend_rejects_absolute_paths_outside_workspace() {
1263 let temp = tempfile::tempdir().unwrap();
1264 let services = WorkspaceServices::local(temp.path());
1265 let outside = temp.path().parent().unwrap().join("secret.txt");
1266 let err = services
1267 .normalize_path(outside.to_str().unwrap())
1268 .expect_err("outside absolute path should be rejected");
1269 assert!(err.to_string().contains("escapes workspace"));
1270 }
1271
1272 #[test]
1273 fn local_backend_rejects_backslash_parent_escape() {
1274 let temp = tempfile::tempdir().unwrap();
1275 let services = WorkspaceServices::local(temp.path());
1276 let err = services
1277 .normalize_path(r"..\secret.txt")
1278 .expect_err("backslash parent traversal should be rejected");
1279 assert!(err.to_string().contains("escapes workspace"));
1280 }
1281
1282 #[test]
1283 fn local_backend_allows_absolute_paths_inside_workspace() {
1284 let temp = tempfile::tempdir().unwrap();
1285 let services = WorkspaceServices::local(temp.path());
1286 let absolute = temp.path().join("src/main.rs");
1287 let path = services
1288 .normalize_path(absolute.to_str().unwrap())
1289 .expect("absolute path inside workspace should normalize");
1290 assert_eq!(path.as_str(), "src/main.rs");
1291 }
1292}