Skip to main content

lean_ctx/core/
pathutil.rs

1use std::path::{Path, PathBuf};
2
3/// Canonicalize a path and strip the Windows verbatim/extended-length prefix (`\\?\`)
4/// that `std::fs::canonicalize` adds on Windows. This prefix breaks many tools and
5/// string-based path comparisons.
6///
7/// On non-Windows platforms this is equivalent to `std::fs::canonicalize`.
8pub fn safe_canonicalize(path: &Path) -> std::io::Result<PathBuf> {
9    let canon = std::fs::canonicalize(path)?;
10    Ok(strip_verbatim(canon))
11}
12
13/// Like `safe_canonicalize` but returns the original path on failure.
14pub fn safe_canonicalize_or_self(path: &Path) -> PathBuf {
15    safe_canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
16}
17
18/// Canonicalize with a timeout guard. Protects against hangs on WSL2 DrvFS,
19/// Windows reparse points, NFS, FUSE, sshfs, and other slow filesystems.
20/// Falls back to the original path if canonicalize doesn't complete within the timeout.
21/// Self-healing: after a timeout, subsequent calls to slow mounts skip the thread entirely.
22pub 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
54/// Remove the `\\?\` / `//?/` verbatim prefix from a `PathBuf`.
55/// Handles both regular verbatim (`\\?\C:\...`) and UNC verbatim (`\\?\UNC\...`).
56pub 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
65/// Remove the `\\?\` / `//?/` verbatim prefix from a path string.
66/// Returns `Some(cleaned)` if a prefix was found, `None` otherwise.
67pub 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
79/// Normalize paths from any client format to a consistent OS-native form.
80/// Handles MSYS2/Git Bash (`/c/Users/...` -> `C:/Users/...`), mixed separators,
81/// double slashes, and trailing slashes. Uses forward slashes for consistency.
82pub 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    // MSYS2/Git Bash: /c/Users/... -> C:/Users/...
89    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    // Collapse double slashes (preserve UNC paths starting with //)
101    while p.contains("//") && !p.starts_with("//") {
102        p = p.replace("//", "/");
103    }
104
105    // Remove trailing slash (unless root like "/" or "C:/")
106    if p.len() > 1 && p.ends_with('/') && !p.ends_with(":/") {
107        p.pop();
108    }
109
110    p
111}
112
113/// Returns `true` if the directory is too broad to be a valid project root.
114/// Rejects home directory, filesystem root, `.` (bare CWD), and agent sandbox
115/// directories (`.claude`, `.codex`). Used to prevent writing project-scoped
116/// data (overlays, policies) into the global `~/.lean-ctx/` data directory.
117pub 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
133/// Returns `true` if `project_root` collides with the lean-ctx data directory.
134/// This prevents project-scoped files (overlays.json, policies.json) from being
135/// written into `~/.lean-ctx/` or `~/.config/lean-ctx/`.
136pub 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
149/// Returns the project-scoped `.lean-ctx/` directory if the project root is safe.
150/// Returns `Err` if the project root collides with the global data directory.
151pub 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}