1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5use crate::core::config::base_dir;
6
7pub const REGISTRATION_KIND: &str = "session";
11
12fn default_kind() -> String {
13 REGISTRATION_KIND.to_string()
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SessionRegistration {
18 #[serde(default = "default_kind")]
20 pub kind: String,
21 pub session_id: String,
22 pub name: Option<String>,
23 pub socket_path: String,
24 pub pid: u32,
25 pub started_at: DateTime<Utc>,
26}
27
28pub fn registry_dir() -> PathBuf {
35 let dir = std::env::var_os("SYNAPS_RUNTIME_DIR")
36 .map(PathBuf::from)
37 .unwrap_or_else(|| base_dir().join("run"));
38 if let Err(e) = std::fs::create_dir_all(&dir) {
39 tracing::warn!("registry: failed to create run dir {:?}: {}", dir, e);
40 }
41 #[cfg(unix)]
42 {
43 use std::os::unix::fs::PermissionsExt;
44 let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
45 }
46 dir
47}
48
49pub fn sanitize_session_id(raw: &str) -> String {
53 raw.chars()
58 .map(|c| {
59 if c.is_alphanumeric() || c == '-' || c == '_' {
60 c
61 } else {
62 '_'
63 }
64 })
65 .collect::<String>()
66}
67
68pub fn socket_path_for_session(session_id: &str) -> String {
74 socket_path_in_dir(®istry_dir(), session_id)
75}
76
77fn socket_path_in_dir(dir: &std::path::Path, session_id: &str) -> String {
78 let safe_id = sanitize_session_id(session_id);
79 dir.join(format!("{}.sock", safe_id))
80 .to_string_lossy()
81 .into_owned()
82}
83
84pub fn register_session(reg: &SessionRegistration) -> Result<(), String> {
86 register_session_in(reg, ®istry_dir())
87}
88
89fn register_session_in(reg: &SessionRegistration, dir: &std::path::Path) -> Result<(), String> {
90 let safe_id = sanitize_session_id(®.session_id);
91 let path = dir.join(format!("{}.json", safe_id));
92 let tmp = path.with_extension("tmp");
93
94 let json = serde_json::to_string(reg).map_err(|e| format!("serialize error: {}", e))?;
95
96 std::fs::write(&tmp, &json).map_err(|e| format!("write error: {}", e))?;
97
98 #[cfg(unix)]
99 {
100 use std::os::unix::fs::PermissionsExt;
101 let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
102 }
103
104 std::fs::rename(&tmp, &path).map_err(|e| format!("rename error: {}", e))?;
105
106 Ok(())
107}
108
109pub fn update_session_name(session_id: &str, name: Option<&str>) -> Result<(), String> {
113 update_session_name_in(session_id, name, ®istry_dir())
114}
115
116fn update_session_name_in(session_id: &str, name: Option<&str>, dir: &std::path::Path) -> Result<(), String> {
117 let safe_id = sanitize_session_id(session_id);
118 let path = dir.join(format!("{}.json", safe_id));
119 let content = std::fs::read_to_string(&path).map_err(|e| format!("read {}: {}", path.display(), e))?;
120 let mut reg: SessionRegistration =
121 serde_json::from_str(&content).map_err(|e| format!("parse {}: {}", path.display(), e))?;
122 reg.name = name.map(str::to_string);
123 register_session_in(®, dir)
124}
125
126pub fn unregister_session(session_id: &str) {
129 unregister_session_in(session_id, ®istry_dir());
130}
131
132fn unregister_session_in(session_id: &str, dir: &std::path::Path) {
133 let safe_id = sanitize_session_id(session_id);
134 let path = dir.join(format!("{}.json", safe_id));
135
136 if let Ok(content) = std::fs::read_to_string(&path) {
138 if let Ok(reg) = serde_json::from_str::<SessionRegistration>(&content) {
139 let sock = std::path::Path::new(®.socket_path);
140 if sock.starts_with(dir) && sock.extension().is_some_and(|e| e == "sock") {
143 let _ = std::fs::remove_file(sock);
144 }
145 }
146 }
147
148 let _ = std::fs::remove_file(&path);
149}
150
151fn pid_is_alive(pid: u32) -> bool {
153 #[cfg(unix)]
154 {
155 let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
158 result == 0
159 }
160 #[cfg(not(unix))]
161 {
162 let _ = pid;
163 true
164 }
165}
166
167fn is_reserved_json(path: &std::path::Path) -> bool {
170 path.file_stem()
171 .and_then(|s| s.to_str())
172 .is_some_and(|stem| stem == "daemon" || stem.starts_with("daemon-"))
173}
174
175pub fn list_active_sessions() -> Vec<SessionRegistration> {
182 list_active_sessions_in(®istry_dir())
183}
184
185fn list_active_sessions_in(dir: &std::path::Path) -> Vec<SessionRegistration> {
186 let Ok(entries) = std::fs::read_dir(dir) else {
187 return Vec::new();
188 };
189
190 let mut live = Vec::new();
191
192 for entry in entries.flatten() {
193 let path = entry.path();
194 if !path.extension().is_some_and(|e| e == "json") || is_reserved_json(&path) {
195 continue;
196 }
197 let Ok(content) = std::fs::read_to_string(&path) else {
198 continue;
199 };
200 let reg = match serde_json::from_str::<SessionRegistration>(&content) {
201 Ok(reg) if reg.kind == REGISTRATION_KIND => reg,
202 Ok(reg) => {
203 tracing::debug!(path = %path.display(), kind = %reg.kind, "registry: not a session registration — skipped");
204 continue;
205 }
206 Err(e) => {
207 tracing::debug!(path = %path.display(), error = %e, "registry: unparseable json — skipped, not ours");
208 continue;
209 }
210 };
211
212 if pid_is_alive(reg.pid) {
213 live.push(reg);
214 } else {
215 let sock = std::path::Path::new(®.socket_path);
218 if sock.starts_with(dir) && sock.extension().is_some_and(|e| e == "sock") {
219 let _ = std::fs::remove_file(sock);
220 }
221 let _ = std::fs::remove_file(&path);
222 }
223 }
224
225 live
226}
227
228pub fn find_session_registration(query: &str) -> Option<SessionRegistration> {
233 find_session_registration_in(query, ®istry_dir())
234}
235
236fn find_session_registration_in(query: &str, dir: &std::path::Path) -> Option<SessionRegistration> {
237 let sessions = list_active_sessions_in(dir);
238
239 if let Some(reg) = sessions.iter().find(|r| r.session_id == query) {
241 return Some(reg.clone());
242 }
243
244 if let Some(reg) = sessions.iter().find(|r| r.name.as_deref() == Some(query)) {
246 return Some(reg.clone());
247 }
248
249 let matches: Vec<_> = sessions
251 .iter()
252 .filter(|r| r.session_id.starts_with(query))
253 .collect();
254
255 if matches.len() == 1 {
256 Some(matches[0].clone())
257 } else {
258 None
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use serial_test::serial;
266 use tempfile::TempDir;
267
268 fn tmp_registry() -> TempDir {
269 let dir = tempfile::tempdir().unwrap();
270 std::fs::create_dir_all(dir.path()).unwrap();
271 dir
272 }
273
274 fn make_reg(id: &str, name: Option<&str>, pid: u32) -> SessionRegistration {
275 SessionRegistration {
276 kind: REGISTRATION_KIND.to_string(),
277 session_id: id.to_string(),
278 name: name.map(|s| s.to_string()),
279 socket_path: socket_path_for_session(id),
280 pid,
281 started_at: Utc::now(),
282 }
283 }
284
285 fn dir_buf(tmp: &TempDir) -> PathBuf {
286 tmp.path().to_path_buf()
287 }
288
289 #[test]
290 fn register_creates_file() {
291 let tmp = tmp_registry();
292 let dir = dir_buf(&tmp);
293 let reg = make_reg("abc-1234", None, std::process::id());
294 register_session_in(®, &dir).unwrap();
295 assert!(dir.join("abc-1234.json").exists());
296 }
297
298 #[test]
299 fn list_returns_live_sessions() {
300 let tmp = tmp_registry();
301 let dir = dir_buf(&tmp);
302 let pid = std::process::id();
303 let reg = make_reg("live-0001", Some("my-agent"), pid);
304 register_session_in(®, &dir).unwrap();
305
306 let sessions = list_active_sessions_in(&dir);
307 assert!(sessions.iter().any(|r| r.session_id == "live-0001"));
308 }
309
310 #[test]
311 fn find_by_exact_id() {
312 let tmp = tmp_registry();
313 let dir = dir_buf(&tmp);
314 let reg = make_reg("find-exact-01", None, std::process::id());
315 register_session_in(®, &dir).unwrap();
316
317 let found = find_session_registration_in("find-exact-01", &dir);
318 assert!(found.is_some());
319 assert_eq!(found.unwrap().session_id, "find-exact-01");
320 }
321
322 #[test]
323 fn find_by_name() {
324 let tmp = tmp_registry();
325 let dir = dir_buf(&tmp);
326 let reg = make_reg("named-session-01", Some("prod-agent"), std::process::id());
327 register_session_in(®, &dir).unwrap();
328
329 let found = find_session_registration_in("prod-agent", &dir);
330 assert!(found.is_some());
331 assert_eq!(found.unwrap().session_id, "named-session-01");
332 }
333
334 #[test]
335 fn find_by_partial_prefix() {
336 let tmp = tmp_registry();
337 let dir = dir_buf(&tmp);
338 let reg = make_reg("prefix-abcdef-01", None, std::process::id());
339 register_session_in(®, &dir).unwrap();
340
341 let found = find_session_registration_in("prefix-abc", &dir);
342 assert!(found.is_some());
343 assert_eq!(found.unwrap().session_id, "prefix-abcdef-01");
344 }
345
346 #[test]
347 fn ambiguous_prefix_returns_none() {
348 let tmp = tmp_registry();
349 let dir = dir_buf(&tmp);
350 let pid = std::process::id();
351 register_session_in(&make_reg("dup-aaaa-01", None, pid), &dir).unwrap();
352 register_session_in(&make_reg("dup-aaaa-02", None, pid), &dir).unwrap();
353
354 let found = find_session_registration_in("dup-aaaa", &dir);
355 assert!(found.is_none(), "ambiguous prefix should return None");
356 }
357
358 #[test]
361 fn foreign_json_is_never_unlinked() {
362 let tmp = tmp_registry();
363 let dir = dir_buf(&tmp);
364 let daemon = dir.join("daemon.json");
365 std::fs::write(
366 &daemon,
367 r#"{"pid":1,"version":"0.9.0","exe":"/usr/bin/synaps","socket":"/x/daemon.sock","protocol":2}"#,
368 )
369 .unwrap();
370 let garbage = dir.join("garbage.json");
371 std::fs::write(&garbage, "this is not json {").unwrap();
372 let other = dir.join("other-kind.json");
374 std::fs::write(
375 &other,
376 r#"{"kind":"widget","session_id":"x","name":null,"socket_path":"/x.sock","pid":1,"started_at":"2024-01-01T00:00:00Z"}"#,
377 )
378 .unwrap();
379 let legacy = dir.join("legacy-0001.json");
381 std::fs::write(
382 &legacy,
383 format!(
384 r#"{{"session_id":"legacy-0001","name":"old","socket_path":"{}","pid":{},"started_at":"2024-01-01T00:00:00Z"}}"#,
385 dir.join("legacy-0001.sock").display(),
386 std::process::id()
387 ),
388 )
389 .unwrap();
390 let reg = make_reg("live-0002", Some("ambient"), std::process::id());
391 register_session_in(®, &dir).unwrap();
392
393 for _ in 0..2 {
394 let found = find_session_registration_in("ambient", &dir).expect("session resolved");
395 assert_eq!(found.session_id, "live-0002");
396 assert!(find_session_registration_in("old", &dir).is_some(), "legacy reg counts");
397 let ids: Vec<_> = list_active_sessions_in(&dir).into_iter().map(|r| r.session_id).collect();
398 assert!(!ids.contains(&"x".to_string()), "foreign kind not listed");
399 assert!(daemon.exists(), "daemon.json must survive");
400 assert!(garbage.exists(), "garbage.json must survive");
401 assert!(other.exists(), "other-kind.json must survive");
402 assert!(legacy.exists());
403 }
404 assert!(std::fs::read_to_string(&daemon).unwrap().contains("0.9.0"));
405 }
406
407 #[test]
411 fn update_name_rewrites_registration() {
412 let tmp = tmp_registry();
413 let dir = dir_buf(&tmp);
414 let reg = make_reg("rename-0001", None, std::process::id());
415 register_session_in(®, &dir).unwrap();
416 assert!(find_session_registration_in("ambient", &dir).is_none());
417
418 update_session_name_in("rename-0001", Some("ambient"), &dir).unwrap();
419 let found = find_session_registration_in("ambient", &dir).expect("resolves by new name");
420 assert_eq!(found.session_id, "rename-0001");
421 assert_eq!(found.socket_path, reg.socket_path, "rest of the record kept");
422
423 update_session_name_in("rename-0001", None, &dir).unwrap();
424 assert!(find_session_registration_in("ambient", &dir).is_none(), "cleared");
425 assert!(update_session_name_in("ghost", Some("x"), &dir).is_err(), "no reg → error");
426 }
427
428 #[test]
429 fn unregister_removes_file() {
430 let tmp = tmp_registry();
431 let dir = dir_buf(&tmp);
432 let reg = make_reg("unreg-0001", None, std::process::id());
433 register_session_in(®, &dir).unwrap();
434
435 let path = dir.join("unreg-0001.json");
436 assert!(path.exists());
437
438 unregister_session_in("unreg-0001", &dir);
439 assert!(!path.exists());
440 }
441
442 #[test]
443 fn unregister_is_idempotent() {
444 let tmp = tmp_registry();
445 let dir = dir_buf(&tmp);
446 unregister_session_in("ghost-session-99", &dir);
448 }
449
450 #[test]
451 fn stale_pid_pruned() {
452 let tmp = tmp_registry();
453 let dir = dir_buf(&tmp);
454 let reg = make_reg("stale-dead-pid", None, 999999);
456 register_session_in(®, &dir).unwrap();
457
458 let sessions = list_active_sessions_in(&dir);
459 assert!(
460 !sessions.iter().any(|r| r.session_id == "stale-dead-pid"),
461 "stale registration should have been pruned"
462 );
463
464 assert!(!dir.join("stale-dead-pid.json").exists());
466 }
467
468 #[test]
469 #[serial(synaps_base_dir)]
474 fn socket_path_format() {
475 let path = socket_path_for_session("20240101-120000-ab12");
476 assert!(
478 path.ends_with("/run/20240101-120000-ab12.sock"),
479 "got: {}",
480 path
481 );
482 assert!(!path.contains("/tmp/"), "socket should not be in /tmp");
483 }
484
485 #[cfg(unix)]
488 #[test]
489 fn short_runtime_dirs_keep_long_session_socket_bindable_and_isolated() {
490 use std::os::unix::net::UnixListener;
491 let long_session = "s".repeat(80);
492 let path_a = socket_path_in_dir(std::path::Path::new("/tmp/a"), &long_session);
494 let path_b = socket_path_in_dir(std::path::Path::new("/tmp/b"), &long_session);
495 assert!(path_a.len() < 108, "Unix socket path too long: {path_a}");
496 assert_ne!(
497 path_a, path_b,
498 "per-user runtime roots must isolate same session ids"
499 );
500
501 let pid = std::process::id();
507 let a = std::path::PathBuf::from(format!("/tmp/sa{pid}"));
508 let b = std::path::PathBuf::from(format!("/tmp/sb{pid}"));
509 std::fs::create_dir_all(&a).unwrap();
510 std::fs::create_dir_all(&b).unwrap();
511 let _a = UnixListener::bind(socket_path_in_dir(&a, &long_session)).unwrap();
512 let _b = UnixListener::bind(socket_path_in_dir(&b, &long_session)).unwrap();
513 let _ = std::fs::remove_dir_all(&a);
514 let _ = std::fs::remove_dir_all(&b);
515 }
516
517 #[cfg(unix)]
518 #[test]
519 fn registration_file_is_0600() {
520 use std::os::unix::fs::PermissionsExt;
521 let tmp = tmp_registry();
522 let dir = dir_buf(&tmp);
523 let reg = make_reg("perms-check-01", None, std::process::id());
524 register_session_in(®, &dir).unwrap();
525
526 let path = dir.join("perms-check-01.json");
527 let perms = std::fs::metadata(&path).unwrap().permissions();
528 assert_eq!(perms.mode() & 0o777, 0o600, "registry file should be 0600");
529 }
530}
531
532#[derive(Debug, Clone, PartialEq, Eq)]
537pub struct DaemonPaths {
538 pub dir: PathBuf,
539 pub sock: PathBuf,
540 pub lock: PathBuf,
541 pub json: PathBuf,
542 pub pid: PathBuf,
543}
544
545pub fn daemon_paths(profile: Option<&str>) -> DaemonPaths {
547 daemon_paths_in(®istry_dir(), profile)
548}
549
550pub fn daemon_paths_in(dir: &std::path::Path, profile: Option<&str>) -> DaemonPaths {
552 let stem = match profile.map(sanitize_session_id).filter(|p| !p.is_empty()) {
553 Some(p) => format!("daemon-{p}"),
554 None => "daemon".to_string(),
555 };
556 DaemonPaths {
557 dir: dir.to_path_buf(),
558 sock: dir.join(format!("{stem}.sock")),
559 lock: dir.join(format!("{stem}.lock")),
560 json: dir.join(format!("{stem}.json")),
561 pid: dir.join(format!("{stem}.pid")),
562 }
563}
564
565#[cfg(test)]
566mod daemon_paths_tests {
567 use super::*;
568
569 #[test]
570 fn daemon_paths_are_profile_scoped_and_sanitised() {
571 let d = std::path::Path::new("/run/x");
572 let p = daemon_paths_in(d, None);
573 assert_eq!(p.sock, d.join("daemon.sock"));
574 assert_eq!(p.lock, d.join("daemon.lock"));
575 assert_eq!(p.json, d.join("daemon.json"));
576 assert_eq!(p.pid, d.join("daemon.pid"));
577 let p = daemon_paths_in(d, Some("work/../evil"));
578 assert_eq!(p.sock, d.join("daemon-work____evil.sock"));
579 }
580}