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