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