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::warn!(
46            "[SECURITY] canonicalize timed out ({}ms) for {}; PathJail checks on \
47             uncanonicalized paths may be less reliable",
48            effective_timeout.as_millis(),
49            path.display()
50        );
51        path.to_path_buf()
52    }
53}
54
55/// Remove the `\\?\` / `//?/` verbatim prefix from a `PathBuf`.
56/// Handles both regular verbatim (`\\?\C:\...`) and UNC verbatim (`\\?\UNC\...`).
57pub fn strip_verbatim(path: PathBuf) -> PathBuf {
58    let s = path.to_string_lossy();
59    if let Some(stripped) = strip_verbatim_str(&s) {
60        PathBuf::from(stripped)
61    } else {
62        path
63    }
64}
65
66/// Remove the `\\?\` / `//?/` verbatim prefix from a path string.
67/// Returns `Some(cleaned)` if a prefix was found, `None` otherwise.
68pub fn strip_verbatim_str(path: &str) -> Option<String> {
69    let normalized = path.replace('\\', "/");
70
71    if let Some(rest) = normalized.strip_prefix("//?/UNC/") {
72        Some(format!("//{rest}"))
73    } else {
74        normalized
75            .strip_prefix("//?/")
76            .map(std::string::ToString::to_string)
77    }
78}
79
80/// Normalize paths from any client format to a consistent OS-native form.
81/// Handles MSYS2/Git Bash (`/c/Users/...` -> `C:/Users/...`), mixed separators,
82/// double slashes, and trailing slashes. Uses forward slashes for consistency.
83pub fn normalize_tool_path(path: &str) -> String {
84    let mut p = match strip_verbatim_str(path) {
85        Some(stripped) => stripped,
86        None => path.to_string(),
87    };
88
89    // MSYS2/Git Bash: /c/Users/... -> C:/Users/...
90    if p.len() >= 3
91        && p.starts_with('/')
92        && p.as_bytes()[1].is_ascii_alphabetic()
93        && p.as_bytes()[2] == b'/'
94    {
95        let drive = p.as_bytes()[1].to_ascii_uppercase() as char;
96        p = format!("{drive}:{}", &p[2..]);
97    }
98
99    p = p.replace('\\', "/");
100
101    // Collapse double slashes (preserve UNC paths starting with //)
102    while p.contains("//") && !p.starts_with("//") {
103        p = p.replace("//", "/");
104    }
105
106    // Remove trailing slash (unless root like "/" or "C:/")
107    if p.len() > 1 && p.ends_with('/') && !p.ends_with(":/") {
108        p.pop();
109    }
110
111    // Resolve symlinks for absolute paths to ensure cache key consistency.
112    // Skip relative paths (preserve "." / "../" as-is), root-only paths (/ or C:/),
113    // and slow mounts (WSL DrvFS /mnt/) where canonicalize can hang.
114    // Uses safe_canonicalize to strip Windows \\?\ prefix.
115    let is_absolute = p.starts_with('/') || (p.len() >= 3 && p.as_bytes()[1] == b':');
116    let is_root_only = p == "/" || (p.len() <= 3 && p.ends_with('/') && is_absolute);
117    if is_absolute && !is_root_only && !crate::core::io_health::is_slow_mount(&p) {
118        if let Ok(canonical) = safe_canonicalize(Path::new(&*p)) {
119            let canonical_str = canonical.to_string_lossy().replace('\\', "/");
120            if !canonical_str.is_empty() {
121                p = canonical_str;
122            }
123        }
124    }
125
126    p
127}
128
129/// Returns `true` if the directory is too broad to be a valid project root.
130/// Rejects home directory, filesystem root, `.` (bare CWD), and agent sandbox
131/// directories (`.claude`, `.codex`). Used to prevent writing project-scoped
132/// data (overlays, policies) into the global `~/.lean-ctx/` data directory.
133pub fn is_broad_or_unsafe_root(dir: &Path) -> bool {
134    if let Some(home) = dirs::home_dir() {
135        if dir == home {
136            return true;
137        }
138    }
139    let s = dir.to_string_lossy();
140    if s == "/" || s == "\\" || s == "." {
141        return true;
142    }
143    s.ends_with("/.claude")
144        || s.ends_with("/.codex")
145        || s.contains("/.claude/")
146        || s.contains("/.codex/")
147}
148
149/// Well-known project markers used to identify project roots.
150pub const PROJECT_MARKERS: &[&str] = &[
151    ".git",
152    "Cargo.toml",
153    "package.json",
154    "go.mod",
155    "pyproject.toml",
156    "setup.py",
157    "pom.xml",
158    "build.gradle",
159    "Makefile",
160    "project.godot",
161    ".lean-ctx.toml",
162    ".planning",
163];
164
165/// Returns `true` if `dir` contains at least one known project marker.
166pub fn has_project_marker(dir: &Path) -> bool {
167    PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
168}
169
170/// Returns `true` if the (lstat) metadata describes a symlink — or, on
171/// Windows, *any* reparse point (junctions, mount points, app-exec links).
172///
173/// Security boundaries must use this instead of `FileType::is_symlink`:
174/// Rust's `is_symlink()` reports `false` for NTFS junctions, which redirect
175/// exactly like directory symlinks and would otherwise bypass jail/TOCTOU
176/// checks on Windows (GL#442).
177pub fn is_symlink_or_reparse(meta: &std::fs::Metadata) -> bool {
178    if meta.file_type().is_symlink() {
179        return true;
180    }
181    #[cfg(windows)]
182    {
183        use std::os::windows::fs::MetadataExt;
184        const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
185        return meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0;
186    }
187    #[cfg(not(windows))]
188    false
189}
190
191/// Returns `true` if `dir` is the home directory or one of the macOS "magic"
192/// home subdirectories (`Documents`, `Desktop`, `Downloads`).
193///
194/// macOS guards these with TCC: the first time a process *enumerates or stats
195/// inside* one, the OS pops a privacy prompt ("lean-ctx would like to access
196/// files in your Documents folder", #356). They are also never valid project
197/// roots or multi-repo workspace parents, so scan heuristics should treat them
198/// as off-limits *without* calling `read_dir` (which is what trips the prompt).
199pub fn is_tcc_sensitive_home_dir(dir: &Path) -> bool {
200    let Some(home) = dirs::home_dir() else {
201        return false;
202    };
203    if dir == home {
204        return true;
205    }
206    if dir.parent() != Some(home.as_path()) {
207        return false;
208    }
209    matches!(
210        dir.file_name().and_then(|n| n.to_str()),
211        Some("Documents" | "Desktop" | "Downloads")
212    )
213}
214
215/// Returns `true` if `dir` is a multi-repo workspace parent — i.e. it has at
216/// least 2 immediate child directories that each contain a project marker.
217pub fn has_multi_repo_children(dir: &Path) -> bool {
218    // Never enumerate the home dir or macOS TCC-protected dirs: read_dir there
219    // pops a macOS privacy prompt (#356) and they are never workspace parents.
220    if is_tcc_sensitive_home_dir(dir) {
221        return false;
222    }
223    let Ok(entries) = std::fs::read_dir(dir) else {
224        return false;
225    };
226    let count = entries
227        .filter_map(Result::ok)
228        .filter(|e| e.file_type().is_ok_and(|ft| ft.is_dir()))
229        .filter(|e| has_project_marker(&e.path()))
230        .take(2)
231        .count();
232    count >= 2
233}
234
235/// Returns `true` if `project_root` collides with the lean-ctx data directory.
236/// This prevents project-scoped files (overlays.json, policies.json) from being
237/// written into `~/.lean-ctx/` or `~/.config/lean-ctx/`.
238pub fn is_data_dir_collision(project_root: &Path) -> bool {
239    if is_broad_or_unsafe_root(project_root) {
240        return true;
241    }
242    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
243        let project_lean_ctx = project_root.join(".lean-ctx");
244        if project_lean_ctx == data_dir || data_dir.starts_with(&project_lean_ctx) {
245            return true;
246        }
247    }
248    false
249}
250
251/// Returns the project-scoped `.lean-ctx/` directory if the project root is safe.
252/// Returns `Err` if the project root collides with the global data directory.
253pub fn safe_project_data_dir(project_root: &Path) -> Result<PathBuf, String> {
254    if is_data_dir_collision(project_root) {
255        return Err(format!(
256            "project root {} collides with global data directory; \
257             skipping project-scoped write",
258            project_root.display()
259        ));
260    }
261    Ok(project_root.join(".lean-ctx"))
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn strip_regular_verbatim() {
270        let p = PathBuf::from(r"\\?\C:\Users\dev\project");
271        let result = strip_verbatim(p);
272        assert_eq!(result, PathBuf::from("C:/Users/dev/project"));
273    }
274
275    #[test]
276    fn tcc_sensitive_home_dir_matches_home_and_magic_dirs() {
277        let Some(home) = dirs::home_dir() else {
278            return;
279        };
280        // Home itself and the macOS magic dirs are off-limits (#356).
281        assert!(is_tcc_sensitive_home_dir(&home));
282        assert!(is_tcc_sensitive_home_dir(&home.join("Documents")));
283        assert!(is_tcc_sensitive_home_dir(&home.join("Desktop")));
284        assert!(is_tcc_sensitive_home_dir(&home.join("Downloads")));
285    }
286
287    #[test]
288    fn tcc_sensitive_home_dir_allows_real_projects() {
289        let Some(home) = dirs::home_dir() else {
290            return;
291        };
292        // A real project (even nested under Documents) and non-magic home children
293        // are scannable — only the bare magic dirs / home are blocked.
294        assert!(!is_tcc_sensitive_home_dir(
295            &home.join("Documents").join("my-project")
296        ));
297        assert!(!is_tcc_sensitive_home_dir(&home.join("code")));
298        assert!(!is_tcc_sensitive_home_dir(&home.join("Projects")));
299    }
300
301    #[test]
302    fn strip_unc_verbatim() {
303        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
304        let result = strip_verbatim(p);
305        assert_eq!(result, PathBuf::from("//server/share/dir"));
306    }
307
308    #[test]
309    fn no_prefix_unchanged() {
310        let p = PathBuf::from("/home/user/project");
311        let result = strip_verbatim(p.clone());
312        assert_eq!(result, p);
313    }
314
315    #[test]
316    fn windows_drive_unchanged() {
317        let p = PathBuf::from("C:/Users/dev");
318        let result = strip_verbatim(p.clone());
319        assert_eq!(result, p);
320    }
321
322    #[test]
323    fn strip_str_regular() {
324        assert_eq!(
325            strip_verbatim_str(r"\\?\E:\code\lean-ctx"),
326            Some("E:/code/lean-ctx".to_string())
327        );
328    }
329
330    #[test]
331    fn strip_str_unc() {
332        assert_eq!(
333            strip_verbatim_str(r"\\?\UNC\myserver\data"),
334            Some("//myserver/data".to_string())
335        );
336    }
337
338    #[test]
339    fn strip_str_forward_slash_variant() {
340        assert_eq!(
341            strip_verbatim_str("//?/C:/Users/dev"),
342            Some("C:/Users/dev".to_string())
343        );
344    }
345
346    #[test]
347    fn strip_str_no_prefix() {
348        assert_eq!(strip_verbatim_str("/home/user"), None);
349    }
350
351    #[test]
352    fn safe_canonicalize_or_self_nonexistent() {
353        let p = Path::new("/this/path/should/not/exist/xyzzy");
354        let result = safe_canonicalize_or_self(p);
355        assert_eq!(result, p.to_path_buf());
356    }
357
358    #[test]
359    fn normalize_msys_path_to_native() {
360        assert_eq!(
361            normalize_tool_path("/c/Users/ABC/AppData/lean-ctx"),
362            "C:/Users/ABC/AppData/lean-ctx"
363        );
364    }
365
366    #[test]
367    fn normalize_msys_uppercase_drive() {
368        assert_eq!(
369            normalize_tool_path("/D/Program Files/lean-ctx.exe"),
370            "D:/Program Files/lean-ctx.exe"
371        );
372    }
373
374    #[test]
375    fn normalize_native_windows_path_unchanged() {
376        assert_eq!(
377            normalize_tool_path("C:/Users/ABC/lean-ctx.exe"),
378            "C:/Users/ABC/lean-ctx.exe"
379        );
380    }
381
382    #[test]
383    fn normalize_backslash_windows_path() {
384        assert_eq!(
385            normalize_tool_path(r"C:\Users\ABC\lean-ctx.exe"),
386            "C:/Users/ABC/lean-ctx.exe"
387        );
388    }
389
390    #[test]
391    fn normalize_unix_path_unchanged() {
392        assert_eq!(
393            normalize_tool_path("/usr/local/bin/lean-ctx"),
394            "/usr/local/bin/lean-ctx"
395        );
396    }
397
398    #[test]
399    fn normalize_windows_path_with_spaces_and_backslashes() {
400        // The exact "paths with spaces" scenario reported on Windows (#324):
401        // backslashes are converted to forward slashes (so client render layers
402        // never escape-mangle them) while spaces in directory names survive.
403        assert_eq!(
404            normalize_tool_path(r"C:\Users\My Name\My Project\src\main.rs"),
405            "C:/Users/My Name/My Project/src/main.rs"
406        );
407        assert_eq!(
408            normalize_tool_path(r"C:\Program Files\app\config.toml"),
409            "C:/Program Files/app/config.toml"
410        );
411    }
412
413    #[test]
414    fn normalize_double_slashes() {
415        assert_eq!(
416            normalize_tool_path("C:/Users//ABC//lean-ctx"),
417            "C:/Users/ABC/lean-ctx"
418        );
419    }
420
421    #[test]
422    fn normalize_trailing_slash_removed() {
423        assert_eq!(normalize_tool_path("/c/Users/ABC/"), "C:/Users/ABC");
424    }
425
426    #[test]
427    fn normalize_root_slash_preserved() {
428        assert_eq!(normalize_tool_path("/"), "/");
429    }
430
431    #[test]
432    fn normalize_drive_root_preserved() {
433        assert_eq!(normalize_tool_path("C:/"), "C:/");
434    }
435
436    #[test]
437    fn normalize_verbatim_with_msys() {
438        assert_eq!(normalize_tool_path(r"\\?\C:\Users\dev"), "C:/Users/dev");
439    }
440
441    #[test]
442    fn broad_root_rejects_home() {
443        if let Some(home) = dirs::home_dir() {
444            assert!(is_broad_or_unsafe_root(&home));
445        }
446    }
447
448    #[test]
449    fn broad_root_rejects_filesystem_root() {
450        assert!(is_broad_or_unsafe_root(Path::new("/")));
451    }
452
453    #[test]
454    fn broad_root_rejects_dot() {
455        assert!(is_broad_or_unsafe_root(Path::new(".")));
456    }
457
458    #[test]
459    fn broad_root_rejects_agent_dirs() {
460        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.claude")));
461        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.codex")));
462    }
463
464    #[test]
465    fn broad_root_allows_project_subdir() {
466        let tmp = tempfile::tempdir().unwrap();
467        let subdir = tmp.path().join("my-project");
468        std::fs::create_dir_all(&subdir).unwrap();
469        assert!(!is_broad_or_unsafe_root(&subdir));
470    }
471
472    #[test]
473    fn broad_root_allows_home_subdirs() {
474        if let Some(home) = dirs::home_dir() {
475            let subdir = home.join("projects").join("my-app");
476            assert!(!is_broad_or_unsafe_root(&subdir));
477        }
478    }
479
480    #[test]
481    fn data_dir_collision_rejects_home() {
482        if let Some(home) = dirs::home_dir() {
483            assert!(is_data_dir_collision(&home));
484        }
485    }
486
487    #[test]
488    fn data_dir_collision_allows_normal_project() {
489        let tmp = tempfile::tempdir().unwrap();
490        let project = tmp.path().join("my-project");
491        std::fs::create_dir_all(&project).unwrap();
492        assert!(!is_data_dir_collision(&project));
493    }
494
495    #[test]
496    fn has_project_marker_detects_git() {
497        let tmp = tempfile::tempdir().unwrap();
498        let root = tmp.path().join("repo");
499        std::fs::create_dir_all(&root).unwrap();
500        assert!(!has_project_marker(&root));
501        std::fs::create_dir(root.join(".git")).unwrap();
502        assert!(has_project_marker(&root));
503    }
504
505    #[test]
506    fn has_project_marker_detects_cargo_toml() {
507        let tmp = tempfile::tempdir().unwrap();
508        let root = tmp.path().join("rust-project");
509        std::fs::create_dir_all(&root).unwrap();
510        std::fs::write(root.join("Cargo.toml"), "[package]").unwrap();
511        assert!(has_project_marker(&root));
512    }
513
514    #[test]
515    fn has_project_marker_detects_godot_project() {
516        let tmp = tempfile::tempdir().unwrap();
517        let root = tmp.path().join("godot-game");
518        std::fs::create_dir_all(&root).unwrap();
519        std::fs::write(root.join("project.godot"), "config_version=5\n").unwrap();
520        assert!(has_project_marker(&root));
521    }
522
523    #[test]
524    fn multi_repo_children_needs_two() {
525        let tmp = tempfile::tempdir().unwrap();
526        let parent = tmp.path().join("code");
527        std::fs::create_dir_all(&parent).unwrap();
528
529        // 0 repos → false
530        assert!(!has_multi_repo_children(&parent));
531
532        // 1 repo → false
533        let repo1 = parent.join("repo1");
534        std::fs::create_dir_all(repo1.join(".git")).unwrap();
535        assert!(!has_multi_repo_children(&parent));
536
537        // 2 repos → true
538        let repo2 = parent.join("repo2");
539        std::fs::create_dir_all(repo2.join(".git")).unwrap();
540        assert!(has_multi_repo_children(&parent));
541    }
542
543    #[test]
544    fn multi_repo_children_ignores_files() {
545        let tmp = tempfile::tempdir().unwrap();
546        let parent = tmp.path().join("mixed");
547        std::fs::create_dir_all(&parent).unwrap();
548
549        // One repo dir + one plain file with .git name (not a dir)
550        let repo1 = parent.join("repo1");
551        std::fs::create_dir_all(repo1.join(".git")).unwrap();
552        std::fs::write(parent.join("not-a-repo"), "file").unwrap();
553        assert!(!has_multi_repo_children(&parent));
554
555        // Add second actual repo
556        let repo2 = parent.join("repo2");
557        std::fs::create_dir_all(&repo2).unwrap();
558        std::fs::write(repo2.join("package.json"), "{}").unwrap();
559        assert!(has_multi_repo_children(&parent));
560    }
561
562    #[test]
563    fn multi_repo_children_nonexistent_dir() {
564        assert!(!has_multi_repo_children(Path::new("/nonexistent/path/xyz")));
565    }
566
567    #[test]
568    fn regular_file_is_not_symlink_or_reparse() {
569        let tmp = tempfile::tempdir().unwrap();
570        let file = tmp.path().join("plain.txt");
571        std::fs::write(&file, "x").unwrap();
572        let meta = std::fs::symlink_metadata(&file).unwrap();
573        assert!(!is_symlink_or_reparse(&meta));
574    }
575
576    #[cfg(unix)]
577    #[test]
578    fn unix_symlink_is_detected() {
579        let tmp = tempfile::tempdir().unwrap();
580        let target = tmp.path().join("target.txt");
581        std::fs::write(&target, "x").unwrap();
582        let link = tmp.path().join("link.txt");
583        std::os::unix::fs::symlink(&target, &link).unwrap();
584        let meta = std::fs::symlink_metadata(&link).unwrap();
585        assert!(is_symlink_or_reparse(&meta));
586    }
587
588    /// Runs in the windows-latest CI lane (GL#442). Symlink creation needs
589    /// either admin or Developer Mode — skip gracefully when unavailable.
590    #[cfg(windows)]
591    #[test]
592    fn windows_symlink_is_detected() {
593        let tmp = tempfile::tempdir().unwrap();
594        let target = tmp.path().join("target.txt");
595        std::fs::write(&target, "x").unwrap();
596        let link = tmp.path().join("link.txt");
597        if std::os::windows::fs::symlink_file(&target, &link).is_err() {
598            eprintln!("skipping: symlink creation not permitted on this runner");
599            return;
600        }
601        let meta = std::fs::symlink_metadata(&link).unwrap();
602        assert!(is_symlink_or_reparse(&meta));
603    }
604}