1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use chrono::{DateTime, Utc};
4
5use crate::core::config::base_dir;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct SessionRegistration {
9 pub session_id: String,
10 pub name: Option<String>,
11 pub socket_path: String,
12 pub pid: u32,
13 pub started_at: DateTime<Utc>,
14}
15
16pub fn registry_dir() -> PathBuf {
23 let dir = std::env::var_os("SYNAPS_RUNTIME_DIR")
24 .map(PathBuf::from)
25 .unwrap_or_else(|| base_dir().join("run"));
26 if let Err(e) = std::fs::create_dir_all(&dir) {
27 tracing::warn!("registry: failed to create run dir {:?}: {}", dir, e);
28 }
29 #[cfg(unix)]
30 {
31 use std::os::unix::fs::PermissionsExt;
32 let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
33 }
34 dir
35}
36
37pub fn sanitize_session_id(raw: &str) -> String {
41 raw.chars()
46 .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
47 .collect::<String>()
48}
49
50pub fn socket_path_for_session(session_id: &str) -> String {
56 socket_path_in_dir(®istry_dir(), session_id)
57}
58
59fn socket_path_in_dir(dir: &std::path::Path, session_id: &str) -> String {
60 let safe_id = sanitize_session_id(session_id);
61 dir.join(format!("{}.sock", safe_id))
62 .to_string_lossy()
63 .into_owned()
64}
65
66pub fn register_session(reg: &SessionRegistration) -> Result<(), String> {
68 register_session_in(reg, ®istry_dir())
69}
70
71fn register_session_in(reg: &SessionRegistration, dir: &std::path::Path) -> Result<(), String> {
72 let safe_id = sanitize_session_id(®.session_id);
73 let path = dir.join(format!("{}.json", safe_id));
74 let tmp = path.with_extension("tmp");
75
76 let json = serde_json::to_string(reg)
77 .map_err(|e| format!("serialize error: {}", e))?;
78
79 std::fs::write(&tmp, &json)
80 .map_err(|e| format!("write error: {}", e))?;
81
82 #[cfg(unix)]
83 {
84 use std::os::unix::fs::PermissionsExt;
85 let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
86 }
87
88 std::fs::rename(&tmp, &path)
89 .map_err(|e| format!("rename error: {}", e))?;
90
91 Ok(())
92}
93
94pub fn unregister_session(session_id: &str) {
97 unregister_session_in(session_id, ®istry_dir());
98}
99
100fn unregister_session_in(session_id: &str, dir: &std::path::Path) {
101 let safe_id = sanitize_session_id(session_id);
102 let path = dir.join(format!("{}.json", safe_id));
103
104 if let Ok(content) = std::fs::read_to_string(&path) {
106 if let Ok(reg) = serde_json::from_str::<SessionRegistration>(&content) {
107 let sock = std::path::Path::new(®.socket_path);
108 if sock.starts_with(dir) && sock.extension().is_some_and(|e| e == "sock") {
111 let _ = std::fs::remove_file(sock);
112 }
113 }
114 }
115
116 let _ = std::fs::remove_file(&path);
117}
118
119fn pid_is_alive(pid: u32) -> bool {
121 #[cfg(unix)]
122 {
123 let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
126 result == 0
127 }
128 #[cfg(not(unix))]
129 {
130 let _ = pid;
131 true
132 }
133}
134
135pub fn list_active_sessions() -> Vec<SessionRegistration> {
137 list_active_sessions_in(®istry_dir())
138}
139
140fn list_active_sessions_in(dir: &std::path::Path) -> Vec<SessionRegistration> {
141 let Ok(entries) = std::fs::read_dir(dir) else {
142 return Vec::new();
143 };
144
145 let mut live = Vec::new();
146
147 for entry in entries.flatten() {
148 let path = entry.path();
149 if path.extension().is_some_and(|e| e == "json") {
150 let Ok(content) = std::fs::read_to_string(&path) else { continue };
151 let Ok(reg) = serde_json::from_str::<SessionRegistration>(&content) else {
152 let _ = std::fs::remove_file(&path);
153 continue;
154 };
155
156 if pid_is_alive(reg.pid) {
157 live.push(reg);
158 } else {
159 let _ = std::fs::remove_file(std::path::Path::new(®.socket_path));
160 let _ = std::fs::remove_file(&path);
161 }
162 }
163 }
164
165 live
166}
167
168pub fn find_session_registration(query: &str) -> Option<SessionRegistration> {
173 find_session_registration_in(query, ®istry_dir())
174}
175
176fn find_session_registration_in(query: &str, dir: &std::path::Path) -> Option<SessionRegistration> {
177 let sessions = list_active_sessions_in(dir);
178
179 if let Some(reg) = sessions.iter().find(|r| r.session_id == query) {
181 return Some(reg.clone());
182 }
183
184 if let Some(reg) = sessions.iter().find(|r| r.name.as_deref() == Some(query)) {
186 return Some(reg.clone());
187 }
188
189 let matches: Vec<_> = sessions
191 .iter()
192 .filter(|r| r.session_id.starts_with(query))
193 .collect();
194
195 if matches.len() == 1 {
196 Some(matches[0].clone())
197 } else {
198 None
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use serial_test::serial;
206 use tempfile::TempDir;
207
208 fn tmp_registry() -> TempDir {
209 let dir = tempfile::tempdir().unwrap();
210 std::fs::create_dir_all(dir.path()).unwrap();
211 dir
212 }
213
214 fn make_reg(id: &str, name: Option<&str>, pid: u32) -> SessionRegistration {
215 SessionRegistration {
216 session_id: id.to_string(),
217 name: name.map(|s| s.to_string()),
218 socket_path: socket_path_for_session(id),
219 pid,
220 started_at: Utc::now(),
221 }
222 }
223
224 fn dir_buf(tmp: &TempDir) -> PathBuf {
225 tmp.path().to_path_buf()
226 }
227
228 #[test]
229 fn register_creates_file() {
230 let tmp = tmp_registry();
231 let dir = dir_buf(&tmp);
232 let reg = make_reg("abc-1234", None, std::process::id());
233 register_session_in(®, &dir).unwrap();
234 assert!(dir.join("abc-1234.json").exists());
235 }
236
237 #[test]
238 fn list_returns_live_sessions() {
239 let tmp = tmp_registry();
240 let dir = dir_buf(&tmp);
241 let pid = std::process::id();
242 let reg = make_reg("live-0001", Some("my-agent"), pid);
243 register_session_in(®, &dir).unwrap();
244
245 let sessions = list_active_sessions_in(&dir);
246 assert!(sessions.iter().any(|r| r.session_id == "live-0001"));
247 }
248
249 #[test]
250 fn find_by_exact_id() {
251 let tmp = tmp_registry();
252 let dir = dir_buf(&tmp);
253 let reg = make_reg("find-exact-01", None, std::process::id());
254 register_session_in(®, &dir).unwrap();
255
256 let found = find_session_registration_in("find-exact-01", &dir);
257 assert!(found.is_some());
258 assert_eq!(found.unwrap().session_id, "find-exact-01");
259 }
260
261 #[test]
262 fn find_by_name() {
263 let tmp = tmp_registry();
264 let dir = dir_buf(&tmp);
265 let reg = make_reg("named-session-01", Some("prod-agent"), std::process::id());
266 register_session_in(®, &dir).unwrap();
267
268 let found = find_session_registration_in("prod-agent", &dir);
269 assert!(found.is_some());
270 assert_eq!(found.unwrap().session_id, "named-session-01");
271 }
272
273 #[test]
274 fn find_by_partial_prefix() {
275 let tmp = tmp_registry();
276 let dir = dir_buf(&tmp);
277 let reg = make_reg("prefix-abcdef-01", None, std::process::id());
278 register_session_in(®, &dir).unwrap();
279
280 let found = find_session_registration_in("prefix-abc", &dir);
281 assert!(found.is_some());
282 assert_eq!(found.unwrap().session_id, "prefix-abcdef-01");
283 }
284
285 #[test]
286 fn ambiguous_prefix_returns_none() {
287 let tmp = tmp_registry();
288 let dir = dir_buf(&tmp);
289 let pid = std::process::id();
290 register_session_in(&make_reg("dup-aaaa-01", None, pid), &dir).unwrap();
291 register_session_in(&make_reg("dup-aaaa-02", None, pid), &dir).unwrap();
292
293 let found = find_session_registration_in("dup-aaaa", &dir);
294 assert!(found.is_none(), "ambiguous prefix should return None");
295 }
296
297 #[test]
298 fn unregister_removes_file() {
299 let tmp = tmp_registry();
300 let dir = dir_buf(&tmp);
301 let reg = make_reg("unreg-0001", None, std::process::id());
302 register_session_in(®, &dir).unwrap();
303
304 let path = dir.join("unreg-0001.json");
305 assert!(path.exists());
306
307 unregister_session_in("unreg-0001", &dir);
308 assert!(!path.exists());
309 }
310
311 #[test]
312 fn unregister_is_idempotent() {
313 let tmp = tmp_registry();
314 let dir = dir_buf(&tmp);
315 unregister_session_in("ghost-session-99", &dir);
317 }
318
319 #[test]
320 fn stale_pid_pruned() {
321 let tmp = tmp_registry();
322 let dir = dir_buf(&tmp);
323 let reg = make_reg("stale-dead-pid", None, 999999);
325 register_session_in(®, &dir).unwrap();
326
327 let sessions = list_active_sessions_in(&dir);
328 assert!(
329 !sessions.iter().any(|r| r.session_id == "stale-dead-pid"),
330 "stale registration should have been pruned"
331 );
332
333 assert!(!dir.join("stale-dead-pid.json").exists());
335 }
336
337 #[test]
338 #[serial]
339 fn socket_path_format() {
340 let path = socket_path_for_session("20240101-120000-ab12");
341 assert!(path.ends_with("/run/20240101-120000-ab12.sock"), "got: {}", path);
343 assert!(!path.contains("/tmp/"), "socket should not be in /tmp");
344 }
345
346 #[cfg(unix)]
349 #[test]
350 fn short_runtime_dirs_keep_long_session_socket_bindable_and_isolated() {
351 use std::os::unix::net::UnixListener;
352 let long_session = "s".repeat(80);
353 let path_a = socket_path_in_dir(std::path::Path::new("/tmp/a"), &long_session);
355 let path_b = socket_path_in_dir(std::path::Path::new("/tmp/b"), &long_session);
356 assert!(path_a.len() < 108, "Unix socket path too long: {path_a}");
357 assert_ne!(path_a, path_b, "per-user runtime roots must isolate same session ids");
358
359 let pid = std::process::id();
365 let a = std::path::PathBuf::from(format!("/tmp/sa{pid}"));
366 let b = std::path::PathBuf::from(format!("/tmp/sb{pid}"));
367 std::fs::create_dir_all(&a).unwrap();
368 std::fs::create_dir_all(&b).unwrap();
369 let _a = UnixListener::bind(socket_path_in_dir(&a, &long_session)).unwrap();
370 let _b = UnixListener::bind(socket_path_in_dir(&b, &long_session)).unwrap();
371 let _ = std::fs::remove_dir_all(&a);
372 let _ = std::fs::remove_dir_all(&b);
373 }
374
375 #[cfg(unix)]
376 #[test]
377 fn registration_file_is_0600() {
378 use std::os::unix::fs::PermissionsExt;
379 let tmp = tmp_registry();
380 let dir = dir_buf(&tmp);
381 let reg = make_reg("perms-check-01", None, std::process::id());
382 register_session_in(®, &dir).unwrap();
383
384 let path = dir.join("perms-check-01.json");
385 let perms = std::fs::metadata(&path).unwrap().permissions();
386 assert_eq!(perms.mode() & 0o777, 0o600, "registry file should be 0600");
387 }
388}