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    // TCC choke-point (#356): a launchd-standalone process (daemon/proxy/auto-
10    // updater, ppid 1) must never realpath a path under ~/Documents, ~/Desktop
11    // or ~/Downloads — the `stat` trips the macOS privacy prompt in lean-ctx's
12    // own name, and every release re-invalidates the grant (new cdhash), so it
13    // re-prompts forever. Heuristic call sites (project-root detection, session
14    // matching, path normalization, scan-root checks) all funnel through here,
15    // so guarding the sink protects them centrally instead of one opt-in check
16    // per call site. Return the path unchanged (lexical) rather than touching
17    // the filesystem. Security boundaries (PathJail) deliberately bypass this
18    // guard via `canonicalize_secure` — they only resolve paths the client
19    // explicitly asked to access, where a prompt is legitimate, and must keep
20    // resolving symlinks to detect jail escapes.
21    if !may_probe_path(path) {
22        return Ok(path.to_path_buf());
23    }
24    canonicalize_raw(path)
25}
26
27/// Raw realpath + Windows-verbatim strip, with **no** TCC guard. Internal sink
28/// shared by [`safe_canonicalize`] (which gates it behind `may_probe_path`) and
29/// [`canonicalize_secure`] (which never gates it).
30pub(crate) fn canonicalize_raw(path: &Path) -> std::io::Result<PathBuf> {
31    let canon = std::fs::canonicalize(path)?;
32    Ok(strip_verbatim(canon))
33}
34
35/// Like `safe_canonicalize` but returns the original path on failure.
36pub fn safe_canonicalize_or_self(path: &Path) -> PathBuf {
37    safe_canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
38}
39
40/// SECURITY canonicalize: always resolves symlinks, even under ~/Documents in a
41/// launchd-standalone process. PathJail relies on this to detect symlink jail
42/// escapes (#356 must never weaken the security boundary). A standalone process
43/// only reaches here for a path the client *explicitly* asked to access, where a
44/// one-time TCC prompt is legitimate — unlike the self-initiated heuristic
45/// probes that [`safe_canonicalize`] suppresses.
46pub fn canonicalize_secure(path: &Path) -> std::io::Result<PathBuf> {
47    canonicalize_raw(path)
48}
49
50/// Like `canonicalize_secure` but returns the original path on failure.
51pub fn canonicalize_secure_or_self(path: &Path) -> PathBuf {
52    canonicalize_secure(path).unwrap_or_else(|_| path.to_path_buf())
53}
54
55/// Canonicalize with a timeout guard. Protects against hangs on WSL2 DrvFS,
56/// Windows reparse points, NFS, FUSE, sshfs, and other slow filesystems.
57/// Falls back to the original path if canonicalize doesn't complete within the timeout.
58/// Self-healing: after a timeout, subsequent calls to slow mounts skip the thread entirely.
59///
60/// Heuristic variant — honours the #356 TCC guard (see [`safe_canonicalize`]).
61pub fn safe_canonicalize_bounded(path: &Path, timeout_ms: u64) -> PathBuf {
62    canonicalize_bounded_with(path, timeout_ms, safe_canonicalize_or_self)
63}
64
65/// SECURITY variant of [`safe_canonicalize_bounded`] — bypasses the #356 TCC
66/// guard so PathJail keeps resolving symlinks to detect jail escapes. See
67/// [`canonicalize_secure`] for why a prompt here (explicit request) is legitimate.
68pub fn canonicalize_secure_bounded(path: &Path, timeout_ms: u64) -> PathBuf {
69    canonicalize_bounded_with(path, timeout_ms, canonicalize_secure_or_self)
70}
71
72/// Shared timeout machinery for the bounded canonicalizers. `resolve` selects
73/// the guarded (`safe_canonicalize_or_self`) or security (`canonicalize_secure_or_self`)
74/// sink so both variants get identical slow-mount/self-healing behaviour.
75fn canonicalize_bounded_with(
76    path: &Path,
77    timeout_ms: u64,
78    resolve: fn(&Path) -> PathBuf,
79) -> PathBuf {
80    use super::io_health;
81
82    let path_str = path.to_string_lossy();
83    if io_health::is_slow_mount(&path_str) && io_health::recent_freeze_count() > 0 {
84        return resolve(path);
85    }
86
87    let effective_timeout =
88        io_health::adaptive_timeout(std::time::Duration::from_millis(timeout_ms));
89
90    let path_owned = path.to_path_buf();
91    let (tx, rx) = std::sync::mpsc::channel();
92    let _ = std::thread::Builder::new()
93        .name("canonicalize-bounded".into())
94        .spawn(move || {
95            let _ = tx.send(resolve(&path_owned));
96        });
97    if let Ok(canonical) = rx.recv_timeout(effective_timeout) {
98        canonical
99    } else {
100        io_health::record_freeze();
101        tracing::warn!(
102            "[SECURITY] canonicalize timed out ({}ms) for {}; PathJail checks on \
103             uncanonicalized paths may be less reliable",
104            effective_timeout.as_millis(),
105            path.display()
106        );
107        path.to_path_buf()
108    }
109}
110
111/// Remove the `\\?\` / `//?/` verbatim prefix from a `PathBuf`.
112/// Handles both regular verbatim (`\\?\C:\...`) and UNC verbatim (`\\?\UNC\...`).
113pub fn strip_verbatim(path: PathBuf) -> PathBuf {
114    let s = path.to_string_lossy();
115    if let Some(stripped) = strip_verbatim_str(&s) {
116        PathBuf::from(stripped)
117    } else {
118        path
119    }
120}
121
122/// Remove the `\\?\` / `//?/` verbatim prefix from a path string.
123/// Returns `Some(cleaned)` if a prefix was found, `None` otherwise.
124pub fn strip_verbatim_str(path: &str) -> Option<String> {
125    let normalized = path.replace('\\', "/");
126
127    if let Some(rest) = normalized.strip_prefix("//?/UNC/") {
128        Some(format!("//{rest}"))
129    } else {
130        normalized
131            .strip_prefix("//?/")
132            .map(std::string::ToString::to_string)
133    }
134}
135
136/// MSYS2/Git Bash drive mapping: `/c/Users/...` -> `C:/Users/...`.
137///
138/// Returns `None` when the path does not carry a single-letter drive prefix.
139/// Callers must apply this **only on Windows hosts**: clients running under
140/// MSYS2/Git Bash hand POSIX-style drive paths to a native Windows lean-ctx.
141/// On Linux/macOS `/c/...` is a literal directory and must pass through
142/// untouched (GH #397 — the unconditional rewrite broke every `ctx_*` tool
143/// for Linux projects rooted under `/c/...` and similar paths).
144fn translate_msys_drive_prefix(p: &str) -> Option<String> {
145    if p.len() >= 3
146        && p.starts_with('/')
147        && p.as_bytes()[1].is_ascii_alphabetic()
148        && p.as_bytes()[2] == b'/'
149    {
150        let drive = p.as_bytes()[1].to_ascii_uppercase() as char;
151        Some(format!("{drive}:{}", &p[2..]))
152    } else {
153        None
154    }
155}
156
157/// A Windows absolute path (`C:\…`, `C:/…`) or UNC path (`\\server`, `//server`),
158/// recognised lexically on every platform.
159pub fn is_windows_absolute_path(path: &str) -> bool {
160    let bytes = path.as_bytes();
161    if bytes.len() >= 3
162        && bytes[0].is_ascii_alphabetic()
163        && bytes[1] == b':'
164        && matches!(bytes[2], b'\\' | b'/')
165    {
166        return true;
167    }
168
169    path.starts_with("\\\\") || path.starts_with("//")
170}
171
172/// Lexical (string-only) part of [`normalize_tool_path`]: MSYS2 drive prefix
173/// (Windows hosts only), separators, double slashes, trailing slash. Performs
174/// **no** filesystem access, so it is safe on persisted paths in
175/// TCC-standalone processes (launchd daemon, #356) and as a dedupe key where
176/// symlink resolution is not worth a `realpath` per entry.
177pub fn normalize_tool_path_lexical(path: &str) -> String {
178    let mut p = match strip_verbatim_str(path) {
179        Some(stripped) => stripped,
180        None => path.to_string(),
181    };
182
183    if cfg!(windows)
184        && let Some(translated) = translate_msys_drive_prefix(&p)
185    {
186        p = translated;
187    } else if !cfg!(windows) {
188        p = unescape_shell_path(&p);
189    }
190
191    p = p.replace('\\', "/");
192
193    // Collapse double slashes (preserve UNC paths starting with //)
194    while p.contains("//") && !p.starts_with("//") {
195        p = p.replace("//", "/");
196    }
197
198    // Remove trailing slash (unless root like "/" or "C:/")
199    if p.len() > 1 && p.ends_with('/') && !p.ends_with(":/") {
200        p.pop();
201    }
202
203    p
204}
205
206/// Undo shell quoting that agents copy into tool paths on Unix: one pair of
207/// surrounding quotes (`'/a b/c'`) and backslash escapes (`My\ Project`,
208/// `src/\(app\)`). Without this the separator rewrite below turned
209/// `My\ Project` into `My/ Project` — a directory that does not exist, so
210/// every path below a folder with a space failed. A backslash is never a
211/// separator on Unix and practically never part of a file name; a backslash
212/// before an ordinary character is kept for the rewrite as before.
213fn unescape_shell_path(path: &str) -> String {
214    let trimmed = path.trim();
215    let unquoted = [('\'', '\''), ('"', '"')]
216        .iter()
217        .find_map(|&(open, close)| {
218            trimmed
219                .strip_prefix(open)
220                .and_then(|rest| rest.strip_suffix(close))
221                .filter(|inner| !inner.is_empty())
222        })
223        .unwrap_or(path);
224    if !unquoted.contains('\\') {
225        return unquoted.to_string();
226    }
227    let mut out = String::with_capacity(unquoted.len());
228    let mut chars = unquoted.chars().peekable();
229    while let Some(c) = chars.next() {
230        if c == '\\'
231            && let Some(&next) = chars.peek()
232            && matches!(
233                next,
234                ' ' | '('
235                    | ')'
236                    | '['
237                    | ']'
238                    | '{'
239                    | '}'
240                    | '&'
241                    | '\''
242                    | '"'
243                    | '$'
244                    | '!'
245                    | '#'
246                    | ';'
247                    | '*'
248                    | '?'
249                    | '<'
250                    | '>'
251                    | '|'
252                    | '~'
253                    | '`'
254                    | '='
255            )
256        {
257            out.push(next);
258            chars.next();
259            continue;
260        }
261        out.push(c);
262    }
263    out
264}
265
266/// Normalize paths from any client format to a consistent OS-native form.
267/// Handles MSYS2/Git Bash drive prefixes on Windows hosts
268/// (`/c/Users/...` -> `C:/Users/...`), mixed separators, double slashes, and
269/// trailing slashes. Uses forward slashes for consistency. On non-Windows
270/// hosts `/c/...` is a literal directory and passes through unchanged (#397).
271/// Whether two recorded project roots name the same project: equal after
272/// separator and trailing-slash normalization, or after resolving symlinks.
273/// The one comparison for project-scoped records (agents, messages, shares):
274/// a raw string comparison split one project into several by spelling.
275pub fn same_project(a: &str, b: &str) -> bool {
276    let lexical = |path: &str| {
277        let path = path.trim().replace('\\', "/");
278        let trimmed = path.trim_end_matches('/');
279        if trimmed.is_empty() {
280            path
281        } else {
282            trimmed.to_string()
283        }
284    };
285    a == b || lexical(a) == lexical(b) || normalize_tool_path(a) == normalize_tool_path(b)
286}
287
288pub fn normalize_tool_path(path: &str) -> String {
289    let mut p = normalize_tool_path_lexical(path);
290
291    // Resolve symlinks for absolute paths to ensure cache key consistency.
292    // Skip relative paths (preserve "." / "../" as-is), root-only paths (/ or C:/),
293    // slow mounts (WSL DrvFS /mnt/) where canonicalize can hang, and paths a
294    // TCC-standalone process must not stat (launchd daemon + ~/Documents, #356).
295    // Uses safe_canonicalize to strip Windows \\?\ prefix.
296    let is_absolute = p.starts_with('/') || (p.len() >= 3 && p.as_bytes()[1] == b':');
297    let is_root_only = p == "/" || (p.len() <= 3 && p.ends_with('/') && is_absolute);
298    if is_absolute
299        && !is_root_only
300        && !crate::core::io_health::is_slow_mount(&p)
301        && may_probe_path(Path::new(&*p))
302        && let Ok(canonical) = safe_canonicalize(Path::new(&*p))
303    {
304        let canonical_str = canonical.to_string_lossy().replace('\\', "/");
305        if !canonical_str.is_empty() {
306            p = canonical_str;
307        }
308    }
309
310    p
311}
312
313/// Agent/IDE CLI config or sandbox directories some clients launch their MCP
314/// server from, but which are never a user's project root. Adopting one as the
315/// project root jails every real repository path out — the root cause of #580
316/// (GitHub Copilot CLI launches from `~/.copilot`; Cursor, Windsurf, Gemini CLI
317/// and LM Studio behave similarly). This is the canonical set: every "is this an
318/// agent dir?" check across the codebase delegates here so the list never drifts.
319pub const AGENT_CONFIG_DIRS: &[&str] = &[
320    ".claude",
321    ".codex",
322    ".codebuddy",
323    ".codewhale",
324    ".copilot",
325    ".cursor",
326    ".windsurf",
327    ".gemini",
328    ".lmstudio",
329];
330
331/// Returns `true` if `dir` is — or lies inside — a known agent/IDE config dir
332/// ([`AGENT_CONFIG_DIRS`]). Separator-agnostic so Windows backslash paths
333/// (`C:\Users\me\.copilot`) match too; #580 is a Windows Copilot report.
334///
335/// A checkout inside `<dir>/worktrees/<name>` (where Claude Code places its
336/// git worktrees, e.g. `<repo>/.claude/worktrees/fix-x`) is a real project,
337/// not agent config, and is exempt; `<dir>/worktrees` itself is not.
338pub fn is_agent_config_dir(dir: &Path) -> bool {
339    let s = dir.to_string_lossy().replace('\\', "/");
340    AGENT_CONFIG_DIRS.iter().any(|name| {
341        let segment = format!("/{name}");
342        s.match_indices(&segment).any(|(at, _)| {
343            let tail = &s[at + segment.len()..];
344            (tail.is_empty() || tail.starts_with('/')) && !is_worktree_checkout_tail(tail)
345        })
346    })
347}
348
349/// `tail` (the path after an agent dir) names `/worktrees/<name>[/...]`.
350fn is_worktree_checkout_tail(tail: &str) -> bool {
351    tail.strip_prefix("/worktrees/")
352        .and_then(|rest| rest.split('/').next())
353        .is_some_and(|name| !name.is_empty())
354}
355
356fn is_wsl_windows_user_profile(dir: &Path) -> bool {
357    let normalized = dir.to_string_lossy();
358    if !normalized.starts_with("/mnt/") {
359        return false;
360    }
361
362    let mut components = normalized
363        .split('/')
364        .filter(|component| !component.is_empty());
365    let (Some(mount), Some(drive), Some(users), Some(username)) = (
366        components.next(),
367        components.next(),
368        components.next(),
369        components.next(),
370    ) else {
371        return false;
372    };
373
374    mount.eq_ignore_ascii_case("mnt")
375        && drive.len() == 1
376        && drive.as_bytes()[0].is_ascii_alphabetic()
377        && users.eq_ignore_ascii_case("users")
378        && !username.is_empty()
379        && components.next().is_none()
380}
381
382/// Every spelling of the user's home directory a root guard must refuse: the
383/// profile folder (`dirs::home_dir`, which on Windows ignores the
384/// environment) and the override-friendly home (`HOME`/`USERPROFILE`,
385/// [`crate::core::home::resolve_home_dir`]). On Unix both are `$HOME` and
386/// one entry remains.
387pub fn home_dirs() -> Vec<PathBuf> {
388    let mut homes: Vec<PathBuf> = Vec::with_capacity(2);
389    for home in [dirs::home_dir(), crate::core::home::resolve_home_dir()]
390        .into_iter()
391        .flatten()
392    {
393        if !homes.contains(&home) {
394            homes.push(home);
395        }
396    }
397    homes
398}
399
400/// A startup root the server must not adopt as a jail root: too broad
401/// ([`is_broad_or_unsafe_root`]) or an agent/IDE config directory.
402pub fn is_suspicious_root(dir: &Path) -> bool {
403    is_broad_or_unsafe_root(dir) || is_agent_config_dir(dir)
404}
405
406/// Returns `true` if the directory is too broad to be a valid project root.
407/// Rejects home directories (including WSL-mounted Windows profiles), filesystem
408/// root, `.` (bare CWD), agent/IDE config directories ([`AGENT_CONFIG_DIRS`])
409/// and LeanCTX's own stores ([`is_lean_ctx_store_dir`]). Used to prevent
410/// adopting a bogus project root and writing project-scoped data into the
411/// global `~/.lean-ctx/` data directory.
412pub fn is_broad_or_unsafe_root(dir: &Path) -> bool {
413    // Compared without the Windows verbatim `\\?\` prefix, which
414    // `std::fs::canonicalize` adds there: `\\?\C:\Users\me` is the home
415    // directory as much as `C:\Users\me`.
416    let plain = strip_verbatim(dir.to_path_buf());
417    if home_dirs()
418        .into_iter()
419        .any(|home| plain == strip_verbatim(home))
420    {
421        return true;
422    }
423    if is_wsl_windows_user_profile(dir) {
424        return true;
425    }
426    let s = dir.to_string_lossy();
427    if s == "/" || s == "\\" || s == "." {
428        return true;
429    }
430    is_agent_config_dir(dir) || is_lean_ctx_store_dir(dir)
431}
432
433/// `dir` is LeanCTX's data, state, config or cache directory itself. A CLI
434/// started there without a project (the Cockpit's fallback working directory)
435/// must never adopt it as a project. Folders inside a store stay allowed:
436/// setups and fixtures with `LEAN_CTX_DATA_DIR` in a temp dir keep their
437/// projects there. Resolved read-only: nothing is created. Both sides are
438/// compared as given and canonical, so another spelling of the same folder
439/// (a symlink, macOS `/var` → `/private/var`) matches too.
440pub fn is_lean_ctx_store_dir(dir: &Path) -> bool {
441    let real_dir = dir.canonicalize().ok();
442    [
443        crate::core::data_dir::resolve_data_dir(),
444        crate::core::paths::state_dir_read_only(),
445        crate::core::paths::config_dir_read_only(),
446        crate::core::paths::cache_dir_read_only(),
447    ]
448    .into_iter()
449    .flatten()
450    .filter(|store| store.is_absolute())
451    .any(|store| {
452        let real_store = store.canonicalize().ok();
453        dir == store
454            || real_store.as_deref() == Some(dir)
455            || (real_dir.is_some() && real_dir == real_store)
456    })
457}
458
459/// Well-known project markers used to identify project roots.
460pub const PROJECT_MARKERS: &[&str] = &[
461    ".git",
462    "Cargo.toml",
463    "package.json",
464    "go.mod",
465    "pyproject.toml",
466    "setup.py",
467    "pom.xml",
468    "build.gradle",
469    "Makefile",
470    "project.godot",
471    ".lean-ctx.toml",
472    ".planning",
473];
474
475/// Returns `true` if `dir` contains at least one known project marker.
476///
477/// TCC guard (#356): a launchd-owned process (daemon/proxy/auto-updater) must
478/// not stat marker files under `~/Documents` & co. — the probe itself pops the
479/// macOS privacy prompt. For those processes this conservatively reports
480/// "no marker" without touching the filesystem.
481pub fn has_project_marker(dir: &Path) -> bool {
482    if !may_probe_path(dir) {
483        return false;
484    }
485    PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
486}
487
488/// Returns `true` if the (lstat) metadata describes a symlink — or, on
489/// Windows, *any* reparse point (junctions, mount points, app-exec links).
490///
491/// Security boundaries must use this instead of `FileType::is_symlink`:
492/// Rust's `is_symlink()` reports `false` for NTFS junctions, which redirect
493/// exactly like directory symlinks and would otherwise bypass jail/TOCTOU
494/// checks on Windows (GL#442).
495pub fn is_symlink_or_reparse(meta: &std::fs::Metadata) -> bool {
496    if meta.file_type().is_symlink() {
497        return true;
498    }
499    #[cfg(windows)]
500    {
501        use std::os::windows::fs::MetadataExt;
502        const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
503        meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
504    }
505    #[cfg(not(windows))]
506    false
507}
508
509/// Returns `true` if `dir` is the home directory or one of the macOS "magic"
510/// home subdirectories (`Documents`, `Desktop`, `Downloads`).
511///
512/// macOS guards these with TCC: the first time a process *enumerates or stats
513/// inside* one, the OS pops a privacy prompt ("lean-ctx would like to access
514/// files in your Documents folder", #356). They are also never valid project
515/// roots or multi-repo workspace parents, so scan heuristics should treat them
516/// as off-limits *without* calling `read_dir` (which is what trips the prompt).
517pub fn is_tcc_sensitive_home_dir(dir: &Path) -> bool {
518    let Some(home) = dirs::home_dir() else {
519        return false;
520    };
521    if dir == home {
522        return true;
523    }
524    if dir.parent() != Some(home.as_path()) {
525        return false;
526    }
527    matches!(
528        dir.file_name().and_then(|n| n.to_str()),
529        Some("Documents" | "Desktop" | "Downloads")
530    )
531}
532
533/// Returns `true` if `path` lies inside (or is) one of the macOS TCC-protected
534/// home folders (`~/Documents`, `~/Desktop`, `~/Downloads`). Pure string/path
535/// comparison — performs **no** filesystem access itself.
536///
537/// Unlike [`is_tcc_sensitive_home_dir`] (which only matches the magic dirs
538/// themselves), this also matches nested paths like `~/Documents/proj/src`,
539/// because *any* `stat` below the magic dir trips the TCC prompt (#356).
540pub fn is_under_tcc_protected_dir(path: &Path) -> bool {
541    if !cfg!(target_os = "macos") {
542        return false;
543    }
544    let Some(home) = dirs::home_dir() else {
545        return false;
546    };
547    ["Documents", "Desktop", "Downloads"]
548        .iter()
549        .any(|magic| path.starts_with(home.join(magic)))
550}
551
552/// Returns `true` when this process is its own TCC identity on macOS — i.e.
553/// it was started (or re-parented) by `launchd` rather than by a
554/// TCC-granted host like a terminal or an editor.
555///
556/// Context (#356): TCC permissions attach to the *responsible process*. The
557/// lean-ctx daemon/proxy LaunchAgents and the scheduled auto-updater run
558/// directly under `launchd` (ppid 1), so any `stat`/`read_dir` they perform
559/// under `~/Documents` pops the privacy prompt **in lean-ctx's own name** —
560/// and because every release replaces the ad-hoc-signed binary (new cdhash),
561/// a previously granted permission is invalidated on each update, re-prompting
562/// forever. Such processes must never probe TCC-protected paths on their own
563/// initiative. Child processes of a terminal or editor (MCP server, CLI)
564/// inherit their host's TCC grant and keep full functionality.
565pub fn process_is_tcc_standalone() -> bool {
566    #[cfg(target_os = "macos")]
567    {
568        // Deliberately uncached: getppid is a cheap syscall, the env override
569        // must stay testable within one process, and a daemonizing fork could
570        // change the answer after startup.
571        if let Ok(v) = std::env::var("LEAN_CTX_TCC_STANDALONE") {
572            match v.trim() {
573                "1" | "true" => return true,
574                "0" | "false" => return false,
575                _ => {}
576            }
577        }
578        // A process carrying the deny-~/Documents seatbelt sentinel is, by
579        // construction, a launchd-standalone descendant: the sentinel is set
580        // only by the LaunchAgent plist env and the self re-exec, and child
581        // processes inherit it. This catches a daemon the long-lived standalone
582        // proxy spawned via `start_daemon` (ppid = proxy, not 1), whose code-side
583        // path guards would otherwise stay off because `getppid()` is no longer
584        // 1. (#356)
585        if std::env::var_os(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL).is_some() {
586            return true;
587        }
588        // SAFETY: `getppid` takes no arguments and cannot fail.
589        (unsafe { libc::getppid() }) == 1
590    }
591    #[cfg(not(target_os = "macos"))]
592    {
593        false
594    }
595}
596
597/// Returns `true` when this process may `stat`/`read_dir`/`canonicalize`
598/// `path` without risking a macOS TCC privacy prompt in lean-ctx's name.
599///
600/// Heuristic call sites (project-marker probes, session/root matching) must
601/// consult this before touching paths from persisted state; security
602/// boundaries (PathJail) are exempt — they only ever canonicalize paths the
603/// client explicitly asked to access, in which case a prompt is legitimate.
604pub fn may_probe_path(path: &Path) -> bool {
605    !(process_is_tcc_standalone() && is_under_tcc_protected_dir(path))
606}
607
608/// Returns `true` if `dir` is a multi-repo workspace parent — i.e. it has at
609/// least 2 immediate child directories that each contain a project marker.
610pub fn has_multi_repo_children(dir: &Path) -> bool {
611    // Never enumerate the home dir or macOS TCC-protected dirs: read_dir there
612    // pops a macOS privacy prompt (#356) and they are never workspace parents.
613    // `is_tcc_sensitive_home_dir` only matches the magic dirs themselves;
614    // `!may_probe_path` additionally refuses *nested* paths like
615    // `~/Documents/proj` when this process is launchd-standalone.
616    if is_tcc_sensitive_home_dir(dir) || !may_probe_path(dir) {
617        return false;
618    }
619    let Ok(entries) = std::fs::read_dir(dir) else {
620        return false;
621    };
622    let count = entries
623        .filter_map(Result::ok)
624        .filter(|e| e.file_type().is_ok_and(|ft| ft.is_dir()))
625        .filter(|e| has_project_marker(&e.path()))
626        .take(2)
627        .count();
628    count >= 2
629}
630
631/// Returns `true` if `project_root` collides with the lean-ctx data directory.
632/// This prevents project-scoped files (overlays.json, policies.json) from being
633/// written into `~/.lean-ctx/` or `~/.config/lean-ctx/`.
634pub fn is_data_dir_collision(project_root: &Path) -> bool {
635    if is_broad_or_unsafe_root(project_root) {
636        return true;
637    }
638    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
639        let project_lean_ctx = project_root.join(".lean-ctx");
640        if project_lean_ctx == data_dir || data_dir.starts_with(&project_lean_ctx) {
641            return true;
642        }
643    }
644    false
645}
646
647/// Returns the project-scoped `.lean-ctx/` directory if the project root is safe.
648/// Returns `Err` if the project root collides with the global data directory.
649pub fn safe_project_data_dir(project_root: &Path) -> Result<PathBuf, String> {
650    if is_data_dir_collision(project_root) {
651        return Err(format!(
652            "project root {} collides with global data directory; \
653             skipping project-scoped write",
654            project_root.display()
655        ));
656    }
657    Ok(project_root.join(".lean-ctx"))
658}
659
660/// First executable named `name` on `PATH`, resolved in-process.
661///
662/// Replaces spawning `where`/`which`: on Windows every spawn from the
663/// console-less daemon allocates a `conhost.exe`, and detection runs often.
664/// Windows tries each `PATHEXT` extension before the bare name, as `cmd`
665/// does when it runs a command, so an npm install yields `lean-ctx.cmd` rather
666/// than the extensionless shell shim beside it. Unix requires an execute bit,
667/// like `which`.
668pub fn find_on_path(name: &str) -> Option<PathBuf> {
669    find_all_on_path(name).into_iter().next()
670}
671
672/// Every executable named `name` on `PATH`, in lookup order: the one
673/// [`find_on_path`] returns first, then the ones it shadows.
674pub fn find_all_on_path(name: &str) -> Vec<PathBuf> {
675    if name.is_empty() {
676        return Vec::new();
677    }
678    let Some(path_var) = std::env::var_os("PATH") else {
679        return Vec::new();
680    };
681    let candidates = path_candidates(name);
682    std::env::split_paths(&path_var)
683        .filter(|dir| !dir.as_os_str().is_empty())
684        .flat_map(|dir| candidates.iter().map(move |c| dir.join(c)))
685        .filter(|p| is_executable_file(p))
686        .collect()
687}
688
689#[cfg(windows)]
690fn path_candidates(name: &str) -> Vec<String> {
691    let mut out = Vec::new();
692    if Path::new(name).extension().is_none() {
693        let exts = std::env::var("PATHEXT").unwrap_or_default();
694        let exts = if exts.trim().is_empty() {
695            ".COM;.EXE;.BAT;.CMD".to_string()
696        } else {
697            exts
698        };
699        out.extend(
700            exts.split(';')
701                .map(str::trim)
702                .filter(|e| !e.is_empty())
703                .map(|e| format!("{name}{e}")),
704        );
705    }
706    out.push(name.to_string());
707    out
708}
709
710#[cfg(not(windows))]
711fn path_candidates(name: &str) -> Vec<String> {
712    vec![name.to_string()]
713}
714
715#[cfg(unix)]
716fn is_executable_file(p: &Path) -> bool {
717    use std::os::unix::fs::PermissionsExt;
718    std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
719}
720
721#[cfg(not(unix))]
722fn is_executable_file(p: &Path) -> bool {
723    p.is_file()
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729
730    #[test]
731    fn find_on_path_resolves_extensionless_name_and_misses_unknown() {
732        let _env_lock = crate::core::data_dir::test_env_lock();
733        let tmp = tempfile::tempdir().unwrap();
734        #[cfg(windows)]
735        let file_name = "leanctx-fop-probe.cmd";
736        #[cfg(not(windows))]
737        let file_name = "leanctx-fop-probe";
738        let file = tmp.path().join(file_name);
739        std::fs::write(&file, "x").unwrap();
740        // An npm install puts an extensionless shell shim beside the `.cmd`;
741        // Windows must resolve to the runnable `.cmd`, not the shim.
742        #[cfg(windows)]
743        std::fs::write(tmp.path().join("leanctx-fop-probe"), "#!/bin/sh").unwrap();
744        #[cfg(unix)]
745        {
746            use std::os::unix::fs::PermissionsExt;
747            std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o755)).unwrap();
748        }
749
750        let old = std::env::var_os("PATH");
751        let mut dirs = vec![tmp.path().to_path_buf()];
752        dirs.extend(old.iter().flat_map(std::env::split_paths));
753        // SAFETY: serialized by test_env_lock; restored below.
754        unsafe { std::env::set_var("PATH", std::env::join_paths(dirs).unwrap()) };
755        let found = find_on_path("leanctx-fop-probe");
756        let missing = find_on_path("leanctx-fop-definitely-missing");
757        match old {
758            // SAFETY: still serialized by test_env_lock; restores the saved PATH.
759            Some(v) => unsafe { std::env::set_var("PATH", v) },
760            // SAFETY: still serialized by test_env_lock; PATH was unset before.
761            None => unsafe { std::env::remove_var("PATH") },
762        }
763
764        let found = found.expect("probe on PATH");
765        assert!(
766            found
767                .to_string_lossy()
768                .eq_ignore_ascii_case(&file.to_string_lossy()),
769            "{found:?} vs {file:?}"
770        );
771        assert!(missing.is_none());
772    }
773
774    /// AP04 review I1: project-scoped records compare roots by project, not
775    /// by spelling — a symlinked root, a trailing slash and Windows
776    /// separators name the same project; another directory does not.
777    #[cfg(unix)]
778    #[test]
779    fn same_project_ignores_spelling_but_not_identity() {
780        let tmp = tempfile::tempdir().unwrap();
781        let real = tmp.path().join("repo");
782        let other = tmp.path().join("other");
783        std::fs::create_dir_all(&real).unwrap();
784        std::fs::create_dir_all(&other).unwrap();
785        let link = tmp.path().join("link");
786        std::os::unix::fs::symlink(&real, &link).unwrap();
787        let (real, link, other) = (
788            real.to_string_lossy().to_string(),
789            link.to_string_lossy().to_string(),
790            other.to_string_lossy().to_string(),
791        );
792
793        assert!(same_project(&real, &link));
794        assert!(same_project(&real, &format!("{real}/")));
795        assert!(same_project(r"C:\work\repo", "C:/work/repo/"));
796        assert!(!same_project(&real, &other));
797    }
798
799    #[test]
800    fn lean_ctx_stores_are_never_a_project_root() {
801        let iso = crate::core::data_dir::isolated_data_dir();
802        let project = tempfile::tempdir().unwrap();
803        // The Cockpit's fallback working directory itself.
804        assert!(is_broad_or_unsafe_root(iso.path()));
805        // A fixture project inside a temp data dir stays a project.
806        assert!(!is_broad_or_unsafe_root(&iso.path().join("proj")));
807        assert!(!is_broad_or_unsafe_root(project.path()));
808    }
809
810    #[cfg(unix)]
811    #[test]
812    fn a_store_under_another_spelling_is_still_refused() {
813        let iso = crate::core::data_dir::isolated_data_dir();
814        let links = tempfile::tempdir().unwrap();
815        let alias = links.path().join("data-alias");
816        std::os::unix::fs::symlink(iso.path(), &alias).unwrap();
817        assert!(is_broad_or_unsafe_root(&alias));
818        // A folder that only links into the store is not the store.
819        let inner = iso.path().join("proj");
820        std::fs::create_dir_all(&inner).unwrap();
821        let inner_alias = links.path().join("proj-alias");
822        std::os::unix::fs::symlink(&inner, &inner_alias).unwrap();
823        assert!(!is_broad_or_unsafe_root(&inner_alias));
824    }
825
826    #[test]
827    fn strip_regular_verbatim() {
828        let p = PathBuf::from(r"\\?\C:\Users\dev\project");
829        let result = strip_verbatim(p);
830        assert_eq!(result, PathBuf::from("C:/Users/dev/project"));
831    }
832
833    #[test]
834    fn tcc_sensitive_home_dir_matches_home_and_magic_dirs() {
835        // HOME is read here and again inside the check; tests that set HOME
836        // hold the env lock, so holding it keeps both reads the same.
837        let _env_lock = crate::core::data_dir::test_env_lock();
838        let Some(home) = dirs::home_dir() else {
839            return;
840        };
841        // Home itself and the macOS magic dirs are off-limits (#356).
842        assert!(is_tcc_sensitive_home_dir(&home));
843        assert!(is_tcc_sensitive_home_dir(&home.join("Documents")));
844        assert!(is_tcc_sensitive_home_dir(&home.join("Desktop")));
845        assert!(is_tcc_sensitive_home_dir(&home.join("Downloads")));
846    }
847
848    #[test]
849    fn tcc_sensitive_home_dir_allows_real_projects() {
850        let _env_lock = crate::core::data_dir::test_env_lock();
851        let Some(home) = dirs::home_dir() else {
852            return;
853        };
854        // A real project (even nested under Documents) and non-magic home children
855        // are scannable — only the bare magic dirs / home are blocked.
856        assert!(!is_tcc_sensitive_home_dir(
857            &home.join("Documents").join("my-project")
858        ));
859        assert!(!is_tcc_sensitive_home_dir(&home.join("code")));
860        assert!(!is_tcc_sensitive_home_dir(&home.join("Projects")));
861    }
862
863    #[test]
864    #[cfg(target_os = "macos")]
865    fn under_tcc_protected_dir_matches_nested_paths() {
866        let _env_lock = crate::core::data_dir::test_env_lock();
867        let Some(home) = dirs::home_dir() else {
868            return;
869        };
870        // The magic dirs themselves and anything nested below them (#356).
871        assert!(is_under_tcc_protected_dir(&home.join("Documents")));
872        assert!(is_under_tcc_protected_dir(
873            &home.join("Documents/deep/nested/project")
874        ));
875        assert!(is_under_tcc_protected_dir(&home.join("Desktop/scratch")));
876        assert!(is_under_tcc_protected_dir(&home.join("Downloads/x.zip")));
877        // Home itself, siblings, and non-home paths are fine.
878        assert!(!is_under_tcc_protected_dir(&home));
879        assert!(!is_under_tcc_protected_dir(&home.join("code/project")));
880        assert!(!is_under_tcc_protected_dir(Path::new("/tmp/Documents")));
881    }
882
883    #[test]
884    #[cfg(target_os = "macos")]
885    #[serial_test::serial]
886    fn tcc_standalone_blocks_probes_under_protected_dirs() {
887        // `#[serial]` and `test_env_lock` are two different mutexes; the tests
888        // in this module need both. `LEAN_CTX_TCC_STANDALONE` is read by
889        // `is_tcc_standalone` (line ~376) on every path normalization, so
890        // setting it here reaches any test that resolves a path — most of which
891        // serialize on `test_env_lock`, not on `#[serial]`.
892        let _env_lock = crate::core::data_dir::test_env_lock();
893        let Some(home) = dirs::home_dir() else {
894            return;
895        };
896        let doc_proj = home.join("Documents/some-project");
897
898        crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
899        assert!(process_is_tcc_standalone());
900        assert!(!may_probe_path(&doc_proj));
901        // Non-protected paths stay probeable even for standalone processes.
902        assert!(may_probe_path(Path::new("/tmp/some-project")));
903        // has_project_marker must refuse without touching the filesystem.
904        assert!(!has_project_marker(&doc_proj));
905
906        crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
907        assert!(!process_is_tcc_standalone());
908        assert!(may_probe_path(&doc_proj));
909        crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
910    }
911
912    #[test]
913    #[cfg(target_os = "macos")]
914    #[serial_test::serial]
915    fn tcc_standalone_detected_via_seatbelt_sentinel() {
916        let _env_lock = crate::core::data_dir::test_env_lock();
917        let Some(home) = dirs::home_dir() else {
918            return;
919        };
920        let doc_proj = home.join("Documents/some-project");
921
922        // No explicit override: a process carrying the deny-~/Documents seatbelt
923        // sentinel (inherited from its sandboxed launchd parent) counts as
924        // standalone even when ppid != 1, so its heuristic probes stay
925        // suppressed — this is the proxy→daemon chain the ppid check missed. (#356)
926        crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
927        crate::test_env::set_var(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL, "1");
928        assert!(process_is_tcc_standalone());
929        assert!(!may_probe_path(&doc_proj));
930        crate::test_env::remove_var(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL);
931
932        // With neither override nor sentinel a normal test process (ppid != 1)
933        // is not standalone, so the sentinel is what flipped the result above.
934        assert!(!process_is_tcc_standalone());
935    }
936
937    #[test]
938    #[cfg(target_os = "macos")]
939    #[serial_test::serial]
940    fn tcc_standalone_skips_canonicalize_under_protected_dirs() {
941        let _env_lock = crate::core::data_dir::test_env_lock();
942        let Some(home) = dirs::home_dir() else {
943            return;
944        };
945        // A path that does NOT exist under ~/Documents. With the TCC choke-point
946        // guard active, `safe_canonicalize` returns Ok(input) *without* calling
947        // `std::fs::canonicalize` (which would Err on a missing path) — proving
948        // the filesystem is never touched (#356). This is the structural fix:
949        // every heuristic canonicalize funnels through here.
950        let missing = home.join("Documents/lean-ctx-tcc-test-does-not-exist-xyzzy");
951
952        crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
953        let guarded = safe_canonicalize(&missing);
954        assert!(
955            guarded.is_ok(),
956            "standalone safe_canonicalize must short-circuit (no stat) under ~/Documents"
957        );
958        assert_eq!(guarded.unwrap(), missing);
959        assert_eq!(safe_canonicalize_or_self(&missing), missing);
960
961        // Outside the protected dirs the guard never engages, even when standalone.
962        let tmp_missing = Path::new("/tmp/lean-ctx-tcc-test-does-not-exist-xyzzy");
963        assert!(safe_canonicalize(tmp_missing).is_err());
964
965        // Without standalone the guard is inactive: a missing ~/Documents path
966        // Errs from the real `std::fs::canonicalize` as before.
967        crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
968        assert!(safe_canonicalize(&missing).is_err());
969
970        crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
971    }
972
973    #[test]
974    #[cfg(target_os = "macos")]
975    #[serial_test::serial]
976    fn canonicalize_secure_bypasses_tcc_guard_for_pathjail() {
977        let _env_lock = crate::core::data_dir::test_env_lock();
978        // SECURITY counterpart to the test above (#356): PathJail must keep
979        // resolving symlinks even when standalone under ~/Documents, so the jail
980        // can detect escapes. `canonicalize_secure` therefore must NOT honour the
981        // guard — it always touches the filesystem. We prove that by feeding a
982        // missing ~/Documents path while standalone: the guarded path returns
983        // Ok(lexical) (no stat), while the secure path Errs (it did stat).
984        let Some(home) = dirs::home_dir() else {
985            return;
986        };
987        let missing = home.join("Documents/lean-ctx-secure-canon-does-not-exist-xyzzy");
988
989        crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
990        // Guarded sink short-circuits (no fs access).
991        assert_eq!(safe_canonicalize(&missing).unwrap(), missing);
992        // Security sink ignores the guard and actually stats -> Err on a missing
993        // path. If this ever returns Ok(lexical), the jail's symlink-escape
994        // detection has silently regressed under ~/Documents.
995        assert!(
996            canonicalize_secure(&missing).is_err(),
997            "canonicalize_secure must bypass the TCC guard and touch the filesystem"
998        );
999        assert_eq!(canonicalize_secure_or_self(&missing), missing);
1000        crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
1001    }
1002
1003    #[test]
1004    fn strip_unc_verbatim() {
1005        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
1006        let result = strip_verbatim(p);
1007        assert_eq!(result, PathBuf::from("//server/share/dir"));
1008    }
1009
1010    #[test]
1011    fn no_prefix_unchanged() {
1012        let p = PathBuf::from("/home/user/project");
1013        let result = strip_verbatim(p.clone());
1014        assert_eq!(result, p);
1015    }
1016
1017    #[test]
1018    fn windows_drive_unchanged() {
1019        let p = PathBuf::from("C:/Users/dev");
1020        let result = strip_verbatim(p.clone());
1021        assert_eq!(result, p);
1022    }
1023
1024    #[test]
1025    fn strip_str_regular() {
1026        assert_eq!(
1027            strip_verbatim_str(r"\\?\E:\code\lean-ctx"),
1028            Some("E:/code/lean-ctx".to_string())
1029        );
1030    }
1031
1032    #[test]
1033    fn strip_str_unc() {
1034        assert_eq!(
1035            strip_verbatim_str(r"\\?\UNC\myserver\data"),
1036            Some("//myserver/data".to_string())
1037        );
1038    }
1039
1040    #[test]
1041    fn strip_str_forward_slash_variant() {
1042        assert_eq!(
1043            strip_verbatim_str("//?/C:/Users/dev"),
1044            Some("C:/Users/dev".to_string())
1045        );
1046    }
1047
1048    #[test]
1049    fn strip_str_no_prefix() {
1050        assert_eq!(strip_verbatim_str("/home/user"), None);
1051    }
1052
1053    #[test]
1054    fn safe_canonicalize_or_self_nonexistent() {
1055        let p = Path::new("/this/path/should/not/exist/xyzzy");
1056        let result = safe_canonicalize_or_self(p);
1057        assert_eq!(result, p.to_path_buf());
1058    }
1059
1060    // The drive translation itself is platform-independent and testable
1061    // everywhere; only its *application* is gated on Windows hosts (#397).
1062    #[test]
1063    fn msys_drive_prefix_translation() {
1064        assert_eq!(
1065            translate_msys_drive_prefix("/c/Users/ABC").as_deref(),
1066            Some("C:/Users/ABC")
1067        );
1068        assert_eq!(
1069            translate_msys_drive_prefix("/D/Program Files").as_deref(),
1070            Some("D:/Program Files")
1071        );
1072        assert_eq!(translate_msys_drive_prefix("/usr/local/bin"), None);
1073        assert_eq!(translate_msys_drive_prefix("/c"), None);
1074        assert_eq!(translate_msys_drive_prefix("c/Users"), None);
1075    }
1076
1077    #[cfg(windows)]
1078    #[test]
1079    fn normalize_msys_path_to_native() {
1080        assert_eq!(
1081            normalize_tool_path("/c/Users/ABC/AppData/lean-ctx"),
1082            "C:/Users/ABC/AppData/lean-ctx"
1083        );
1084        assert_eq!(
1085            normalize_tool_path("/D/Program Files/lean-ctx.exe"),
1086            "D:/Program Files/lean-ctx.exe"
1087        );
1088    }
1089
1090    // GH #397: on Linux/macOS, /c/… is a literal directory — a Linux project
1091    // rooted there must not be rewritten to a Windows drive path.
1092    #[cfg(not(windows))]
1093    #[test]
1094    fn normalize_single_letter_unix_path_untouched() {
1095        assert_eq!(
1096            normalize_tool_path_lexical("/c/Users/me/proj"),
1097            "/c/Users/me/proj"
1098        );
1099        assert_eq!(
1100            normalize_tool_path_lexical("/x/projects/app/src"),
1101            "/x/projects/app/src"
1102        );
1103    }
1104
1105    #[test]
1106    fn normalize_native_windows_path_unchanged() {
1107        assert_eq!(
1108            normalize_tool_path("C:/Users/ABC/lean-ctx.exe"),
1109            "C:/Users/ABC/lean-ctx.exe"
1110        );
1111    }
1112
1113    #[test]
1114    fn normalize_backslash_windows_path() {
1115        assert_eq!(
1116            normalize_tool_path(r"C:\Users\ABC\lean-ctx.exe"),
1117            "C:/Users/ABC/lean-ctx.exe"
1118        );
1119    }
1120
1121    #[test]
1122    fn normalize_unix_path_unchanged() {
1123        assert_eq!(
1124            normalize_tool_path("/usr/local/bin/lean-ctx"),
1125            "/usr/local/bin/lean-ctx"
1126        );
1127    }
1128
1129    #[cfg(not(windows))]
1130    #[test]
1131    fn normalize_unescapes_shell_quoting_on_unix() {
1132        assert_eq!(
1133            normalize_tool_path_lexical(r"/Users/me/Pfadi\ Scouting/app/main.ts"),
1134            "/Users/me/Pfadi Scouting/app/main.ts",
1135            "an escaped space must not become a separator"
1136        );
1137        assert_eq!(
1138            normalize_tool_path_lexical(r"/srv/\(group\)/a\&b.txt"),
1139            "/srv/(group)/a&b.txt"
1140        );
1141        assert_eq!(
1142            normalize_tool_path_lexical("'/Users/me/My Project/src'"),
1143            "/Users/me/My Project/src"
1144        );
1145        assert_eq!(normalize_tool_path_lexical("\"/tmp/a b\""), "/tmp/a b");
1146        assert_eq!(
1147            normalize_tool_path_lexical("'"),
1148            "'",
1149            "a lone quote is a name"
1150        );
1151    }
1152
1153    #[test]
1154    fn normalize_windows_path_with_spaces_and_backslashes() {
1155        // The exact "paths with spaces" scenario reported on Windows (#324):
1156        // backslashes are converted to forward slashes (so client render layers
1157        // never escape-mangle them) while spaces in directory names survive.
1158        assert_eq!(
1159            normalize_tool_path(r"C:\Users\My Name\My Project\src\main.rs"),
1160            "C:/Users/My Name/My Project/src/main.rs"
1161        );
1162        assert_eq!(
1163            normalize_tool_path(r"C:\Program Files\app\config.toml"),
1164            "C:/Program Files/app/config.toml"
1165        );
1166    }
1167
1168    #[test]
1169    fn normalize_double_slashes() {
1170        assert_eq!(
1171            normalize_tool_path("C:/Users//ABC//lean-ctx"),
1172            "C:/Users/ABC/lean-ctx"
1173        );
1174    }
1175
1176    #[test]
1177    fn normalize_trailing_slash_removed() {
1178        assert_eq!(normalize_tool_path("C:/Users/ABC/"), "C:/Users/ABC");
1179        assert_eq!(
1180            normalize_tool_path_lexical("/tmp/nonexistent-dir-xyzzy/"),
1181            "/tmp/nonexistent-dir-xyzzy"
1182        );
1183    }
1184
1185    #[test]
1186    fn normalize_root_slash_preserved() {
1187        assert_eq!(normalize_tool_path("/"), "/");
1188    }
1189
1190    #[test]
1191    fn normalize_drive_root_preserved() {
1192        assert_eq!(normalize_tool_path("C:/"), "C:/");
1193    }
1194
1195    #[test]
1196    fn normalize_verbatim_with_msys() {
1197        assert_eq!(normalize_tool_path(r"\\?\C:\Users\dev"), "C:/Users/dev");
1198    }
1199
1200    #[test]
1201    fn broad_root_rejects_home() {
1202        let _home = crate::core::data_dir::test_env_lock();
1203        if let Some(home) = dirs::home_dir() {
1204            assert!(is_broad_or_unsafe_root(&home));
1205        }
1206    }
1207
1208    #[test]
1209    fn broad_root_rejects_wsl_windows_user_profile() {
1210        assert!(is_broad_or_unsafe_root(Path::new("/mnt/c/Users/dev")));
1211        assert!(!is_broad_or_unsafe_root(Path::new(
1212            "/mnt/c/Users/dev/projects/my-app"
1213        )));
1214    }
1215
1216    #[test]
1217    fn broad_root_rejects_filesystem_root() {
1218        assert!(is_broad_or_unsafe_root(Path::new("/")));
1219    }
1220
1221    #[test]
1222    fn broad_root_rejects_dot() {
1223        assert!(is_broad_or_unsafe_root(Path::new(".")));
1224    }
1225
1226    #[test]
1227    fn broad_root_rejects_agent_dirs() {
1228        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.claude")));
1229        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.codex")));
1230    }
1231
1232    #[test]
1233    fn broad_root_rejects_copilot_and_friends() {
1234        // #580: the previously-missing agent/IDE dirs must now be rejected so
1235        // they are never adopted as the project root (the Copilot CLI case).
1236        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.copilot")));
1237        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.cursor")));
1238        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.windsurf")));
1239        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.gemini")));
1240        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.lmstudio")));
1241    }
1242
1243    #[test]
1244    fn agent_config_dir_matches_every_known_client() {
1245        for name in AGENT_CONFIG_DIRS {
1246            let leaf = format!("/home/user/{name}");
1247            assert!(is_agent_config_dir(Path::new(&leaf)), "{leaf}");
1248            let nested = format!("/home/user/{name}/mcp");
1249            assert!(is_agent_config_dir(Path::new(&nested)), "{nested}");
1250        }
1251    }
1252
1253    #[test]
1254    fn claude_code_worktree_checkout_is_not_agent_config() {
1255        // Claude Code puts git worktrees at `<repo>/.claude/worktrees/<name>`;
1256        // those are the user's project, not agent config (#1903 regression).
1257        for p in [
1258            "/repo/.claude/worktrees/fix-x",
1259            "/repo/.claude/worktrees/fix-x/rust/src",
1260            r"C:\repo\.claude\worktrees\fix-x",
1261        ] {
1262            assert!(!is_agent_config_dir(Path::new(p)), "{p}");
1263            assert!(!is_broad_or_unsafe_root(Path::new(p)), "{p}");
1264        }
1265        for p in [
1266            "/repo/.claude/worktrees",
1267            "/repo/.claude/worktrees/",
1268            "/repo/.claude/settings",
1269            "/repo/.claude/worktrees/fix-x/.claude",
1270            "/repo/.claude/worktrees/fix-x/.codex/mcp",
1271            "/home/user/.claude/worktreesx/a",
1272        ] {
1273            assert!(is_agent_config_dir(Path::new(p)), "{p}");
1274        }
1275        assert!(!is_agent_config_dir(Path::new("/repo/.claudex/a")));
1276    }
1277
1278    #[test]
1279    fn agent_config_dir_matches_windows_backslash() {
1280        // #580 is a Windows Copilot report — backslash paths must match too.
1281        assert!(is_agent_config_dir(Path::new(r"C:\Users\me\.copilot")));
1282        assert!(is_agent_config_dir(Path::new(r"C:\Users\me\.copilot\mcp")));
1283    }
1284
1285    #[test]
1286    fn agent_config_dir_ignores_real_projects() {
1287        assert!(!is_agent_config_dir(Path::new("/home/user/code/lean-ctx")));
1288        assert!(!is_agent_config_dir(Path::new(r"C:\src\app")));
1289    }
1290
1291    #[test]
1292    fn broad_root_allows_project_subdir() {
1293        let tmp = tempfile::tempdir().unwrap();
1294        let subdir = tmp.path().join("my-project");
1295        std::fs::create_dir_all(&subdir).unwrap();
1296        assert!(!is_broad_or_unsafe_root(&subdir));
1297    }
1298
1299    #[test]
1300    fn broad_root_allows_home_subdirs() {
1301        let _home = crate::core::data_dir::test_env_lock();
1302        if let Some(home) = dirs::home_dir() {
1303            let subdir = home.join("projects").join("my-app");
1304            assert!(!is_broad_or_unsafe_root(&subdir));
1305        }
1306    }
1307
1308    #[test]
1309    fn data_dir_collision_rejects_home() {
1310        let _home = crate::core::data_dir::test_env_lock();
1311        if let Some(home) = dirs::home_dir() {
1312            assert!(is_data_dir_collision(&home));
1313        }
1314    }
1315
1316    #[test]
1317    fn data_dir_collision_allows_normal_project() {
1318        let tmp = tempfile::tempdir().unwrap();
1319        let project = tmp.path().join("my-project");
1320        std::fs::create_dir_all(&project).unwrap();
1321        assert!(!is_data_dir_collision(&project));
1322    }
1323
1324    #[test]
1325    fn has_project_marker_detects_git() {
1326        let tmp = tempfile::tempdir().unwrap();
1327        let root = tmp.path().join("repo");
1328        std::fs::create_dir_all(&root).unwrap();
1329        assert!(!has_project_marker(&root));
1330        std::fs::create_dir(root.join(".git")).unwrap();
1331        assert!(has_project_marker(&root));
1332    }
1333
1334    #[test]
1335    fn has_project_marker_detects_cargo_toml() {
1336        let tmp = tempfile::tempdir().unwrap();
1337        let root = tmp.path().join("rust-project");
1338        std::fs::create_dir_all(&root).unwrap();
1339        std::fs::write(root.join("Cargo.toml"), "[package]").unwrap();
1340        assert!(has_project_marker(&root));
1341    }
1342
1343    #[test]
1344    fn has_project_marker_detects_godot_project() {
1345        let tmp = tempfile::tempdir().unwrap();
1346        let root = tmp.path().join("godot-game");
1347        std::fs::create_dir_all(&root).unwrap();
1348        std::fs::write(root.join("project.godot"), "config_version=5\n").unwrap();
1349        assert!(has_project_marker(&root));
1350    }
1351
1352    #[test]
1353    fn multi_repo_children_needs_two() {
1354        let tmp = tempfile::tempdir().unwrap();
1355        let parent = tmp.path().join("code");
1356        std::fs::create_dir_all(&parent).unwrap();
1357
1358        // 0 repos → false
1359        assert!(!has_multi_repo_children(&parent));
1360
1361        // 1 repo → false
1362        let repo1 = parent.join("repo1");
1363        std::fs::create_dir_all(repo1.join(".git")).unwrap();
1364        assert!(!has_multi_repo_children(&parent));
1365
1366        // 2 repos → true
1367        let repo2 = parent.join("repo2");
1368        std::fs::create_dir_all(repo2.join(".git")).unwrap();
1369        assert!(has_multi_repo_children(&parent));
1370    }
1371
1372    #[test]
1373    fn multi_repo_children_ignores_files() {
1374        let tmp = tempfile::tempdir().unwrap();
1375        let parent = tmp.path().join("mixed");
1376        std::fs::create_dir_all(&parent).unwrap();
1377
1378        // One repo dir + one plain file with .git name (not a dir)
1379        let repo1 = parent.join("repo1");
1380        std::fs::create_dir_all(repo1.join(".git")).unwrap();
1381        std::fs::write(parent.join("not-a-repo"), "file").unwrap();
1382        assert!(!has_multi_repo_children(&parent));
1383
1384        // Add second actual repo
1385        let repo2 = parent.join("repo2");
1386        std::fs::create_dir_all(&repo2).unwrap();
1387        std::fs::write(repo2.join("package.json"), "{}").unwrap();
1388        assert!(has_multi_repo_children(&parent));
1389    }
1390
1391    #[test]
1392    fn multi_repo_children_nonexistent_dir() {
1393        assert!(!has_multi_repo_children(Path::new("/nonexistent/path/xyz")));
1394    }
1395
1396    #[test]
1397    fn regular_file_is_not_symlink_or_reparse() {
1398        let tmp = tempfile::tempdir().unwrap();
1399        let file = tmp.path().join("plain.txt");
1400        std::fs::write(&file, "x").unwrap();
1401        let meta = std::fs::symlink_metadata(&file).unwrap();
1402        assert!(!is_symlink_or_reparse(&meta));
1403    }
1404
1405    #[cfg(unix)]
1406    #[test]
1407    fn unix_symlink_is_detected() {
1408        let tmp = tempfile::tempdir().unwrap();
1409        let target = tmp.path().join("target.txt");
1410        std::fs::write(&target, "x").unwrap();
1411        let link = tmp.path().join("link.txt");
1412        std::os::unix::fs::symlink(&target, &link).unwrap();
1413        let meta = std::fs::symlink_metadata(&link).unwrap();
1414        assert!(is_symlink_or_reparse(&meta));
1415    }
1416
1417    /// Runs in the windows-latest CI lane (GL#442). Symlink creation needs
1418    /// either admin or Developer Mode — skip gracefully when unavailable.
1419    #[cfg(windows)]
1420    #[test]
1421    fn windows_symlink_is_detected() {
1422        let tmp = tempfile::tempdir().unwrap();
1423        let target = tmp.path().join("target.txt");
1424        std::fs::write(&target, "x").unwrap();
1425        let link = tmp.path().join("link.txt");
1426        if std::os::windows::fs::symlink_file(&target, &link).is_err() {
1427            eprintln!("skipping: symlink creation not permitted on this runner");
1428            return;
1429        }
1430        let meta = std::fs::symlink_metadata(&link).unwrap();
1431        assert!(is_symlink_or_reparse(&meta));
1432    }
1433}