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