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 secure_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
38 let joined = if user_path.is_absolute() {
40 user_path.to_path_buf()
41 } else {
42 workspace_root.join(user_path)
43 };
44
45 let canonical = std::fs::canonicalize(&joined)
47 .with_context(|| format!("Failed to canonicalize path {}", joined.display()))?;
48
49 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
66pub fn is_safe_relative_path(path: &str) -> bool {
68 let path = path.trim();
69 if path.is_empty() {
70 return false;
71 }
72
73 if path.contains("..") {
75 return false;
76 }
77
78 if path.starts_with('/') || path.contains(':') {
80 return false;
81 }
82
83 true
84}
85
86pub 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
95pub trait WorkspacePaths: Send + Sync {
97 fn workspace_root(&self) -> &Path;
99
100 fn config_dir(&self) -> PathBuf;
102
103 fn cache_dir(&self) -> Option<PathBuf> {
105 None
106 }
107
108 fn telemetry_dir(&self) -> Option<PathBuf> {
110 None
111 }
112
113 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
147pub trait PathResolver: WorkspacePaths {
149 fn resolve<P>(&self, relative: P) -> PathBuf
151 where
152 P: AsRef<Path>,
153 {
154 self.workspace_root().join(relative)
155 }
156
157 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum PathScope {
171 Workspace,
172 Config,
173 Cache,
174 Telemetry,
175}
176
177impl PathScope {
178 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}