1use anyhow::{Context, Result, anyhow};
2use std::path::{Component, Path, PathBuf};
3use tracing::warn;
4
5pub 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
22pub 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
34pub 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
63pub fn secure_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
67 resolve_workspace_path(workspace_root, user_path)
69}
70
71pub 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
82pub fn is_safe_relative_path(path: &str) -> bool {
84 let path = path.trim();
85 if path.is_empty() {
86 return false;
87 }
88
89 if path.contains("..") {
91 return false;
92 }
93
94 if path.starts_with('/') || path.contains(':') {
96 return false;
97 }
98
99 true
100}
101
102pub 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
111pub trait WorkspacePaths: Send + Sync {
113 fn workspace_root(&self) -> &Path;
115
116 fn config_dir(&self) -> PathBuf;
118
119 fn cache_dir(&self) -> Option<PathBuf> {
121 None
122 }
123
124 fn telemetry_dir(&self) -> Option<PathBuf> {
126 None
127 }
128
129 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
163pub trait PathResolver: WorkspacePaths {
165 fn resolve<P>(&self, relative: P) -> PathBuf
167 where
168 P: AsRef<Path>,
169 {
170 self.workspace_root().join(relative)
171 }
172
173 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum PathScope {
187 Workspace,
188 Config,
189 Cache,
190 Telemetry,
191}
192
193impl PathScope {
194 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}