Skip to main content

lean_ctx/server/
roots.rs

1use std::path::Path;
2
3const PROJECT_MARKERS: &[&str] = &[
4    ".git",
5    "Cargo.toml",
6    "package.json",
7    "go.mod",
8    "pyproject.toml",
9    "setup.py",
10    "pom.xml",
11    "build.gradle",
12    "Makefile",
13    ".lean-ctx.toml",
14];
15
16/// Parse a `file://` URI to a validated local path string.
17/// Rejects non-file URIs, null bytes, `..` traversal, and non-directory paths.
18/// Returns a canonicalized absolute path.
19pub fn uri_to_path(uri: &str) -> Option<String> {
20    let raw = uri.strip_prefix("file://")?;
21    if raw.contains("%00") {
22        return None;
23    }
24    let decoded = percent_decode(raw);
25    if decoded.is_empty() || decoded.contains('\0') {
26        return None;
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
72pub(super) fn has_project_marker(dir: &Path) -> bool {
73    PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
74}
75
76/// Select the best project root from MCP client roots.
77/// Only considers paths that are existing directories.
78/// Prefers roots with project markers (.git, Cargo.toml, etc.).
79/// Falls back to the first valid directory if none have markers — but never
80/// accepts a broad/unsafe root (HOME, filesystem root, agent sandbox dirs),
81/// which would otherwise contaminate sessions across projects.
82pub fn best_root_from_uris(uris: &[String]) -> Option<String> {
83    let paths: Vec<String> = uris
84        .iter()
85        .filter_map(|u| uri_to_path(u))
86        .filter(|p| Path::new(p).is_dir())
87        .collect();
88
89    if paths.is_empty() {
90        return None;
91    }
92
93    for p in &paths {
94        if has_project_marker(Path::new(p)) {
95            return Some(p.clone());
96        }
97    }
98
99    // No markers: fall back to the first *safe* directory. A client that reports
100    // its workspace root as HOME (some do) must not turn HOME into the project
101    // root — that is the root cause of cross-project session contamination.
102    paths
103        .into_iter()
104        .find(|p| !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(p)))
105}
106
107/// Filter and validate URIs to existing directories only.
108pub fn valid_dir_paths_from_uris(uris: &[String]) -> Vec<String> {
109    uris.iter()
110        .filter_map(|u| uri_to_path(u))
111        .filter(|p| Path::new(p).is_dir())
112        .collect()
113}
114
115/// Detect project root from IDE-specific environment variables.
116/// Priority: LEAN_CTX_PROJECT_ROOT > CLAUDE_PROJECT_DIR
117pub fn root_from_env() -> Option<String> {
118    for var in ["LEAN_CTX_PROJECT_ROOT", "CLAUDE_PROJECT_DIR"] {
119        if let Ok(val) = std::env::var(var) {
120            let trimmed = val.trim().to_string();
121            if !trimmed.is_empty()
122                && Path::new(&trimmed).is_dir()
123                && !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(&trimmed))
124            {
125                return Some(trimmed);
126            }
127        }
128    }
129    None
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[cfg(unix)]
137    #[test]
138    fn parse_file_uri_unix() {
139        assert_eq!(
140            uri_to_path("file:///home/user/project"),
141            Some("/home/user/project".to_string())
142        );
143    }
144
145    #[cfg(unix)]
146    #[test]
147    fn parse_file_uri_windows() {
148        assert_eq!(
149            uri_to_path("file:///C:/Users/dev/project"),
150            Some("/C:/Users/dev/project".to_string())
151        );
152    }
153
154    #[cfg(unix)]
155    #[test]
156    fn parse_file_uri_with_spaces() {
157        assert_eq!(
158            uri_to_path("file:///home/user/my%20project"),
159            Some("/home/user/my project".to_string())
160        );
161    }
162
163    #[test]
164    fn parse_non_file_uri_returns_none() {
165        assert!(uri_to_path("https://example.com").is_none());
166        assert!(uri_to_path("").is_none());
167    }
168
169    #[test]
170    fn rejects_null_bytes() {
171        assert!(uri_to_path("file:///tmp/evil%00path").is_none());
172    }
173
174    #[test]
175    fn rejects_relative_uri() {
176        assert!(uri_to_path("file://relative/path").is_none());
177    }
178
179    #[test]
180    fn canonicalizes_traversal() {
181        let tmp = tempfile::tempdir().unwrap();
182        let sub = tmp.path().join("a").join("b");
183        std::fs::create_dir_all(&sub).unwrap();
184        let traversal = format!("file://{}/a/b/../..", tmp.path().display());
185        let result = uri_to_path(&traversal);
186        assert!(result.is_some());
187        let resolved = result.unwrap();
188        assert!(
189            !resolved.contains(".."),
190            "should be canonicalized: {resolved}"
191        );
192    }
193
194    #[test]
195    fn best_root_prefers_marker() {
196        let tmp = tempfile::tempdir().unwrap();
197        let with_marker = tmp.path().join("has_git");
198        let without = tmp.path().join("plain");
199        std::fs::create_dir_all(&with_marker).unwrap();
200        std::fs::create_dir_all(&without).unwrap();
201        std::fs::create_dir(with_marker.join(".git")).unwrap();
202
203        let uris = vec![
204            format!("file://{}", without.display()),
205            format!("file://{}", with_marker.display()),
206        ];
207        let result = best_root_from_uris(&uris).unwrap();
208        assert!(result.contains("has_git"));
209    }
210
211    #[test]
212    fn best_root_falls_back_to_first_existing_dir() {
213        let tmp = tempfile::tempdir().unwrap();
214        let a = tmp.path().join("dir_a");
215        let b = tmp.path().join("dir_b");
216        std::fs::create_dir_all(&a).unwrap();
217        std::fs::create_dir_all(&b).unwrap();
218
219        let uris = vec![
220            format!("file://{}", a.display()),
221            format!("file://{}", b.display()),
222        ];
223        let result = best_root_from_uris(&uris).unwrap();
224        assert!(result.contains("dir_a"));
225    }
226
227    #[test]
228    fn best_root_skips_nonexistent() {
229        let uris = vec!["file:///nonexistent_abc_123".to_string()];
230        assert!(best_root_from_uris(&uris).is_none());
231    }
232
233    #[test]
234    fn best_root_empty_returns_none() {
235        assert!(best_root_from_uris(&[]).is_none());
236    }
237
238    #[test]
239    fn env_override_returns_none_when_unset() {
240        let _ = root_from_env();
241    }
242
243    #[test]
244    fn best_root_rejects_home_without_marker() {
245        // A client reporting HOME as its workspace root must NOT turn HOME into
246        // the project root (root cause of cross-project session contamination).
247        if let Some(home) = dirs::home_dir() {
248            let uris = vec![format!("file://{}", home.display())];
249            assert_eq!(
250                best_root_from_uris(&uris),
251                None,
252                "HOME must never be accepted as a marker-less project root"
253            );
254        }
255    }
256
257    #[test]
258    fn best_root_prefers_safe_dir_over_home() {
259        if let Some(home) = dirs::home_dir() {
260            let tmp = tempfile::tempdir().unwrap();
261            let safe = tmp.path().join("real_project");
262            std::fs::create_dir_all(&safe).unwrap();
263            let uris = vec![
264                format!("file://{}", home.display()),
265                format!("file://{}", safe.display()),
266            ];
267            let result = best_root_from_uris(&uris).unwrap();
268            assert!(result.contains("real_project"));
269        }
270    }
271
272    #[test]
273    fn best_root_rejects_filesystem_root() {
274        let uris = vec!["file:///".to_string()];
275        assert!(best_root_from_uris(&uris).is_none());
276    }
277
278    #[test]
279    fn all_paths_from_uris() {
280        let tmp = tempfile::tempdir().unwrap();
281        let a = tmp.path().join("project_a");
282        let b = tmp.path().join("project_b");
283        std::fs::create_dir_all(&a).unwrap();
284        std::fs::create_dir_all(&b).unwrap();
285        std::fs::create_dir(a.join(".git")).unwrap();
286
287        let uris = vec![
288            format!("file://{}", a.display()),
289            format!("file://{}", b.display()),
290        ];
291
292        let paths: Vec<String> = uris.iter().filter_map(|u| uri_to_path(u)).collect();
293        assert_eq!(paths.len(), 2);
294        assert!(paths[0].contains("project_a"));
295        assert!(paths[1].contains("project_b"));
296
297        let best = best_root_from_uris(&uris).unwrap();
298        assert!(best.contains("project_a"));
299    }
300}