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/// MSYS2/Git Bash drive mapping: `/c/Users/...` -> `C:/Users/...`.
81///
82/// Returns `None` when the path does not carry a single-letter drive prefix.
83/// Callers must apply this **only on Windows hosts**: clients running under
84/// MSYS2/Git Bash hand POSIX-style drive paths to a native Windows lean-ctx.
85/// On Linux/macOS `/c/...` is a literal directory and must pass through
86/// untouched (GH #397 — the unconditional rewrite broke every `ctx_*` tool
87/// for Linux projects rooted under `/c/...` and similar paths).
88fn translate_msys_drive_prefix(p: &str) -> Option<String> {
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        Some(format!("{drive}:{}", &p[2..]))
96    } else {
97        None
98    }
99}
100
101/// Lexical (string-only) part of [`normalize_tool_path`]: MSYS2 drive prefix
102/// (Windows hosts only), separators, double slashes, trailing slash. Performs
103/// **no** filesystem access, so it is safe on persisted paths in
104/// TCC-standalone processes (launchd daemon, #356) and as a dedupe key where
105/// symlink resolution is not worth a `realpath` per entry.
106pub fn normalize_tool_path_lexical(path: &str) -> String {
107    let mut p = match strip_verbatim_str(path) {
108        Some(stripped) => stripped,
109        None => path.to_string(),
110    };
111
112    if cfg!(windows) {
113        if let Some(translated) = translate_msys_drive_prefix(&p) {
114            p = translated;
115        }
116    }
117
118    p = p.replace('\\', "/");
119
120    // Collapse double slashes (preserve UNC paths starting with //)
121    while p.contains("//") && !p.starts_with("//") {
122        p = p.replace("//", "/");
123    }
124
125    // Remove trailing slash (unless root like "/" or "C:/")
126    if p.len() > 1 && p.ends_with('/') && !p.ends_with(":/") {
127        p.pop();
128    }
129
130    p
131}
132
133/// Normalize paths from any client format to a consistent OS-native form.
134/// Handles MSYS2/Git Bash drive prefixes on Windows hosts
135/// (`/c/Users/...` -> `C:/Users/...`), mixed separators, double slashes, and
136/// trailing slashes. Uses forward slashes for consistency. On non-Windows
137/// hosts `/c/...` is a literal directory and passes through unchanged (#397).
138pub fn normalize_tool_path(path: &str) -> String {
139    let mut p = normalize_tool_path_lexical(path);
140
141    // Resolve symlinks for absolute paths to ensure cache key consistency.
142    // Skip relative paths (preserve "." / "../" as-is), root-only paths (/ or C:/),
143    // slow mounts (WSL DrvFS /mnt/) where canonicalize can hang, and paths a
144    // TCC-standalone process must not stat (launchd daemon + ~/Documents, #356).
145    // Uses safe_canonicalize to strip Windows \\?\ prefix.
146    let is_absolute = p.starts_with('/') || (p.len() >= 3 && p.as_bytes()[1] == b':');
147    let is_root_only = p == "/" || (p.len() <= 3 && p.ends_with('/') && is_absolute);
148    if is_absolute
149        && !is_root_only
150        && !crate::core::io_health::is_slow_mount(&p)
151        && may_probe_path(Path::new(&*p))
152    {
153        if let Ok(canonical) = safe_canonicalize(Path::new(&*p)) {
154            let canonical_str = canonical.to_string_lossy().replace('\\', "/");
155            if !canonical_str.is_empty() {
156                p = canonical_str;
157            }
158        }
159    }
160
161    p
162}
163
164/// Returns `true` if the directory is too broad to be a valid project root.
165/// Rejects home directory, filesystem root, `.` (bare CWD), and agent sandbox
166/// directories (`.claude`, `.codex`). Used to prevent writing project-scoped
167/// data (overlays, policies) into the global `~/.lean-ctx/` data directory.
168pub fn is_broad_or_unsafe_root(dir: &Path) -> bool {
169    if let Some(home) = dirs::home_dir() {
170        if dir == home {
171            return true;
172        }
173    }
174    let s = dir.to_string_lossy();
175    if s == "/" || s == "\\" || s == "." {
176        return true;
177    }
178    s.ends_with("/.claude")
179        || s.ends_with("/.codex")
180        || s.ends_with("/.codebuddy")
181        || s.contains("/.claude/")
182        || s.contains("/.codex/")
183        || s.contains("/.codebuddy/")
184}
185
186/// Well-known project markers used to identify project roots.
187pub const PROJECT_MARKERS: &[&str] = &[
188    ".git",
189    "Cargo.toml",
190    "package.json",
191    "go.mod",
192    "pyproject.toml",
193    "setup.py",
194    "pom.xml",
195    "build.gradle",
196    "Makefile",
197    "project.godot",
198    ".lean-ctx.toml",
199    ".planning",
200];
201
202/// Returns `true` if `dir` contains at least one known project marker.
203///
204/// TCC guard (#356): a launchd-owned process (daemon/proxy/auto-updater) must
205/// not stat marker files under `~/Documents` & co. — the probe itself pops the
206/// macOS privacy prompt. For those processes this conservatively reports
207/// "no marker" without touching the filesystem.
208pub fn has_project_marker(dir: &Path) -> bool {
209    if !may_probe_path(dir) {
210        return false;
211    }
212    PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
213}
214
215/// Returns `true` if the (lstat) metadata describes a symlink — or, on
216/// Windows, *any* reparse point (junctions, mount points, app-exec links).
217///
218/// Security boundaries must use this instead of `FileType::is_symlink`:
219/// Rust's `is_symlink()` reports `false` for NTFS junctions, which redirect
220/// exactly like directory symlinks and would otherwise bypass jail/TOCTOU
221/// checks on Windows (GL#442).
222pub fn is_symlink_or_reparse(meta: &std::fs::Metadata) -> bool {
223    if meta.file_type().is_symlink() {
224        return true;
225    }
226    #[cfg(windows)]
227    {
228        use std::os::windows::fs::MetadataExt;
229        const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
230        return meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0;
231    }
232    #[cfg(not(windows))]
233    false
234}
235
236/// Returns `true` if `dir` is the home directory or one of the macOS "magic"
237/// home subdirectories (`Documents`, `Desktop`, `Downloads`).
238///
239/// macOS guards these with TCC: the first time a process *enumerates or stats
240/// inside* one, the OS pops a privacy prompt ("lean-ctx would like to access
241/// files in your Documents folder", #356). They are also never valid project
242/// roots or multi-repo workspace parents, so scan heuristics should treat them
243/// as off-limits *without* calling `read_dir` (which is what trips the prompt).
244pub fn is_tcc_sensitive_home_dir(dir: &Path) -> bool {
245    let Some(home) = dirs::home_dir() else {
246        return false;
247    };
248    if dir == home {
249        return true;
250    }
251    if dir.parent() != Some(home.as_path()) {
252        return false;
253    }
254    matches!(
255        dir.file_name().and_then(|n| n.to_str()),
256        Some("Documents" | "Desktop" | "Downloads")
257    )
258}
259
260/// Returns `true` if `path` lies inside (or is) one of the macOS TCC-protected
261/// home folders (`~/Documents`, `~/Desktop`, `~/Downloads`). Pure string/path
262/// comparison — performs **no** filesystem access itself.
263///
264/// Unlike [`is_tcc_sensitive_home_dir`] (which only matches the magic dirs
265/// themselves), this also matches nested paths like `~/Documents/proj/src`,
266/// because *any* `stat` below the magic dir trips the TCC prompt (#356).
267pub fn is_under_tcc_protected_dir(path: &Path) -> bool {
268    if !cfg!(target_os = "macos") {
269        return false;
270    }
271    let Some(home) = dirs::home_dir() else {
272        return false;
273    };
274    ["Documents", "Desktop", "Downloads"]
275        .iter()
276        .any(|magic| path.starts_with(home.join(magic)))
277}
278
279/// Returns `true` when this process is its own TCC identity on macOS — i.e.
280/// it was started (or re-parented) by `launchd` rather than by a
281/// TCC-granted host like a terminal or an editor.
282///
283/// Context (#356): TCC permissions attach to the *responsible process*. The
284/// lean-ctx daemon/proxy LaunchAgents and the scheduled auto-updater run
285/// directly under `launchd` (ppid 1), so any `stat`/`read_dir` they perform
286/// under `~/Documents` pops the privacy prompt **in lean-ctx's own name** —
287/// and because every release replaces the ad-hoc-signed binary (new cdhash),
288/// a previously granted permission is invalidated on each update, re-prompting
289/// forever. Such processes must never probe TCC-protected paths on their own
290/// initiative. Child processes of a terminal or editor (MCP server, CLI)
291/// inherit their host's TCC grant and keep full functionality.
292pub fn process_is_tcc_standalone() -> bool {
293    #[cfg(target_os = "macos")]
294    {
295        // Deliberately uncached: getppid is a cheap syscall, the env override
296        // must stay testable within one process, and a daemonizing fork could
297        // change the answer after startup.
298        if let Ok(v) = std::env::var("LEAN_CTX_TCC_STANDALONE") {
299            match v.trim() {
300                "1" | "true" => return true,
301                "0" | "false" => return false,
302                _ => {}
303            }
304        }
305        // SAFETY: `getppid` takes no arguments and cannot fail.
306        (unsafe { libc::getppid() }) == 1
307    }
308    #[cfg(not(target_os = "macos"))]
309    {
310        false
311    }
312}
313
314/// Returns `true` when this process may `stat`/`read_dir`/`canonicalize`
315/// `path` without risking a macOS TCC privacy prompt in lean-ctx's name.
316///
317/// Heuristic call sites (project-marker probes, session/root matching) must
318/// consult this before touching paths from persisted state; security
319/// boundaries (PathJail) are exempt — they only ever canonicalize paths the
320/// client explicitly asked to access, in which case a prompt is legitimate.
321pub fn may_probe_path(path: &Path) -> bool {
322    !(process_is_tcc_standalone() && is_under_tcc_protected_dir(path))
323}
324
325/// Returns `true` if `dir` is a multi-repo workspace parent — i.e. it has at
326/// least 2 immediate child directories that each contain a project marker.
327pub fn has_multi_repo_children(dir: &Path) -> bool {
328    // Never enumerate the home dir or macOS TCC-protected dirs: read_dir there
329    // pops a macOS privacy prompt (#356) and they are never workspace parents.
330    if is_tcc_sensitive_home_dir(dir) {
331        return false;
332    }
333    let Ok(entries) = std::fs::read_dir(dir) else {
334        return false;
335    };
336    let count = entries
337        .filter_map(Result::ok)
338        .filter(|e| e.file_type().is_ok_and(|ft| ft.is_dir()))
339        .filter(|e| has_project_marker(&e.path()))
340        .take(2)
341        .count();
342    count >= 2
343}
344
345/// Returns `true` if `project_root` collides with the lean-ctx data directory.
346/// This prevents project-scoped files (overlays.json, policies.json) from being
347/// written into `~/.lean-ctx/` or `~/.config/lean-ctx/`.
348pub fn is_data_dir_collision(project_root: &Path) -> bool {
349    if is_broad_or_unsafe_root(project_root) {
350        return true;
351    }
352    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
353        let project_lean_ctx = project_root.join(".lean-ctx");
354        if project_lean_ctx == data_dir || data_dir.starts_with(&project_lean_ctx) {
355            return true;
356        }
357    }
358    false
359}
360
361/// Returns the project-scoped `.lean-ctx/` directory if the project root is safe.
362/// Returns `Err` if the project root collides with the global data directory.
363pub fn safe_project_data_dir(project_root: &Path) -> Result<PathBuf, String> {
364    if is_data_dir_collision(project_root) {
365        return Err(format!(
366            "project root {} collides with global data directory; \
367             skipping project-scoped write",
368            project_root.display()
369        ));
370    }
371    Ok(project_root.join(".lean-ctx"))
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn strip_regular_verbatim() {
380        let p = PathBuf::from(r"\\?\C:\Users\dev\project");
381        let result = strip_verbatim(p);
382        assert_eq!(result, PathBuf::from("C:/Users/dev/project"));
383    }
384
385    #[test]
386    fn tcc_sensitive_home_dir_matches_home_and_magic_dirs() {
387        let Some(home) = dirs::home_dir() else {
388            return;
389        };
390        // Home itself and the macOS magic dirs are off-limits (#356).
391        assert!(is_tcc_sensitive_home_dir(&home));
392        assert!(is_tcc_sensitive_home_dir(&home.join("Documents")));
393        assert!(is_tcc_sensitive_home_dir(&home.join("Desktop")));
394        assert!(is_tcc_sensitive_home_dir(&home.join("Downloads")));
395    }
396
397    #[test]
398    fn tcc_sensitive_home_dir_allows_real_projects() {
399        let Some(home) = dirs::home_dir() else {
400            return;
401        };
402        // A real project (even nested under Documents) and non-magic home children
403        // are scannable — only the bare magic dirs / home are blocked.
404        assert!(!is_tcc_sensitive_home_dir(
405            &home.join("Documents").join("my-project")
406        ));
407        assert!(!is_tcc_sensitive_home_dir(&home.join("code")));
408        assert!(!is_tcc_sensitive_home_dir(&home.join("Projects")));
409    }
410
411    #[test]
412    #[cfg(target_os = "macos")]
413    fn under_tcc_protected_dir_matches_nested_paths() {
414        let Some(home) = dirs::home_dir() else {
415            return;
416        };
417        // The magic dirs themselves and anything nested below them (#356).
418        assert!(is_under_tcc_protected_dir(&home.join("Documents")));
419        assert!(is_under_tcc_protected_dir(
420            &home.join("Documents/deep/nested/project")
421        ));
422        assert!(is_under_tcc_protected_dir(&home.join("Desktop/scratch")));
423        assert!(is_under_tcc_protected_dir(&home.join("Downloads/x.zip")));
424        // Home itself, siblings, and non-home paths are fine.
425        assert!(!is_under_tcc_protected_dir(&home));
426        assert!(!is_under_tcc_protected_dir(&home.join("code/project")));
427        assert!(!is_under_tcc_protected_dir(Path::new("/tmp/Documents")));
428    }
429
430    #[test]
431    #[cfg(target_os = "macos")]
432    #[serial_test::serial]
433    fn tcc_standalone_blocks_probes_under_protected_dirs() {
434        let Some(home) = dirs::home_dir() else {
435            return;
436        };
437        let doc_proj = home.join("Documents/some-project");
438
439        std::env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
440        assert!(process_is_tcc_standalone());
441        assert!(!may_probe_path(&doc_proj));
442        // Non-protected paths stay probeable even for standalone processes.
443        assert!(may_probe_path(Path::new("/tmp/some-project")));
444        // has_project_marker must refuse without touching the filesystem.
445        assert!(!has_project_marker(&doc_proj));
446
447        std::env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
448        assert!(!process_is_tcc_standalone());
449        assert!(may_probe_path(&doc_proj));
450        std::env::remove_var("LEAN_CTX_TCC_STANDALONE");
451    }
452
453    #[test]
454    fn strip_unc_verbatim() {
455        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
456        let result = strip_verbatim(p);
457        assert_eq!(result, PathBuf::from("//server/share/dir"));
458    }
459
460    #[test]
461    fn no_prefix_unchanged() {
462        let p = PathBuf::from("/home/user/project");
463        let result = strip_verbatim(p.clone());
464        assert_eq!(result, p);
465    }
466
467    #[test]
468    fn windows_drive_unchanged() {
469        let p = PathBuf::from("C:/Users/dev");
470        let result = strip_verbatim(p.clone());
471        assert_eq!(result, p);
472    }
473
474    #[test]
475    fn strip_str_regular() {
476        assert_eq!(
477            strip_verbatim_str(r"\\?\E:\code\lean-ctx"),
478            Some("E:/code/lean-ctx".to_string())
479        );
480    }
481
482    #[test]
483    fn strip_str_unc() {
484        assert_eq!(
485            strip_verbatim_str(r"\\?\UNC\myserver\data"),
486            Some("//myserver/data".to_string())
487        );
488    }
489
490    #[test]
491    fn strip_str_forward_slash_variant() {
492        assert_eq!(
493            strip_verbatim_str("//?/C:/Users/dev"),
494            Some("C:/Users/dev".to_string())
495        );
496    }
497
498    #[test]
499    fn strip_str_no_prefix() {
500        assert_eq!(strip_verbatim_str("/home/user"), None);
501    }
502
503    #[test]
504    fn safe_canonicalize_or_self_nonexistent() {
505        let p = Path::new("/this/path/should/not/exist/xyzzy");
506        let result = safe_canonicalize_or_self(p);
507        assert_eq!(result, p.to_path_buf());
508    }
509
510    // The drive translation itself is platform-independent and testable
511    // everywhere; only its *application* is gated on Windows hosts (#397).
512    #[test]
513    fn msys_drive_prefix_translation() {
514        assert_eq!(
515            translate_msys_drive_prefix("/c/Users/ABC").as_deref(),
516            Some("C:/Users/ABC")
517        );
518        assert_eq!(
519            translate_msys_drive_prefix("/D/Program Files").as_deref(),
520            Some("D:/Program Files")
521        );
522        assert_eq!(translate_msys_drive_prefix("/usr/local/bin"), None);
523        assert_eq!(translate_msys_drive_prefix("/c"), None);
524        assert_eq!(translate_msys_drive_prefix("c/Users"), None);
525    }
526
527    #[cfg(windows)]
528    #[test]
529    fn normalize_msys_path_to_native() {
530        assert_eq!(
531            normalize_tool_path("/c/Users/ABC/AppData/lean-ctx"),
532            "C:/Users/ABC/AppData/lean-ctx"
533        );
534        assert_eq!(
535            normalize_tool_path("/D/Program Files/lean-ctx.exe"),
536            "D:/Program Files/lean-ctx.exe"
537        );
538    }
539
540    // GH #397: on Linux/macOS, /c/… is a literal directory — a Linux project
541    // rooted there must not be rewritten to a Windows drive path.
542    #[cfg(not(windows))]
543    #[test]
544    fn normalize_single_letter_unix_path_untouched() {
545        assert_eq!(
546            normalize_tool_path_lexical("/c/Users/me/proj"),
547            "/c/Users/me/proj"
548        );
549        assert_eq!(
550            normalize_tool_path_lexical("/x/projects/app/src"),
551            "/x/projects/app/src"
552        );
553    }
554
555    #[test]
556    fn normalize_native_windows_path_unchanged() {
557        assert_eq!(
558            normalize_tool_path("C:/Users/ABC/lean-ctx.exe"),
559            "C:/Users/ABC/lean-ctx.exe"
560        );
561    }
562
563    #[test]
564    fn normalize_backslash_windows_path() {
565        assert_eq!(
566            normalize_tool_path(r"C:\Users\ABC\lean-ctx.exe"),
567            "C:/Users/ABC/lean-ctx.exe"
568        );
569    }
570
571    #[test]
572    fn normalize_unix_path_unchanged() {
573        assert_eq!(
574            normalize_tool_path("/usr/local/bin/lean-ctx"),
575            "/usr/local/bin/lean-ctx"
576        );
577    }
578
579    #[test]
580    fn normalize_windows_path_with_spaces_and_backslashes() {
581        // The exact "paths with spaces" scenario reported on Windows (#324):
582        // backslashes are converted to forward slashes (so client render layers
583        // never escape-mangle them) while spaces in directory names survive.
584        assert_eq!(
585            normalize_tool_path(r"C:\Users\My Name\My Project\src\main.rs"),
586            "C:/Users/My Name/My Project/src/main.rs"
587        );
588        assert_eq!(
589            normalize_tool_path(r"C:\Program Files\app\config.toml"),
590            "C:/Program Files/app/config.toml"
591        );
592    }
593
594    #[test]
595    fn normalize_double_slashes() {
596        assert_eq!(
597            normalize_tool_path("C:/Users//ABC//lean-ctx"),
598            "C:/Users/ABC/lean-ctx"
599        );
600    }
601
602    #[test]
603    fn normalize_trailing_slash_removed() {
604        assert_eq!(normalize_tool_path("C:/Users/ABC/"), "C:/Users/ABC");
605        assert_eq!(
606            normalize_tool_path_lexical("/tmp/nonexistent-dir-xyzzy/"),
607            "/tmp/nonexistent-dir-xyzzy"
608        );
609    }
610
611    #[test]
612    fn normalize_root_slash_preserved() {
613        assert_eq!(normalize_tool_path("/"), "/");
614    }
615
616    #[test]
617    fn normalize_drive_root_preserved() {
618        assert_eq!(normalize_tool_path("C:/"), "C:/");
619    }
620
621    #[test]
622    fn normalize_verbatim_with_msys() {
623        assert_eq!(normalize_tool_path(r"\\?\C:\Users\dev"), "C:/Users/dev");
624    }
625
626    #[test]
627    fn broad_root_rejects_home() {
628        if let Some(home) = dirs::home_dir() {
629            assert!(is_broad_or_unsafe_root(&home));
630        }
631    }
632
633    #[test]
634    fn broad_root_rejects_filesystem_root() {
635        assert!(is_broad_or_unsafe_root(Path::new("/")));
636    }
637
638    #[test]
639    fn broad_root_rejects_dot() {
640        assert!(is_broad_or_unsafe_root(Path::new(".")));
641    }
642
643    #[test]
644    fn broad_root_rejects_agent_dirs() {
645        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.claude")));
646        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.codex")));
647    }
648
649    #[test]
650    fn broad_root_allows_project_subdir() {
651        let tmp = tempfile::tempdir().unwrap();
652        let subdir = tmp.path().join("my-project");
653        std::fs::create_dir_all(&subdir).unwrap();
654        assert!(!is_broad_or_unsafe_root(&subdir));
655    }
656
657    #[test]
658    fn broad_root_allows_home_subdirs() {
659        if let Some(home) = dirs::home_dir() {
660            let subdir = home.join("projects").join("my-app");
661            assert!(!is_broad_or_unsafe_root(&subdir));
662        }
663    }
664
665    #[test]
666    fn data_dir_collision_rejects_home() {
667        if let Some(home) = dirs::home_dir() {
668            assert!(is_data_dir_collision(&home));
669        }
670    }
671
672    #[test]
673    fn data_dir_collision_allows_normal_project() {
674        let tmp = tempfile::tempdir().unwrap();
675        let project = tmp.path().join("my-project");
676        std::fs::create_dir_all(&project).unwrap();
677        assert!(!is_data_dir_collision(&project));
678    }
679
680    #[test]
681    fn has_project_marker_detects_git() {
682        let tmp = tempfile::tempdir().unwrap();
683        let root = tmp.path().join("repo");
684        std::fs::create_dir_all(&root).unwrap();
685        assert!(!has_project_marker(&root));
686        std::fs::create_dir(root.join(".git")).unwrap();
687        assert!(has_project_marker(&root));
688    }
689
690    #[test]
691    fn has_project_marker_detects_cargo_toml() {
692        let tmp = tempfile::tempdir().unwrap();
693        let root = tmp.path().join("rust-project");
694        std::fs::create_dir_all(&root).unwrap();
695        std::fs::write(root.join("Cargo.toml"), "[package]").unwrap();
696        assert!(has_project_marker(&root));
697    }
698
699    #[test]
700    fn has_project_marker_detects_godot_project() {
701        let tmp = tempfile::tempdir().unwrap();
702        let root = tmp.path().join("godot-game");
703        std::fs::create_dir_all(&root).unwrap();
704        std::fs::write(root.join("project.godot"), "config_version=5\n").unwrap();
705        assert!(has_project_marker(&root));
706    }
707
708    #[test]
709    fn multi_repo_children_needs_two() {
710        let tmp = tempfile::tempdir().unwrap();
711        let parent = tmp.path().join("code");
712        std::fs::create_dir_all(&parent).unwrap();
713
714        // 0 repos → false
715        assert!(!has_multi_repo_children(&parent));
716
717        // 1 repo → false
718        let repo1 = parent.join("repo1");
719        std::fs::create_dir_all(repo1.join(".git")).unwrap();
720        assert!(!has_multi_repo_children(&parent));
721
722        // 2 repos → true
723        let repo2 = parent.join("repo2");
724        std::fs::create_dir_all(repo2.join(".git")).unwrap();
725        assert!(has_multi_repo_children(&parent));
726    }
727
728    #[test]
729    fn multi_repo_children_ignores_files() {
730        let tmp = tempfile::tempdir().unwrap();
731        let parent = tmp.path().join("mixed");
732        std::fs::create_dir_all(&parent).unwrap();
733
734        // One repo dir + one plain file with .git name (not a dir)
735        let repo1 = parent.join("repo1");
736        std::fs::create_dir_all(repo1.join(".git")).unwrap();
737        std::fs::write(parent.join("not-a-repo"), "file").unwrap();
738        assert!(!has_multi_repo_children(&parent));
739
740        // Add second actual repo
741        let repo2 = parent.join("repo2");
742        std::fs::create_dir_all(&repo2).unwrap();
743        std::fs::write(repo2.join("package.json"), "{}").unwrap();
744        assert!(has_multi_repo_children(&parent));
745    }
746
747    #[test]
748    fn multi_repo_children_nonexistent_dir() {
749        assert!(!has_multi_repo_children(Path::new("/nonexistent/path/xyz")));
750    }
751
752    #[test]
753    fn regular_file_is_not_symlink_or_reparse() {
754        let tmp = tempfile::tempdir().unwrap();
755        let file = tmp.path().join("plain.txt");
756        std::fs::write(&file, "x").unwrap();
757        let meta = std::fs::symlink_metadata(&file).unwrap();
758        assert!(!is_symlink_or_reparse(&meta));
759    }
760
761    #[cfg(unix)]
762    #[test]
763    fn unix_symlink_is_detected() {
764        let tmp = tempfile::tempdir().unwrap();
765        let target = tmp.path().join("target.txt");
766        std::fs::write(&target, "x").unwrap();
767        let link = tmp.path().join("link.txt");
768        std::os::unix::fs::symlink(&target, &link).unwrap();
769        let meta = std::fs::symlink_metadata(&link).unwrap();
770        assert!(is_symlink_or_reparse(&meta));
771    }
772
773    /// Runs in the windows-latest CI lane (GL#442). Symlink creation needs
774    /// either admin or Developer Mode — skip gracefully when unavailable.
775    #[cfg(windows)]
776    #[test]
777    fn windows_symlink_is_detected() {
778        let tmp = tempfile::tempdir().unwrap();
779        let target = tmp.path().join("target.txt");
780        std::fs::write(&target, "x").unwrap();
781        let link = tmp.path().join("link.txt");
782        if std::os::windows::fs::symlink_file(&target, &link).is_err() {
783            eprintln!("skipping: symlink creation not permitted on this runner");
784            return;
785        }
786        let meta = std::fs::symlink_metadata(&link).unwrap();
787        assert!(is_symlink_or_reparse(&meta));
788    }
789}