Skip to main content

lean_ctx/lsp/
editor_bridge.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Editor semantic bridges (VS Code, Cursor, Windsurf, …).
3//!
4//! The lean-ctx editor extension serves the JetBrains plugin's loopback HTTP
5//! protocol (`/health`, `/definition`, `/declaration`, `/references`,
6//! `/implementations`, `/type_hierarchy`) from the editor's own language
7//! features, and announces itself with one JSON file per workspace folder in
8//! [`bridge_dir`]. A bridge is matched by its `project_root`, canonicalized
9//! here — not by a hash both sides would have to compute identically.
10//!
11//! Bridges answer read-only semantic queries when no language server of the
12//! router is live and no JetBrains IDE is attached. They never start anything
13//! and `ctx_refactor` never routes edits to them.
14
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17use std::sync::{LazyLock, Mutex, PoisonError};
18use std::time::{Duration, Instant};
19
20use serde::Deserialize;
21
22use super::jetbrains_backend::JetBrainsHttpBackend;
23use super::port_discovery::pid_alive;
24
25/// How long a discovery result (found or not) is reused.
26const DISCOVERY_TTL: Duration = Duration::from_secs(10);
27/// A bridge file is a few hundred bytes; anything far larger is not one.
28const MAX_BRIDGE_FILE_BYTES: u64 = 64 * 1024;
29
30/// One announced bridge (the extension's JSON file).
31#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
32pub struct BridgeFile {
33    pub port: u16,
34    pub token: String,
35    pub pid: u32,
36    pub project_root: String,
37    /// Editor id (`vscode`, `cursor`, `windsurf`, …).
38    pub editor: String,
39    #[serde(default)]
40    pub editor_version: String,
41    /// Digest of the installed extensions (and so of the language providers
42    /// that answer): part of the backend identity, so cached answers retire
43    /// when a language extension is updated while the editor version is not.
44    #[serde(default)]
45    pub provider_fingerprint: String,
46}
47
48impl BridgeFile {
49    /// Version part of the identity: editor version plus provider digest.
50    fn version(&self) -> String {
51        let fingerprint: String = self
52            .provider_fingerprint
53            .chars()
54            .filter(char::is_ascii_alphanumeric)
55            .take(12)
56            .collect();
57        if fingerprint.is_empty() {
58            self.editor_version.clone()
59        } else {
60            format!("{}+{fingerprint}", self.editor_version)
61        }
62    }
63
64    /// A backend speaking to this bridge for `project_root`.
65    pub fn backend(&self, project_root: &str) -> JetBrainsHttpBackend {
66        JetBrainsHttpBackend::editor(
67            self.port,
68            self.token.clone(),
69            project_root,
70            self.pid,
71            &self.editor,
72            &self.version(),
73        )
74    }
75
76    /// Backend identity, as cached semantic answers record it.
77    pub fn identity(&self, project_root: &str) -> String {
78        use super::backend::LspBackend;
79        self.backend(project_root).backend_info().identity()
80    }
81}
82
83/// Where extensions announce bridges: `<data dir>/editor-bridges`.
84pub fn bridge_dir() -> Option<PathBuf> {
85    crate::core::data_dir::lean_ctx_data_dir()
86        .ok()
87        .map(|d| d.join("editor-bridges"))
88}
89
90/// [`bridge_dir`], created owner-only — for `lean-ctx editor-bridge dir`.
91/// Fails rather than hand out a directory others could write to.
92pub fn ensure_bridge_dir() -> Result<PathBuf, String> {
93    let dir = bridge_dir().ok_or("cannot resolve the lean-ctx data directory")?;
94    std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
95    #[cfg(unix)]
96    {
97        use std::os::unix::fs::PermissionsExt;
98        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
99            .map_err(|e| format!("restrict {}: {e}", dir.display()))?;
100    }
101    if !private_to_user(&dir, true) {
102        return Err(format!(
103            "{} is not a directory private to the current user",
104            dir.display()
105        ));
106    }
107    Ok(dir)
108}
109
110/// Whether `path` is a real directory (`dir`) or regular file — not a
111/// symlink — owned by the current user and writable by nobody else. An
112/// announcement carries a token and names an endpoint lean-ctx will trust,
113/// so one another local user could have planted or edited is ignored. On
114/// Windows the data directory lives in the user's profile, protected by its
115/// ACL; only the symlink check applies.
116fn private_to_user(path: &Path, dir: bool) -> bool {
117    let Ok(meta) = std::fs::symlink_metadata(path) else {
118        return false;
119    };
120    let kind_ok = if dir { meta.is_dir() } else { meta.is_file() };
121    #[cfg(unix)]
122    {
123        use std::os::unix::fs::MetadataExt;
124        // SAFETY: geteuid has no preconditions and cannot fail.
125        let me = unsafe { libc::geteuid() };
126        kind_ok && meta.uid() == me && meta.mode() & 0o022 == 0
127    }
128    #[cfg(not(unix))]
129    {
130        kind_ok
131    }
132}
133
134fn read_bridge_file(path: &Path) -> Option<BridgeFile> {
135    use std::io::Read;
136    let mut text = String::new();
137    std::fs::File::open(path)
138        .ok()?
139        .take(MAX_BRIDGE_FILE_BYTES + 1)
140        .read_to_string(&mut text)
141        .ok()?;
142    if text.len() as u64 > MAX_BRIDGE_FILE_BYTES {
143        return None;
144    }
145    serde_json::from_str(&text).ok()
146}
147
148fn canonical(path: &str) -> PathBuf {
149    crate::core::pathutil::canonicalize_secure_or_self(Path::new(path))
150}
151
152/// Bridge files in `dir` announcing `project_root`, newest first.
153fn candidates(dir: &Path, project_root: &str) -> Vec<BridgeFile> {
154    let root = canonical(project_root);
155    if !private_to_user(dir, true) {
156        return Vec::new();
157    }
158    let Ok(entries) = std::fs::read_dir(dir) else {
159        return Vec::new();
160    };
161    let mut found: Vec<(std::time::SystemTime, String, BridgeFile)> = entries
162        .flatten()
163        .map(|e| e.path())
164        .filter(|p| p.extension().is_some_and(|e| e == "json"))
165        .filter(|p| private_to_user(p, false))
166        .filter_map(|p| {
167            let bridge = read_bridge_file(&p)?;
168            (canonical(&bridge.project_root) == root).then(|| {
169                let modified = p
170                    .metadata()
171                    .and_then(|m| m.modified())
172                    .unwrap_or(std::time::UNIX_EPOCH);
173                (modified, p.to_string_lossy().into_owned(), bridge)
174            })
175        })
176        .collect();
177    // Newest first; the path breaks ties deterministically.
178    found.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
179    found.into_iter().map(|(_, _, b)| b).collect()
180}
181
182/// `GET /health` answers with the bridge's own identity — not merely some
183/// 2xx from whatever now listens on that port.
184fn answers_as_announced(bridge: &BridgeFile) -> bool {
185    let url = format!("http://127.0.0.1:{}/health", bridge.port);
186    let Ok(resp) = ureq::get(&url)
187        .config()
188        .timeout_global(Some(Duration::from_millis(300)))
189        .build()
190        .header("X-LeanCtx-Token", &bridge.token)
191        .call()
192    else {
193        return false;
194    };
195    let Ok(text) = resp.into_body().read_to_string() else {
196        return false;
197    };
198    serde_json::from_str::<serde_json::Value>(&text).is_ok_and(|v| {
199        v.get("status").and_then(|s| s.as_str()) == Some("ok")
200            && v.get("editor").and_then(|e| e.as_str()) == Some(bridge.editor.as_str())
201    })
202}
203
204/// Discovery results per canonical project root: `(bridge, checked at)`.
205static DISCOVERED: LazyLock<Mutex<HashMap<PathBuf, (Option<BridgeFile>, Instant)>>> =
206    LazyLock::new(|| Mutex::new(HashMap::new()));
207
208/// The live bridge announced for `project_root`: its process alive and its
209/// `/health` answering. Answers are reused for `DISCOVERY_TTL` (10 s).
210pub fn discover(project_root: &str) -> Option<BridgeFile> {
211    let key = canonical(project_root);
212    if let Some((bridge, at)) = DISCOVERED
213        .lock()
214        .unwrap_or_else(PoisonError::into_inner)
215        .get(&key)
216        && at.elapsed() < DISCOVERY_TTL
217    {
218        return bridge.clone();
219    }
220    let bridge = bridge_dir().and_then(|dir| {
221        candidates(&dir, project_root)
222            .into_iter()
223            .find(|b| pid_alive(b.pid) && answers_as_announced(b))
224    });
225    let mut cache = DISCOVERED.lock().unwrap_or_else(PoisonError::into_inner);
226    cache.retain(|_, (_, at)| at.elapsed() < DISCOVERY_TTL);
227    cache.insert(key, (bridge.clone(), Instant::now()));
228    bridge
229}
230
231/// Drops the cached discovery for `project_root` (a call to the bridge
232/// failed — the editor may have closed).
233pub fn forget(project_root: &str) {
234    DISCOVERED
235        .lock()
236        .unwrap_or_else(PoisonError::into_inner)
237        .remove(&canonical(project_root));
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    fn write_bridge(dir: &Path, name: &str, root: &str, editor: &str) {
245        std::fs::write(
246            dir.join(name),
247            serde_json::json!({
248                "port": 1, "token": "t", "pid": 1,
249                "project_root": root, "editor": editor, "editor_version": "1.0",
250            })
251            .to_string(),
252        )
253        .unwrap();
254    }
255
256    /// A bridge is found by its canonical project root — another spelling of
257    /// the same directory matches, another project or a stray file does not.
258    #[test]
259    fn bridges_match_by_canonical_project_root() {
260        let dir = tempfile::tempdir().unwrap();
261        let project = tempfile::tempdir().unwrap();
262        let other = tempfile::tempdir().unwrap();
263        let root = project.path().to_string_lossy().to_string();
264        write_bridge(dir.path(), "a.json", &format!("{root}/"), "vscode");
265        write_bridge(
266            dir.path(),
267            "b.json",
268            &other.path().to_string_lossy(),
269            "cursor",
270        );
271        std::fs::write(dir.path().join("c.json"), "not json").unwrap();
272        std::fs::write(dir.path().join("d.txt"), "{}").unwrap();
273
274        let found = candidates(dir.path(), &root);
275        assert_eq!(found.len(), 1);
276        assert_eq!(found[0].editor, "vscode");
277        {
278            let found = &candidates(dir.path(), "/nonexistent/project");
279            assert!(found.is_empty(), "{found:?}");
280        }
281    }
282
283    /// An announcement names an endpoint lean-ctx trusts: one that another
284    /// user could have planted or redirected — a symlink, a file or directory
285    /// writable by others — is ignored.
286    #[cfg(unix)]
287    #[test]
288    fn announcements_others_could_write_are_ignored() {
289        use std::os::unix::fs::PermissionsExt;
290        let project = tempfile::tempdir().unwrap();
291        let root = project.path().to_string_lossy().to_string();
292        let set_mode = |p: &Path, mode| {
293            std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode)).unwrap();
294        };
295
296        let dir = tempfile::tempdir().unwrap();
297        let elsewhere = tempfile::tempdir().unwrap();
298        write_bridge(elsewhere.path(), "real.json", &root, "vscode");
299        std::os::unix::fs::symlink(
300            elsewhere.path().join("real.json"),
301            dir.path().join("link.json"),
302        )
303        .unwrap();
304        write_bridge(dir.path(), "shared.json", &root, "cursor");
305        set_mode(&dir.path().join("shared.json"), 0o666);
306        assert!(
307            candidates(dir.path(), &root).is_empty(),
308            "symlink and group-writable file"
309        );
310
311        set_mode(&dir.path().join("shared.json"), 0o600);
312        assert_eq!(candidates(dir.path(), &root).len(), 1);
313        set_mode(dir.path(), 0o777);
314        assert!(
315            candidates(dir.path(), &root).is_empty(),
316            "world-writable directory"
317        );
318        set_mode(dir.path(), 0o700);
319    }
320}