Skip to main content

lean_ctx/server/
roots.rs

1use std::path::Path;
2
3/// Parse a `file://` URI to a validated local path string.
4/// Rejects non-file URIs, null bytes, `..` traversal, and non-directory paths.
5/// Returns a canonicalized absolute path.
6pub fn uri_to_path(uri: &str) -> Option<String> {
7    let raw = uri.strip_prefix("file://")?;
8    if raw.contains("%00") {
9        return None;
10    }
11    let decoded = percent_decode(raw);
12    if decoded.is_empty() || decoded.contains('\0') {
13        return None;
14    }
15    // Windows `file:///C:/path` URIs strip to `/C:/path`, which is NOT an
16    // absolute Windows path (no drive prefix at the start) and would be rejected
17    // below — so on Windows+Cursor every workspace root failed to parse and the
18    // session fell back to the home dir as project root (GL discussion #273:
19    // "MCP root misconfigured (resolves to C:/Users/<user>)"). Drop the single
20    // leading slash before the drive letter so it parses as `C:/path`. POSIX
21    // paths keep their leading slash (on Unix `/C:/x` is a legitimate path).
22    #[cfg(windows)]
23    let decoded = if has_leading_slash_drive(&decoded) {
24        decoded[1..].to_string()
25    } else {
26        decoded
27    };
28    let path = Path::new(&decoded);
29    if !path.is_absolute() {
30        return None;
31    }
32    let canonical = crate::core::pathutil::safe_canonicalize_or_self(path);
33    let s = canonical.to_string_lossy().to_string();
34    if s.is_empty() {
35        return None;
36    }
37    Some(s)
38}
39
40fn percent_decode(s: &str) -> String {
41    let mut out = String::with_capacity(s.len());
42    let mut chars = s.bytes();
43    while let Some(b) = chars.next() {
44        if b == b'%' {
45            let hi = chars.next().and_then(hex_val);
46            let lo = chars.next().and_then(hex_val);
47            if let (Some(h), Some(l)) = (hi, lo) {
48                let byte = h << 4 | l;
49                if byte == 0 {
50                    continue;
51                }
52                out.push(byte as char);
53            } else {
54                out.push('%');
55            }
56        } else {
57            out.push(b as char);
58        }
59    }
60    out
61}
62
63fn hex_val(b: u8) -> Option<u8> {
64    match b {
65        b'0'..=b'9' => Some(b - b'0'),
66        b'a'..=b'f' => Some(b - b'a' + 10),
67        b'A'..=b'F' => Some(b - b'A' + 10),
68        _ => None,
69    }
70}
71
72/// True for a `file://` URI path of the form `/C:/…` — a leading slash, an ASCII
73/// drive letter, then a colon. This shape comes from a Windows
74/// `file:///C:/path` URI and is not an absolute Windows path until the leading
75/// slash is removed. Pure predicate so the logic is unit-tested on every
76/// platform, even though it is only wired into [`uri_to_path`] on Windows
77/// (`allow(dead_code)` elsewhere keeps `-D warnings` clean).
78#[cfg_attr(not(windows), allow(dead_code))]
79fn has_leading_slash_drive(p: &str) -> bool {
80    let b = p.as_bytes();
81    b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':'
82}
83
84pub(super) fn has_project_marker(dir: &Path) -> bool {
85    crate::core::pathutil::has_project_marker(dir)
86}
87
88/// Select the best project root from MCP client roots.
89/// Only considers paths that are existing directories.
90/// Prefers roots with project markers (.git, Cargo.toml, etc.).
91/// Falls back to the first valid directory if none have markers — but never
92/// accepts a broad/unsafe root (HOME, filesystem root, agent sandbox dirs),
93/// which would otherwise contaminate sessions across projects.
94pub fn best_root_from_uris(uris: &[String]) -> Option<String> {
95    best_root_from_paths(uris.iter().filter_map(|u| uri_to_path(u)).collect())
96}
97
98/// Pick the best project root from a list of candidate directory paths.
99///
100/// Prefers a path with a project marker (`.git`, `Cargo.toml`, …); otherwise
101/// falls back to the first *safe* directory. A caller that reports its workspace
102/// root as HOME (some do) must not turn HOME into the project root — that is the
103/// root cause of cross-project session contamination — so a broad/unsafe root is
104/// never accepted as a marker-less fallback.
105fn best_root_from_paths(paths: Vec<String>) -> Option<String> {
106    let paths: Vec<String> = paths
107        .into_iter()
108        .filter(|p| Path::new(p).is_dir())
109        .collect();
110
111    if paths.is_empty() {
112        return None;
113    }
114
115    for p in &paths {
116        if has_project_marker(Path::new(p)) {
117            return Some(p.clone());
118        }
119    }
120
121    paths
122        .into_iter()
123        .find(|p| !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(p)))
124}
125
126/// Filter and validate URIs to existing directories only.
127pub fn valid_dir_paths_from_uris(uris: &[String]) -> Vec<String> {
128    uris.iter()
129        .filter_map(|u| uri_to_path(u))
130        .filter(|p| Path::new(p).is_dir())
131        .collect()
132}
133
134/// Detect project root from IDE-specific environment variables.
135///
136/// Priority (first match wins):
137///   LEAN_CTX_PROJECT_ROOT — explicit override (user/CI)
138///   CURSOR_PROJECT_DIR    — Cursor ≥ 3.7
139///   CLAUDE_PROJECT_DIR    — Claude Code
140///   WINDSURF_PROJECT_DIR  — Windsurf (Codeium)
141///   TRAE_PROJECT_DIR      — Trae (ByteDance)
142///   KIRO_PROJECT_DIR      — AWS Kiro
143///   CODEX_SANDBOX_DIR     — Codex desktop sandbox
144///   GIT_WORK_TREE         — git worktree env
145pub fn root_from_env() -> Option<String> {
146    const VARS: &[&str] = &[
147        "LEAN_CTX_PROJECT_ROOT",
148        "CURSOR_PROJECT_DIR",
149        "CLAUDE_PROJECT_DIR",
150        "WINDSURF_PROJECT_DIR",
151        "TRAE_PROJECT_DIR",
152        "KIRO_PROJECT_DIR",
153        "CODEX_SANDBOX_DIR",
154        "GIT_WORK_TREE",
155    ];
156    for var in VARS {
157        if let Ok(val) = std::env::var(var) {
158            let trimmed = val.trim().to_string();
159            if !trimmed.is_empty()
160                && Path::new(&trimmed).is_dir()
161                && !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(&trimmed))
162            {
163                return Some(trimmed);
164            }
165        }
166    }
167    None
168}
169
170/// Split a `WORKSPACE_FOLDER_PATHS` value into individual paths.
171///
172/// Cursor separates entries with `,` (observed). We also tolerate the OS
173/// path-list delimiter for robustness — `;` on Windows (never `:`, which is part
174/// of `C:` drive specs) and `:` on Unix (never part of a POSIX path).
175fn split_workspace_paths(raw: &str) -> Vec<String> {
176    let delims: &[char] = if cfg!(windows) {
177        &[',', ';']
178    } else {
179        &[',', ':']
180    };
181    raw.split(delims)
182        .map(str::trim)
183        .filter(|s| !s.is_empty())
184        .map(ToString::to_string)
185        .collect()
186}
187
188/// Best project root from the IDE-injected `WORKSPACE_FOLDER_PATHS` env var.
189///
190/// Cursor declares the MCP `roots` capability but does NOT implement
191/// `roots/list` (it answers `-32601 Method not found`) and launches stdio MCP
192/// servers with `cwd = /`. Without this signal the project root falls back to an
193/// unsafe directory and relative tool paths resolve against the wrong tree
194/// (#699). This variable is the Cursor-sanctioned way to learn the active
195/// workspace folder(s). The same broad/unsafe-root guards as MCP roots apply.
196pub fn root_from_workspace_env() -> Option<String> {
197    for var in ["WORKSPACE_FOLDER_PATHS", "VSCODE_WORKSPACE_FOLDER"] {
198        if let Ok(raw) = std::env::var(var) {
199            if let Some(root) = best_root_from_paths(split_workspace_paths(&raw)) {
200                return Some(root);
201            }
202        }
203    }
204    None
205}
206
207/// All valid, safe workspace directories from `WORKSPACE_FOLDER_PATHS`.
208///
209/// Used to register the sibling folders of a multi-root workspace as extra
210/// trusted roots, so explicit paths into them are not rejected by the path jail.
211pub fn workspace_roots_from_env() -> Vec<String> {
212    for var in ["WORKSPACE_FOLDER_PATHS", "VSCODE_WORKSPACE_FOLDER"] {
213        if let Ok(raw) = std::env::var(var) {
214            let paths: Vec<String> = split_workspace_paths(&raw)
215                .into_iter()
216                .filter(|p| Path::new(p).is_dir())
217                .filter(|p| !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(p)))
218                .collect();
219            if !paths.is_empty() {
220                return paths;
221            }
222        }
223    }
224    Vec::new()
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[cfg(unix)]
232    #[test]
233    fn parse_file_uri_unix() {
234        assert_eq!(
235            uri_to_path("file:///home/user/project"),
236            Some("/home/user/project".to_string())
237        );
238    }
239
240    #[cfg(unix)]
241    #[test]
242    fn parse_file_uri_windows() {
243        assert_eq!(
244            uri_to_path("file:///C:/Users/dev/project"),
245            Some("/C:/Users/dev/project".to_string())
246        );
247    }
248
249    #[cfg(unix)]
250    #[test]
251    fn parse_file_uri_with_spaces() {
252        assert_eq!(
253            uri_to_path("file:///home/user/my%20project"),
254            Some("/home/user/my project".to_string())
255        );
256    }
257
258    #[test]
259    fn parse_non_file_uri_returns_none() {
260        assert!(uri_to_path("https://example.com").is_none());
261        assert!(uri_to_path("").is_none());
262    }
263
264    #[test]
265    fn detects_leading_slash_windows_drive() {
266        // GL #273: the `/C:/…` shape (from a Windows `file:///C:/…` URI) must be
267        // recognised so the leading slash can be stripped. Runs on every
268        // platform so Linux CI guards the logic the Windows-only wiring uses.
269        assert!(has_leading_slash_drive("/C:/Users/dev"));
270        assert!(has_leading_slash_drive("/c:/proj"));
271        assert!(has_leading_slash_drive("/Z:"));
272        // POSIX paths, already-stripped drives, UNC shares and root stay intact.
273        assert!(!has_leading_slash_drive("/home/user/proj"));
274        assert!(!has_leading_slash_drive("C:/already"));
275        assert!(!has_leading_slash_drive("//server/share"));
276        assert!(!has_leading_slash_drive("/"));
277        assert!(!has_leading_slash_drive("/1:/x"));
278    }
279
280    #[cfg(windows)]
281    #[test]
282    fn parse_file_uri_windows_drive_strips_leading_slash() {
283        // GL #273: Cursor on Windows reports roots as `file:///C:/…`; these must
284        // parse to an absolute `C:/` path instead of being rejected (which left
285        // the session falling back to the home dir as project root).
286        let got = uri_to_path("file:///C:/Users/dev/project").expect("windows drive uri");
287        assert!(
288            !got.starts_with('/'),
289            "leading slash must be stripped: {got}"
290        );
291        assert!(
292            got.to_ascii_lowercase().starts_with("c:"),
293            "drive prefix must survive: {got}"
294        );
295    }
296
297    #[cfg(windows)]
298    #[test]
299    fn parse_file_uri_windows_percent_encoded_colon() {
300        // Some clients percent-encode the drive colon (`C%3A`).
301        let got = uri_to_path("file:///C%3A/Users/dev/project").expect("encoded colon uri");
302        assert!(
303            !got.starts_with('/'),
304            "leading slash must be stripped: {got}"
305        );
306        assert!(got.to_ascii_lowercase().starts_with("c:"), "got: {got}");
307    }
308
309    #[test]
310    fn rejects_null_bytes() {
311        assert!(uri_to_path("file:///tmp/evil%00path").is_none());
312    }
313
314    #[test]
315    fn rejects_relative_uri() {
316        assert!(uri_to_path("file://relative/path").is_none());
317    }
318
319    #[test]
320    fn canonicalizes_traversal() {
321        let tmp = tempfile::tempdir().unwrap();
322        let sub = tmp.path().join("a").join("b");
323        std::fs::create_dir_all(&sub).unwrap();
324        let traversal = format!("file://{}/a/b/../..", tmp.path().display());
325        let result = uri_to_path(&traversal);
326        assert!(result.is_some());
327        let resolved = result.unwrap();
328        assert!(
329            !resolved.contains(".."),
330            "should be canonicalized: {resolved}"
331        );
332    }
333
334    #[test]
335    fn best_root_prefers_marker() {
336        let tmp = tempfile::tempdir().unwrap();
337        let with_marker = tmp.path().join("has_git");
338        let without = tmp.path().join("plain");
339        std::fs::create_dir_all(&with_marker).unwrap();
340        std::fs::create_dir_all(&without).unwrap();
341        std::fs::create_dir(with_marker.join(".git")).unwrap();
342
343        let uris = vec![
344            format!("file://{}", without.display()),
345            format!("file://{}", with_marker.display()),
346        ];
347        let result = best_root_from_uris(&uris).unwrap();
348        assert!(result.contains("has_git"));
349    }
350
351    #[test]
352    fn best_root_falls_back_to_first_existing_dir() {
353        let tmp = tempfile::tempdir().unwrap();
354        let a = tmp.path().join("dir_a");
355        let b = tmp.path().join("dir_b");
356        std::fs::create_dir_all(&a).unwrap();
357        std::fs::create_dir_all(&b).unwrap();
358
359        let uris = vec![
360            format!("file://{}", a.display()),
361            format!("file://{}", b.display()),
362        ];
363        let result = best_root_from_uris(&uris).unwrap();
364        assert!(result.contains("dir_a"));
365    }
366
367    #[test]
368    fn best_root_skips_nonexistent() {
369        let uris = vec!["file:///nonexistent_abc_123".to_string()];
370        assert!(best_root_from_uris(&uris).is_none());
371    }
372
373    #[test]
374    fn best_root_empty_returns_none() {
375        assert!(best_root_from_uris(&[]).is_none());
376    }
377
378    #[test]
379    fn env_override_returns_none_when_unset() {
380        let _ = root_from_env();
381    }
382
383    #[test]
384    fn best_root_rejects_home_without_marker() {
385        // A client reporting HOME as its workspace root must NOT turn HOME into
386        // the project root (root cause of cross-project session contamination).
387        if let Some(home) = dirs::home_dir() {
388            let uris = vec![format!("file://{}", home.display())];
389            assert_eq!(
390                best_root_from_uris(&uris),
391                None,
392                "HOME must never be accepted as a marker-less project root"
393            );
394        }
395    }
396
397    #[test]
398    fn best_root_prefers_safe_dir_over_home() {
399        if let Some(home) = dirs::home_dir() {
400            let tmp = tempfile::tempdir().unwrap();
401            let safe = tmp.path().join("real_project");
402            std::fs::create_dir_all(&safe).unwrap();
403            let uris = vec![
404                format!("file://{}", home.display()),
405                format!("file://{}", safe.display()),
406            ];
407            let result = best_root_from_uris(&uris).unwrap();
408            assert!(result.contains("real_project"));
409        }
410    }
411
412    #[test]
413    fn best_root_rejects_filesystem_root() {
414        let uris = vec!["file:///".to_string()];
415        assert!(best_root_from_uris(&uris).is_none());
416    }
417
418    #[test]
419    fn all_paths_from_uris() {
420        let tmp = tempfile::tempdir().unwrap();
421        let a = tmp.path().join("project_a");
422        let b = tmp.path().join("project_b");
423        std::fs::create_dir_all(&a).unwrap();
424        std::fs::create_dir_all(&b).unwrap();
425        std::fs::create_dir(a.join(".git")).unwrap();
426
427        let uris = vec![
428            format!("file://{}", a.display()),
429            format!("file://{}", b.display()),
430        ];
431
432        let paths: Vec<String> = uris.iter().filter_map(|u| uri_to_path(u)).collect();
433        assert_eq!(paths.len(), 2);
434        assert!(paths[0].contains("project_a"));
435        assert!(paths[1].contains("project_b"));
436
437        let best = best_root_from_uris(&uris).unwrap();
438        assert!(best.contains("project_a"));
439    }
440
441    #[test]
442    fn split_workspace_paths_comma_separated() {
443        assert_eq!(
444            split_workspace_paths("/home/u/proj-a,/home/u/proj-b"),
445            vec!["/home/u/proj-a".to_string(), "/home/u/proj-b".to_string()]
446        );
447    }
448
449    #[test]
450    fn split_workspace_paths_trims_and_drops_empty() {
451        assert_eq!(
452            split_workspace_paths(" /a , , /b ,"),
453            vec!["/a".to_string(), "/b".to_string()]
454        );
455    }
456
457    #[cfg(unix)]
458    #[test]
459    fn split_workspace_paths_unix_colon_delimiter() {
460        // Unix path-list delimiter is ':'; POSIX paths never contain it.
461        assert_eq!(
462            split_workspace_paths("/a:/b"),
463            vec!["/a".to_string(), "/b".to_string()]
464        );
465    }
466
467    #[test]
468    fn best_root_from_paths_prefers_marker_over_first() {
469        let tmp = tempfile::tempdir().unwrap();
470        let plain = tmp.path().join("plain");
471        let marked = tmp.path().join("marked");
472        std::fs::create_dir_all(&plain).unwrap();
473        std::fs::create_dir_all(&marked).unwrap();
474        std::fs::create_dir(marked.join(".git")).unwrap();
475        let got = best_root_from_paths(vec![
476            plain.to_string_lossy().to_string(),
477            marked.to_string_lossy().to_string(),
478        ])
479        .unwrap();
480        assert!(got.contains("marked"), "marker dir must win: {got}");
481    }
482
483    #[test]
484    fn best_root_from_paths_filters_nonexistent() {
485        let tmp = tempfile::tempdir().unwrap();
486        let safe = tmp.path().join("real_proj");
487        std::fs::create_dir_all(&safe).unwrap();
488        let got = best_root_from_paths(vec![
489            "/nonexistent_xyz_987".to_string(),
490            safe.to_string_lossy().to_string(),
491        ])
492        .unwrap();
493        assert!(got.contains("real_proj"));
494    }
495
496    #[test]
497    fn best_root_from_paths_empty_returns_none() {
498        assert!(best_root_from_paths(vec![]).is_none());
499        assert!(best_root_from_paths(vec!["/nonexistent_abc".to_string()]).is_none());
500    }
501
502    #[test]
503    fn workspace_env_value_picks_marker_root() {
504        // Mirrors Cursor's `WORKSPACE_FOLDER_PATHS` (comma-separated multi-root):
505        // the folder carrying a project marker must win over a sibling.
506        let tmp = tempfile::tempdir().unwrap();
507        let a = tmp.path().join("ws_a");
508        let b = tmp.path().join("ws_b");
509        std::fs::create_dir_all(&a).unwrap();
510        std::fs::create_dir_all(&b).unwrap();
511        std::fs::write(b.join("Cargo.toml"), "[package]").unwrap();
512        let raw = format!("{},{}", a.display(), b.display());
513        let got = best_root_from_paths(split_workspace_paths(&raw)).unwrap();
514        assert!(got.contains("ws_b"), "marker workspace must win: {got}");
515    }
516
517    #[test]
518    fn workspace_env_readers_do_not_panic() {
519        // Smoke test: both env readers tolerate the variable being set or unset.
520        let _ = root_from_workspace_env();
521        let _ = workspace_roots_from_env();
522    }
523}