lean_ctx/core/
pathutil.rs1use std::path::{Path, PathBuf};
2
3pub fn safe_canonicalize(path: &Path) -> std::io::Result<PathBuf> {
9 let canon = std::fs::canonicalize(path)?;
10 Ok(strip_verbatim(canon))
11}
12
13pub fn safe_canonicalize_or_self(path: &Path) -> PathBuf {
15 safe_canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
16}
17
18pub fn safe_canonicalize_bounded(path: &Path, timeout_ms: u64) -> PathBuf {
23 use super::io_health;
24
25 let path_str = path.to_string_lossy();
26 if io_health::is_slow_mount(&path_str) && io_health::recent_freeze_count() > 0 {
27 return safe_canonicalize_or_self(path);
28 }
29
30 let effective_timeout =
31 io_health::adaptive_timeout(std::time::Duration::from_millis(timeout_ms));
32
33 let path_owned = path.to_path_buf();
34 let (tx, rx) = std::sync::mpsc::channel();
35 let _ = std::thread::Builder::new()
36 .name("canonicalize-bounded".into())
37 .spawn(move || {
38 let result = safe_canonicalize(&path_owned).unwrap_or(path_owned);
39 let _ = tx.send(result);
40 });
41 if let Ok(canonical) = rx.recv_timeout(effective_timeout) {
42 canonical
43 } else {
44 io_health::record_freeze();
45 tracing::debug!(
46 "canonicalize timed out ({}ms) for {}; using original path",
47 effective_timeout.as_millis(),
48 path.display()
49 );
50 path.to_path_buf()
51 }
52}
53
54pub fn strip_verbatim(path: PathBuf) -> PathBuf {
57 let s = path.to_string_lossy();
58 if let Some(stripped) = strip_verbatim_str(&s) {
59 PathBuf::from(stripped)
60 } else {
61 path
62 }
63}
64
65pub fn strip_verbatim_str(path: &str) -> Option<String> {
68 let normalized = path.replace('\\', "/");
69
70 if let Some(rest) = normalized.strip_prefix("//?/UNC/") {
71 Some(format!("//{rest}"))
72 } else {
73 normalized
74 .strip_prefix("//?/")
75 .map(std::string::ToString::to_string)
76 }
77}
78
79pub fn normalize_tool_path(path: &str) -> String {
83 let mut p = match strip_verbatim_str(path) {
84 Some(stripped) => stripped,
85 None => path.to_string(),
86 };
87
88 if p.len() >= 3
90 && p.starts_with('/')
91 && p.as_bytes()[1].is_ascii_alphabetic()
92 && p.as_bytes()[2] == b'/'
93 {
94 let drive = p.as_bytes()[1].to_ascii_uppercase() as char;
95 p = format!("{drive}:{}", &p[2..]);
96 }
97
98 p = p.replace('\\', "/");
99
100 while p.contains("//") && !p.starts_with("//") {
102 p = p.replace("//", "/");
103 }
104
105 if p.len() > 1 && p.ends_with('/') && !p.ends_with(":/") {
107 p.pop();
108 }
109
110 p
111}
112
113pub fn is_broad_or_unsafe_root(dir: &Path) -> bool {
118 if let Some(home) = dirs::home_dir() {
119 if dir == home {
120 return true;
121 }
122 }
123 let s = dir.to_string_lossy();
124 if s == "/" || s == "\\" || s == "." {
125 return true;
126 }
127 s.ends_with("/.claude")
128 || s.ends_with("/.codex")
129 || s.contains("/.claude/")
130 || s.contains("/.codex/")
131}
132
133pub fn is_data_dir_collision(project_root: &Path) -> bool {
137 if is_broad_or_unsafe_root(project_root) {
138 return true;
139 }
140 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
141 let project_lean_ctx = project_root.join(".lean-ctx");
142 if project_lean_ctx == data_dir || data_dir.starts_with(&project_lean_ctx) {
143 return true;
144 }
145 }
146 false
147}
148
149pub fn safe_project_data_dir(project_root: &Path) -> Result<PathBuf, String> {
152 if is_data_dir_collision(project_root) {
153 return Err(format!(
154 "project root {} collides with global data directory; \
155 skipping project-scoped write",
156 project_root.display()
157 ));
158 }
159 Ok(project_root.join(".lean-ctx"))
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 #[test]
167 fn strip_regular_verbatim() {
168 let p = PathBuf::from(r"\\?\C:\Users\dev\project");
169 let result = strip_verbatim(p);
170 assert_eq!(result, PathBuf::from("C:/Users/dev/project"));
171 }
172
173 #[test]
174 fn strip_unc_verbatim() {
175 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
176 let result = strip_verbatim(p);
177 assert_eq!(result, PathBuf::from("//server/share/dir"));
178 }
179
180 #[test]
181 fn no_prefix_unchanged() {
182 let p = PathBuf::from("/home/user/project");
183 let result = strip_verbatim(p.clone());
184 assert_eq!(result, p);
185 }
186
187 #[test]
188 fn windows_drive_unchanged() {
189 let p = PathBuf::from("C:/Users/dev");
190 let result = strip_verbatim(p.clone());
191 assert_eq!(result, p);
192 }
193
194 #[test]
195 fn strip_str_regular() {
196 assert_eq!(
197 strip_verbatim_str(r"\\?\E:\code\lean-ctx"),
198 Some("E:/code/lean-ctx".to_string())
199 );
200 }
201
202 #[test]
203 fn strip_str_unc() {
204 assert_eq!(
205 strip_verbatim_str(r"\\?\UNC\myserver\data"),
206 Some("//myserver/data".to_string())
207 );
208 }
209
210 #[test]
211 fn strip_str_forward_slash_variant() {
212 assert_eq!(
213 strip_verbatim_str("//?/C:/Users/dev"),
214 Some("C:/Users/dev".to_string())
215 );
216 }
217
218 #[test]
219 fn strip_str_no_prefix() {
220 assert_eq!(strip_verbatim_str("/home/user"), None);
221 }
222
223 #[test]
224 fn safe_canonicalize_or_self_nonexistent() {
225 let p = Path::new("/this/path/should/not/exist/xyzzy");
226 let result = safe_canonicalize_or_self(p);
227 assert_eq!(result, p.to_path_buf());
228 }
229
230 #[test]
231 fn normalize_msys_path_to_native() {
232 assert_eq!(
233 normalize_tool_path("/c/Users/ABC/AppData/lean-ctx"),
234 "C:/Users/ABC/AppData/lean-ctx"
235 );
236 }
237
238 #[test]
239 fn normalize_msys_uppercase_drive() {
240 assert_eq!(
241 normalize_tool_path("/D/Program Files/lean-ctx.exe"),
242 "D:/Program Files/lean-ctx.exe"
243 );
244 }
245
246 #[test]
247 fn normalize_native_windows_path_unchanged() {
248 assert_eq!(
249 normalize_tool_path("C:/Users/ABC/lean-ctx.exe"),
250 "C:/Users/ABC/lean-ctx.exe"
251 );
252 }
253
254 #[test]
255 fn normalize_backslash_windows_path() {
256 assert_eq!(
257 normalize_tool_path(r"C:\Users\ABC\lean-ctx.exe"),
258 "C:/Users/ABC/lean-ctx.exe"
259 );
260 }
261
262 #[test]
263 fn normalize_unix_path_unchanged() {
264 assert_eq!(
265 normalize_tool_path("/usr/local/bin/lean-ctx"),
266 "/usr/local/bin/lean-ctx"
267 );
268 }
269
270 #[test]
271 fn normalize_double_slashes() {
272 assert_eq!(
273 normalize_tool_path("C:/Users//ABC//lean-ctx"),
274 "C:/Users/ABC/lean-ctx"
275 );
276 }
277
278 #[test]
279 fn normalize_trailing_slash_removed() {
280 assert_eq!(normalize_tool_path("/c/Users/ABC/"), "C:/Users/ABC");
281 }
282
283 #[test]
284 fn normalize_root_slash_preserved() {
285 assert_eq!(normalize_tool_path("/"), "/");
286 }
287
288 #[test]
289 fn normalize_drive_root_preserved() {
290 assert_eq!(normalize_tool_path("C:/"), "C:/");
291 }
292
293 #[test]
294 fn normalize_verbatim_with_msys() {
295 assert_eq!(normalize_tool_path(r"\\?\C:\Users\dev"), "C:/Users/dev");
296 }
297
298 #[test]
299 fn broad_root_rejects_home() {
300 if let Some(home) = dirs::home_dir() {
301 assert!(is_broad_or_unsafe_root(&home));
302 }
303 }
304
305 #[test]
306 fn broad_root_rejects_filesystem_root() {
307 assert!(is_broad_or_unsafe_root(Path::new("/")));
308 }
309
310 #[test]
311 fn broad_root_rejects_dot() {
312 assert!(is_broad_or_unsafe_root(Path::new(".")));
313 }
314
315 #[test]
316 fn broad_root_rejects_agent_dirs() {
317 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.claude")));
318 assert!(is_broad_or_unsafe_root(Path::new("/home/user/.codex")));
319 }
320
321 #[test]
322 fn broad_root_allows_project_subdir() {
323 let tmp = tempfile::tempdir().unwrap();
324 let subdir = tmp.path().join("my-project");
325 std::fs::create_dir_all(&subdir).unwrap();
326 assert!(!is_broad_or_unsafe_root(&subdir));
327 }
328
329 #[test]
330 fn broad_root_allows_home_subdirs() {
331 if let Some(home) = dirs::home_dir() {
332 let subdir = home.join("projects").join("my-app");
333 assert!(!is_broad_or_unsafe_root(&subdir));
334 }
335 }
336
337 #[test]
338 fn data_dir_collision_rejects_home() {
339 if let Some(home) = dirs::home_dir() {
340 assert!(is_data_dir_collision(&home));
341 }
342 }
343
344 #[test]
345 fn data_dir_collision_allows_normal_project() {
346 let tmp = tempfile::tempdir().unwrap();
347 let project = tmp.path().join("my-project");
348 std::fs::create_dir_all(&project).unwrap();
349 assert!(!is_data_dir_collision(&project));
350 }
351}