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/// Lexical (string-only) part of [`normalize_tool_path`]: MSYS2 drive prefix
158/// (Windows hosts only), separators, double slashes, trailing slash. Performs
159/// **no** filesystem access, so it is safe on persisted paths in
160/// TCC-standalone processes (launchd daemon, #356) and as a dedupe key where
161/// symlink resolution is not worth a `realpath` per entry.
162pub fn normalize_tool_path_lexical(path: &str) -> String {
163    let mut p = match strip_verbatim_str(path) {
164        Some(stripped) => stripped,
165        None => path.to_string(),
166    };
167
168    if cfg!(windows)
169        && let Some(translated) = translate_msys_drive_prefix(&p)
170    {
171        p = translated;
172    }
173
174    p = p.replace('\\', "/");
175
176    // Collapse double slashes (preserve UNC paths starting with //)
177    while p.contains("//") && !p.starts_with("//") {
178        p = p.replace("//", "/");
179    }
180
181    // Remove trailing slash (unless root like "/" or "C:/")
182    if p.len() > 1 && p.ends_with('/') && !p.ends_with(":/") {
183        p.pop();
184    }
185
186    p
187}
188
189/// Normalize paths from any client format to a consistent OS-native form.
190/// Handles MSYS2/Git Bash drive prefixes on Windows hosts
191/// (`/c/Users/...` -> `C:/Users/...`), mixed separators, double slashes, and
192/// trailing slashes. Uses forward slashes for consistency. On non-Windows
193/// hosts `/c/...` is a literal directory and passes through unchanged (#397).
194pub fn normalize_tool_path(path: &str) -> String {
195    let mut p = normalize_tool_path_lexical(path);
196
197    // Resolve symlinks for absolute paths to ensure cache key consistency.
198    // Skip relative paths (preserve "." / "../" as-is), root-only paths (/ or C:/),
199    // slow mounts (WSL DrvFS /mnt/) where canonicalize can hang, and paths a
200    // TCC-standalone process must not stat (launchd daemon + ~/Documents, #356).
201    // Uses safe_canonicalize to strip Windows \\?\ prefix.
202    let is_absolute = p.starts_with('/') || (p.len() >= 3 && p.as_bytes()[1] == b':');
203    let is_root_only = p == "/" || (p.len() <= 3 && p.ends_with('/') && is_absolute);
204    if is_absolute
205        && !is_root_only
206        && !crate::core::io_health::is_slow_mount(&p)
207        && may_probe_path(Path::new(&*p))
208        && let Ok(canonical) = safe_canonicalize(Path::new(&*p))
209    {
210        let canonical_str = canonical.to_string_lossy().replace('\\', "/");
211        if !canonical_str.is_empty() {
212            p = canonical_str;
213        }
214    }
215
216    p
217}
218
219/// Agent/IDE CLI config or sandbox directories some clients launch their MCP
220/// server from, but which are never a user's project root. Adopting one as the
221/// project root jails every real repository path out — the root cause of #580
222/// (GitHub Copilot CLI launches from `~/.copilot`; Cursor, Windsurf, Gemini CLI
223/// and LM Studio behave similarly). This is the canonical set: every "is this an
224/// agent dir?" check across the codebase delegates here so the list never drifts.
225pub const AGENT_CONFIG_DIRS: &[&str] = &[
226    ".claude",
227    ".codex",
228    ".codebuddy",
229    ".copilot",
230    ".cursor",
231    ".windsurf",
232    ".gemini",
233    ".lmstudio",
234];
235
236/// Returns `true` if `dir` is — or lies inside — a known agent/IDE config dir
237/// ([`AGENT_CONFIG_DIRS`]). Separator-agnostic so Windows backslash paths
238/// (`C:\Users\me\.copilot`) match too; #580 is a Windows Copilot report.
239pub fn is_agent_config_dir(dir: &Path) -> bool {
240    let s = dir.to_string_lossy().replace('\\', "/");
241    AGENT_CONFIG_DIRS
242        .iter()
243        .any(|name| s.ends_with(&format!("/{name}")) || s.contains(&format!("/{name}/")))
244}
245
246fn is_wsl_windows_user_profile(dir: &Path) -> bool {
247    let normalized = dir.to_string_lossy();
248    if !normalized.starts_with("/mnt/") {
249        return false;
250    }
251
252    let mut components = normalized
253        .split('/')
254        .filter(|component| !component.is_empty());
255    let (Some(mount), Some(drive), Some(users), Some(username)) = (
256        components.next(),
257        components.next(),
258        components.next(),
259        components.next(),
260    ) else {
261        return false;
262    };
263
264    mount.eq_ignore_ascii_case("mnt")
265        && drive.len() == 1
266        && drive.as_bytes()[0].is_ascii_alphabetic()
267        && users.eq_ignore_ascii_case("users")
268        && !username.is_empty()
269        && components.next().is_none()
270}
271
272/// Returns `true` if the directory is too broad to be a valid project root.
273/// Rejects home directories (including WSL-mounted Windows profiles), filesystem
274/// root, `.` (bare CWD), and agent/IDE config directories
275/// ([`AGENT_CONFIG_DIRS`]). Used to prevent adopting a bogus project root and
276/// writing project-scoped data into the global `~/.lean-ctx/` data directory.
277pub fn is_broad_or_unsafe_root(dir: &Path) -> bool {
278    if let Some(home) = dirs::home_dir()
279        && dir == home
280    {
281        return true;
282    }
283    if is_wsl_windows_user_profile(dir) {
284        return true;
285    }
286    let s = dir.to_string_lossy();
287    if s == "/" || s == "\\" || s == "." {
288        return true;
289    }
290    is_agent_config_dir(dir)
291}
292
293/// Well-known project markers used to identify project roots.
294pub const PROJECT_MARKERS: &[&str] = &[
295    ".git",
296    "Cargo.toml",
297    "package.json",
298    "go.mod",
299    "pyproject.toml",
300    "setup.py",
301    "pom.xml",
302    "build.gradle",
303    "Makefile",
304    "project.godot",
305    ".lean-ctx.toml",
306    ".planning",
307];
308
309/// Returns `true` if `dir` contains at least one known project marker.
310///
311/// TCC guard (#356): a launchd-owned process (daemon/proxy/auto-updater) must
312/// not stat marker files under `~/Documents` & co. — the probe itself pops the
313/// macOS privacy prompt. For those processes this conservatively reports
314/// "no marker" without touching the filesystem.
315pub fn has_project_marker(dir: &Path) -> bool {
316    if !may_probe_path(dir) {
317        return false;
318    }
319    PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
320}
321
322/// Returns `true` if the (lstat) metadata describes a symlink — or, on
323/// Windows, *any* reparse point (junctions, mount points, app-exec links).
324///
325/// Security boundaries must use this instead of `FileType::is_symlink`:
326/// Rust's `is_symlink()` reports `false` for NTFS junctions, which redirect
327/// exactly like directory symlinks and would otherwise bypass jail/TOCTOU
328/// checks on Windows (GL#442).
329pub fn is_symlink_or_reparse(meta: &std::fs::Metadata) -> bool {
330    if meta.file_type().is_symlink() {
331        return true;
332    }
333    #[cfg(windows)]
334    {
335        use std::os::windows::fs::MetadataExt;
336        const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
337        return meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0;
338    }
339    #[cfg(not(windows))]
340    false
341}
342
343/// Returns `true` if `dir` is the home directory or one of the macOS "magic"
344/// home subdirectories (`Documents`, `Desktop`, `Downloads`).
345///
346/// macOS guards these with TCC: the first time a process *enumerates or stats
347/// inside* one, the OS pops a privacy prompt ("lean-ctx would like to access
348/// files in your Documents folder", #356). They are also never valid project
349/// roots or multi-repo workspace parents, so scan heuristics should treat them
350/// as off-limits *without* calling `read_dir` (which is what trips the prompt).
351pub fn is_tcc_sensitive_home_dir(dir: &Path) -> bool {
352    let Some(home) = dirs::home_dir() else {
353        return false;
354    };
355    if dir == home {
356        return true;
357    }
358    if dir.parent() != Some(home.as_path()) {
359        return false;
360    }
361    matches!(
362        dir.file_name().and_then(|n| n.to_str()),
363        Some("Documents" | "Desktop" | "Downloads")
364    )
365}
366
367/// Returns `true` if `path` lies inside (or is) one of the macOS TCC-protected
368/// home folders (`~/Documents`, `~/Desktop`, `~/Downloads`). Pure string/path
369/// comparison — performs **no** filesystem access itself.
370///
371/// Unlike [`is_tcc_sensitive_home_dir`] (which only matches the magic dirs
372/// themselves), this also matches nested paths like `~/Documents/proj/src`,
373/// because *any* `stat` below the magic dir trips the TCC prompt (#356).
374pub fn is_under_tcc_protected_dir(path: &Path) -> bool {
375    if !cfg!(target_os = "macos") {
376        return false;
377    }
378    let Some(home) = dirs::home_dir() else {
379        return false;
380    };
381    ["Documents", "Desktop", "Downloads"]
382        .iter()
383        .any(|magic| path.starts_with(home.join(magic)))
384}
385
386/// Returns `true` when this process is its own TCC identity on macOS — i.e.
387/// it was started (or re-parented) by `launchd` rather than by a
388/// TCC-granted host like a terminal or an editor.
389///
390/// Context (#356): TCC permissions attach to the *responsible process*. The
391/// lean-ctx daemon/proxy LaunchAgents and the scheduled auto-updater run
392/// directly under `launchd` (ppid 1), so any `stat`/`read_dir` they perform
393/// under `~/Documents` pops the privacy prompt **in lean-ctx's own name** —
394/// and because every release replaces the ad-hoc-signed binary (new cdhash),
395/// a previously granted permission is invalidated on each update, re-prompting
396/// forever. Such processes must never probe TCC-protected paths on their own
397/// initiative. Child processes of a terminal or editor (MCP server, CLI)
398/// inherit their host's TCC grant and keep full functionality.
399pub fn process_is_tcc_standalone() -> bool {
400    #[cfg(target_os = "macos")]
401    {
402        // Deliberately uncached: getppid is a cheap syscall, the env override
403        // must stay testable within one process, and a daemonizing fork could
404        // change the answer after startup.
405        if let Ok(v) = std::env::var("LEAN_CTX_TCC_STANDALONE") {
406            match v.trim() {
407                "1" | "true" => return true,
408                "0" | "false" => return false,
409                _ => {}
410            }
411        }
412        // A process carrying the deny-~/Documents seatbelt sentinel is, by
413        // construction, a launchd-standalone descendant: the sentinel is set
414        // only by the LaunchAgent plist env and the self re-exec, and child
415        // processes inherit it. This catches a daemon the long-lived standalone
416        // proxy spawned via `start_daemon` (ppid = proxy, not 1), whose code-side
417        // path guards would otherwise stay off because `getppid()` is no longer
418        // 1. (#356)
419        if std::env::var_os(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL).is_some() {
420            return true;
421        }
422        // SAFETY: `getppid` takes no arguments and cannot fail.
423        (unsafe { libc::getppid() }) == 1
424    }
425    #[cfg(not(target_os = "macos"))]
426    {
427        false
428    }
429}
430
431/// Returns `true` when this process may `stat`/`read_dir`/`canonicalize`
432/// `path` without risking a macOS TCC privacy prompt in lean-ctx's name.
433///
434/// Heuristic call sites (project-marker probes, session/root matching) must
435/// consult this before touching paths from persisted state; security
436/// boundaries (PathJail) are exempt — they only ever canonicalize paths the
437/// client explicitly asked to access, in which case a prompt is legitimate.
438pub fn may_probe_path(path: &Path) -> bool {
439    !(process_is_tcc_standalone() && is_under_tcc_protected_dir(path))
440}
441
442/// Returns `true` if `dir` is a multi-repo workspace parent — i.e. it has at
443/// least 2 immediate child directories that each contain a project marker.
444pub fn has_multi_repo_children(dir: &Path) -> bool {
445    // Never enumerate the home dir or macOS TCC-protected dirs: read_dir there
446    // pops a macOS privacy prompt (#356) and they are never workspace parents.
447    // `is_tcc_sensitive_home_dir` only matches the magic dirs themselves;
448    // `!may_probe_path` additionally refuses *nested* paths like
449    // `~/Documents/proj` when this process is launchd-standalone.
450    if is_tcc_sensitive_home_dir(dir) || !may_probe_path(dir) {
451        return false;
452    }
453    let Ok(entries) = std::fs::read_dir(dir) else {
454        return false;
455    };
456    let count = entries
457        .filter_map(Result::ok)
458        .filter(|e| e.file_type().is_ok_and(|ft| ft.is_dir()))
459        .filter(|e| has_project_marker(&e.path()))
460        .take(2)
461        .count();
462    count >= 2
463}
464
465/// Returns `true` if `project_root` collides with the lean-ctx data directory.
466/// This prevents project-scoped files (overlays.json, policies.json) from being
467/// written into `~/.lean-ctx/` or `~/.config/lean-ctx/`.
468pub fn is_data_dir_collision(project_root: &Path) -> bool {
469    if is_broad_or_unsafe_root(project_root) {
470        return true;
471    }
472    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
473        let project_lean_ctx = project_root.join(".lean-ctx");
474        if project_lean_ctx == data_dir || data_dir.starts_with(&project_lean_ctx) {
475            return true;
476        }
477    }
478    false
479}
480
481/// Returns the project-scoped `.lean-ctx/` directory if the project root is safe.
482/// Returns `Err` if the project root collides with the global data directory.
483pub fn safe_project_data_dir(project_root: &Path) -> Result<PathBuf, String> {
484    if is_data_dir_collision(project_root) {
485        return Err(format!(
486            "project root {} collides with global data directory; \
487             skipping project-scoped write",
488            project_root.display()
489        ));
490    }
491    Ok(project_root.join(".lean-ctx"))
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn strip_regular_verbatim() {
500        let p = PathBuf::from(r"\\?\C:\Users\dev\project");
501        let result = strip_verbatim(p);
502        assert_eq!(result, PathBuf::from("C:/Users/dev/project"));
503    }
504
505    #[test]
506    fn tcc_sensitive_home_dir_matches_home_and_magic_dirs() {
507        let Some(home) = dirs::home_dir() else {
508            return;
509        };
510        // Home itself and the macOS magic dirs are off-limits (#356).
511        assert!(is_tcc_sensitive_home_dir(&home));
512        assert!(is_tcc_sensitive_home_dir(&home.join("Documents")));
513        assert!(is_tcc_sensitive_home_dir(&home.join("Desktop")));
514        assert!(is_tcc_sensitive_home_dir(&home.join("Downloads")));
515    }
516
517    #[test]
518    fn tcc_sensitive_home_dir_allows_real_projects() {
519        let Some(home) = dirs::home_dir() else {
520            return;
521        };
522        // A real project (even nested under Documents) and non-magic home children
523        // are scannable — only the bare magic dirs / home are blocked.
524        assert!(!is_tcc_sensitive_home_dir(
525            &home.join("Documents").join("my-project")
526        ));
527        assert!(!is_tcc_sensitive_home_dir(&home.join("code")));
528        assert!(!is_tcc_sensitive_home_dir(&home.join("Projects")));
529    }
530
531    #[test]
532    #[cfg(target_os = "macos")]
533    fn under_tcc_protected_dir_matches_nested_paths() {
534        let Some(home) = dirs::home_dir() else {
535            return;
536        };
537        // The magic dirs themselves and anything nested below them (#356).
538        assert!(is_under_tcc_protected_dir(&home.join("Documents")));
539        assert!(is_under_tcc_protected_dir(
540            &home.join("Documents/deep/nested/project")
541        ));
542        assert!(is_under_tcc_protected_dir(&home.join("Desktop/scratch")));
543        assert!(is_under_tcc_protected_dir(&home.join("Downloads/x.zip")));
544        // Home itself, siblings, and non-home paths are fine.
545        assert!(!is_under_tcc_protected_dir(&home));
546        assert!(!is_under_tcc_protected_dir(&home.join("code/project")));
547        assert!(!is_under_tcc_protected_dir(Path::new("/tmp/Documents")));
548    }
549
550    #[test]
551    #[cfg(target_os = "macos")]
552    #[serial_test::serial]
553    fn tcc_standalone_blocks_probes_under_protected_dirs() {
554        // `#[serial]` and `test_env_lock` are two different mutexes; the tests
555        // in this module need both. `LEAN_CTX_TCC_STANDALONE` is read by
556        // `is_tcc_standalone` (line ~376) on every path normalization, so
557        // setting it here reaches any test that resolves a path — most of which
558        // serialize on `test_env_lock`, not on `#[serial]`.
559        let _env_lock = crate::core::data_dir::test_env_lock();
560        let Some(home) = dirs::home_dir() else {
561            return;
562        };
563        let doc_proj = home.join("Documents/some-project");
564
565        crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
566        assert!(process_is_tcc_standalone());
567        assert!(!may_probe_path(&doc_proj));
568        // Non-protected paths stay probeable even for standalone processes.
569        assert!(may_probe_path(Path::new("/tmp/some-project")));
570        // has_project_marker must refuse without touching the filesystem.
571        assert!(!has_project_marker(&doc_proj));
572
573        crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
574        assert!(!process_is_tcc_standalone());
575        assert!(may_probe_path(&doc_proj));
576        crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
577    }
578
579    #[test]
580    #[cfg(target_os = "macos")]
581    #[serial_test::serial]
582    fn tcc_standalone_detected_via_seatbelt_sentinel() {
583        let _env_lock = crate::core::data_dir::test_env_lock();
584        let Some(home) = dirs::home_dir() else {
585            return;
586        };
587        let doc_proj = home.join("Documents/some-project");
588
589        // No explicit override: a process carrying the deny-~/Documents seatbelt
590        // sentinel (inherited from its sandboxed launchd parent) counts as
591        // standalone even when ppid != 1, so its heuristic probes stay
592        // suppressed — this is the proxy→daemon chain the ppid check missed. (#356)
593        crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
594        crate::test_env::set_var(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL, "1");
595        assert!(process_is_tcc_standalone());
596        assert!(!may_probe_path(&doc_proj));
597        crate::test_env::remove_var(crate::core::tcc_guard_sandbox::SEATBELT_SENTINEL);
598
599        // With neither override nor sentinel a normal test process (ppid != 1)
600        // is not standalone, so the sentinel is what flipped the result above.
601        assert!(!process_is_tcc_standalone());
602    }
603
604    #[test]
605    #[cfg(target_os = "macos")]
606    #[serial_test::serial]
607    fn tcc_standalone_skips_canonicalize_under_protected_dirs() {
608        let _env_lock = crate::core::data_dir::test_env_lock();
609        let Some(home) = dirs::home_dir() else {
610            return;
611        };
612        // A path that does NOT exist under ~/Documents. With the TCC choke-point
613        // guard active, `safe_canonicalize` returns Ok(input) *without* calling
614        // `std::fs::canonicalize` (which would Err on a missing path) — proving
615        // the filesystem is never touched (#356). This is the structural fix:
616        // every heuristic canonicalize funnels through here.
617        let missing = home.join("Documents/lean-ctx-tcc-test-does-not-exist-xyzzy");
618
619        crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
620        let guarded = safe_canonicalize(&missing);
621        assert!(
622            guarded.is_ok(),
623            "standalone safe_canonicalize must short-circuit (no stat) under ~/Documents"
624        );
625        assert_eq!(guarded.unwrap(), missing);
626        assert_eq!(safe_canonicalize_or_self(&missing), missing);
627
628        // Outside the protected dirs the guard never engages, even when standalone.
629        let tmp_missing = Path::new("/tmp/lean-ctx-tcc-test-does-not-exist-xyzzy");
630        assert!(safe_canonicalize(tmp_missing).is_err());
631
632        // Without standalone the guard is inactive: a missing ~/Documents path
633        // Errs from the real `std::fs::canonicalize` as before.
634        crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "0");
635        assert!(safe_canonicalize(&missing).is_err());
636
637        crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
638    }
639
640    #[test]
641    #[cfg(target_os = "macos")]
642    #[serial_test::serial]
643    fn canonicalize_secure_bypasses_tcc_guard_for_pathjail() {
644        let _env_lock = crate::core::data_dir::test_env_lock();
645        // SECURITY counterpart to the test above (#356): PathJail must keep
646        // resolving symlinks even when standalone under ~/Documents, so the jail
647        // can detect escapes. `canonicalize_secure` therefore must NOT honour the
648        // guard — it always touches the filesystem. We prove that by feeding a
649        // missing ~/Documents path while standalone: the guarded path returns
650        // Ok(lexical) (no stat), while the secure path Errs (it did stat).
651        let Some(home) = dirs::home_dir() else {
652            return;
653        };
654        let missing = home.join("Documents/lean-ctx-secure-canon-does-not-exist-xyzzy");
655
656        crate::test_env::set_var("LEAN_CTX_TCC_STANDALONE", "1");
657        // Guarded sink short-circuits (no fs access).
658        assert_eq!(safe_canonicalize(&missing).unwrap(), missing);
659        // Security sink ignores the guard and actually stats -> Err on a missing
660        // path. If this ever returns Ok(lexical), the jail's symlink-escape
661        // detection has silently regressed under ~/Documents.
662        assert!(
663            canonicalize_secure(&missing).is_err(),
664            "canonicalize_secure must bypass the TCC guard and touch the filesystem"
665        );
666        assert_eq!(canonicalize_secure_or_self(&missing), missing);
667        crate::test_env::remove_var("LEAN_CTX_TCC_STANDALONE");
668    }
669
670    #[test]
671    fn strip_unc_verbatim() {
672        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
673        let result = strip_verbatim(p);
674        assert_eq!(result, PathBuf::from("//server/share/dir"));
675    }
676
677    #[test]
678    fn no_prefix_unchanged() {
679        let p = PathBuf::from("/home/user/project");
680        let result = strip_verbatim(p.clone());
681        assert_eq!(result, p);
682    }
683
684    #[test]
685    fn windows_drive_unchanged() {
686        let p = PathBuf::from("C:/Users/dev");
687        let result = strip_verbatim(p.clone());
688        assert_eq!(result, p);
689    }
690
691    #[test]
692    fn strip_str_regular() {
693        assert_eq!(
694            strip_verbatim_str(r"\\?\E:\code\lean-ctx"),
695            Some("E:/code/lean-ctx".to_string())
696        );
697    }
698
699    #[test]
700    fn strip_str_unc() {
701        assert_eq!(
702            strip_verbatim_str(r"\\?\UNC\myserver\data"),
703            Some("//myserver/data".to_string())
704        );
705    }
706
707    #[test]
708    fn strip_str_forward_slash_variant() {
709        assert_eq!(
710            strip_verbatim_str("//?/C:/Users/dev"),
711            Some("C:/Users/dev".to_string())
712        );
713    }
714
715    #[test]
716    fn strip_str_no_prefix() {
717        assert_eq!(strip_verbatim_str("/home/user"), None);
718    }
719
720    #[test]
721    fn safe_canonicalize_or_self_nonexistent() {
722        let p = Path::new("/this/path/should/not/exist/xyzzy");
723        let result = safe_canonicalize_or_self(p);
724        assert_eq!(result, p.to_path_buf());
725    }
726
727    // The drive translation itself is platform-independent and testable
728    // everywhere; only its *application* is gated on Windows hosts (#397).
729    #[test]
730    fn msys_drive_prefix_translation() {
731        assert_eq!(
732            translate_msys_drive_prefix("/c/Users/ABC").as_deref(),
733            Some("C:/Users/ABC")
734        );
735        assert_eq!(
736            translate_msys_drive_prefix("/D/Program Files").as_deref(),
737            Some("D:/Program Files")
738        );
739        assert_eq!(translate_msys_drive_prefix("/usr/local/bin"), None);
740        assert_eq!(translate_msys_drive_prefix("/c"), None);
741        assert_eq!(translate_msys_drive_prefix("c/Users"), None);
742    }
743
744    #[cfg(windows)]
745    #[test]
746    fn normalize_msys_path_to_native() {
747        assert_eq!(
748            normalize_tool_path("/c/Users/ABC/AppData/lean-ctx"),
749            "C:/Users/ABC/AppData/lean-ctx"
750        );
751        assert_eq!(
752            normalize_tool_path("/D/Program Files/lean-ctx.exe"),
753            "D:/Program Files/lean-ctx.exe"
754        );
755    }
756
757    // GH #397: on Linux/macOS, /c/… is a literal directory — a Linux project
758    // rooted there must not be rewritten to a Windows drive path.
759    #[cfg(not(windows))]
760    #[test]
761    fn normalize_single_letter_unix_path_untouched() {
762        assert_eq!(
763            normalize_tool_path_lexical("/c/Users/me/proj"),
764            "/c/Users/me/proj"
765        );
766        assert_eq!(
767            normalize_tool_path_lexical("/x/projects/app/src"),
768            "/x/projects/app/src"
769        );
770    }
771
772    #[test]
773    fn normalize_native_windows_path_unchanged() {
774        assert_eq!(
775            normalize_tool_path("C:/Users/ABC/lean-ctx.exe"),
776            "C:/Users/ABC/lean-ctx.exe"
777        );
778    }
779
780    #[test]
781    fn normalize_backslash_windows_path() {
782        assert_eq!(
783            normalize_tool_path(r"C:\Users\ABC\lean-ctx.exe"),
784            "C:/Users/ABC/lean-ctx.exe"
785        );
786    }
787
788    #[test]
789    fn normalize_unix_path_unchanged() {
790        assert_eq!(
791            normalize_tool_path("/usr/local/bin/lean-ctx"),
792            "/usr/local/bin/lean-ctx"
793        );
794    }
795
796    #[test]
797    fn normalize_windows_path_with_spaces_and_backslashes() {
798        // The exact "paths with spaces" scenario reported on Windows (#324):
799        // backslashes are converted to forward slashes (so client render layers
800        // never escape-mangle them) while spaces in directory names survive.
801        assert_eq!(
802            normalize_tool_path(r"C:\Users\My Name\My Project\src\main.rs"),
803            "C:/Users/My Name/My Project/src/main.rs"
804        );
805        assert_eq!(
806            normalize_tool_path(r"C:\Program Files\app\config.toml"),
807            "C:/Program Files/app/config.toml"
808        );
809    }
810
811    #[test]
812    fn normalize_double_slashes() {
813        assert_eq!(
814            normalize_tool_path("C:/Users//ABC//lean-ctx"),
815            "C:/Users/ABC/lean-ctx"
816        );
817    }
818
819    #[test]
820    fn normalize_trailing_slash_removed() {
821        assert_eq!(normalize_tool_path("C:/Users/ABC/"), "C:/Users/ABC");
822        assert_eq!(
823            normalize_tool_path_lexical("/tmp/nonexistent-dir-xyzzy/"),
824            "/tmp/nonexistent-dir-xyzzy"
825        );
826    }
827
828    #[test]
829    fn normalize_root_slash_preserved() {
830        assert_eq!(normalize_tool_path("/"), "/");
831    }
832
833    #[test]
834    fn normalize_drive_root_preserved() {
835        assert_eq!(normalize_tool_path("C:/"), "C:/");
836    }
837
838    #[test]
839    fn normalize_verbatim_with_msys() {
840        assert_eq!(normalize_tool_path(r"\\?\C:\Users\dev"), "C:/Users/dev");
841    }
842
843    #[test]
844    fn broad_root_rejects_home() {
845        if let Some(home) = dirs::home_dir() {
846            assert!(is_broad_or_unsafe_root(&home));
847        }
848    }
849
850    #[test]
851    fn broad_root_rejects_wsl_windows_user_profile() {
852        assert!(is_broad_or_unsafe_root(Path::new("/mnt/c/Users/dev")));
853        assert!(!is_broad_or_unsafe_root(Path::new(
854            "/mnt/c/Users/dev/projects/my-app"
855        )));
856    }
857
858    #[test]
859    fn broad_root_rejects_filesystem_root() {
860        assert!(is_broad_or_unsafe_root(Path::new("/")));
861    }
862
863    #[test]
864    fn broad_root_rejects_dot() {
865        assert!(is_broad_or_unsafe_root(Path::new(".")));
866    }
867
868    #[test]
869    fn broad_root_rejects_agent_dirs() {
870        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.claude")));
871        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.codex")));
872    }
873
874    #[test]
875    fn broad_root_rejects_copilot_and_friends() {
876        // #580: the previously-missing agent/IDE dirs must now be rejected so
877        // they are never adopted as the project root (the Copilot CLI case).
878        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.copilot")));
879        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.cursor")));
880        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.windsurf")));
881        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.gemini")));
882        assert!(is_broad_or_unsafe_root(Path::new("/home/user/.lmstudio")));
883    }
884
885    #[test]
886    fn agent_config_dir_matches_every_known_client() {
887        for name in AGENT_CONFIG_DIRS {
888            let leaf = format!("/home/user/{name}");
889            assert!(is_agent_config_dir(Path::new(&leaf)), "{leaf}");
890            let nested = format!("/home/user/{name}/mcp");
891            assert!(is_agent_config_dir(Path::new(&nested)), "{nested}");
892        }
893    }
894
895    #[test]
896    fn agent_config_dir_matches_windows_backslash() {
897        // #580 is a Windows Copilot report — backslash paths must match too.
898        assert!(is_agent_config_dir(Path::new(r"C:\Users\me\.copilot")));
899        assert!(is_agent_config_dir(Path::new(r"C:\Users\me\.copilot\mcp")));
900    }
901
902    #[test]
903    fn agent_config_dir_ignores_real_projects() {
904        assert!(!is_agent_config_dir(Path::new("/home/user/code/lean-ctx")));
905        assert!(!is_agent_config_dir(Path::new(r"C:\src\app")));
906    }
907
908    #[test]
909    fn broad_root_allows_project_subdir() {
910        let tmp = tempfile::tempdir().unwrap();
911        let subdir = tmp.path().join("my-project");
912        std::fs::create_dir_all(&subdir).unwrap();
913        assert!(!is_broad_or_unsafe_root(&subdir));
914    }
915
916    #[test]
917    fn broad_root_allows_home_subdirs() {
918        if let Some(home) = dirs::home_dir() {
919            let subdir = home.join("projects").join("my-app");
920            assert!(!is_broad_or_unsafe_root(&subdir));
921        }
922    }
923
924    #[test]
925    fn data_dir_collision_rejects_home() {
926        if let Some(home) = dirs::home_dir() {
927            assert!(is_data_dir_collision(&home));
928        }
929    }
930
931    #[test]
932    fn data_dir_collision_allows_normal_project() {
933        let tmp = tempfile::tempdir().unwrap();
934        let project = tmp.path().join("my-project");
935        std::fs::create_dir_all(&project).unwrap();
936        assert!(!is_data_dir_collision(&project));
937    }
938
939    #[test]
940    fn has_project_marker_detects_git() {
941        let tmp = tempfile::tempdir().unwrap();
942        let root = tmp.path().join("repo");
943        std::fs::create_dir_all(&root).unwrap();
944        assert!(!has_project_marker(&root));
945        std::fs::create_dir(root.join(".git")).unwrap();
946        assert!(has_project_marker(&root));
947    }
948
949    #[test]
950    fn has_project_marker_detects_cargo_toml() {
951        let tmp = tempfile::tempdir().unwrap();
952        let root = tmp.path().join("rust-project");
953        std::fs::create_dir_all(&root).unwrap();
954        std::fs::write(root.join("Cargo.toml"), "[package]").unwrap();
955        assert!(has_project_marker(&root));
956    }
957
958    #[test]
959    fn has_project_marker_detects_godot_project() {
960        let tmp = tempfile::tempdir().unwrap();
961        let root = tmp.path().join("godot-game");
962        std::fs::create_dir_all(&root).unwrap();
963        std::fs::write(root.join("project.godot"), "config_version=5\n").unwrap();
964        assert!(has_project_marker(&root));
965    }
966
967    #[test]
968    fn multi_repo_children_needs_two() {
969        let tmp = tempfile::tempdir().unwrap();
970        let parent = tmp.path().join("code");
971        std::fs::create_dir_all(&parent).unwrap();
972
973        // 0 repos → false
974        assert!(!has_multi_repo_children(&parent));
975
976        // 1 repo → false
977        let repo1 = parent.join("repo1");
978        std::fs::create_dir_all(repo1.join(".git")).unwrap();
979        assert!(!has_multi_repo_children(&parent));
980
981        // 2 repos → true
982        let repo2 = parent.join("repo2");
983        std::fs::create_dir_all(repo2.join(".git")).unwrap();
984        assert!(has_multi_repo_children(&parent));
985    }
986
987    #[test]
988    fn multi_repo_children_ignores_files() {
989        let tmp = tempfile::tempdir().unwrap();
990        let parent = tmp.path().join("mixed");
991        std::fs::create_dir_all(&parent).unwrap();
992
993        // One repo dir + one plain file with .git name (not a dir)
994        let repo1 = parent.join("repo1");
995        std::fs::create_dir_all(repo1.join(".git")).unwrap();
996        std::fs::write(parent.join("not-a-repo"), "file").unwrap();
997        assert!(!has_multi_repo_children(&parent));
998
999        // Add second actual repo
1000        let repo2 = parent.join("repo2");
1001        std::fs::create_dir_all(&repo2).unwrap();
1002        std::fs::write(repo2.join("package.json"), "{}").unwrap();
1003        assert!(has_multi_repo_children(&parent));
1004    }
1005
1006    #[test]
1007    fn multi_repo_children_nonexistent_dir() {
1008        assert!(!has_multi_repo_children(Path::new("/nonexistent/path/xyz")));
1009    }
1010
1011    #[test]
1012    fn regular_file_is_not_symlink_or_reparse() {
1013        let tmp = tempfile::tempdir().unwrap();
1014        let file = tmp.path().join("plain.txt");
1015        std::fs::write(&file, "x").unwrap();
1016        let meta = std::fs::symlink_metadata(&file).unwrap();
1017        assert!(!is_symlink_or_reparse(&meta));
1018    }
1019
1020    #[cfg(unix)]
1021    #[test]
1022    fn unix_symlink_is_detected() {
1023        let tmp = tempfile::tempdir().unwrap();
1024        let target = tmp.path().join("target.txt");
1025        std::fs::write(&target, "x").unwrap();
1026        let link = tmp.path().join("link.txt");
1027        std::os::unix::fs::symlink(&target, &link).unwrap();
1028        let meta = std::fs::symlink_metadata(&link).unwrap();
1029        assert!(is_symlink_or_reparse(&meta));
1030    }
1031
1032    /// Runs in the windows-latest CI lane (GL#442). Symlink creation needs
1033    /// either admin or Developer Mode — skip gracefully when unavailable.
1034    #[cfg(windows)]
1035    #[test]
1036    fn windows_symlink_is_detected() {
1037        let tmp = tempfile::tempdir().unwrap();
1038        let target = tmp.path().join("target.txt");
1039        std::fs::write(&target, "x").unwrap();
1040        let link = tmp.path().join("link.txt");
1041        if std::os::windows::fs::symlink_file(&target, &link).is_err() {
1042            eprintln!("skipping: symlink creation not permitted on this runner");
1043            return;
1044        }
1045        let meta = std::fs::symlink_metadata(&link).unwrap();
1046        assert!(is_symlink_or_reparse(&meta));
1047    }
1048}