1use super::{
9 default_path_input, has_windows_path_prefix, normalize_relative_path,
10 pathbuf_to_workspace_path, validate_relative_pattern, CommandOutput, CommandRequest,
11 WorkspaceCommandRunner, WorkspaceDirEntry, WorkspaceError, WorkspaceFileSystem,
12 WorkspaceFileType, WorkspaceGit, WorkspaceGitBranch, WorkspaceGitCheckoutOutput,
13 WorkspaceGitCheckoutRequest, WorkspaceGitCommit, WorkspaceGitCreateBranchRequest,
14 WorkspaceGitCreateWorktreeRequest, WorkspaceGitDiffRequest, WorkspaceGitRemote,
15 WorkspaceGitRemoveWorktreeRequest, WorkspaceGitStash, WorkspaceGitStashProvider,
16 WorkspaceGitStashRequest, WorkspaceGitStatus, WorkspaceGitWorktree,
17 WorkspaceGitWorktreeMutation, WorkspaceGitWorktreeProvider, WorkspaceGlobRequest,
18 WorkspaceGlobResult, WorkspaceGrepRequest, WorkspaceGrepResult, WorkspacePath,
19 WorkspacePathResolver, WorkspaceResult, WorkspaceSearch, WorkspaceWriteOutcome,
20};
21use anyhow::{anyhow, bail, Result};
22use async_trait::async_trait;
23use std::path::{Component, Path, PathBuf};
24
25#[derive(Debug)]
27pub struct LocalWorkspaceBackend {
28 pub(super) root: PathBuf,
29}
30
31impl LocalWorkspaceBackend {
32 pub fn new(root: PathBuf) -> Self {
33 let canonical = root.canonicalize();
34 let root = match canonical {
35 Ok(canonical) => canonical,
36 Err(e) => {
37 tracing::warn!(
38 "LocalWorkspaceBackend: failed to canonicalize root '{}' at construction: {} \
39 (path resolution will fail-closed at first use)",
40 root.display(),
41 e
42 );
43 root
44 }
45 };
46 Self { root }
47 }
48
49 fn local_path_for_read(&self, path: &WorkspacePath) -> Result<PathBuf> {
50 a3s_common::tools::resolve_path(&self.root, path.as_str()).map_err(|e| anyhow!("{}", e))
51 }
52
53 fn local_path_for_write(&self, path: &WorkspacePath) -> Result<PathBuf> {
54 let target = if path.is_root() {
55 self.root.clone()
56 } else {
57 self.root.join(path.as_str())
58 };
59
60 if let Some(parent) = target.parent() {
61 std::fs::create_dir_all(parent).map_err(|e| {
62 anyhow!(
63 "Failed to create parent directories for {}: {}",
64 target.display(),
65 e
66 )
67 })?;
68 }
69
70 a3s_common::tools::resolve_path_for_write(&self.root, path.as_str())
71 .map_err(|e| anyhow!("{}", e))
72 }
73}
74
75impl WorkspacePathResolver for LocalWorkspaceBackend {
76 fn normalize(&self, input: &str) -> Result<WorkspacePath> {
77 normalize_local_path(&self.root, input)
78 }
79}
80
81#[async_trait]
82impl WorkspaceFileSystem for LocalWorkspaceBackend {
83 async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String> {
84 let resolved = self.local_path_for_read(path)?;
85 match tokio::fs::read_to_string(&resolved).await {
86 Ok(s) => Ok(s),
87 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(WorkspaceError::NotFound {
88 path: resolved.display().to_string(),
89 }),
90 Err(e) => Err(WorkspaceError::Backend(anyhow!(
91 "Failed to read file {}: {}",
92 resolved.display(),
93 e
94 ))),
95 }
96 }
97
98 async fn write_text(
99 &self,
100 path: &WorkspacePath,
101 content: &str,
102 ) -> WorkspaceResult<WorkspaceWriteOutcome> {
103 let resolved = self.local_path_for_write(path)?;
104 tokio::fs::write(&resolved, content).await.map_err(|e| {
105 WorkspaceError::Backend(anyhow!(
106 "Failed to write file {}: {}",
107 resolved.display(),
108 e
109 ))
110 })?;
111
112 Ok(WorkspaceWriteOutcome {
113 bytes: content.len(),
114 lines: content.lines().count(),
115 })
116 }
117
118 async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>> {
119 let target = self.local_path_for_read(path)?;
120 if !target.exists() {
121 return Err(WorkspaceError::NotFound {
122 path: target.display().to_string(),
123 });
124 }
125 if !target.is_dir() {
126 return Err(WorkspaceError::InvalidArgument {
127 message: format!("Not a directory: {}", target.display()),
128 });
129 }
130
131 let mut dir = tokio::fs::read_dir(&target).await.map_err(|e| {
132 WorkspaceError::Backend(anyhow!(
133 "Failed to read directory {}: {}",
134 target.display(),
135 e
136 ))
137 })?;
138 let mut entries = Vec::new();
139
140 while let Some(entry) = dir
141 .next_entry()
142 .await
143 .map_err(|e| WorkspaceError::Backend(anyhow!("Failed to iterate directory: {}", e)))?
144 {
145 let name = entry.file_name().to_string_lossy().to_string();
146 let file_type = entry.file_type().await;
147 let metadata = entry.metadata().await;
148 let (kind, size) = match (&file_type, &metadata) {
149 (Ok(ft), Ok(m)) => {
150 let kind = if ft.is_dir() {
151 WorkspaceFileType::Directory
152 } else if ft.is_symlink() {
153 WorkspaceFileType::Symlink
154 } else {
155 WorkspaceFileType::File
156 };
157 (kind, m.len())
158 }
159 _ => (WorkspaceFileType::Unknown, 0),
160 };
161 entries.push(WorkspaceDirEntry { name, kind, size });
162 }
163
164 Ok(entries)
165 }
166}
167
168#[async_trait]
169impl WorkspaceSearch for LocalWorkspaceBackend {
170 async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult> {
171 validate_relative_pattern(&request.pattern, "glob pattern")?;
172 let base = self.local_path_for_read(&request.base)?;
173 let full_pattern = base.join(&request.pattern);
174 let full_pattern = full_pattern.to_string_lossy().replace('\\', "/");
175
176 let entries = glob::glob(&full_pattern)
177 .map_err(|e| anyhow!("Invalid glob pattern '{}': {}", request.pattern, e))?;
178
179 let mut matches = Vec::new();
180 for entry in entries {
181 match entry {
182 Ok(path) => {
183 if let Ok(relative) = path.strip_prefix(&self.root) {
184 matches.push(pathbuf_to_workspace_path(relative));
185 }
186 }
187 Err(e) => tracing::warn!("Glob entry error: {}", e),
188 }
189 }
190
191 matches.sort_by(|a, b| a.as_str().cmp(b.as_str()));
192 Ok(WorkspaceGlobResult { matches })
193 }
194
195 async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult> {
196 if let Some(ref glob) = request.glob {
197 validate_relative_pattern(glob, "grep glob filter")?;
198 }
199
200 let regex_pattern = if request.case_insensitive {
201 format!("(?i){}", request.pattern)
202 } else {
203 request.pattern.clone()
204 };
205 let regex = regex::Regex::new(®ex_pattern)
206 .map_err(|e| anyhow!("Invalid regex pattern '{}': {}", request.pattern, e))?;
207
208 let search_path = self.local_path_for_read(&request.base)?;
209 let mut builder = ignore::WalkBuilder::new(&search_path);
210 builder.hidden(false).git_ignore(true).git_global(true);
211
212 if let Some(ref glob_pat) = request.glob {
213 let mut types = ignore::types::TypesBuilder::new();
214 types.add("custom", glob_pat).ok();
215 types.select("custom");
216 if let Ok(built) = types.build() {
217 builder.types(built);
218 }
219 }
220
221 let mut output = String::new();
222 let mut match_count = 0;
223 let mut file_count = 0;
224 let mut total_size = 0;
225
226 for entry in builder.build().flatten() {
227 if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
228 continue;
229 }
230
231 let file_path = entry.path();
232 let content = match std::fs::read_to_string(file_path) {
233 Ok(content) => content,
234 Err(_) => continue,
235 };
236
237 let lines: Vec<&str> = content.lines().collect();
238 let mut file_matches = Vec::new();
239 for (line_idx, line) in lines.iter().enumerate() {
240 if regex.is_match(line) {
241 file_matches.push(line_idx);
242 }
243 }
244
245 if file_matches.is_empty() {
246 continue;
247 }
248
249 file_count += 1;
250 let rel_path = file_path
251 .strip_prefix(&self.root)
252 .unwrap_or(file_path)
253 .to_string_lossy()
254 .replace('\\', "/");
255
256 for &match_idx in &file_matches {
257 if total_size > request.max_output_size {
258 return Ok(WorkspaceGrepResult {
259 output,
260 match_count,
261 file_count,
262 truncated: true,
263 });
264 }
265
266 match_count += 1;
267
268 let start = match_idx.saturating_sub(request.context_lines);
269 let end = (match_idx + request.context_lines + 1).min(lines.len());
270
271 for (i, line) in lines[start..end].iter().enumerate() {
272 let abs_i = start + i;
273 let prefix = if abs_i == match_idx { ">" } else { " " };
274 let line = format!("{}{}:{}: {}\n", prefix, rel_path, abs_i + 1, line);
275 total_size += line.len();
276 output.push_str(&line);
277 }
278
279 if request.context_lines > 0 {
280 output.push_str("--\n");
281 total_size += 3;
282 }
283 }
284 }
285
286 Ok(WorkspaceGrepResult {
287 output,
288 match_count,
289 file_count,
290 truncated: false,
291 })
292 }
293}
294
295#[async_trait]
296impl WorkspaceGit for LocalWorkspaceBackend {
297 async fn is_repository(&self) -> Result<bool> {
298 self.run_blocking_git(|root| Ok(crate::git::is_git_repo(&root)))
299 .await
300 }
301
302 async fn status(&self) -> Result<WorkspaceGitStatus> {
303 self.run_blocking_git(|root| {
304 let status = crate::git::get_status(&root)?;
305 Ok(WorkspaceGitStatus {
306 branch: status.branch,
307 commit: status.commit,
308 is_worktree: status.is_worktree,
309 is_dirty: status.is_dirty,
310 dirty_count: status.dirty_count,
311 })
312 })
313 .await
314 }
315
316 async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
317 self.run_blocking_git(move |root| {
318 Ok(crate::git::get_log(&root, max_count)?
319 .into_iter()
320 .map(|commit| WorkspaceGitCommit {
321 id: commit.id,
322 message: commit.message,
323 author: commit.author,
324 date: commit.date,
325 })
326 .collect())
327 })
328 .await
329 }
330
331 async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
332 self.run_blocking_git(|root| {
333 Ok(crate::git::list_branches(&root)?
334 .into_iter()
335 .map(|branch| WorkspaceGitBranch {
336 name: branch.name,
337 is_current: branch.is_current,
338 })
339 .collect())
340 })
341 .await
342 }
343
344 async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
345 self.run_blocking_git(move |root| {
346 crate::git::create_branch(&root, &request.name, &request.base)
347 })
348 .await
349 }
350
351 async fn checkout(
352 &self,
353 request: WorkspaceGitCheckoutRequest,
354 ) -> Result<WorkspaceGitCheckoutOutput> {
355 let args = if request.force {
356 vec![
357 "checkout".to_string(),
358 "--force".to_string(),
359 request.refspec,
360 ]
361 } else {
362 vec!["checkout".to_string(), request.refspec]
363 };
364 let (success, stdout, stderr) = self.run_git_command(args).await?;
365 if !success {
366 bail!("{}", stderr.trim_end());
367 }
368 Ok(WorkspaceGitCheckoutOutput { stdout })
369 }
370
371 async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
372 self.run_blocking_git(move |root| crate::git::get_diff(&root, request.target.as_deref()))
373 .await
374 }
375
376 async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
377 let (success, stdout, stderr) = self
378 .run_git_command(vec!["remote".to_string(), "-v".to_string()])
379 .await?;
380 if !success {
381 bail!("{}", stderr.trim_end());
382 }
383
384 Ok(stdout.lines().filter_map(parse_git_remote_line).collect())
385 }
386}
387
388#[async_trait]
389impl WorkspaceGitStashProvider for LocalWorkspaceBackend {
390 async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
391 self.run_blocking_git(|root| {
392 Ok(crate::git::list_stashes(&root)?
393 .into_iter()
394 .map(|stash| WorkspaceGitStash {
395 index: stash.index,
396 message: stash.message,
397 })
398 .collect())
399 })
400 .await
401 }
402
403 async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
404 self.run_blocking_git(move |root| {
405 crate::git::stash(&root, request.message.as_deref(), request.include_untracked)
406 })
407 .await
408 }
409}
410
411#[async_trait]
412impl WorkspaceGitWorktreeProvider for LocalWorkspaceBackend {
413 async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>> {
414 self.run_blocking_git(|root| {
415 Ok(crate::git::list_worktrees(&root)?
416 .into_iter()
417 .map(|worktree| WorkspaceGitWorktree {
418 path: worktree.path,
419 branch: worktree.branch,
420 is_bare: worktree.is_bare,
421 is_detached: worktree.is_detached,
422 })
423 .collect())
424 })
425 .await
426 }
427
428 async fn create_worktree(
429 &self,
430 request: WorkspaceGitCreateWorktreeRequest,
431 ) -> Result<WorkspaceGitWorktreeMutation> {
432 let branch = request.branch;
433 let path = request
434 .path
435 .map(|path| {
436 let path = PathBuf::from(path);
437 if path.is_absolute() {
438 path
439 } else {
440 self.root.join(path)
441 }
442 })
443 .unwrap_or_else(|| default_local_worktree_path(&self.root, &branch));
444 let display_path = path.display().to_string();
445 let new_branch = request.new_branch;
446 let branch_for_git = branch.clone();
447
448 self.run_blocking_git(move |root| {
449 crate::git::create_worktree(&root, &branch_for_git, &path, new_branch)
450 })
451 .await?;
452
453 Ok(WorkspaceGitWorktreeMutation {
454 path: display_path,
455 branch: Some(branch),
456 })
457 }
458
459 async fn remove_worktree(
460 &self,
461 request: WorkspaceGitRemoveWorktreeRequest,
462 ) -> Result<WorkspaceGitWorktreeMutation> {
463 let path = PathBuf::from(request.path);
464 let display_path = path.display().to_string();
465 let force = request.force;
466
467 self.run_blocking_git(move |root| crate::git::remove_worktree(&root, &path, force))
468 .await?;
469
470 Ok(WorkspaceGitWorktreeMutation {
471 path: display_path,
472 branch: None,
473 })
474 }
475}
476
477#[async_trait]
478impl WorkspaceCommandRunner for LocalWorkspaceBackend {
479 async fn exec(&self, request: CommandRequest) -> Result<CommandOutput> {
480 #[cfg(windows)]
481 if let Some(output) =
482 crate::tools::builtin::bash::maybe_execute_simple_windows_http_command(&request.command)
483 .await
484 {
485 let exit_code = output
486 .metadata
487 .as_ref()
488 .and_then(|m| m.get("exit_code"))
489 .and_then(|v| v.as_i64())
490 .map(|v| v as i32)
491 .unwrap_or(if output.success { 0 } else { -1 });
492 return Ok(CommandOutput {
493 output: output.content,
494 exit_code,
495 timed_out: false,
496 });
497 }
498
499 let timeout_secs = request.timeout_ms / 1000;
500 let mut child = crate::tools::builtin::bash::spawn_shell(
501 &request.command,
502 &self.root,
503 request.env.as_deref(),
504 )
505 .map_err(|e| anyhow!("Failed to spawn shell: {}", e))?;
506
507 let (output, timed_out) = crate::tools::process::read_process_output(
508 &mut child,
509 timeout_secs,
510 request.output_observer.as_deref(),
511 )
512 .await;
513
514 if timed_out {
515 return Ok(CommandOutput {
516 output,
517 exit_code: -1,
518 timed_out: true,
519 });
520 }
521
522 let status = child
523 .wait()
524 .await
525 .map_err(|e| anyhow!("Failed to wait for shell: {}", e))?;
526 let exit_code = status.code().unwrap_or(-1);
527
528 Ok(CommandOutput {
529 output,
530 exit_code,
531 timed_out: false,
532 })
533 }
534}
535
536impl LocalWorkspaceBackend {
537 async fn run_blocking_git<T, F>(&self, operation: F) -> Result<T>
538 where
539 T: Send + 'static,
540 F: FnOnce(PathBuf) -> Result<T> + Send + 'static,
541 {
542 let root = self.root.clone();
543 tokio::task::spawn_blocking(move || operation(root))
544 .await
545 .map_err(|e| anyhow!("Git worker failed: {}", e))?
546 }
547
548 async fn run_git_command(&self, args: Vec<String>) -> Result<(bool, String, String)> {
549 tokio::task::spawn_blocking(crate::git::ensure_git_installed)
550 .await
551 .map_err(|e| anyhow!("Git worker failed: {}", e))??;
552
553 let output = tokio::process::Command::new("git")
554 .arg("-C")
555 .arg(self.root.as_os_str())
556 .args(&args)
557 .output()
558 .await
559 .map_err(|e| anyhow!("Failed to execute git: {}", e))?;
560
561 Ok((
562 output.status.success(),
563 String::from_utf8_lossy(&output.stdout).to_string(),
564 String::from_utf8_lossy(&output.stderr).to_string(),
565 ))
566 }
567}
568
569fn parse_git_remote_line(line: &str) -> Option<WorkspaceGitRemote> {
570 let mut parts = line.split_whitespace();
571 let name = parts.next()?;
572 let url = parts.next()?;
573 let direction = parts
574 .next()
575 .unwrap_or_default()
576 .trim_start_matches('(')
577 .trim_end_matches(')');
578
579 Some(WorkspaceGitRemote {
580 name: name.to_string(),
581 url: url.to_string(),
582 direction: direction.to_string(),
583 })
584}
585
586fn default_local_worktree_path(root: &Path, branch: &str) -> PathBuf {
587 let repo_name = root
588 .file_name()
589 .map(|name| name.to_string_lossy().to_string())
590 .unwrap_or_else(|| "repo".to_string());
591 root.parent()
592 .unwrap_or(root)
593 .join(format!("{repo_name}-{branch}"))
594}
595
596pub(super) fn normalize_local_path(root: &Path, input: &str) -> Result<WorkspacePath> {
597 let input = default_path_input(input);
598 let candidate = Path::new(input);
599
600 if candidate.is_absolute() {
601 let root = normalize_absolute_path(root)?;
602 let target = normalize_absolute_path(candidate)?;
603 if !target.starts_with(&root) {
604 bail!(
605 "Workspace boundary violation: path '{}' escapes workspace '{}'",
606 input,
607 root.display()
608 );
609 }
610 let relative = target
611 .strip_prefix(&root)
612 .map_err(|_| anyhow!("Failed to compute workspace-relative path"))?;
613 return Ok(pathbuf_to_workspace_path(relative));
614 }
615
616 if has_windows_path_prefix(input) {
617 bail!("Absolute paths are not supported by this workspace backend");
618 }
619
620 let normalized_input = input.replace('\\', "/");
621 let path = Path::new(&normalized_input);
622 if path.is_absolute() {
623 bail!("Absolute paths are not supported by this workspace backend");
624 }
625
626 let relative = normalize_relative_path(path)?;
627 Ok(pathbuf_to_workspace_path(&relative))
628}
629
630fn normalize_absolute_path(path: &Path) -> Result<PathBuf> {
631 let lexical = normalize_absolute_path_lexical(path)?;
632 if let Ok(canonical) = lexical.canonicalize() {
633 return Ok(canonical);
634 }
635
636 let mut current = lexical.as_path();
637 let mut suffix = Vec::new();
638 while !current.exists() {
639 let Some(file_name) = current.file_name() else {
640 return Ok(lexical);
641 };
642 suffix.push(file_name.to_os_string());
643 let Some(parent) = current.parent() else {
644 return Ok(lexical);
645 };
646 current = parent;
647 }
648
649 let mut normalized = current.canonicalize().unwrap_or_else(|_| {
650 normalize_absolute_path_lexical(current).unwrap_or_else(|_| current.into())
651 });
652 for part in suffix.iter().rev() {
653 normalized.push(part);
654 }
655 Ok(normalized)
656}
657
658fn normalize_absolute_path_lexical(path: &Path) -> Result<PathBuf> {
659 let mut out = PathBuf::new();
660 for component in path.components() {
661 match component {
662 Component::Prefix(prefix) => out.push(prefix.as_os_str()),
663 Component::RootDir => out.push(Path::new(std::path::MAIN_SEPARATOR_STR)),
664 Component::CurDir => {}
665 Component::Normal(part) => out.push(part),
666 Component::ParentDir => {
667 if !out.pop() {
668 bail!("Invalid absolute path");
669 }
670 }
671 }
672 }
673 Ok(out)
674}
675
676#[cfg(test)]
677mod tests {
678 use super::super::WorkspaceServices;
679 use super::*;
680
681 #[tokio::test]
682 async fn local_backend_reads_writes_and_lists() {
683 let temp = tempfile::tempdir().unwrap();
684 let services = WorkspaceServices::local(temp.path());
685 let path = services.normalize_path("dir/file.txt").unwrap();
686
687 let written = services
688 .fs()
689 .write_text(&path, "hello\nworld\n")
690 .await
691 .unwrap();
692 assert_eq!(written.bytes, 12);
693 assert_eq!(written.lines, 2);
694
695 let content = services.fs().read_text(&path).await.unwrap();
696 assert_eq!(content, "hello\nworld\n");
697
698 let dir = services.normalize_path("dir").unwrap();
699 let entries = services.fs().list_dir(&dir).await.unwrap();
700 assert_eq!(entries.len(), 1);
701 assert_eq!(entries[0].name, "file.txt");
702 }
703
704 #[tokio::test]
705 async fn local_backend_searches_glob_and_grep() {
706 let temp = tempfile::tempdir().unwrap();
707 let services = WorkspaceServices::local(temp.path());
708 services
709 .fs()
710 .write_text(
711 &services.normalize_path("src/main.rs").unwrap(),
712 "fn main() {\n println!(\"hello\");\n}\n",
713 )
714 .await
715 .unwrap();
716 services
717 .fs()
718 .write_text(
719 &services.normalize_path("README.md").unwrap(),
720 "hello from docs\n",
721 )
722 .await
723 .unwrap();
724
725 let search = services.search().expect("local backend supports search");
726 let glob = search
727 .glob(WorkspaceGlobRequest {
728 base: services.normalize_path("src").unwrap(),
729 pattern: "*.rs".to_string(),
730 })
731 .await
732 .unwrap();
733 assert_eq!(glob.matches[0].as_str(), "src/main.rs");
734
735 let grep = search
736 .grep(WorkspaceGrepRequest {
737 base: WorkspacePath::root(),
738 pattern: "hello".to_string(),
739 glob: Some("**/*.rs".to_string()),
740 context_lines: 0,
741 case_insensitive: false,
742 max_output_size: 1024,
743 })
744 .await
745 .unwrap();
746 assert_eq!(grep.match_count, 1);
747 assert_eq!(grep.file_count, 1);
748 assert!(grep.output.contains("src/main.rs:2"));
749 }
750
751 #[test]
752 fn local_backend_rejects_absolute_paths_outside_workspace() {
753 let temp = tempfile::tempdir().unwrap();
754 let services = WorkspaceServices::local(temp.path());
755 let outside = temp.path().parent().unwrap().join("secret.txt");
756 let err = services
757 .normalize_path(outside.to_str().unwrap())
758 .expect_err("outside absolute path should be rejected");
759 assert!(err.to_string().contains("escapes workspace"));
760 }
761
762 #[test]
763 fn local_backend_rejects_backslash_parent_escape() {
764 let temp = tempfile::tempdir().unwrap();
765 let services = WorkspaceServices::local(temp.path());
766 let err = services
767 .normalize_path(r"..\secret.txt")
768 .expect_err("backslash parent traversal should be rejected");
769 assert!(err.to_string().contains("escapes workspace"));
770 }
771
772 #[test]
773 fn local_backend_allows_absolute_paths_inside_workspace() {
774 let temp = tempfile::tempdir().unwrap();
775 let services = WorkspaceServices::local(temp.path());
776 let absolute = temp.path().join("src/main.rs");
777 let path = services
778 .normalize_path(absolute.to_str().unwrap())
779 .expect("absolute path inside workspace should normalize");
780 assert_eq!(path.as_str(), "src/main.rs");
781 }
782}