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 crate::core::secure_fs::restrict_to_owner(&dir)
96 .map_err(|e| format!("restrict {}: {e}", dir.display()))?;
97 if !private_to_user(&dir, true) {
98 return Err(format!(
99 "{} is not a directory private to the current user",
100 dir.display()
101 ));
102 }
103 Ok(dir)
104}
105
106fn private_to_user(path: &Path, dir: bool) -> bool {
113 let Ok(meta) = std::fs::symlink_metadata(path) else {
114 return false;
115 };
116 let kind_ok = if dir { meta.is_dir() } else { meta.is_file() };
117 #[cfg(unix)]
118 {
119 use std::os::unix::fs::MetadataExt;
120 let me = unsafe { libc::geteuid() };
122 kind_ok && meta.uid() == me && meta.mode() & 0o022 == 0
123 }
124 #[cfg(not(unix))]
125 {
126 kind_ok
127 }
128}
129
130fn read_bridge_file(path: &Path) -> Option<BridgeFile> {
131 use std::io::Read;
132 let mut text = String::new();
133 std::fs::File::open(path)
134 .ok()?
135 .take(MAX_BRIDGE_FILE_BYTES + 1)
136 .read_to_string(&mut text)
137 .ok()?;
138 if text.len() as u64 > MAX_BRIDGE_FILE_BYTES {
139 return None;
140 }
141 serde_json::from_str(&text).ok()
142}
143
144fn canonical(path: &str) -> PathBuf {
145 crate::core::pathutil::canonicalize_secure_or_self(Path::new(path))
146}
147
148fn candidates(dir: &Path, project_root: &str) -> Vec<BridgeFile> {
150 let root = canonical(project_root);
151 if !private_to_user(dir, true) {
152 return Vec::new();
153 }
154 let Ok(entries) = std::fs::read_dir(dir) else {
155 return Vec::new();
156 };
157 let mut found: Vec<(std::time::SystemTime, String, BridgeFile)> = entries
158 .flatten()
159 .map(|e| e.path())
160 .filter(|p| p.extension().is_some_and(|e| e == "json"))
161 .filter(|p| private_to_user(p, false))
162 .filter_map(|p| {
163 let bridge = read_bridge_file(&p)?;
164 (canonical(&bridge.project_root) == root).then(|| {
165 let modified = p
166 .metadata()
167 .and_then(|m| m.modified())
168 .unwrap_or(std::time::UNIX_EPOCH);
169 (modified, p.to_string_lossy().into_owned(), bridge)
170 })
171 })
172 .collect();
173 found.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
175 found.into_iter().map(|(_, _, b)| b).collect()
176}
177
178fn answers_as_announced(bridge: &BridgeFile) -> bool {
181 let url = format!("http://127.0.0.1:{}/health", bridge.port);
182 let Ok(resp) = ureq::get(&url)
183 .config()
184 .timeout_global(Some(Duration::from_millis(300)))
185 .build()
186 .header("X-LeanCtx-Token", &bridge.token)
187 .call()
188 else {
189 return false;
190 };
191 let Ok(text) = resp.into_body().read_to_string() else {
192 return false;
193 };
194 serde_json::from_str::<serde_json::Value>(&text).is_ok_and(|v| {
195 v.get("status").and_then(|s| s.as_str()) == Some("ok")
196 && v.get("editor").and_then(|e| e.as_str()) == Some(bridge.editor.as_str())
197 })
198}
199
200static DISCOVERED: LazyLock<Mutex<HashMap<PathBuf, (Option<BridgeFile>, Instant)>>> =
202 LazyLock::new(|| Mutex::new(HashMap::new()));
203
204pub fn discover(project_root: &str) -> Option<BridgeFile> {
207 let key = canonical(project_root);
208 if let Some((bridge, at)) = DISCOVERED
209 .lock()
210 .unwrap_or_else(PoisonError::into_inner)
211 .get(&key)
212 && at.elapsed() < DISCOVERY_TTL
213 {
214 return bridge.clone();
215 }
216 let bridge = bridge_dir().and_then(|dir| {
217 candidates(&dir, project_root)
218 .into_iter()
219 .find(|b| pid_alive(b.pid) && answers_as_announced(b))
220 });
221 let mut cache = DISCOVERED.lock().unwrap_or_else(PoisonError::into_inner);
222 cache.retain(|_, (_, at)| at.elapsed() < DISCOVERY_TTL);
223 cache.insert(key, (bridge.clone(), Instant::now()));
224 bridge
225}
226
227pub fn forget(project_root: &str) {
230 DISCOVERED
231 .lock()
232 .unwrap_or_else(PoisonError::into_inner)
233 .remove(&canonical(project_root));
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 fn write_bridge(dir: &Path, name: &str, root: &str, editor: &str) {
241 std::fs::write(
242 dir.join(name),
243 serde_json::json!({
244 "port": 1, "token": "t", "pid": 1,
245 "project_root": root, "editor": editor, "editor_version": "1.0",
246 })
247 .to_string(),
248 )
249 .unwrap();
250 }
251
252 #[test]
255 fn bridges_match_by_canonical_project_root() {
256 let dir = tempfile::tempdir().unwrap();
257 let project = tempfile::tempdir().unwrap();
258 let other = tempfile::tempdir().unwrap();
259 let root = project.path().to_string_lossy().to_string();
260 write_bridge(dir.path(), "a.json", &format!("{root}/"), "vscode");
261 write_bridge(
262 dir.path(),
263 "b.json",
264 &other.path().to_string_lossy(),
265 "cursor",
266 );
267 std::fs::write(dir.path().join("c.json"), "not json").unwrap();
268 std::fs::write(dir.path().join("d.txt"), "{}").unwrap();
269
270 let found = candidates(dir.path(), &root);
271 assert_eq!(found.len(), 1);
272 assert_eq!(found[0].editor, "vscode");
273 {
274 let found = &candidates(dir.path(), "/nonexistent/project");
275 assert!(found.is_empty(), "{found:?}");
276 }
277 }
278
279 #[cfg(unix)]
283 #[test]
284 fn announcements_others_could_write_are_ignored() {
285 use std::os::unix::fs::PermissionsExt;
286 let project = tempfile::tempdir().unwrap();
287 let root = project.path().to_string_lossy().to_string();
288 let set_mode = |p: &Path, mode| {
289 std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode)).unwrap();
290 };
291
292 let dir = tempfile::tempdir().unwrap();
293 let elsewhere = tempfile::tempdir().unwrap();
294 write_bridge(elsewhere.path(), "real.json", &root, "vscode");
295 std::os::unix::fs::symlink(
296 elsewhere.path().join("real.json"),
297 dir.path().join("link.json"),
298 )
299 .unwrap();
300 write_bridge(dir.path(), "shared.json", &root, "cursor");
301 set_mode(&dir.path().join("shared.json"), 0o666);
302 assert!(
303 candidates(dir.path(), &root).is_empty(),
304 "symlink and group-writable file"
305 );
306
307 set_mode(&dir.path().join("shared.json"), 0o600);
308 assert_eq!(candidates(dir.path(), &root).len(), 1);
309 set_mode(dir.path(), 0o777);
310 assert!(
311 candidates(dir.path(), &root).is_empty(),
312 "world-writable directory"
313 );
314 set_mode(dir.path(), 0o700);
315 }
316}