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 {
486 if pid == 0 || pid > i32::MAX as u32 {
487 return false;
488 }
489 #[cfg(unix)]
490 {
491 let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
494 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
495 }
496 #[cfg(windows)]
497 {
498 crate::daemon::win::pid_is_alive(pid)
499 }
500 #[cfg(not(any(unix, windows)))]
501 {
502 let _ = pid;
503 true
504 }
505}
506
507fn is_reserved_json(path: &std::path::Path) -> bool {
510 path.file_stem()
511 .and_then(|s| s.to_str())
512 .is_some_and(|stem| stem == "daemon" || stem.starts_with("daemon-"))
513}
514
515pub fn list_active_sessions() -> Vec<SessionRegistration> {
522 list_active_sessions_in(®istry_dir())
523}
524
525fn list_active_sessions_in(dir: &std::path::Path) -> Vec<SessionRegistration> {
526 scan_registrations(dir, None).0
527}
528
529fn scan_registrations(
532 dir: &std::path::Path,
533 own_pid: Option<u32>,
534) -> (Vec<SessionRegistration>, usize) {
535 let Ok(_lock) = lock_dir(dir) else {
536 return (Vec::new(), 0);
537 };
538 scan_registrations_locked(dir, own_pid)
539}
540
541fn scan_registrations_locked(
542 dir: &std::path::Path,
543 own_pid: Option<u32>,
544) -> (Vec<SessionRegistration>, usize) {
545 let Ok(entries) = std::fs::read_dir(dir) else {
546 return (Vec::new(), 0);
547 };
548
549 let mut live = Vec::new();
550 let mut removed = 0;
551
552 for entry in entries.flatten() {
553 let path = entry.path();
554 if path.extension().is_none_or(|e| e != "json") || is_reserved_json(&path) {
555 continue;
556 }
557 if !regular_card(&path) {
558 continue;
559 }
560 let Ok(content) = std::fs::read_to_string(&path) else {
561 continue;
562 };
563 let reg = match serde_json::from_str::<SessionRegistration>(&content) {
564 Ok(reg) if reg.kind == REGISTRATION_KIND => reg,
565 Ok(reg) => {
566 tracing::debug!(path = %path.display(), kind = %reg.kind, "registry: not a session registration — skipped");
567 continue;
568 }
569 Err(e) => {
570 tracing::debug!(path = %path.display(), error = %e, "registry: unparseable json — skipped, not ours");
571 continue;
572 }
573 };
574
575 if !valid_card(&path, ®) {
576 continue;
577 }
578 if own_pid != Some(reg.pid)
579 && pid_is_alive(reg.pid)
580 && !generated_endpoint_missing(dir, ®)
581 {
582 live.push(reg);
583 } else {
584 remove_registration_files(dir, &path, ®);
585 removed += 1;
586 }
587 }
588
589 (live, removed)
590}
591
592pub fn find_session_registration(query: &str) -> Option<SessionRegistration> {
597 find_session_registration_in(query, ®istry_dir())
598}
599
600fn find_session_registration_in(query: &str, dir: &std::path::Path) -> Option<SessionRegistration> {
601 let sessions = list_active_sessions_in(dir);
602
603 if let Some(reg) = sessions.iter().find(|r| r.session_id == query) {
605 return Some(reg.clone());
606 }
607
608 if let Some(reg) = sessions.iter().find(|r| r.name.as_deref() == Some(query)) {
610 return Some(reg.clone());
611 }
612
613 let matches: Vec<_> = sessions
615 .iter()
616 .filter(|r| r.session_id.starts_with(query))
617 .collect();
618
619 if matches.len() == 1 {
620 Some(matches[0].clone())
621 } else {
622 None
623 }
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629 use serial_test::serial;
630 use tempfile::TempDir;
631
632 #[cfg(unix)]
633 #[tokio::test]
634 async fn locked_drop_removes_endpoint_and_next_scan_repairs_live_pid_card() {
635 let tmp = tmp_registry();
636 let endpoint = SessionEndpoint::start_in(
637 tmp.path(),
638 "locked",
639 None,
640 std::sync::Arc::new(super::super::queue::EventQueue::new(10)),
641 )
642 .unwrap();
643 let path = endpoint.registration.socket_path.clone();
644 let lock = lock_dir(tmp.path()).unwrap();
645 drop(endpoint);
646 assert!(!std::path::Path::new(&path).exists());
647 assert!(tmp.path().join("locked.json").exists());
648 drop(lock);
649 assert_eq!(sweep_stale_registrations_in(tmp.path(), None), 1);
650 assert!(!tmp.path().join("locked.json").exists());
651 }
652
653 #[tokio::test]
654 async fn owned_rename_refuses_replacement_and_uses_captured_root() {
655 let tmp = tmp_registry();
656 let other = tmp_registry();
657 let queue = std::sync::Arc::new(super::super::queue::EventQueue::new(10));
658 let old = SessionEndpoint::start_in(tmp.path(), "same", None, queue.clone()).unwrap();
659 let new = SessionEndpoint::start_in(tmp.path(), "same", None, queue.clone()).unwrap();
660 let unrelated = SessionEndpoint::start_in(other.path(), "same", None, queue).unwrap();
661 assert!(old.update_name(Some("wrong")).is_err());
662 new.update_name(Some("right")).unwrap();
663 assert_eq!(
664 list_active_sessions_in(tmp.path())[0].name.as_deref(),
665 Some("right")
666 );
667 assert!(list_active_sessions_in(other.path())[0].name.is_none());
668 drop(new);
669 assert!(other.path().join("same.json").exists());
670 drop(unrelated);
671 }
672
673 #[tokio::test]
674 async fn publication_failure_rolls_back_bound_endpoint() {
675 let tmp = tmp_registry();
676 assert!(SessionEndpoint::start_in(
677 tmp.path(),
678 "daemon",
679 None,
680 std::sync::Arc::new(super::super::queue::EventQueue::new(10))
681 )
682 .is_err());
683 let names: Vec<_> = std::fs::read_dir(tmp.path())
684 .unwrap()
685 .map(|e| e.unwrap().file_name())
686 .collect();
687 assert_eq!(
688 names,
689 vec![std::ffi::OsString::from(".session-registry.lock")]
690 );
691 }
692
693 #[cfg(unix)]
694 #[tokio::test]
695 async fn replacement_survives_old_task_ending_and_immediate_drop() {
696 let tmp = tmp_registry();
697 let queue = std::sync::Arc::new(super::super::queue::EventQueue::new(10));
698 let old = SessionEndpoint::start_in(tmp.path(), "same", None, queue.clone()).unwrap();
699 let old_path = old.registration.socket_path.clone();
700 let new = SessionEndpoint::start_in(tmp.path(), "same", None, queue).unwrap();
701 assert_ne!(old_path, new.registration.socket_path);
702 update_session_name_in("same", Some("renamed"), tmp.path()).unwrap();
703 drop(old);
705 assert!(!std::path::Path::new(&old_path).exists());
706 tokio::task::yield_now().await;
707 let _client = tokio::net::UnixStream::connect(&new.registration.socket_path)
708 .await
709 .unwrap();
710 let new_path = new.registration.socket_path.clone();
711 assert_eq!(
712 list_active_sessions_in(tmp.path())[0].name.as_deref(),
713 Some("renamed")
714 );
715 drop(new);
716 assert!(!std::path::Path::new(&new_path).exists());
717 assert!(!tmp.path().join("same.json").exists());
718 }
719
720 #[test]
721 fn lock_is_permanent_and_contention_is_bounded() {
722 let tmp = tmp_registry();
723 let lock = lock_dir(tmp.path()).unwrap();
724 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
725 let ready = barrier.clone();
726 let dir = tmp.path().to_path_buf();
727 let contender = std::thread::spawn(move || {
728 ready.wait();
729 assert!(lock_dir(&dir).is_err());
730 });
731 barrier.wait();
732 contender.join().unwrap();
733 drop(lock);
734 assert!(tmp.path().join(".session-registry.lock").exists());
735 assert!(lock_dir(tmp.path()).is_ok());
736 }
737
738 #[cfg(unix)]
739 #[tokio::test]
740 async fn stale_sweep_and_registration_are_serialized() {
741 let tmp = tmp_registry();
742 reg_with_socket(tmp.path(), "same", 0);
743 let lock = lock_dir(tmp.path()).unwrap();
744 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
745 let ready = barrier.clone();
746 let dir = tmp.path().to_path_buf();
747 let sweep = std::thread::spawn(move || {
748 ready.wait();
749 assert_eq!(
751 sweep_stale_registrations_in(&dir, Some(std::process::id())),
752 0
753 );
754 });
755 barrier.wait();
756 sweep.join().unwrap();
757 drop(lock);
758 let endpoint = SessionEndpoint::start_in(
759 tmp.path(),
760 "same",
761 None,
762 std::sync::Arc::new(super::super::queue::EventQueue::new(10)),
763 )
764 .unwrap();
765 assert_eq!(sweep_stale_registrations_in(tmp.path(), None), 0);
766 let _client = tokio::net::UnixStream::connect(&endpoint.registration.socket_path)
767 .await
768 .unwrap();
769 }
770
771 #[cfg(unix)]
772 #[test]
773 fn malicious_socket_claims_leave_user_files_and_other_sockets() {
774 let tmp = tmp_registry();
775 let outside = tmp_registry();
776 let outside_path = outside.path().join("victim.sock");
777 let _outside = std::os::unix::net::UnixListener::bind(&outside_path).unwrap();
778 let unrelated = tmp.path().join("other.sock");
779 let _other = std::os::unix::net::UnixListener::bind(&unrelated).unwrap();
780 let regular = tmp.path().join("victim.sock");
781 std::fs::write(®ular, b"user data").unwrap();
782 std::os::unix::fs::symlink(outside.path(), tmp.path().join("link")).unwrap();
783 for path in [
784 outside_path.clone(),
785 unrelated.clone(),
786 regular.clone(),
787 tmp.path().join("link/victim.sock"),
788 tmp.path().join("../victim.sock"),
789 ] {
790 let mut reg = make_reg("victim", None, 0);
791 reg.socket_path = path.to_string_lossy().into_owned();
792 register_session_in(®, tmp.path()).unwrap();
793 sweep_stale_registrations_in(tmp.path(), None);
794 }
795 assert!(outside_path.exists());
796 assert!(unrelated.exists());
797 assert_eq!(std::fs::read(regular).unwrap(), b"user data");
798 }
799
800 #[cfg(windows)]
801 #[tokio::test]
802 async fn replacement_pipe_is_bound_before_publication() {
803 let tmp = tmp_registry();
804 let queue = std::sync::Arc::new(super::super::queue::EventQueue::new(10));
805 let old = SessionEndpoint::start_in(tmp.path(), "same", None, queue.clone()).unwrap();
806 let new = SessionEndpoint::start_in(tmp.path(), "same", None, queue).unwrap();
807 drop(old);
808 let pipe = super::super::socket::pipe_name_for(&new.registration.socket_path);
809 let _client = tokio::net::windows::named_pipe::ClientOptions::new()
810 .open(pipe)
811 .unwrap();
812 }
813
814 fn tmp_registry() -> TempDir {
815 let dir = tempfile::tempdir().unwrap();
816 std::fs::create_dir_all(dir.path()).unwrap();
817 dir
818 }
819
820 fn make_reg(id: &str, name: Option<&str>, pid: u32) -> SessionRegistration {
821 SessionRegistration {
822 kind: REGISTRATION_KIND.to_string(),
823 session_id: id.to_string(),
824 name: name.map(|s| s.to_string()),
825 socket_path: format!("/unused/{id}.sock"),
826 pid,
827 started_at: Utc::now(),
828 }
829 }
830
831 fn dir_buf(tmp: &TempDir) -> PathBuf {
832 tmp.path().to_path_buf()
833 }
834
835 #[test]
836 fn register_creates_file() {
837 let tmp = tmp_registry();
838 let dir = dir_buf(&tmp);
839 let reg = make_reg("abc-1234", None, std::process::id());
840 register_session_in(®, &dir).unwrap();
841 assert!(dir.join("abc-1234.json").exists());
842 }
843
844 #[test]
845 fn list_returns_live_sessions() {
846 let tmp = tmp_registry();
847 let dir = dir_buf(&tmp);
848 let pid = std::process::id();
849 let reg = make_reg("live-0001", Some("my-agent"), pid);
850 register_session_in(®, &dir).unwrap();
851
852 let sessions = list_active_sessions_in(&dir);
853 assert!(sessions.iter().any(|r| r.session_id == "live-0001"));
854 }
855
856 #[test]
857 fn find_by_exact_id() {
858 let tmp = tmp_registry();
859 let dir = dir_buf(&tmp);
860 let reg = make_reg("find-exact-01", None, std::process::id());
861 register_session_in(®, &dir).unwrap();
862
863 let found = find_session_registration_in("find-exact-01", &dir);
864 assert!(found.is_some());
865 assert_eq!(found.unwrap().session_id, "find-exact-01");
866 }
867
868 #[test]
869 fn find_by_name() {
870 let tmp = tmp_registry();
871 let dir = dir_buf(&tmp);
872 let reg = make_reg("named-session-01", Some("prod-agent"), std::process::id());
873 register_session_in(®, &dir).unwrap();
874
875 let found = find_session_registration_in("prod-agent", &dir);
876 assert!(found.is_some());
877 assert_eq!(found.unwrap().session_id, "named-session-01");
878 }
879
880 #[test]
881 fn find_by_partial_prefix() {
882 let tmp = tmp_registry();
883 let dir = dir_buf(&tmp);
884 let reg = make_reg("prefix-abcdef-01", None, std::process::id());
885 register_session_in(®, &dir).unwrap();
886
887 let found = find_session_registration_in("prefix-abc", &dir);
888 assert!(found.is_some());
889 assert_eq!(found.unwrap().session_id, "prefix-abcdef-01");
890 }
891
892 #[test]
893 fn ambiguous_prefix_returns_none() {
894 let tmp = tmp_registry();
895 let dir = dir_buf(&tmp);
896 let pid = std::process::id();
897 register_session_in(&make_reg("dup-aaaa-01", None, pid), &dir).unwrap();
898 register_session_in(&make_reg("dup-aaaa-02", None, pid), &dir).unwrap();
899
900 let found = find_session_registration_in("dup-aaaa", &dir);
901 assert!(found.is_none(), "ambiguous prefix should return None");
902 }
903
904 #[test]
907 fn foreign_json_is_never_unlinked() {
908 let tmp = tmp_registry();
909 let dir = dir_buf(&tmp);
910 let daemon = dir.join("daemon.json");
911 std::fs::write(
912 &daemon,
913 r#"{"pid":1,"version":"0.9.0","exe":"/usr/bin/synaps","socket":"/x/daemon.sock","protocol":2}"#,
914 )
915 .unwrap();
916 let garbage = dir.join("garbage.json");
917 std::fs::write(&garbage, "this is not json {").unwrap();
918 let other = dir.join("other-kind.json");
920 std::fs::write(
921 &other,
922 r#"{"kind":"widget","session_id":"x","name":null,"socket_path":"/x.sock","pid":1,"started_at":"2024-01-01T00:00:00Z"}"#,
923 )
924 .unwrap();
925 let legacy = dir.join("legacy-0001.json");
927 std::fs::write(
928 &legacy,
929 format!(
930 r#"{{"session_id":"legacy-0001","name":"old","socket_path":"{}","pid":{},"started_at":"2024-01-01T00:00:00Z"}}"#,
931 dir.join("legacy-0001.sock").display(),
932 std::process::id()
933 ),
934 )
935 .unwrap();
936 let reg = make_reg("live-0002", Some("ambient"), std::process::id());
937 register_session_in(®, &dir).unwrap();
938
939 for _ in 0..2 {
940 let found = find_session_registration_in("ambient", &dir).expect("session resolved");
941 assert_eq!(found.session_id, "live-0002");
942 assert!(
943 find_session_registration_in("old", &dir).is_some(),
944 "legacy reg counts"
945 );
946 let ids: Vec<_> = list_active_sessions_in(&dir)
947 .into_iter()
948 .map(|r| r.session_id)
949 .collect();
950 assert!(!ids.contains(&"x".to_string()), "foreign kind not listed");
951 assert!(daemon.exists(), "daemon.json must survive");
952 assert!(garbage.exists(), "garbage.json must survive");
953 assert!(other.exists(), "other-kind.json must survive");
954 assert!(legacy.exists());
955 }
956 assert!(std::fs::read_to_string(&daemon).unwrap().contains("0.9.0"));
957 }
958
959 #[test]
963 fn update_name_rewrites_registration() {
964 let tmp = tmp_registry();
965 let dir = dir_buf(&tmp);
966 let reg = make_reg("rename-0001", None, std::process::id());
967 register_session_in(®, &dir).unwrap();
968 assert!(find_session_registration_in("ambient", &dir).is_none());
969
970 update_session_name_in("rename-0001", Some("ambient"), &dir).unwrap();
971 let found = find_session_registration_in("ambient", &dir).expect("resolves by new name");
972 assert_eq!(found.session_id, "rename-0001");
973 assert_eq!(
974 found.socket_path, reg.socket_path,
975 "rest of the record kept"
976 );
977
978 update_session_name_in("rename-0001", None, &dir).unwrap();
979 assert!(
980 find_session_registration_in("ambient", &dir).is_none(),
981 "cleared"
982 );
983 assert!(
984 update_session_name_in("ghost", Some("x"), &dir).is_err(),
985 "no reg → error"
986 );
987 }
988
989 #[test]
990 fn unregister_removes_file() {
991 let tmp = tmp_registry();
992 let dir = dir_buf(&tmp);
993 let reg = make_reg("unreg-0001", None, std::process::id());
994 register_session_in(®, &dir).unwrap();
995
996 let path = dir.join("unreg-0001.json");
997 assert!(path.exists());
998
999 unregister_session_in("unreg-0001", &dir);
1000 assert!(!path.exists());
1001 }
1002
1003 #[test]
1004 fn unregister_is_idempotent() {
1005 let tmp = tmp_registry();
1006 let dir = dir_buf(&tmp);
1007 unregister_session_in("ghost-session-99", &dir);
1009 }
1010
1011 #[cfg(unix)]
1012 #[test]
1013 fn stale_pid_pruned() {
1014 let tmp = tmp_registry();
1015 let dir = dir_buf(&tmp);
1016 let reg = make_reg("stale-dead-pid", None, 999999);
1018 register_session_in(®, &dir).unwrap();
1019
1020 let sessions = list_active_sessions_in(&dir);
1021 assert!(
1022 !sessions.iter().any(|r| r.session_id == "stale-dead-pid"),
1023 "stale registration should have been pruned"
1024 );
1025
1026 assert!(!dir.join("stale-dead-pid.json").exists());
1028 }
1029
1030 #[cfg(unix)]
1033 fn reg_with_socket(dir: &std::path::Path, id: &str, pid: u32) -> SessionRegistration {
1034 let reg = SessionRegistration {
1035 kind: REGISTRATION_KIND.to_string(),
1036 session_id: id.to_string(),
1037 name: None,
1038 socket_path: socket_path_in_dir(dir, id),
1039 pid,
1040 started_at: Utc::now(),
1041 };
1042 let _ = std::fs::remove_file(®.socket_path);
1043 let _socket = std::os::unix::net::UnixListener::bind(®.socket_path).unwrap();
1044 register_session_in(®, dir).unwrap();
1045 reg
1046 }
1047
1048 #[cfg(unix)]
1049 #[test]
1050 fn unregister_owned_removes_its_own_registration_and_socket() {
1051 let tmp = tmp_registry();
1052 let dir = dir_buf(&tmp);
1053 let reg = reg_with_socket(&dir, "owned", std::process::id());
1054 unregister_owned_in(®, &dir);
1055 assert!(!dir.join("owned.json").exists());
1056 assert!(!dir.join("owned.sock").exists());
1057 unregister_owned_in(®, &dir);
1059 }
1060
1061 #[cfg(unix)]
1062 #[test]
1063 fn unregister_owned_leaves_a_newer_registration_of_the_same_id() {
1064 let tmp = tmp_registry();
1065 let dir = dir_buf(&tmp);
1066 let old = reg_with_socket(&dir, "same-id", std::process::id());
1067 std::thread::sleep(std::time::Duration::from_millis(2));
1068 let newer = reg_with_socket(&dir, "same-id", std::process::id());
1069 assert_ne!(old.started_at, newer.started_at);
1070
1071 unregister_owned_in(&old, &dir);
1072 assert!(dir.join("same-id.json").exists(), "newer registration kept");
1073 assert!(dir.join("same-id.sock").exists(), "newer socket kept");
1074 let live = list_active_sessions_in(&dir);
1075 assert_eq!(live.len(), 1);
1076 assert_eq!(live[0].started_at, newer.started_at);
1077 }
1078
1079 #[cfg(unix)]
1080 #[test]
1081 fn a_renamed_registration_is_still_owned_and_removed() {
1082 let tmp = tmp_registry();
1083 let dir = dir_buf(&tmp);
1084 let reg = reg_with_socket(&dir, "renamed", std::process::id());
1085 update_session_name_in("renamed", Some("ambient"), &dir).unwrap();
1086 unregister_owned_in(®, &dir);
1087 assert!(
1088 !dir.join("renamed.json").exists(),
1089 "a rename keeps ownership"
1090 );
1091 }
1092
1093 #[cfg(unix)]
1094 #[test]
1095 fn daemon_start_sweep_removes_dead_and_own_pid_and_keeps_other_live_owners() {
1096 let tmp = tmp_registry();
1097 let dir = dir_buf(&tmp);
1098 let own = std::process::id();
1099 let other_live = unsafe { libc::getppid() } as u32;
1102 reg_with_socket(&dir, "dead-owner", 999_999);
1103 reg_with_socket(&dir, "pre-reload", own);
1104 reg_with_socket(&dir, "other-process", other_live);
1105 std::fs::write(dir.join("daemon.json"), r#"{"pid":999999}"#).unwrap();
1106
1107 assert_eq!(sweep_stale_registrations_in(&dir, Some(own)), 2);
1108 for gone in ["dead-owner", "pre-reload"] {
1109 assert!(
1110 !dir.join(format!("{gone}.json")).exists(),
1111 "{gone} card removed"
1112 );
1113 assert!(
1114 !dir.join(format!("{gone}.sock")).exists(),
1115 "{gone} socket removed"
1116 );
1117 }
1118 assert!(dir.join("other-process.json").exists());
1119 assert!(dir.join("other-process.sock").exists());
1120 assert!(
1121 dir.join("daemon.json").exists(),
1122 "daemon.json is never a session"
1123 );
1124 }
1125
1126 #[cfg(unix)]
1127 #[test]
1128 fn sweep_without_own_pid_keeps_registrations_of_this_process() {
1129 let tmp = tmp_registry();
1130 let dir = dir_buf(&tmp);
1131 reg_with_socket(&dir, "mine", std::process::id());
1132 reg_with_socket(&dir, "dead", 999_999);
1133 assert_eq!(sweep_stale_registrations_in(&dir, None), 1);
1134 assert!(dir.join("mine.json").exists());
1135 assert!(!dir.join("dead.json").exists());
1136 }
1137
1138 #[test]
1139 #[serial(synaps_base_dir)]
1143 fn socket_path_format() {
1144 let base = crate::test_env::BaseDirGuard::new();
1147 let _runtime = crate::test_env::EnvVarGuard::remove("SYNAPS_RUNTIME_DIR");
1148 let path = socket_path_for_session("20240101-120000-ab12");
1149 let run = std::fs::canonicalize(base.path().join("run")).unwrap();
1152 assert_eq!(
1153 std::path::PathBuf::from(&path),
1154 run.join("20240101-120000-ab12.sock"),
1155 "got: {}",
1156 path
1157 );
1158 }
1159
1160 #[cfg(unix)]
1163 #[test]
1164 fn short_runtime_dirs_keep_long_session_socket_bindable_and_isolated() {
1165 use std::os::unix::net::UnixListener;
1166 let long_session = "s".repeat(80);
1167 let path_a = socket_path_in_dir(std::path::Path::new("/tmp/a"), &long_session);
1169 let path_b = socket_path_in_dir(std::path::Path::new("/tmp/b"), &long_session);
1170 assert!(path_a.len() < 108, "Unix socket path too long: {path_a}");
1171 assert_ne!(
1172 path_a, path_b,
1173 "per-user runtime roots must isolate same session ids"
1174 );
1175
1176 let pid = std::process::id();
1182 let a = std::path::PathBuf::from(format!("/tmp/sa{pid}"));
1183 let b = std::path::PathBuf::from(format!("/tmp/sb{pid}"));
1184 std::fs::create_dir_all(&a).unwrap();
1185 std::fs::create_dir_all(&b).unwrap();
1186 let _a = UnixListener::bind(socket_path_in_dir(&a, &long_session)).unwrap();
1187 let _b = UnixListener::bind(socket_path_in_dir(&b, &long_session)).unwrap();
1188 let _ = std::fs::remove_dir_all(&a);
1189 let _ = std::fs::remove_dir_all(&b);
1190 }
1191
1192 #[cfg(unix)]
1193 #[test]
1194 fn registration_file_is_0600() {
1195 use std::os::unix::fs::PermissionsExt;
1196 let tmp = tmp_registry();
1197 let dir = dir_buf(&tmp);
1198 let reg = make_reg("perms-check-01", None, std::process::id());
1199 register_session_in(®, &dir).unwrap();
1200
1201 let path = dir.join("perms-check-01.json");
1202 let perms = std::fs::metadata(&path).unwrap().permissions();
1203 assert_eq!(perms.mode() & 0o777, 0o600, "registry file should be 0600");
1204 }
1205}
1206
1207#[derive(Debug, Clone, PartialEq, Eq)]
1212pub struct DaemonPaths {
1213 pub dir: PathBuf,
1214 pub sock: PathBuf,
1215 pub lock: PathBuf,
1216 pub json: PathBuf,
1217 pub pid: PathBuf,
1218}
1219
1220pub fn daemon_paths(profile: Option<&str>) -> DaemonPaths {
1222 daemon_paths_in(®istry_dir(), profile)
1223}
1224
1225pub fn daemon_paths_in(dir: &std::path::Path, profile: Option<&str>) -> DaemonPaths {
1227 let stem = match profile.map(sanitize_session_id).filter(|p| !p.is_empty()) {
1228 Some(p) => format!("daemon-{p}"),
1229 None => "daemon".to_string(),
1230 };
1231 DaemonPaths {
1232 dir: dir.to_path_buf(),
1233 sock: dir.join(format!("{stem}.sock")),
1234 lock: dir.join(format!("{stem}.lock")),
1235 json: dir.join(format!("{stem}.json")),
1236 pid: dir.join(format!("{stem}.pid")),
1237 }
1238}
1239
1240#[cfg(test)]
1241mod daemon_paths_tests {
1242 use super::*;
1243
1244 #[test]
1245 fn daemon_paths_are_profile_scoped_and_sanitised() {
1246 let d = std::path::Path::new("/run/x");
1247 let p = daemon_paths_in(d, None);
1248 assert_eq!(p.sock, d.join("daemon.sock"));
1249 assert_eq!(p.lock, d.join("daemon.lock"));
1250 assert_eq!(p.json, d.join("daemon.json"));
1251 assert_eq!(p.pid, d.join("daemon.pid"));
1252 let p = daemon_paths_in(d, Some("work/../evil"));
1253 assert_eq!(p.sock, d.join("daemon-work____evil.sock"));
1254 }
1255}