Skip to main content

vtcode_commons/
paths.rs

1use anyhow::{Context, Result, anyhow};
2use std::path::{Component, Path, PathBuf};
3use tracing::warn;
4
5/// Normalize a path by resolving `.` and `..` components lexically.
6pub fn normalize_path(path: &Path) -> PathBuf {
7    let mut normalized = PathBuf::new();
8    for component in path.components() {
9        match component {
10            Component::ParentDir => {
11                normalized.pop();
12            }
13            Component::CurDir => {}
14            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
15            Component::RootDir => normalized.push(component.as_os_str()),
16            Component::Normal(part) => normalized.push(part),
17        }
18    }
19    normalized
20}
21
22/// Canonicalize a path with fallback to the original path if canonicalization fails.
23pub fn canonicalize_workspace(workspace_root: &Path) -> PathBuf {
24    std::fs::canonicalize(workspace_root).unwrap_or_else(|error| {
25        warn!(
26            path = %workspace_root.display(),
27            %error,
28            "Failed to canonicalize workspace root; falling back to provided path"
29        );
30        workspace_root.to_path_buf()
31    })
32}
33
34/// Resolve a path relative to a workspace root and ensure it stays within it.
35pub fn resolve_workspace_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
36    let candidate = if user_path.is_absolute() {
37        user_path.to_path_buf()
38    } else {
39        workspace_root.join(user_path)
40    };
41
42    let canonical = std::fs::canonicalize(&candidate)
43        .with_context(|| format!("Failed to canonicalize path {}", candidate.display()))?;
44
45    let workspace_canonical = std::fs::canonicalize(workspace_root).with_context(|| {
46        format!(
47            "Failed to canonicalize workspace root {}",
48            workspace_root.display()
49        )
50    })?;
51
52    if !canonical.starts_with(&workspace_canonical) {
53        return Err(anyhow!(
54            "Path {} escapes workspace root {}",
55            canonical.display(),
56            workspace_canonical.display()
57        ));
58    }
59
60    Ok(canonical)
61}
62
63/// Return a canonicalised absolute path that is guaranteed to reside inside the
64/// provided `workspace_root`.  If the path is outside the workspace an error is
65/// returned.
66pub fn secure_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
67    // Resolve relative paths against the workspace root.
68    resolve_workspace_path(workspace_root, user_path)
69}
70
71/// Normalize identifiers to ASCII alphanumerics with lowercase output.
72pub fn normalize_ascii_identifier(value: &str) -> String {
73    let mut normalized = String::new();
74    for ch in value.chars() {
75        if ch.is_ascii_alphanumeric() {
76            normalized.push(ch.to_ascii_lowercase());
77        }
78    }
79    normalized
80}
81
82/// Check if a path string is a safe relative path (no traversal, no absolute).
83pub fn is_safe_relative_path(path: &str) -> bool {
84    let path = path.trim();
85    if path.is_empty() {
86        return false;
87    }
88
89    // Check for path traversal attempts
90    if path.contains("..") {
91        return false;
92    }
93
94    // Block absolute paths for security
95    if path.starts_with('/') || path.contains(':') {
96        return false;
97    }
98
99    true
100}
101
102/// Extract the filename from a path, with fallback to the full path.
103pub fn file_name_from_path(path: &str) -> String {
104    Path::new(path)
105        .file_name()
106        .and_then(|name| name.to_str())
107        .map(|s| s.to_string())
108        .unwrap_or_else(|| path.to_string())
109}
110
111/// Provides the root directories an application uses to store data.
112pub trait WorkspacePaths: Send + Sync {
113    /// Absolute path to the application's workspace root.
114    fn workspace_root(&self) -> &Path;
115
116    /// Returns the directory where configuration files should be stored.
117    fn config_dir(&self) -> PathBuf;
118
119    /// Returns an optional cache directory for transient data.
120    fn cache_dir(&self) -> Option<PathBuf> {
121        None
122    }
123
124    /// Returns an optional directory for telemetry or log artifacts.
125    fn telemetry_dir(&self) -> Option<PathBuf> {
126        None
127    }
128
129    /// Determine the [`PathScope`] for a given path based on workspace directories.
130    ///
131    /// Returns the most specific scope matching the path:
132    /// - `Workspace` if under `workspace_root()`
133    /// - `Config` if under `config_dir()`
134    /// - `Cache` if under `cache_dir()`
135    /// - `Telemetry` if under `telemetry_dir()`
136    /// - Falls back to `Cache` if no match
137    fn scope_for_path(&self, path: &Path) -> PathScope {
138        if path.starts_with(self.workspace_root()) {
139            return PathScope::Workspace;
140        }
141
142        let config_dir = self.config_dir();
143        if path.starts_with(&config_dir) {
144            return PathScope::Config;
145        }
146
147        if let Some(cache_dir) = self.cache_dir() {
148            if path.starts_with(&cache_dir) {
149                return PathScope::Cache;
150            }
151        }
152
153        if let Some(telemetry_dir) = self.telemetry_dir() {
154            if path.starts_with(&telemetry_dir) {
155                return PathScope::Telemetry;
156            }
157        }
158
159        PathScope::Cache
160    }
161}
162
163/// Helper trait that adds path resolution helpers on top of [`WorkspacePaths`].
164pub trait PathResolver: WorkspacePaths {
165    /// Resolve a path relative to the workspace root.
166    fn resolve<P>(&self, relative: P) -> PathBuf
167    where
168        P: AsRef<Path>,
169    {
170        self.workspace_root().join(relative)
171    }
172
173    /// Resolve a path within the configuration directory.
174    fn resolve_config<P>(&self, relative: P) -> PathBuf
175    where
176        P: AsRef<Path>,
177    {
178        self.config_dir().join(relative)
179    }
180}
181
182impl<T> PathResolver for T where T: WorkspacePaths + ?Sized {}
183
184/// Enumeration describing the conceptual scope of a file path.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum PathScope {
187    Workspace,
188    Config,
189    Cache,
190    Telemetry,
191}
192
193impl PathScope {
194    /// Returns a human-readable description used in error messages.
195    pub fn description(self) -> &'static str {
196        match self {
197            Self::Workspace => "workspace",
198            Self::Config => "configuration",
199            Self::Cache => "cache",
200            Self::Telemetry => "telemetry",
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use std::path::PathBuf;
209
210    struct StaticPaths {
211        root: PathBuf,
212        config: PathBuf,
213    }
214
215    impl WorkspacePaths for StaticPaths {
216        fn workspace_root(&self) -> &Path {
217            &self.root
218        }
219
220        fn config_dir(&self) -> PathBuf {
221            self.config.clone()
222        }
223
224        fn cache_dir(&self) -> Option<PathBuf> {
225            Some(self.root.join("cache"))
226        }
227    }
228
229    #[test]
230    fn resolves_relative_paths() {
231        let paths = StaticPaths {
232            root: PathBuf::from("/tmp/project"),
233            config: PathBuf::from("/tmp/project/config"),
234        };
235
236        assert_eq!(
237            PathResolver::resolve(&paths, "subdir/file.txt"),
238            PathBuf::from("/tmp/project/subdir/file.txt")
239        );
240        assert_eq!(
241            PathResolver::resolve_config(&paths, "settings.toml"),
242            PathBuf::from("/tmp/project/config/settings.toml")
243        );
244        assert_eq!(paths.cache_dir(), Some(PathBuf::from("/tmp/project/cache")));
245    }
246}