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