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