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    let path = Path::new(&decoded);
16    if !path.is_absolute() {
17        return None;
18    }
19    let canonical = crate::core::pathutil::safe_canonicalize_or_self(path);
20    let s = canonical.to_string_lossy().to_string();
21    if s.is_empty() {
22        return None;
23    }
24    Some(s)
25}
26
27fn percent_decode(s: &str) -> String {
28    let mut out = String::with_capacity(s.len());
29    let mut chars = s.bytes();
30    while let Some(b) = chars.next() {
31        if b == b'%' {
32            let hi = chars.next().and_then(hex_val);
33            let lo = chars.next().and_then(hex_val);
34            if let (Some(h), Some(l)) = (hi, lo) {
35                let byte = h << 4 | l;
36                if byte == 0 {
37                    continue;
38                }
39                out.push(byte as char);
40            } else {
41                out.push('%');
42            }
43        } else {
44            out.push(b as char);
45        }
46    }
47    out
48}
49
50fn hex_val(b: u8) -> Option<u8> {
51    match b {
52        b'0'..=b'9' => Some(b - b'0'),
53        b'a'..=b'f' => Some(b - b'a' + 10),
54        b'A'..=b'F' => Some(b - b'A' + 10),
55        _ => None,
56    }
57}
58
59pub(super) fn has_project_marker(dir: &Path) -> bool {
60    crate::core::pathutil::has_project_marker(dir)
61}
62
63/// Select the best project root from MCP client roots.
64/// Only considers paths that are existing directories.
65/// Prefers roots with project markers (.git, Cargo.toml, etc.).
66/// Falls back to the first valid directory if none have markers — but never
67/// accepts a broad/unsafe root (HOME, filesystem root, agent sandbox dirs),
68/// which would otherwise contaminate sessions across projects.
69pub fn best_root_from_uris(uris: &[String]) -> Option<String> {
70    let paths: Vec<String> = uris
71        .iter()
72        .filter_map(|u| uri_to_path(u))
73        .filter(|p| Path::new(p).is_dir())
74        .collect();
75
76    if paths.is_empty() {
77        return None;
78    }
79
80    for p in &paths {
81        if has_project_marker(Path::new(p)) {
82            return Some(p.clone());
83        }
84    }
85
86    // No markers: fall back to the first *safe* directory. A client that reports
87    // its workspace root as HOME (some do) must not turn HOME into the project
88    // root — that is the root cause of cross-project session contamination.
89    paths
90        .into_iter()
91        .find(|p| !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(p)))
92}
93
94/// Filter and validate URIs to existing directories only.
95pub fn valid_dir_paths_from_uris(uris: &[String]) -> Vec<String> {
96    uris.iter()
97        .filter_map(|u| uri_to_path(u))
98        .filter(|p| Path::new(p).is_dir())
99        .collect()
100}
101
102/// Detect project root from IDE-specific environment variables.
103/// Priority: LEAN_CTX_PROJECT_ROOT > CLAUDE_PROJECT_DIR
104pub fn root_from_env() -> Option<String> {
105    for var in ["LEAN_CTX_PROJECT_ROOT", "CLAUDE_PROJECT_DIR"] {
106        if let Ok(val) = std::env::var(var) {
107            let trimmed = val.trim().to_string();
108            if !trimmed.is_empty()
109                && Path::new(&trimmed).is_dir()
110                && !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(&trimmed))
111            {
112                return Some(trimmed);
113            }
114        }
115    }
116    None
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[cfg(unix)]
124    #[test]
125    fn parse_file_uri_unix() {
126        assert_eq!(
127            uri_to_path("file:///home/user/project"),
128            Some("/home/user/project".to_string())
129        );
130    }
131
132    #[cfg(unix)]
133    #[test]
134    fn parse_file_uri_windows() {
135        assert_eq!(
136            uri_to_path("file:///C:/Users/dev/project"),
137            Some("/C:/Users/dev/project".to_string())
138        );
139    }
140
141    #[cfg(unix)]
142    #[test]
143    fn parse_file_uri_with_spaces() {
144        assert_eq!(
145            uri_to_path("file:///home/user/my%20project"),
146            Some("/home/user/my project".to_string())
147        );
148    }
149
150    #[test]
151    fn parse_non_file_uri_returns_none() {
152        assert!(uri_to_path("https://example.com").is_none());
153        assert!(uri_to_path("").is_none());
154    }
155
156    #[test]
157    fn rejects_null_bytes() {
158        assert!(uri_to_path("file:///tmp/evil%00path").is_none());
159    }
160
161    #[test]
162    fn rejects_relative_uri() {
163        assert!(uri_to_path("file://relative/path").is_none());
164    }
165
166    #[test]
167    fn canonicalizes_traversal() {
168        let tmp = tempfile::tempdir().unwrap();
169        let sub = tmp.path().join("a").join("b");
170        std::fs::create_dir_all(&sub).unwrap();
171        let traversal = format!("file://{}/a/b/../..", tmp.path().display());
172        let result = uri_to_path(&traversal);
173        assert!(result.is_some());
174        let resolved = result.unwrap();
175        assert!(
176            !resolved.contains(".."),
177            "should be canonicalized: {resolved}"
178        );
179    }
180
181    #[test]
182    fn best_root_prefers_marker() {
183        let tmp = tempfile::tempdir().unwrap();
184        let with_marker = tmp.path().join("has_git");
185        let without = tmp.path().join("plain");
186        std::fs::create_dir_all(&with_marker).unwrap();
187        std::fs::create_dir_all(&without).unwrap();
188        std::fs::create_dir(with_marker.join(".git")).unwrap();
189
190        let uris = vec![
191            format!("file://{}", without.display()),
192            format!("file://{}", with_marker.display()),
193        ];
194        let result = best_root_from_uris(&uris).unwrap();
195        assert!(result.contains("has_git"));
196    }
197
198    #[test]
199    fn best_root_falls_back_to_first_existing_dir() {
200        let tmp = tempfile::tempdir().unwrap();
201        let a = tmp.path().join("dir_a");
202        let b = tmp.path().join("dir_b");
203        std::fs::create_dir_all(&a).unwrap();
204        std::fs::create_dir_all(&b).unwrap();
205
206        let uris = vec![
207            format!("file://{}", a.display()),
208            format!("file://{}", b.display()),
209        ];
210        let result = best_root_from_uris(&uris).unwrap();
211        assert!(result.contains("dir_a"));
212    }
213
214    #[test]
215    fn best_root_skips_nonexistent() {
216        let uris = vec!["file:///nonexistent_abc_123".to_string()];
217        assert!(best_root_from_uris(&uris).is_none());
218    }
219
220    #[test]
221    fn best_root_empty_returns_none() {
222        assert!(best_root_from_uris(&[]).is_none());
223    }
224
225    #[test]
226    fn env_override_returns_none_when_unset() {
227        let _ = root_from_env();
228    }
229
230    #[test]
231    fn best_root_rejects_home_without_marker() {
232        // A client reporting HOME as its workspace root must NOT turn HOME into
233        // the project root (root cause of cross-project session contamination).
234        if let Some(home) = dirs::home_dir() {
235            let uris = vec![format!("file://{}", home.display())];
236            assert_eq!(
237                best_root_from_uris(&uris),
238                None,
239                "HOME must never be accepted as a marker-less project root"
240            );
241        }
242    }
243
244    #[test]
245    fn best_root_prefers_safe_dir_over_home() {
246        if let Some(home) = dirs::home_dir() {
247            let tmp = tempfile::tempdir().unwrap();
248            let safe = tmp.path().join("real_project");
249            std::fs::create_dir_all(&safe).unwrap();
250            let uris = vec![
251                format!("file://{}", home.display()),
252                format!("file://{}", safe.display()),
253            ];
254            let result = best_root_from_uris(&uris).unwrap();
255            assert!(result.contains("real_project"));
256        }
257    }
258
259    #[test]
260    fn best_root_rejects_filesystem_root() {
261        let uris = vec!["file:///".to_string()];
262        assert!(best_root_from_uris(&uris).is_none());
263    }
264
265    #[test]
266    fn all_paths_from_uris() {
267        let tmp = tempfile::tempdir().unwrap();
268        let a = tmp.path().join("project_a");
269        let b = tmp.path().join("project_b");
270        std::fs::create_dir_all(&a).unwrap();
271        std::fs::create_dir_all(&b).unwrap();
272        std::fs::create_dir(a.join(".git")).unwrap();
273
274        let uris = vec![
275            format!("file://{}", a.display()),
276            format!("file://{}", b.display()),
277        ];
278
279        let paths: Vec<String> = uris.iter().filter_map(|u| uri_to_path(u)).collect();
280        assert_eq!(paths.len(), 2);
281        assert!(paths[0].contains("project_a"));
282        assert!(paths[1].contains("project_b"));
283
284        let best = best_root_from_uris(&uris).unwrap();
285        assert!(best.contains("project_a"));
286    }
287}