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 std::fs::canonicalize(&dir).unwrap_or(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 {
76 socket_path_in_dir(®istry_dir(), session_id)
77}
78
79fn socket_path_in_dir(dir: &std::path::Path, session_id: &str) -> String {
80 let safe_id = sanitize_session_id(session_id);
81 dir.join(format!("{}.sock", safe_id))
82 .to_string_lossy()
83 .into_owned()
84}
85
86pub(crate) fn lock_dir(dir: &std::path::Path) -> Result<std::fs::File, String> {
89 let mut options = std::fs::OpenOptions::new();
90 options.read(true).write(true).create(true);
91 #[cfg(unix)]
92 {
93 use std::os::unix::fs::OpenOptionsExt;
94 options
95 .mode(0o600)
96 .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
97 }
98 let file = options
99 .open(dir.join(".session-registry.lock"))
100 .map_err(|e| e.to_string())?;
101 if !file.metadata().map_err(|e| e.to_string())?.is_file() {
102 return Err("registry lock is not a regular file".into());
103 }
104 #[cfg(unix)]
105 {
106 use std::os::unix::fs::MetadataExt;
107 let meta = file.metadata().map_err(|e| e.to_string())?;
108 if meta.uid() != unsafe { libc::geteuid() }
110 || meta.nlink() != 1
111 || meta.mode() & 0o777 != 0o600
112 {
113 return Err("unsafe registry lock owner, links or permissions".into());
114 }
115 }
116 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(100);
117 loop {
118 if fs4::fs_std::FileExt::try_lock_exclusive(&file).map_err(|e| e.to_string())? {
119 return Ok(file);
120 }
121 if std::time::Instant::now() >= deadline {
122 return Err("registry lock busy".into());
123 }
124 std::thread::sleep(std::time::Duration::from_millis(2));
125 }
126}
127
128fn same_owner(a: &SessionRegistration, b: &SessionRegistration) -> bool {
129 a.session_id == b.session_id
130 && a.pid == b.pid
131 && a.started_at == b.started_at
132 && a.socket_path == b.socket_path
133}
134
135fn generated_endpoint_missing(dir: &std::path::Path, reg: &SessionRegistration) -> bool {
136 #[cfg(unix)]
137 {
138 let path = dir.join(generated_basename(reg));
139 std::path::Path::new(®.socket_path) == path
140 && std::fs::symlink_metadata(path)
141 .is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound)
142 }
143 #[cfg(not(unix))]
144 {
145 let _ = (dir, reg);
146 false
147 }
148}
149
150fn generated_basename(reg: &SessionRegistration) -> String {
151 use sha2::{Digest, Sha256};
152 let identity = format!("{}\0{}\0{}", reg.session_id, reg.pid, reg.started_at);
153 let digest = Sha256::digest(identity.as_bytes());
154 let hex: String = digest[..16].iter().map(|b| format!("{b:02x}")).collect();
155 format!("s-{hex}.sock")
156}
157
158fn regular_card(path: &std::path::Path) -> bool {
159 !is_reserved_json(path)
160 && std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_file())
161}
162
163fn valid_card(path: &std::path::Path, reg: &SessionRegistration) -> bool {
164 reg.kind == REGISTRATION_KIND
165 && !is_reserved_json(path)
166 && path.file_name()
167 == Some(std::ffi::OsStr::new(&format!(
168 "{}.json",
169 sanitize_session_id(®.session_id)
170 )))
171 && std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_file())
172}
173
174fn remove_socket(dir: &std::path::Path, reg: &SessionRegistration) {
175 let sock = std::path::Path::new(®.socket_path);
176 let legacy = format!("{}.sock", sanitize_session_id(®.session_id));
178 if (sock == dir.join(generated_basename(reg)) || sock == dir.join(legacy))
179 && sock.parent() == Some(dir)
180 {
181 super::socket::cleanup_socket(®.socket_path);
182 }
183}
184
185pub struct SessionEndpoint {
191 pub registration: SessionRegistration,
192 dir: PathBuf,
193 shutdown: std::sync::Arc<std::sync::atomic::AtomicBool>,
194 task: tokio::task::JoinHandle<()>,
195 socket: super::socket::SocketIdentity,
196}
197
198impl SessionEndpoint {
199 pub fn start(
200 id: &str,
201 name: Option<String>,
202 queue: std::sync::Arc<super::queue::EventQueue>,
203 ) -> Result<Self, String> {
204 let dir = std::fs::canonicalize(registry_dir()).map_err(|e| e.to_string())?;
205 Self::start_in(&dir, id, name, queue)
206 }
207
208 fn start_in(
209 dir: &std::path::Path,
210 id: &str,
211 name: Option<String>,
212 queue: std::sync::Arc<super::queue::EventQueue>,
213 ) -> Result<Self, String> {
214 let _lock = lock_dir(dir)?;
215 scan_registrations_locked(dir, None);
216 let mut registration = SessionRegistration {
217 kind: REGISTRATION_KIND.into(),
218 session_id: id.into(),
219 name,
220 socket_path: String::new(),
221 pid: std::process::id(),
222 started_at: Utc::now(),
223 };
224 registration.socket_path = dir
225 .join(generated_basename(®istration))
226 .to_string_lossy()
227 .into_owned();
228 let (listener, socket) = super::socket::bind_session_socket(®istration.socket_path)
229 .map_err(|e| e.to_string())?;
230 if let Err(e) = write_registration(®istration, dir) {
231 socket.remove();
232 return Err(e);
233 }
234 let shutdown = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
235 let task = super::socket::serve_session_socket(
236 listener,
237 registration.socket_path.clone(),
238 queue,
239 shutdown.clone(),
240 );
241 Ok(Self {
242 registration,
243 dir: dir.to_path_buf(),
244 shutdown,
245 task,
246 socket,
247 })
248 }
249
250 pub fn update_name(&self, name: Option<&str>) -> Result<(), String> {
252 let _lock = lock_dir(&self.dir)?;
253 let path = self.dir.join(format!(
254 "{}.json",
255 sanitize_session_id(&self.registration.session_id)
256 ));
257 if !regular_card(&path) {
258 return Err("session card missing".into());
259 }
260 let mut disk: SessionRegistration =
261 serde_json::from_str(&std::fs::read_to_string(&path).map_err(|e| e.to_string())?)
262 .map_err(|e| e.to_string())?;
263 if !valid_card(&path, &disk) || !same_owner(&disk, &self.registration) {
264 return Err("session generation replaced".into());
265 }
266 disk.name = name.map(str::to_owned);
267 write_registration(&disk, &self.dir)
268 }
269
270 pub fn shutdown(&self) {
271 self.shutdown
272 .store(true, std::sync::atomic::Ordering::Release);
273 self.task.abort();
274 self.socket.remove();
277 let Ok(_lock) = lock_dir(&self.dir) else {
278 return;
282 };
283 unregister_owned_locked(&self.registration, &self.dir, false);
284 }
285}
286
287impl Drop for SessionEndpoint {
288 fn drop(&mut self) {
289 self.shutdown();
290 }
291}
292
293pub fn register_session(reg: &SessionRegistration) -> Result<(), String> {
295 register_session_in(reg, ®istry_dir())
296}
297
298fn register_session_in(reg: &SessionRegistration, dir: &std::path::Path) -> Result<(), String> {
299 let _lock = lock_dir(dir)?;
300 write_registration(reg, dir)
301}
302
303fn write_registration(reg: &SessionRegistration, dir: &std::path::Path) -> Result<(), String> {
304 use std::io::Write;
305 let safe_id = sanitize_session_id(®.session_id);
306 let path = dir.join(format!("{}.json", safe_id));
307 if is_reserved_json(&path) {
308 return Err("reserved session id".into());
309 }
310 match std::fs::symlink_metadata(&path) {
312 Ok(meta) => {
313 if !meta.file_type().is_file() {
314 return Err("not a regular session card".into());
315 }
316 let old: SessionRegistration =
317 serde_json::from_str(&std::fs::read_to_string(&path).map_err(|e| e.to_string())?)
318 .map_err(|e| e.to_string())?;
319 if !valid_card(&path, &old) || old.session_id != reg.session_id {
320 return Err("refusing to replace a foreign session card".into());
321 }
322 }
323 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
324 Err(e) => return Err(e.to_string()),
325 }
326 let tmp = dir.join(format!(".session-{}.tmp", uuid::Uuid::new_v4().simple()));
327
328 let json = serde_json::to_string(reg).map_err(|e| format!("serialize error: {}", e))?;
329
330 let mut options = std::fs::OpenOptions::new();
331 options.write(true).create_new(true);
332 #[cfg(unix)]
333 {
334 use std::os::unix::fs::OpenOptionsExt;
335 options.mode(0o600);
336 }
337 let result = (|| {
338 let mut file = options.open(&tmp)?;
339 file.write_all(json.as_bytes())?;
340 drop(file);
341 std::fs::rename(&tmp, &path)
342 })();
343 if result.is_err() {
344 let _ = std::fs::remove_file(&tmp);
345 }
346 result.map_err(|e: std::io::Error| e.to_string())?;
347
348 Ok(())
349}
350
351pub fn update_session_name(session_id: &str, name: Option<&str>) -> Result<(), String> {
355 update_session_name_in(session_id, name, ®istry_dir())
356}
357
358fn update_session_name_in(
359 session_id: &str,
360 name: Option<&str>,
361 dir: &std::path::Path,
362) -> Result<(), String> {
363 let _lock = lock_dir(dir)?;
364 let safe_id = sanitize_session_id(session_id);
365 let path = dir.join(format!("{}.json", safe_id));
366 if !regular_card(&path) {
367 return Err("not a regular session card".into());
368 }
369 let content =
370 std::fs::read_to_string(&path).map_err(|e| format!("read {}: {}", path.display(), e))?;
371 let mut reg: SessionRegistration =
372 serde_json::from_str(&content).map_err(|e| format!("parse {}: {}", path.display(), e))?;
373 if !valid_card(&path, ®) {
374 return Err("not a session card".into());
375 }
376 reg.name = name.map(str::to_string);
377 write_registration(®, dir)
378}
379
380pub fn unregister_session(session_id: &str) {
384 unregister_session_in(session_id, ®istry_dir());
385}
386
387fn unregister_session_in(session_id: &str, dir: &std::path::Path) {
388 let Ok(_lock) = lock_dir(dir) else {
389 return;
390 };
391 let path = dir.join(format!("{}.json", sanitize_session_id(session_id)));
392 if !regular_card(&path) {
393 return;
394 }
395 if let Ok(content) = std::fs::read_to_string(&path) {
396 if let Ok(reg) = serde_json::from_str::<SessionRegistration>(&content) {
397 if valid_card(&path, ®) {
398 remove_registration_files(dir, &path, ®);
399 }
400 }
401 }
402}
403
404pub fn unregister_owned(reg: &SessionRegistration) {
413 unregister_owned_in(reg, ®istry_dir());
414}
415
416fn unregister_owned_in(reg: &SessionRegistration, dir: &std::path::Path) {
417 let Ok(_lock) = lock_dir(dir) else {
418 return;
419 };
420 unregister_owned_locked(reg, dir, true);
421}
422
423fn unregister_owned_locked(
424 reg: &SessionRegistration,
425 dir: &std::path::Path,
426 remove_claimed_socket: bool,
427) {
428 let safe_id = sanitize_session_id(®.session_id);
429 let path = dir.join(format!("{}.json", safe_id));
430 if !regular_card(&path) {
431 return;
432 }
433 let Ok(content) = std::fs::read_to_string(&path) else {
434 return;
435 };
436 let Ok(on_disk) = serde_json::from_str::<SessionRegistration>(&content) else {
437 return;
438 };
439 if !valid_card(&path, &on_disk)
440 || on_disk.session_id != reg.session_id
441 || on_disk.pid != reg.pid
442 || on_disk.started_at != reg.started_at
443 || on_disk.socket_path != reg.socket_path
444 {
445 tracing::debug!(session = %reg.session_id, "registry: registration was replaced; leaving the newer one");
446 return;
447 }
448 if remove_claimed_socket {
449 remove_socket(dir, &on_disk);
450 }
451 let _ = std::fs::remove_file(path);
452}
453
454fn remove_registration_files(
458 dir: &std::path::Path,
459 path: &std::path::Path,
460 reg: &SessionRegistration,
461) {
462 if !valid_card(path, reg) {
463 return;
464 }
465 remove_socket(dir, reg);
466 let _ = std::fs::remove_file(path);
467}
468
469pub fn sweep_stale_registrations(own_pid: Option<u32>) -> usize {
476 sweep_stale_registrations_in(®istry_dir(), own_pid)
477}
478
479fn sweep_stale_registrations_in(dir: &std::path::Path, own_pid: Option<u32>) -> usize {
480 scan_registrations(dir, own_pid).1
481}
482
483fn pid_is_alive(pid: u32) -> bool {
485 if pid == 0 || pid > i32::MAX as u32 {
486 return false;
487 }
488 #[cfg(unix)]
489 {
490 let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
493 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
494 }
495 #[cfg(not(unix))]
496 {
497 let _ = pid;
498 true
499 }
500}
501
502fn is_reserved_json(path: &std::path::Path) -> bool {
505 path.file_stem()
506 .and_then(|s| s.to_str())
507 .is_some_and(|stem| stem == "daemon" || stem.starts_with("daemon-"))
508}
509
510pub fn list_active_sessions() -> Vec<SessionRegistration> {
517 list_active_sessions_in(®istry_dir())
518}
519
520fn list_active_sessions_in(dir: &std::path::Path) -> Vec<SessionRegistration> {
521 scan_registrations(dir, None).0
522}
523
524fn scan_registrations(
527 dir: &std::path::Path,
528 own_pid: Option<u32>,
529) -> (Vec<SessionRegistration>, usize) {
530 let Ok(_lock) = lock_dir(dir) else {
531 return (Vec::new(), 0);
532 };
533 scan_registrations_locked(dir, own_pid)
534}
535
536fn scan_registrations_locked(
537 dir: &std::path::Path,
538 own_pid: Option<u32>,
539) -> (Vec<SessionRegistration>, usize) {
540 let Ok(entries) = std::fs::read_dir(dir) else {
541 return (Vec::new(), 0);
542 };
543
544 let mut live = Vec::new();
545 let mut removed = 0;
546
547 for entry in entries.flatten() {
548 let path = entry.path();
549 if !path.extension().is_some_and(|e| e == "json") || is_reserved_json(&path) {
550 continue;
551 }
552 if !regular_card(&path) {
553 continue;
554 }
555 let Ok(content) = std::fs::read_to_string(&path) else {
556 continue;
557 };
558 let reg = match serde_json::from_str::<SessionRegistration>(&content) {
559 Ok(reg) if reg.kind == REGISTRATION_KIND => reg,
560 Ok(reg) => {
561 tracing::debug!(path = %path.display(), kind = %reg.kind, "registry: not a session registration — skipped");
562 continue;
563 }
564 Err(e) => {
565 tracing::debug!(path = %path.display(), error = %e, "registry: unparseable json — skipped, not ours");
566 continue;
567 }
568 };
569
570 if !valid_card(&path, ®) {
571 continue;
572 }
573 if own_pid != Some(reg.pid)
574 && pid_is_alive(reg.pid)
575 && !generated_endpoint_missing(dir, ®)
576 {
577 live.push(reg);
578 } else {
579 remove_registration_files(dir, &path, ®);
580 removed += 1;
581 }
582 }
583
584 (live, removed)
585}
586
587pub fn find_session_registration(query: &str) -> Option<SessionRegistration> {
592 find_session_registration_in(query, ®istry_dir())
593}
594
595fn find_session_registration_in(query: &str, dir: &std::path::Path) -> Option<SessionRegistration> {
596 let sessions = list_active_sessions_in(dir);
597
598 if let Some(reg) = sessions.iter().find(|r| r.session_id == query) {
600 return Some(reg.clone());
601 }
602
603 if let Some(reg) = sessions.iter().find(|r| r.name.as_deref() == Some(query)) {
605 return Some(reg.clone());
606 }
607
608 let matches: Vec<_> = sessions
610 .iter()
611 .filter(|r| r.session_id.starts_with(query))
612 .collect();
613
614 if matches.len() == 1 {
615 Some(matches[0].clone())
616 } else {
617 None
618 }
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624 use serial_test::serial;
625 use tempfile::TempDir;
626
627 #[cfg(unix)]
628 #[tokio::test]
629 async fn locked_drop_removes_endpoint_and_next_scan_repairs_live_pid_card() {
630 let tmp = tmp_registry();
631 let endpoint = SessionEndpoint::start_in(
632 tmp.path(),
633 "locked",
634 None,
635 std::sync::Arc::new(super::super::queue::EventQueue::new(10)),
636 )
637 .unwrap();
638 let path = endpoint.registration.socket_path.clone();
639 let lock = lock_dir(tmp.path()).unwrap();
640 drop(endpoint);
641 assert!(!std::path::Path::new(&path).exists());
642 assert!(tmp.path().join("locked.json").exists());
643 drop(lock);
644 assert_eq!(sweep_stale_registrations_in(tmp.path(), None), 1);
645 assert!(!tmp.path().join("locked.json").exists());
646 }
647
648 #[tokio::test]
649 async fn owned_rename_refuses_replacement_and_uses_captured_root() {
650 let tmp = tmp_registry();
651 let other = tmp_registry();
652 let queue = std::sync::Arc::new(super::super::queue::EventQueue::new(10));
653 let old = SessionEndpoint::start_in(tmp.path(), "same", None, queue.clone()).unwrap();
654 let new = SessionEndpoint::start_in(tmp.path(), "same", None, queue.clone()).unwrap();
655 let unrelated = SessionEndpoint::start_in(other.path(), "same", None, queue).unwrap();
656 assert!(old.update_name(Some("wrong")).is_err());
657 new.update_name(Some("right")).unwrap();
658 assert_eq!(
659 list_active_sessions_in(tmp.path())[0].name.as_deref(),
660 Some("right")
661 );
662 assert!(list_active_sessions_in(other.path())[0].name.is_none());
663 drop(new);
664 assert!(other.path().join("same.json").exists());
665 drop(unrelated);
666 }
667
668 #[tokio::test]
669 async fn publication_failure_rolls_back_bound_endpoint() {
670 let tmp = tmp_registry();
671 assert!(SessionEndpoint::start_in(
672 tmp.path(),
673 "daemon",
674 None,
675 std::sync::Arc::new(super::super::queue::EventQueue::new(10))
676 )
677 .is_err());
678 let names: Vec<_> = std::fs::read_dir(tmp.path())
679 .unwrap()
680 .map(|e| e.unwrap().file_name())
681 .collect();
682 assert_eq!(
683 names,
684 vec![std::ffi::OsString::from(".session-registry.lock")]
685 );
686 }
687
688 #[cfg(unix)]
689 #[tokio::test]
690 async fn replacement_survives_old_task_ending_and_immediate_drop() {
691 let tmp = tmp_registry();
692 let queue = std::sync::Arc::new(super::super::queue::EventQueue::new(10));
693 let old = SessionEndpoint::start_in(tmp.path(), "same", None, queue.clone()).unwrap();
694 let old_path = old.registration.socket_path.clone();
695 let new = SessionEndpoint::start_in(tmp.path(), "same", None, queue).unwrap();
696 assert_ne!(old_path, new.registration.socket_path);
697 update_session_name_in("same", Some("renamed"), tmp.path()).unwrap();
698 drop(old);
700 assert!(!std::path::Path::new(&old_path).exists());
701 tokio::task::yield_now().await;
702 let _client = tokio::net::UnixStream::connect(&new.registration.socket_path)
703 .await
704 .unwrap();
705 let new_path = new.registration.socket_path.clone();
706 assert_eq!(
707 list_active_sessions_in(tmp.path())[0].name.as_deref(),
708 Some("renamed")
709 );
710 drop(new);
711 assert!(!std::path::Path::new(&new_path).exists());
712 assert!(!tmp.path().join("same.json").exists());
713 }
714
715 #[test]
716 fn lock_is_permanent_and_contention_is_bounded() {
717 let tmp = tmp_registry();
718 let lock = lock_dir(tmp.path()).unwrap();
719 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
720 let ready = barrier.clone();
721 let dir = tmp.path().to_path_buf();
722 let contender = std::thread::spawn(move || {
723 ready.wait();
724 assert!(lock_dir(&dir).is_err());
725 });
726 barrier.wait();
727 contender.join().unwrap();
728 drop(lock);
729 assert!(tmp.path().join(".session-registry.lock").exists());
730 assert!(lock_dir(tmp.path()).is_ok());
731 }
732
733 #[cfg(unix)]
734 #[tokio::test]
735 async fn stale_sweep_and_registration_are_serialized() {
736 let tmp = tmp_registry();
737 reg_with_socket(tmp.path(), "same", 0);
738 let lock = lock_dir(tmp.path()).unwrap();
739 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
740 let ready = barrier.clone();
741 let dir = tmp.path().to_path_buf();
742 let sweep = std::thread::spawn(move || {
743 ready.wait();
744 assert_eq!(
746 sweep_stale_registrations_in(&dir, Some(std::process::id())),
747 0
748 );
749 });
750 barrier.wait();
751 sweep.join().unwrap();
752 drop(lock);
753 let endpoint = SessionEndpoint::start_in(
754 tmp.path(),
755 "same",
756 None,
757 std::sync::Arc::new(super::super::queue::EventQueue::new(10)),
758 )
759 .unwrap();
760 assert_eq!(sweep_stale_registrations_in(tmp.path(), None), 0);
761 let _client = tokio::net::UnixStream::connect(&endpoint.registration.socket_path)
762 .await
763 .unwrap();
764 }
765
766 #[cfg(unix)]
767 #[test]
768 fn malicious_socket_claims_leave_user_files_and_other_sockets() {
769 let tmp = tmp_registry();
770 let outside = tmp_registry();
771 let outside_path = outside.path().join("victim.sock");
772 let _outside = std::os::unix::net::UnixListener::bind(&outside_path).unwrap();
773 let unrelated = tmp.path().join("other.sock");
774 let _other = std::os::unix::net::UnixListener::bind(&unrelated).unwrap();
775 let regular = tmp.path().join("victim.sock");
776 std::fs::write(®ular, b"user data").unwrap();
777 std::os::unix::fs::symlink(outside.path(), tmp.path().join("link")).unwrap();
778 for path in [
779 outside_path.clone(),
780 unrelated.clone(),
781 regular.clone(),
782 tmp.path().join("link/victim.sock"),
783 tmp.path().join("../victim.sock"),
784 ] {
785 let mut reg = make_reg("victim", None, 0);
786 reg.socket_path = path.to_string_lossy().into_owned();
787 register_session_in(®, tmp.path()).unwrap();
788 sweep_stale_registrations_in(tmp.path(), None);
789 }
790 assert!(outside_path.exists());
791 assert!(unrelated.exists());
792 assert_eq!(std::fs::read(regular).unwrap(), b"user data");
793 }
794
795 #[cfg(windows)]
796 #[tokio::test]
797 async fn replacement_pipe_is_bound_before_publication() {
798 let tmp = tmp_registry();
799 let queue = std::sync::Arc::new(super::super::queue::EventQueue::new(10));
800 let old = SessionEndpoint::start_in(tmp.path(), "same", None, queue.clone()).unwrap();
801 let new = SessionEndpoint::start_in(tmp.path(), "same", None, queue).unwrap();
802 drop(old);
803 let pipe = super::super::socket::pipe_name_for(&new.registration.socket_path);
804 let _client = tokio::net::windows::named_pipe::ClientOptions::new()
805 .open(pipe)
806 .unwrap();
807 }
808
809 fn tmp_registry() -> TempDir {
810 let dir = tempfile::tempdir().unwrap();
811 std::fs::create_dir_all(dir.path()).unwrap();
812 dir
813 }
814
815 fn make_reg(id: &str, name: Option<&str>, pid: u32) -> SessionRegistration {
816 SessionRegistration {
817 kind: REGISTRATION_KIND.to_string(),
818 session_id: id.to_string(),
819 name: name.map(|s| s.to_string()),
820 socket_path: format!("/unused/{id}.sock"),
821 pid,
822 started_at: Utc::now(),
823 }
824 }
825
826 fn dir_buf(tmp: &TempDir) -> PathBuf {
827 tmp.path().to_path_buf()
828 }
829
830 #[test]
831 fn register_creates_file() {
832 let tmp = tmp_registry();
833 let dir = dir_buf(&tmp);
834 let reg = make_reg("abc-1234", None, std::process::id());
835 register_session_in(®, &dir).unwrap();
836 assert!(dir.join("abc-1234.json").exists());
837 }
838
839 #[test]
840 fn list_returns_live_sessions() {
841 let tmp = tmp_registry();
842 let dir = dir_buf(&tmp);
843 let pid = std::process::id();
844 let reg = make_reg("live-0001", Some("my-agent"), pid);
845 register_session_in(®, &dir).unwrap();
846
847 let sessions = list_active_sessions_in(&dir);
848 assert!(sessions.iter().any(|r| r.session_id == "live-0001"));
849 }
850
851 #[test]
852 fn find_by_exact_id() {
853 let tmp = tmp_registry();
854 let dir = dir_buf(&tmp);
855 let reg = make_reg("find-exact-01", None, std::process::id());
856 register_session_in(®, &dir).unwrap();
857
858 let found = find_session_registration_in("find-exact-01", &dir);
859 assert!(found.is_some());
860 assert_eq!(found.unwrap().session_id, "find-exact-01");
861 }
862
863 #[test]
864 fn find_by_name() {
865 let tmp = tmp_registry();
866 let dir = dir_buf(&tmp);
867 let reg = make_reg("named-session-01", Some("prod-agent"), std::process::id());
868 register_session_in(®, &dir).unwrap();
869
870 let found = find_session_registration_in("prod-agent", &dir);
871 assert!(found.is_some());
872 assert_eq!(found.unwrap().session_id, "named-session-01");
873 }
874
875 #[test]
876 fn find_by_partial_prefix() {
877 let tmp = tmp_registry();
878 let dir = dir_buf(&tmp);
879 let reg = make_reg("prefix-abcdef-01", None, std::process::id());
880 register_session_in(®, &dir).unwrap();
881
882 let found = find_session_registration_in("prefix-abc", &dir);
883 assert!(found.is_some());
884 assert_eq!(found.unwrap().session_id, "prefix-abcdef-01");
885 }
886
887 #[test]
888 fn ambiguous_prefix_returns_none() {
889 let tmp = tmp_registry();
890 let dir = dir_buf(&tmp);
891 let pid = std::process::id();
892 register_session_in(&make_reg("dup-aaaa-01", None, pid), &dir).unwrap();
893 register_session_in(&make_reg("dup-aaaa-02", None, pid), &dir).unwrap();
894
895 let found = find_session_registration_in("dup-aaaa", &dir);
896 assert!(found.is_none(), "ambiguous prefix should return None");
897 }
898
899 #[test]
902 fn foreign_json_is_never_unlinked() {
903 let tmp = tmp_registry();
904 let dir = dir_buf(&tmp);
905 let daemon = dir.join("daemon.json");
906 std::fs::write(
907 &daemon,
908 r#"{"pid":1,"version":"0.9.0","exe":"/usr/bin/synaps","socket":"/x/daemon.sock","protocol":2}"#,
909 )
910 .unwrap();
911 let garbage = dir.join("garbage.json");
912 std::fs::write(&garbage, "this is not json {").unwrap();
913 let other = dir.join("other-kind.json");
915 std::fs::write(
916 &other,
917 r#"{"kind":"widget","session_id":"x","name":null,"socket_path":"/x.sock","pid":1,"started_at":"2024-01-01T00:00:00Z"}"#,
918 )
919 .unwrap();
920 let legacy = dir.join("legacy-0001.json");
922 std::fs::write(
923 &legacy,
924 format!(
925 r#"{{"session_id":"legacy-0001","name":"old","socket_path":"{}","pid":{},"started_at":"2024-01-01T00:00:00Z"}}"#,
926 dir.join("legacy-0001.sock").display(),
927 std::process::id()
928 ),
929 )
930 .unwrap();
931 let reg = make_reg("live-0002", Some("ambient"), std::process::id());
932 register_session_in(®, &dir).unwrap();
933
934 for _ in 0..2 {
935 let found = find_session_registration_in("ambient", &dir).expect("session resolved");
936 assert_eq!(found.session_id, "live-0002");
937 assert!(
938 find_session_registration_in("old", &dir).is_some(),
939 "legacy reg counts"
940 );
941 let ids: Vec<_> = list_active_sessions_in(&dir)
942 .into_iter()
943 .map(|r| r.session_id)
944 .collect();
945 assert!(!ids.contains(&"x".to_string()), "foreign kind not listed");
946 assert!(daemon.exists(), "daemon.json must survive");
947 assert!(garbage.exists(), "garbage.json must survive");
948 assert!(other.exists(), "other-kind.json must survive");
949 assert!(legacy.exists());
950 }
951 assert!(std::fs::read_to_string(&daemon).unwrap().contains("0.9.0"));
952 }
953
954 #[test]
958 fn update_name_rewrites_registration() {
959 let tmp = tmp_registry();
960 let dir = dir_buf(&tmp);
961 let reg = make_reg("rename-0001", None, std::process::id());
962 register_session_in(®, &dir).unwrap();
963 assert!(find_session_registration_in("ambient", &dir).is_none());
964
965 update_session_name_in("rename-0001", Some("ambient"), &dir).unwrap();
966 let found = find_session_registration_in("ambient", &dir).expect("resolves by new name");
967 assert_eq!(found.session_id, "rename-0001");
968 assert_eq!(
969 found.socket_path, reg.socket_path,
970 "rest of the record kept"
971 );
972
973 update_session_name_in("rename-0001", None, &dir).unwrap();
974 assert!(
975 find_session_registration_in("ambient", &dir).is_none(),
976 "cleared"
977 );
978 assert!(
979 update_session_name_in("ghost", Some("x"), &dir).is_err(),
980 "no reg → error"
981 );
982 }
983
984 #[test]
985 fn unregister_removes_file() {
986 let tmp = tmp_registry();
987 let dir = dir_buf(&tmp);
988 let reg = make_reg("unreg-0001", None, std::process::id());
989 register_session_in(®, &dir).unwrap();
990
991 let path = dir.join("unreg-0001.json");
992 assert!(path.exists());
993
994 unregister_session_in("unreg-0001", &dir);
995 assert!(!path.exists());
996 }
997
998 #[test]
999 fn unregister_is_idempotent() {
1000 let tmp = tmp_registry();
1001 let dir = dir_buf(&tmp);
1002 unregister_session_in("ghost-session-99", &dir);
1004 }
1005
1006 #[cfg(unix)]
1007 #[test]
1008 fn stale_pid_pruned() {
1009 let tmp = tmp_registry();
1010 let dir = dir_buf(&tmp);
1011 let reg = make_reg("stale-dead-pid", None, 999999);
1013 register_session_in(®, &dir).unwrap();
1014
1015 let sessions = list_active_sessions_in(&dir);
1016 assert!(
1017 !sessions.iter().any(|r| r.session_id == "stale-dead-pid"),
1018 "stale registration should have been pruned"
1019 );
1020
1021 assert!(!dir.join("stale-dead-pid.json").exists());
1023 }
1024
1025 #[cfg(unix)]
1028 fn reg_with_socket(dir: &std::path::Path, id: &str, pid: u32) -> SessionRegistration {
1029 let reg = SessionRegistration {
1030 kind: REGISTRATION_KIND.to_string(),
1031 session_id: id.to_string(),
1032 name: None,
1033 socket_path: socket_path_in_dir(dir, id),
1034 pid,
1035 started_at: Utc::now(),
1036 };
1037 let _ = std::fs::remove_file(®.socket_path);
1038 let _socket = std::os::unix::net::UnixListener::bind(®.socket_path).unwrap();
1039 register_session_in(®, dir).unwrap();
1040 reg
1041 }
1042
1043 #[cfg(unix)]
1044 #[test]
1045 fn unregister_owned_removes_its_own_registration_and_socket() {
1046 let tmp = tmp_registry();
1047 let dir = dir_buf(&tmp);
1048 let reg = reg_with_socket(&dir, "owned", std::process::id());
1049 unregister_owned_in(®, &dir);
1050 assert!(!dir.join("owned.json").exists());
1051 assert!(!dir.join("owned.sock").exists());
1052 unregister_owned_in(®, &dir);
1054 }
1055
1056 #[cfg(unix)]
1057 #[test]
1058 fn unregister_owned_leaves_a_newer_registration_of_the_same_id() {
1059 let tmp = tmp_registry();
1060 let dir = dir_buf(&tmp);
1061 let old = reg_with_socket(&dir, "same-id", std::process::id());
1062 std::thread::sleep(std::time::Duration::from_millis(2));
1063 let newer = reg_with_socket(&dir, "same-id", std::process::id());
1064 assert_ne!(old.started_at, newer.started_at);
1065
1066 unregister_owned_in(&old, &dir);
1067 assert!(dir.join("same-id.json").exists(), "newer registration kept");
1068 assert!(dir.join("same-id.sock").exists(), "newer socket kept");
1069 let live = list_active_sessions_in(&dir);
1070 assert_eq!(live.len(), 1);
1071 assert_eq!(live[0].started_at, newer.started_at);
1072 }
1073
1074 #[cfg(unix)]
1075 #[test]
1076 fn a_renamed_registration_is_still_owned_and_removed() {
1077 let tmp = tmp_registry();
1078 let dir = dir_buf(&tmp);
1079 let reg = reg_with_socket(&dir, "renamed", std::process::id());
1080 update_session_name_in("renamed", Some("ambient"), &dir).unwrap();
1081 unregister_owned_in(®, &dir);
1082 assert!(
1083 !dir.join("renamed.json").exists(),
1084 "a rename keeps ownership"
1085 );
1086 }
1087
1088 #[cfg(unix)]
1089 #[test]
1090 fn daemon_start_sweep_removes_dead_and_own_pid_and_keeps_other_live_owners() {
1091 let tmp = tmp_registry();
1092 let dir = dir_buf(&tmp);
1093 let own = std::process::id();
1094 let other_live = unsafe { libc::getppid() } as u32;
1097 reg_with_socket(&dir, "dead-owner", 999_999);
1098 reg_with_socket(&dir, "pre-reload", own);
1099 reg_with_socket(&dir, "other-process", other_live);
1100 std::fs::write(dir.join("daemon.json"), r#"{"pid":999999}"#).unwrap();
1101
1102 assert_eq!(sweep_stale_registrations_in(&dir, Some(own)), 2);
1103 for gone in ["dead-owner", "pre-reload"] {
1104 assert!(
1105 !dir.join(format!("{gone}.json")).exists(),
1106 "{gone} card removed"
1107 );
1108 assert!(
1109 !dir.join(format!("{gone}.sock")).exists(),
1110 "{gone} socket removed"
1111 );
1112 }
1113 assert!(dir.join("other-process.json").exists());
1114 assert!(dir.join("other-process.sock").exists());
1115 assert!(
1116 dir.join("daemon.json").exists(),
1117 "daemon.json is never a session"
1118 );
1119 }
1120
1121 #[cfg(unix)]
1122 #[test]
1123 fn sweep_without_own_pid_keeps_registrations_of_this_process() {
1124 let tmp = tmp_registry();
1125 let dir = dir_buf(&tmp);
1126 reg_with_socket(&dir, "mine", std::process::id());
1127 reg_with_socket(&dir, "dead", 999_999);
1128 assert_eq!(sweep_stale_registrations_in(&dir, None), 1);
1129 assert!(dir.join("mine.json").exists());
1130 assert!(!dir.join("dead.json").exists());
1131 }
1132
1133 #[test]
1134 #[serial(synaps_base_dir)]
1139 fn socket_path_format() {
1140 let path = socket_path_for_session("20240101-120000-ab12");
1141 assert!(
1143 path.ends_with("/run/20240101-120000-ab12.sock"),
1144 "got: {}",
1145 path
1146 );
1147 assert!(!path.contains("/tmp/"), "socket should not be in /tmp");
1148 }
1149
1150 #[cfg(unix)]
1153 #[test]
1154 fn short_runtime_dirs_keep_long_session_socket_bindable_and_isolated() {
1155 use std::os::unix::net::UnixListener;
1156 let long_session = "s".repeat(80);
1157 let path_a = socket_path_in_dir(std::path::Path::new("/tmp/a"), &long_session);
1159 let path_b = socket_path_in_dir(std::path::Path::new("/tmp/b"), &long_session);
1160 assert!(path_a.len() < 108, "Unix socket path too long: {path_a}");
1161 assert_ne!(
1162 path_a, path_b,
1163 "per-user runtime roots must isolate same session ids"
1164 );
1165
1166 let pid = std::process::id();
1172 let a = std::path::PathBuf::from(format!("/tmp/sa{pid}"));
1173 let b = std::path::PathBuf::from(format!("/tmp/sb{pid}"));
1174 std::fs::create_dir_all(&a).unwrap();
1175 std::fs::create_dir_all(&b).unwrap();
1176 let _a = UnixListener::bind(socket_path_in_dir(&a, &long_session)).unwrap();
1177 let _b = UnixListener::bind(socket_path_in_dir(&b, &long_session)).unwrap();
1178 let _ = std::fs::remove_dir_all(&a);
1179 let _ = std::fs::remove_dir_all(&b);
1180 }
1181
1182 #[cfg(unix)]
1183 #[test]
1184 fn registration_file_is_0600() {
1185 use std::os::unix::fs::PermissionsExt;
1186 let tmp = tmp_registry();
1187 let dir = dir_buf(&tmp);
1188 let reg = make_reg("perms-check-01", None, std::process::id());
1189 register_session_in(®, &dir).unwrap();
1190
1191 let path = dir.join("perms-check-01.json");
1192 let perms = std::fs::metadata(&path).unwrap().permissions();
1193 assert_eq!(perms.mode() & 0o777, 0o600, "registry file should be 0600");
1194 }
1195}
1196
1197#[derive(Debug, Clone, PartialEq, Eq)]
1202pub struct DaemonPaths {
1203 pub dir: PathBuf,
1204 pub sock: PathBuf,
1205 pub lock: PathBuf,
1206 pub json: PathBuf,
1207 pub pid: PathBuf,
1208}
1209
1210pub fn daemon_paths(profile: Option<&str>) -> DaemonPaths {
1212 daemon_paths_in(®istry_dir(), profile)
1213}
1214
1215pub fn daemon_paths_in(dir: &std::path::Path, profile: Option<&str>) -> DaemonPaths {
1217 let stem = match profile.map(sanitize_session_id).filter(|p| !p.is_empty()) {
1218 Some(p) => format!("daemon-{p}"),
1219 None => "daemon".to_string(),
1220 };
1221 DaemonPaths {
1222 dir: dir.to_path_buf(),
1223 sock: dir.join(format!("{stem}.sock")),
1224 lock: dir.join(format!("{stem}.lock")),
1225 json: dir.join(format!("{stem}.json")),
1226 pid: dir.join(format!("{stem}.pid")),
1227 }
1228}
1229
1230#[cfg(test)]
1231mod daemon_paths_tests {
1232 use super::*;
1233
1234 #[test]
1235 fn daemon_paths_are_profile_scoped_and_sanitised() {
1236 let d = std::path::Path::new("/run/x");
1237 let p = daemon_paths_in(d, None);
1238 assert_eq!(p.sock, d.join("daemon.sock"));
1239 assert_eq!(p.lock, d.join("daemon.lock"));
1240 assert_eq!(p.json, d.join("daemon.json"));
1241 assert_eq!(p.pid, d.join("daemon.pid"));
1242 let p = daemon_paths_in(d, Some("work/../evil"));
1243 assert_eq!(p.sock, d.join("daemon-work____evil.sock"));
1244 }
1245}