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#[cfg(test)]
661mod tests {
662    use super::*;
663
664    /// AP04 review I1: project-scoped records compare roots by project, not
665    /// by spelling — a symlinked root, a trailing slash and Windows
666    /// separators name the same project; another directory does not.
667    #[cfg(unix)]
668    #[test]
669    fn same_project_ignores_spelling_but_not_identity() {
670        let tmp = tempfile::tempdir().unwrap();
671        let real = tmp.path().join("repo");
672        let other = tmp.path().join("other");
673        std::fs::create_dir_all(&real).unwrap();
674        std::fs::create_dir_all(&other).unwrap();
675        let link = tmp.path().join("link");
676        std::os::unix::fs::symlink(&real, &link).unwrap();
677        let (real, link, other) = (
678            real.to_string_lossy().to_string(),
679            link.to_string_lossy().to_string(),
680            other.to_string_lossy().to_string(),
681        );
682
683        assert!(same_project(&real, &link));
684        assert!(same_project(&real, &format!("{real}/")));
685        assert!(same_project(r"C:\work\repo", "C:/work/repo/"));
686        assert!(!same_project(&real, &other));
687    }
688
689    #[test]
690    fn lean_ctx_stores_are_never_a_project_root() {
691        let iso = crate::core::data_dir::isolated_data_dir();
692        let project = tempfile::tempdir().unwrap();
693        // The Cockpit's fallback working directory itself.
694        assert!(is_broad_or_unsafe_root(iso.path()));
695        // A fixture project inside a temp data dir stays a project.
696        assert!(!is_broad_or_unsafe_root(&iso.path().join("proj")));
697        assert!(!is_broad_or_unsafe_root(project.path()));
698    }
699
700    #[cfg(unix)]
701    #[test]
702    fn a_store_under_another_spelling_is_still_refused() {
703        let iso = crate::core::data_dir::isolated_data_dir();
704        let links = tempfile::tempdir().unwrap();
705        let alias = links.path().join("data-alias");
706        std::os::unix::fs::symlink(iso.path(), &alias).unwrap();
707        assert!(is_broad_or_unsafe_root(&alias));
708        // A folder that only links into the store is not the store.
709        let inner = iso.path().join("proj");
710        std::fs::create_dir_all(&inner).unwrap();
711        let inner_alias = links.path().join("proj-alias");
712        std::os::unix::fs::symlink(&inner, &inner_alias).unwrap();
713        assert!(!is_broad_or_unsafe_root(&inner_alias));
714    }
715
716    #[test]
717    fn strip_regular_verbatim() {
718        let p = PathBuf::from(r"\\?\C:\Users\dev\project");
719        let result = strip_verbatim(p);
720        assert_eq!(result, PathBuf::from("C:/Users/dev/project"));
721    }
722
723    #[test]
724    fn tcc_sensitive_home_dir_matches_home_and_magic_dirs() {
725        // HOME is read here and again inside the check; tests that set HOME
726        // hold the env lock, so holding it keeps both reads the same.
727        let _env_lock = crate::core::data_dir::test_env_lock();
728        let Some(home) = dirs::home_dir() else {
729            return;
730        };
731        // Home itself and the macOS magic dirs are off-limits (#356).
732        assert!(is_tcc_sensitive_home_dir(&home));
733        assert!(is_tcc_sensitive_home_dir(&home.join("Documents")));
734        assert!(is_tcc_sensitive_home_dir(&home.join("Desktop")));
735        assert!(is_tcc_sensitive_home_dir(&home.join("Downloads")));
736    }
737
738    #[test]
739    fn tcc_sensitive_home_dir_allows_real_projects() {
740        let _env_lock = crate::core::data_dir::test_env_lock();
741        let Some(home) = dirs::home_dir() else {
742            return;
743        };
744        // A real project (even nested under Documents) and non-magic home children
745        // are scannable — only the bare magic dirs / home are blocked.
746        assert!(!is_tcc_sensitive_home_dir(
747            &home.join("Documents").join("my-project")
748        ));
749        assert!(!is_tcc_sensitive_home_dir(&home.join("code")));
750        assert!(!is_tcc_sensitive_home_dir(&home.join("Projects")));
751    }
752
753    #[test]
754    #[cfg(target_os = "macos")]
755    fn under_tcc_protected_dir_matches_nested_paths() {
756        let _env_lock = crate::core::data_dir::test_env_lock();
757        let Some(home) = dirs::home_dir() else {
758            return;
759        };
760        // The magic dirs themselves and anything nested below them (#356).
761        assert!(is_under_tcc_protected_dir(&home.join("Documents")));
762        assert!(is_under_tcc_protected_dir(
763            &home.join("Documents/deep/nested/project")
764        ));
765        assert!(is_under_tcc_protected_dir(&home.join("Desktop/scratch")));
766        assert!(is_under_tcc_protected_dir(&home.join("Downloads/x.zip")));
767        // Home itself, siblings, and non-home paths are fine.
768        assert!(!is_under_tcc_protected_dir(&home));
769        assert!(!is_under_tcc_protected_dir(&home.join("code/project")));
770        assert!(!is_under_tcc_protected_dir(Path::new("/tmp/Documents")));
771    }
772
773    #[test]
774    #[cfg(target_os = "macos")]
775    #[serial_test::serial]
776    fn tcc_standalone_blocks_probes_under_protected_dirs() {
777        // `#[serial]` and `test_env_lock` are two different mutexes; the tests
778        // in this module need both. `LEAN_CTX_TCC_STANDALONE` is read by
779        // `is_tcc_standalone` (line ~376) on every path normalization, so
780        // setting it here reaches any test that resolves a path — most of which
781        // serialize on `test_env_lock`, not on `#[serial]`.
782        let _env_lock = crate::core::data_dir::test_env_lock();
783        let Some(home) = dirs::home_dir() else {
784            return;
785        };
786        let doc_proj = home.join("Documents/some-project");
787
788        crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
789        assert!(process_is_tcc_standalone());
790        assert!(!may_probe_path(&doc_proj));
791        // Non-protected paths stay probeable even for standalone processes.
792        assert!(may_probe_path(Path::new("/tmp/some-project")));
793        // has_project_marker must refuse without touching the filesystem.
794        assert!(!has_project_marker(&doc_proj));
795
796        crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
797        assert!(!process_is_tcc_standalone());
798        assert!(may_probe_path(&doc_proj));
799        crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
800    }
801
802    #[test]
803    #[cfg(target_os = "macos")]
804    #[serial_test::serial]
805    fn tcc_standalone_detected_via_seatbelt_sentinel() {
806        let _env_lock = crate::core::data_dir::test_env_lock();
807        let Some(home) = dirs::home_dir() else {
808            return;
809        };
810        let doc_proj = home.join("Documents/some-project");
811
812        // No explicit override: a process carrying the deny-~/Documents seatbelt
813        // sentinel (inherited from its sandboxed launchd parent) counts as
814        // standalone even when ppid != 1, so its heuristic probes stay
815        // suppressed — this is the proxy→daemon chain the ppid check missed. (#356)
816        crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
817        crate::test_env::set_var(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL, "1");
818        assert!(process_is_tcc_standalone());
819        assert!(!may_probe_path(&doc_proj));
820        crate::test_env::remove_var(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL);
821
822        // With neither override nor sentinel a normal test process (ppid != 1)
823        // is not standalone, so the sentinel is what flipped the result above.
824        assert!(!process_is_tcc_standalone());
825    }
826
827    #[test]
828    #[cfg(target_os = "macos")]
829    #[serial_test::serial]
830    fn tcc_standalone_skips_canonicalize_under_protected_dirs() {
831        let _env_lock = crate::core::data_dir::test_env_lock();
832        let Some(home) = dirs::home_dir() else {
833            return;
834        };
835        // A path that does NOT exist under ~/Documents. With the TCC choke-point
836        // guard active, `safe_canonicalize` returns Ok(input) *without* calling
837        // `std::fs::canonicalize` (which would Err on a missing path) — proving
838        // the filesystem is never touched (#356). This is the structural fix:
839        // every heuristic canonicalize funnels through here.
840        let missing = home.join("Documents/lean-ctx-tcc-test-does-not-exist-xyzzy");
841
842        crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
843        let guarded = safe_canonicalize(&missing);
844        assert!(
845            guarded.is_ok(),
846            "standalone safe_canonicalize must short-circuit (no stat) under ~/Documents"
847        );
848        assert_eq!(guarded.unwrap(), missing);
849        assert_eq!(safe_canonicalize_or_self(&missing), missing);
850
851        // Outside the protected dirs the guard never engages, even when standalone.
852        let tmp_missing = Path::new("/tmp/lean-ctx-tcc-test-does-not-exist-xyzzy");
853        assert!(safe_canonicalize(tmp_missing).is_err());
854
855        // Without standalone the guard is inactive: a missing ~/Documents path
856        // Errs from the real `std::fs::canonicalize` as before.
857        crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
858        assert!(safe_canonicalize(&missing).is_err());
859
860        crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
861    }
862
863    #[test]
864    #[cfg(target_os = "macos")]
865    #[serial_test::serial]
866    fn canonicalize_secure_bypasses_tcc_guard_for_pathjail() {
867        let _env_lock = crate::core::data_dir::test_env_lock();
868        // SECURITY counterpart to the test above (#356): PathJail must keep
869        // resolving symlinks even when standalone under ~/Documents, so the jail
870        // can detect escapes. `canonicalize_secure` therefore must NOT honour the
871        // guard — it always touches the filesystem. We prove that by feeding a
872        // missing ~/Documents path while standalone: the guarded path returns
873        // Ok(lexical) (no stat), while the secure path Errs (it did stat).
874        let Some(home) = dirs::home_dir() else {
875            return;
876        };
877        let missing = home.join("Documents/lean-ctx-secure-canon-does-not-exist-xyzzy");
878
879        crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
880        // Guarded sink short-circuits (no fs access).
881        assert_eq!(safe_canonicalize(&missing).unwrap(), missing);
882        // Security sink ignores the guard and actually stats -> Err on a missing
883        // path. If this ever returns Ok(lexical), the jail's symlink-escape
884        // detection has silently regressed under ~/Documents.
885        assert!(
886            canonicalize_secure(&missing).is_err(),
887            "canonicalize_secure must bypass the TCC guard and touch the filesystem"
888        );
889        assert_eq!(canonicalize_secure_or_self(&missing), missing);
890        crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
891    }
892
893    #[test]
894    fn strip_unc_verbatim() {
895        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
896        let result = strip_verbatim(p);
897        assert_eq!(result, PathBuf::from("//server/share/dir"));
898    }
899
900    #[test]
901    fn no_prefix_unchanged() {
902        let p = PathBuf::from("/home/user/project");
903        let result = strip_verbatim(p.clone());
904        assert_eq!(result, p);
905    }
906
907    #[test]
908    fn windows_drive_unchanged() {
909        let p = PathBuf::from("C:/Users/dev");
910        let result = strip_verbatim(p.clone());
911        assert_eq!(result, p);
912    }
913
914    #[test]
915    fn strip_str_regular() {
916        assert_eq!(
917            strip_verbatim_str(r"\\?\E:\code\lean-ctx"),
918            Some("E:/code/lean-ctx".to_string())
919        );
920    }
921
922    #[test]
923    fn strip_str_unc() {
924        assert_eq!(
925            strip_verbatim_str(r"\\?\UNC\myserver\data"),
926            Some("//myserver/data".to_string())
927        );
928    }
929
930    #[test]
931    fn strip_str_forward_slash_variant() {
932        assert_eq!(
933            strip_verbatim_str("//?/C:/Users/dev"),
934            Some("C:/Users/dev".to_string())
935        );
936    }
937
938    #[test]
939    fn strip_str_no_prefix() {
940        assert_eq!(strip_verbatim_str("/home/user"), None);
941    }
942
943    #[test]
944    fn safe_canonicalize_or_self_nonexistent() {
945        let p = Path::new("/this/path/should/not/exist/xyzzy");
946        let result = safe_canonicalize_or_self(p);
947        assert_eq!(result, p.to_path_buf());
948    }
949
950    // The drive translation itself is platform-independent and testable
951    // everywhere; only its *application* is gated on Windows hosts (#397).
952    #[test]
953    fn msys_drive_prefix_translation() {
954        assert_eq!(
955            translate_msys_drive_prefix("/c/Users/ABC").as_deref(),
956            Some("C:/Users/ABC")
957        );
958        assert_eq!(
959            translate_msys_drive_prefix("/D/Program Files").as_deref(),
960            Some("D:/Program Files")
961        );
962        assert_eq!(translate_msys_drive_prefix("/usr/local/bin"), None);
963        assert_eq!(translate_msys_drive_prefix("/c"), None);
964        assert_eq!(translate_msys_drive_prefix("c/Users"), None);
965    }
966
967    #[cfg(windows)]
968    #[test]
969    fn normalize_msys_path_to_native() {
970        assert_eq!(
971            normalize_tool_path("/c/Users/ABC/AppData/lean-ctx"),
972            "C:/Users/ABC/AppData/lean-ctx"
973        );
974        assert_eq!(
975            normalize_tool_path("/D/Program Files/lean-ctx.exe"),
976            "D:/Program Files/lean-ctx.exe"
977        );
978    }
979
980    // GH #397: on Linux/macOS, /c/… is a literal directory — a Linux project
981    // rooted there must not be rewritten to a Windows drive path.
982    #[cfg(not(windows))]
983    #[test]
984    fn normalize_single_letter_unix_path_untouched() {
985        assert_eq!(
986            normalize_tool_path_lexical("/c/Users/me/proj"),
987            "/c/Users/me/proj"
988        );
989        assert_eq!(
990            normalize_tool_path_lexical("/x/projects/app/src"),
991            "/x/projects/app/src"
992        );
993    }
994
995    #[test]
996    fn normalize_native_windows_path_unchanged() {
997        assert_eq!(
998            normalize_tool_path("C:/Users/ABC/lean-ctx.exe"),
999            "C:/Users/ABC/lean-ctx.exe"
1000        );
1001    }
1002
1003    #[test]
1004    fn normalize_backslash_windows_path() {
1005        assert_eq!(
1006            normalize_tool_path(r"C:\Users\ABC\lean-ctx.exe"),
1007            "C:/Users/ABC/lean-ctx.exe"
1008        );
1009    }
1010
1011    #[test]
1012    fn normalize_unix_path_unchanged() {
1013        assert_eq!(
1014            normalize_tool_path("/usr/local/bin/lean-ctx"),
1015            "/usr/local/bin/lean-ctx"
1016        );
1017    }
1018
1019    #[cfg(not(windows))]
1020    #[test]
1021    fn normalize_unescapes_shell_quoting_on_unix() {
1022        assert_eq!(
1023            normalize_tool_path_lexical(r"/Users/me/Pfadi\ Scouting/app/main.ts"),
1024            "/Users/me/Pfadi Scouting/app/main.ts",
1025            "an escaped space must not become a separator"
1026        );
1027        assert_eq!(
1028            normalize_tool_path_lexical(r"/srv/\(group\)/a\&b.txt"),
1029            "/srv/(group)/a&b.txt"
1030        );
1031        assert_eq!(
1032            normalize_tool_path_lexical("'/Users/me/My Project/src'"),
1033            "/Users/me/My Project/src"
1034        );
1035        assert_eq!(normalize_tool_path_lexical("\"/tmp/a b\""), "/tmp/a b");
1036        assert_eq!(
1037            normalize_tool_path_lexical("'"),
1038            "'",
1039            "a lone quote is a name"
1040        );
1041    }
1042
1043    #[test]
1044    fn normalize_windows_path_with_spaces_and_backslashes() {
1045        // The exact "paths with spaces" scenario reported on Windows (#324):
1046        // backslashes are converted to forward slashes (so client render layers
1047        // never escape-mangle them) while spaces in directory names survive.
1048        assert_eq!(
1049            normalize_tool_path(r"C:\Users\My Name\My Project\src\main.rs"),
1050            "C:/Users/My Name/My Project/src/main.rs"
1051        );
1052        assert_eq!(
1053            normalize_tool_path(r"C:\Program Files\app\config.toml"),
1054            "C:/Program Files/app/config.toml"
1055        );
1056    }
1057
1058    #[test]
1059    fn normalize_double_slashes() {
1060        assert_eq!(
1061            normalize_tool_path("C:/Users//ABC//lean-ctx"),
1062            "C:/Users/ABC/lean-ctx"
1063        );
1064    }
1065
1066    #[test]
1067    fn normalize_trailing_slash_removed() {
1068        assert_eq!(normalize_tool_path("C:/Users/ABC/"), "C:/Users/ABC");
1069        assert_eq!(
1070            normalize_tool_path_lexical("/tmp/nonexistent-dir-xyzzy/"),
1071            "/tmp/nonexistent-dir-xyzzy"
1072        );
1073    }
1074
1075    #[test]
1076    fn normalize_root_slash_preserved() {
1077        assert_eq!(normalize_tool_path("/"), "/");
1078    }
1079
1080    #[test]
1081    fn normalize_drive_root_preserved() {
1082        assert_eq!(normalize_tool_path("C:/"), "C:/");
1083    }
1084
1085    #[test]
1086    fn normalize_verbatim_with_msys() {
1087        assert_eq!(normalize_tool_path(r"\\?\C:\Users\dev"), "C:/Users/dev");
1088    }
1089
1090    #[test]
1091    fn broad_root_rejects_home() {
1092        if let Some(home) = dirs::home_dir() {
1093            assert!(is_broad_or_unsafe_root(&home));
1094        }
1095    }
1096
1097    #[test]
1098    fn broad_root_rejects_wsl_windows_user_profile() {
1099        assert!(is_broad_or_unsafe_root(Path::new("/mnt/c/Users/dev")));
1100        assert!(!is_broad_or_unsafe_root(Path::new(
1101            "/mnt/c/Users/dev/projects/my-app"
1102        )));
1103    }
1104
1105    #[test]
1106    fn broad_root_rejects_filesystem_root() {
1107        assert!(is_broad_or_unsafe_root(Path::new("/")));
1108    }
1109
1110    #[test]
1111    fn broad_root_rejects_dot() {
1112        assert!(is_broad_or_unsafe_root(Path::new(".")));
1113    }
1114
1115    #[test]
1116    fn broad_root_rejects_agent_dirs() {
1117        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.claude")));
1118        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.codex")));
1119    }
1120
1121    #[test]
1122    fn broad_root_rejects_copilot_and_friends() {
1123        // #580: the previously-missing agent/IDE dirs must now be rejected so
1124        // they are never adopted as the project root (the Copilot CLI case).
1125        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.copilot")));
1126        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.cursor")));
1127        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.windsurf")));
1128        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.gemini")));
1129        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.lmstudio")));
1130    }
1131
1132    #[test]
1133    fn agent_config_dir_matches_every_known_client() {
1134        for name in AGENT_CONFIG_DIRS {
1135            let leaf = format!("/home/user/{name}");
1136            assert!(is_agent_config_dir(Path::new(&leaf)), "{leaf}");
1137            let nested = format!("/home/user/{name}/mcp");
1138            assert!(is_agent_config_dir(Path::new(&nested)), "{nested}");
1139        }
1140    }
1141
1142    #[test]
1143    fn claude_code_worktree_checkout_is_not_agent_config() {
1144        // Claude Code puts git worktrees at `<repo>/.claude/worktrees/<name>`;
1145        // those are the user's project, not agent config (#1903 regression).
1146        for p in [
1147            "/repo/.claude/worktrees/fix-x",
1148            "/repo/.claude/worktrees/fix-x/rust/src",
1149            r"C:\repo\.claude\worktrees\fix-x",
1150        ] {
1151            assert!(!is_agent_config_dir(Path::new(p)), "{p}");
1152            assert!(!is_broad_or_unsafe_root(Path::new(p)), "{p}");
1153        }
1154        for p in [
1155            "/repo/.claude/worktrees",
1156            "/repo/.claude/worktrees/",
1157            "/repo/.claude/settings",
1158            "/repo/.claude/worktrees/fix-x/.claude",
1159            "/repo/.claude/worktrees/fix-x/.codex/mcp",
1160            "/home/user/.claude/worktreesx/a",
1161        ] {
1162            assert!(is_agent_config_dir(Path::new(p)), "{p}");
1163        }
1164        assert!(!is_agent_config_dir(Path::new("/repo/.claudex/a")));
1165    }
1166
1167    #[test]
1168    fn agent_config_dir_matches_windows_backslash() {
1169        // #580 is a Windows Copilot report — backslash paths must match too.
1170        assert!(is_agent_config_dir(Path::new(r"C:\Users\me\.copilot")));
1171        assert!(is_agent_config_dir(Path::new(r"C:\Users\me\.copilot\mcp")));
1172    }
1173
1174    #[test]
1175    fn agent_config_dir_ignores_real_projects() {
1176        assert!(!is_agent_config_dir(Path::new("/home/user/code/lean-ctx")));
1177        assert!(!is_agent_config_dir(Path::new(r"C:\src\app")));
1178    }
1179
1180    #[test]
1181    fn broad_root_allows_project_subdir() {
1182        let tmp = tempfile::tempdir().unwrap();
1183        let subdir = tmp.path().join("my-project");
1184        std::fs::create_dir_all(&subdir).unwrap();
1185        assert!(!is_broad_or_unsafe_root(&subdir));
1186    }
1187
1188    #[test]
1189    fn broad_root_allows_home_subdirs() {
1190        if let Some(home) = dirs::home_dir() {
1191            let subdir = home.join("projects").join("my-app");
1192            assert!(!is_broad_or_unsafe_root(&subdir));
1193        }
1194    }
1195
1196    #[test]
1197    fn data_dir_collision_rejects_home() {
1198        if let Some(home) = dirs::home_dir() {
1199            assert!(is_data_dir_collision(&home));
1200        }
1201    }
1202
1203    #[test]
1204    fn data_dir_collision_allows_normal_project() {
1205        let tmp = tempfile::tempdir().unwrap();
1206        let project = tmp.path().join("my-project");
1207        std::fs::create_dir_all(&project).unwrap();
1208        assert!(!is_data_dir_collision(&project));
1209    }
1210
1211    #[test]
1212    fn has_project_marker_detects_git() {
1213        let tmp = tempfile::tempdir().unwrap();
1214        let root = tmp.path().join("repo");
1215        std::fs::create_dir_all(&root).unwrap();
1216        assert!(!has_project_marker(&root));
1217        std::fs::create_dir(root.join(".git")).unwrap();
1218        assert!(has_project_marker(&root));
1219    }
1220
1221    #[test]
1222    fn has_project_marker_detects_cargo_toml() {
1223        let tmp = tempfile::tempdir().unwrap();
1224        let root = tmp.path().join("rust-project");
1225        std::fs::create_dir_all(&root).unwrap();
1226        std::fs::write(root.join("Cargo.toml"), "[package]").unwrap();
1227        assert!(has_project_marker(&root));
1228    }
1229
1230    #[test]
1231    fn has_project_marker_detects_godot_project() {
1232        let tmp = tempfile::tempdir().unwrap();
1233        let root = tmp.path().join("godot-game");
1234        std::fs::create_dir_all(&root).unwrap();
1235        std::fs::write(root.join("project.godot"), "config_version=5\n").unwrap();
1236        assert!(has_project_marker(&root));
1237    }
1238
1239    #[test]
1240    fn multi_repo_children_needs_two() {
1241        let tmp = tempfile::tempdir().unwrap();
1242        let parent = tmp.path().join("code");
1243        std::fs::create_dir_all(&parent).unwrap();
1244
1245        // 0 repos → false
1246        assert!(!has_multi_repo_children(&parent));
1247
1248        // 1 repo → false
1249        let repo1 = parent.join("repo1");
1250        std::fs::create_dir_all(repo1.join(".git")).unwrap();
1251        assert!(!has_multi_repo_children(&parent));
1252
1253        // 2 repos → true
1254        let repo2 = parent.join("repo2");
1255        std::fs::create_dir_all(repo2.join(".git")).unwrap();
1256        assert!(has_multi_repo_children(&parent));
1257    }
1258
1259    #[test]
1260    fn multi_repo_children_ignores_files() {
1261        let tmp = tempfile::tempdir().unwrap();
1262        let parent = tmp.path().join("mixed");
1263        std::fs::create_dir_all(&parent).unwrap();
1264
1265        // One repo dir + one plain file with .git name (not a dir)
1266        let repo1 = parent.join("repo1");
1267        std::fs::create_dir_all(repo1.join(".git")).unwrap();
1268        std::fs::write(parent.join("not-a-repo"), "file").unwrap();
1269        assert!(!has_multi_repo_children(&parent));
1270
1271        // Add second actual repo
1272        let repo2 = parent.join("repo2");
1273        std::fs::create_dir_all(&repo2).unwrap();
1274        std::fs::write(repo2.join("package.json"), "{}").unwrap();
1275        assert!(has_multi_repo_children(&parent));
1276    }
1277
1278    #[test]
1279    fn multi_repo_children_nonexistent_dir() {
1280        assert!(!has_multi_repo_children(Path::new("/nonexistent/path/xyz")));
1281    }
1282
1283    #[test]
1284    fn regular_file_is_not_symlink_or_reparse() {
1285        let tmp = tempfile::tempdir().unwrap();
1286        let file = tmp.path().join("plain.txt");
1287        std::fs::write(&file, "x").unwrap();
1288        let meta = std::fs::symlink_metadata(&file).unwrap();
1289        assert!(!is_symlink_or_reparse(&meta));
1290    }
1291
1292    #[cfg(unix)]
1293    #[test]
1294    fn unix_symlink_is_detected() {
1295        let tmp = tempfile::tempdir().unwrap();
1296        let target = tmp.path().join("target.txt");
1297        std::fs::write(&target, "x").unwrap();
1298        let link = tmp.path().join("link.txt");
1299        std::os::unix::fs::symlink(&target, &link).unwrap();
1300        let meta = std::fs::symlink_metadata(&link).unwrap();
1301        assert!(is_symlink_or_reparse(&meta));
1302    }
1303
1304    /// Runs in the windows-latest CI lane (GL#442). Symlink creation needs
1305    /// either admin or Developer Mode — skip gracefully when unavailable.
1306    #[cfg(windows)]
1307    #[test]
1308    fn windows_symlink_is_detected() {
1309        let tmp = tempfile::tempdir().unwrap();
1310        let target = tmp.path().join("target.txt");
1311        std::fs::write(&target, "x").unwrap();
1312        let link = tmp.path().join("link.txt");
1313        if std::os::windows::fs::symlink_file(&target, &link).is_err() {
1314            eprintln!("skipping: symlink creation not permitted on this runner");
1315            return;
1316        }
1317        let meta = std::fs::symlink_metadata(&link).unwrap();
1318        assert!(is_symlink_or_reparse(&meta));
1319    }
1320}