1use 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
25const DISCOVERY_TTL: Duration = Duration::from_secs(10);
27const MAX_BRIDGE_FILE_BYTES: u64 = 64 * 1024;
29
30#[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 pub editor: String,
39 #[serde(default)]
40 pub editor_version: String,
41 #[serde(default)]
45 pub provider_fingerprint: String,
46}
47
48impl BridgeFile {
49 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 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 pub fn identity(&self, project_root: &str) -> String {
78 use super::backend::LspBackend;
79 self.backend(project_root).backend_info().identity()
80 }
81}
82
83pub 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
90pub 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
110fn 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 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
152fn 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 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
182fn 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
204static DISCOVERED: LazyLock<Mutex<HashMap<PathBuf, (Option<BridgeFile>, Instant)>>> =
206 LazyLock::new(|| Mutex::new(HashMap::new()));
207
208pub 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
231pub 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 #[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 #[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}