a3s_code_core/workspace/
path.rs1use super::{WorkspacePath, WorkspacePathResolver};
4use anyhow::{bail, Result};
5use std::path::{Component, Path, PathBuf};
6
7#[derive(Debug, Default)]
9pub struct VirtualPathResolver;
10
11impl WorkspacePathResolver for VirtualPathResolver {
12 fn normalize(&self, input: &str) -> Result<WorkspacePath> {
13 normalize_virtual_path(input)
14 }
15}
16
17fn normalize_virtual_path(input: &str) -> Result<WorkspacePath> {
18 let input = default_path_input(input);
19 if has_windows_path_prefix(input) {
20 bail!("Absolute paths are not supported by this workspace backend");
21 }
22
23 let normalized_input = input.replace('\\', "/");
24 let path = Path::new(&normalized_input);
25 if path.is_absolute() {
26 bail!("Absolute paths are not supported by this workspace backend");
27 }
28
29 let relative = normalize_relative_path(path)?;
30 Ok(pathbuf_to_workspace_path(&relative))
31}
32
33pub(super) fn default_path_input(input: &str) -> &str {
34 let trimmed = input.trim();
35 if trimmed.is_empty() {
36 "."
37 } else {
38 trimmed
39 }
40}
41
42pub(super) fn has_windows_path_prefix(input: &str) -> bool {
43 let bytes = input.as_bytes();
44 if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
45 return true;
46 }
47
48 input.starts_with("\\\\") || input.starts_with("//")
49}
50
51pub(crate) fn validate_relative_pattern(pattern: &str, label: &str) -> Result<()> {
52 let pattern = pattern.trim();
53 if pattern.is_empty() {
54 bail!("{label} cannot be empty");
55 }
56 if has_windows_path_prefix(pattern) || Path::new(pattern).is_absolute() {
57 bail!("{label} must be relative to the workspace");
58 }
59
60 let normalized = pattern.replace('\\', "/");
61 if normalized.split('/').any(|component| component == "..") {
62 bail!("{label} must not contain parent directory traversal");
63 }
64
65 Ok(())
66}
67
68pub(super) fn normalize_relative_path(path: &Path) -> Result<PathBuf> {
69 let mut out = PathBuf::new();
70 for component in path.components() {
71 match component {
72 Component::CurDir => {}
73 Component::Normal(part) => out.push(part),
74 Component::ParentDir => {
75 if !out.pop() {
76 bail!("Workspace boundary violation: path escapes workspace");
77 }
78 }
79 Component::RootDir | Component::Prefix(_) => {
80 bail!("Absolute paths are not supported by this workspace backend");
81 }
82 }
83 }
84 Ok(out)
85}
86
87pub(super) fn pathbuf_to_workspace_path(path: &Path) -> WorkspacePath {
88 let display = path.to_string_lossy().replace('\\', "/");
89 WorkspacePath::from_normalized(display)
90}
91
92pub(super) fn escape_control_chars_for_display(path: &str) -> String {
93 let mut escaped = String::with_capacity(path.len());
94 for ch in path.chars() {
95 if ch.is_control() {
96 escaped.extend(ch.escape_default());
97 } else {
98 escaped.push(ch);
99 }
100 }
101 escaped
102}