Skip to main content

agent_engine/events/
registry.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5use crate::core::config::base_dir;
6
7/// Discriminator written into every registration so the registry can tell
8/// its own files from other `*.json` sharing the run dir (`daemon.json`,
9/// tooling drops). Files missing it (older binaries) are treated as sessions.
10pub 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    /// Always `"session"`; see [`REGISTRATION_KIND`].
19    #[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
28/// Returns the session runtime directory, creating it mode 0700 if needed.
29///
30/// `SYNAPS_RUNTIME_DIR` deliberately controls only ephemeral Unix sockets and
31/// registration files. Persistent state and plugin discovery remain rooted at
32/// `SYNAPS_BASE_DIR`; this avoids the 108-byte `sun_path` limit when the base
33/// directory is an EFS session path.
34pub 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
49/// Sanitize a session ID for safe use in filenames and socket paths.
50/// Rejects path separators, `..`, and non-printable characters.
51/// Returns the sanitized string (replaces unsafe chars with `_`).
52pub fn sanitize_session_id(raw: &str) -> String {
53    // Only allow alphanumeric, hyphens, and underscores. Dots are not needed
54    // in session IDs (format is {name}-{timestamp}-{pid}) and allowing them
55    // complicates path traversal prevention (single-pass ".." replace is
56    // incomplete for "..." inputs).
57    raw.chars()
58        .map(|c| {
59            if c.is_alphanumeric() || c == '-' || c == '_' {
60                c
61            } else {
62                '_'
63            }
64        })
65        .collect::<String>()
66}
67
68/// Returns the legacy Unix socket path for a session.
69/// Active endpoint clients must read `SessionRegistration::socket_path`: new
70/// endpoints use bounded generation names, not this pre-publication helper.
71/// Sockets live in the registry dir — `$SYNAPS_RUNTIME_DIR` when set (e.g.
72/// `/run/user/<uid>/synaps`), otherwise `$SYNAPS_BASE_DIR/run` (default
73/// `~/.synaps-cli/run/`) — which is user-owned and mode 0700, avoiding /tmp
74/// symlink squatting and TOCTOU races.
75pub fn socket_path_for_session(session_id: &str) -> String {
76    socket_path_in_dir(&registry_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
86/// A permanent lock inode: never unlink this file, including during cleanup.
87/// Bounded acquisition keeps Drop and synchronous boot fail-soft under contention.
88pub(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        // SAFETY: geteuid has no preconditions.
109        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(&reg.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(&reg.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(&reg.socket_path);
176    // Exact immediate child only; never follow a claimed subdirectory/symlink.
177    let legacy = format!("{}.sock", sanitize_session_id(&reg.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(&reg.socket_path);
182    }
183}
184
185/// Owns a bound, published generation and its captured runtime directory.
186/// A timestamp/hash collision fails binding without replacing the prior socket.
187/// Unix lock-contention leftovers are repaired on the next successful scan;
188/// Windows card cleanup under contention requires retry or own-PID reload sweep.
189/// No asynchronous task can bind after this guard is dropped.
190pub 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(&registration))
226            .to_string_lossy()
227            .into_owned();
228        let (listener, socket) = super::socket::bind_session_socket(&registration.socket_path)
229            .map_err(|e| e.to_string())?;
230        if let Err(e) = write_registration(&registration, 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    /// Rename only this published generation in the captured directory.
251    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        // This unique generation cannot be reused by another successful bind.
275        // Remove only the captured inode even if card cleanup cannot get the lock.
276        self.socket.remove();
277        let Ok(_lock) = lock_dir(&self.dir) else {
278            // Unix scans repair missing generated endpoints, including live-PID
279            // cards. Windows has no filesystem endpoint: card cleanup remains
280            // best effort until explicit shutdown retry / own-PID reload sweep.
281            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
293/// Write `{session_id}.json` atomically (tmp + rename). Chmod 0600 on Unix.
294pub fn register_session(reg: &SessionRegistration) -> Result<(), String> {
295    register_session_in(reg, &registry_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(&reg.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    // Do not overwrite foreign JSON, symlinks or special files sharing run/.
311    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
351/// Rewrite `{session_id}.json` with a new `name` (rename / `saveas`). The
352/// rest of the record (socket, pid, started_at) is kept. Errors when there
353/// is no registration to update.
354pub fn update_session_name(session_id: &str, name: Option<&str>) -> Result<(), String> {
355    update_session_name_in(session_id, name, &registry_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, &reg) {
374        return Err("not a session card".into());
375    }
376    reg.name = name.map(str::to_string);
377    write_registration(&reg, dir)
378}
379
380/// Operator removal by ID (not owner-scoped). No runtime teardown uses this.
381/// Remove only a valid matching registration. Best-effort — never panics.
382/// Also removes the socket file at `socket_path` if it exists.
383pub fn unregister_session(session_id: &str) {
384    unregister_session_in(session_id, &registry_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, &reg) {
398                remove_registration_files(dir, &path, &reg);
399            }
400        }
401    }
402}
403
404/// Remove `reg`'s registration and socket, but only while the file on disk
405/// is still this exact registration (same owner pid, start time and socket).
406///
407/// This is what a session calls when it ends. The check matters because the
408/// same id can be registered again while an older owner is still winding
409/// down (a second actor on one id, a rehydrated session after a reload): the
410/// old owner must not delete the newer registration. Best-effort and
411/// idempotent: a missing or foreign file is left alone.
412pub fn unregister_owned(reg: &SessionRegistration) {
413    unregister_owned_in(reg, &registry_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(&reg.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
454/// Unlink a registration file and its socket. The socket is only removed
455/// when it lies inside the registry dir, so a crafted file can never delete
456/// anything elsewhere.
457fn 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
469/// Remove registrations that no running session can own and return how
470/// many were removed. Always: an owner pid that no longer exists. With
471/// `own_pid` (the daemon, at startup): also registrations naming that pid,
472/// which can only be leftovers. A reload re-execs the same pid without
473/// running any session's shutdown, and the new image has not registered
474/// anything yet when it sweeps.
475pub fn sweep_stale_registrations(own_pid: Option<u32>) -> usize {
476    sweep_stale_registrations_in(&registry_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
483/// Returns true if a process with `pid` is alive (Unix: `kill(pid, 0)`;
484/// Windows: the process object is not yet signalled).
485fn pid_is_alive(pid: u32) -> bool {
486    if pid == 0 || pid > i32::MAX as u32 {
487        return false;
488    }
489    #[cfg(unix)]
490    {
491        // SAFETY: kill with signal 0 never sends a signal; it only checks
492        // whether the process exists and we have permission to signal it.
493        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
507/// Files in the run dir that are never session registrations, whatever
508/// their contents: the daemon's own `daemon.json` / `daemon-<profile>.json`.
509fn 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
515/// Read all registration files, prune stale ones (dead PID), return live set.
516///
517/// Only files this registry wrote are ever unlinked: a `*.json` that is not
518/// a `SessionRegistration` (parse failure, or `kind != "session"`) is logged
519/// and skipped — `daemon.json` lives in the same dir and `synaps send` must
520/// never destroy it.
521pub fn list_active_sessions() -> Vec<SessionRegistration> {
522    list_active_sessions_in(&registry_dir())
523}
524
525fn list_active_sessions_in(dir: &std::path::Path) -> Vec<SessionRegistration> {
526    scan_registrations(dir, None).0
527}
528
529/// Read every session registration in `dir`, unlink the stale ones (dead
530/// owner pid, or `own_pid` when given) and return `(live, removed)`.
531fn 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, &reg) {
576            continue;
577        }
578        if own_pid != Some(reg.pid)
579            && pid_is_alive(reg.pid)
580            && !generated_endpoint_missing(dir, &reg)
581        {
582            live.push(reg);
583        } else {
584            remove_registration_files(dir, &path, &reg);
585            removed += 1;
586        }
587    }
588
589    (live, removed)
590}
591
592/// Resolve a query to a registration. Resolution order:
593/// 1. Exact session_id
594/// 2. Name match
595/// 3. Partial session_id prefix (unambiguous)
596pub fn find_session_registration(query: &str) -> Option<SessionRegistration> {
597    find_session_registration_in(query, &registry_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    // 1. Exact ID
604    if let Some(reg) = sessions.iter().find(|r| r.session_id == query) {
605        return Some(reg.clone());
606    }
607
608    // 2. Name match
609    if let Some(reg) = sessions.iter().find(|r| r.name.as_deref() == Some(query)) {
610        return Some(reg.clone());
611    }
612
613    // 3. Partial prefix — only if unambiguous
614    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        // No await since either start: neither listener task has been polled.
704        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            // The other actor holds the publication lock: no stale deletion.
750            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(&regular, 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(&reg, 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(&reg, &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(&reg, &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(&reg, &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(&reg, &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(&reg, &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    /// Bug: `synaps send` used to unlink every `run/*.json` it could not
905    /// parse as a registration — `daemon.json` included.
906    #[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        // Parses as a registration structurally but declares another kind.
919        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        // A legacy registration without `kind` still counts as a session.
926        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(&reg, &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    /// Bug: the name was frozen at create — `saveas` never reached the
960    /// registry, so `synaps send --session <name>` missed until a
961    /// `--continue`.
962    #[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(&reg, &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(&reg, &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        // Should not panic even if the file was never registered
1008        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        // PID 999999 is effectively guaranteed to not exist
1017        let reg = make_reg("stale-dead-pid", None, 999999);
1018        register_session_in(&reg, &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        // File should also be gone
1027        assert!(!dir.join("stale-dead-pid.json").exists());
1028    }
1029
1030    /// A registration whose socket lives in `dir`, with a real socket file,
1031    /// so removal of both can be observed.
1032    #[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(&reg.socket_path);
1043        let _socket = std::os::unix::net::UnixListener::bind(&reg.socket_path).unwrap();
1044        register_session_in(&reg, 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(&reg, &dir);
1055        assert!(!dir.join("owned.json").exists());
1056        assert!(!dir.join("owned.sock").exists());
1057        // Idempotent: shutdown() and Drop both call it.
1058        unregister_owned_in(&reg, &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(&reg, &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        // SAFETY: getppid has no preconditions; the parent (the test runner's
1100        // parent shell) is alive and owned by the same user.
1101        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    // Keyed to the same lock as every HOME/SYNAPS_BASE_DIR mutator (it pins
1140    // the base dir itself): unkeyed `#[serial]` and
1141    // `#[serial(synaps_base_dir)]` do NOT exclude each other.
1142    #[serial(synaps_base_dir)]
1143    fn socket_path_format() {
1144        // Pin the roots: an ambient SYNAPS_BASE_DIR (often itself under
1145        // /tmp or /var/tmp) or SYNAPS_RUNTIME_DIR must not decide the result.
1146        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        // Sockets live in the registry's run dir under the base dir, not in
1150        // the system temp dir.
1151        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    // Regression: an EFS-backed SYNAPS_BASE_DIR can exceed Linux's 108-byte
1161    // sockaddr_un.sun_path limit. Runtime sockets must be independently rooted.
1162    #[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        // These model deterministic per-UID runtime roots created by guest-agent.
1168        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        // Bind under SHORT, /tmp-rooted dirs. The whole point is short socket
1177        // paths; some platforms' tempdir() (e.g. macOS /var/folders/...) is long
1178        // enough to blow the sun_path limit and flake this exact assertion, so we
1179        // control the root length explicitly. Two sibling dirs model two per-uid
1180        // runtime roots.
1181        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(&reg, &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// ── daemon paths (Phase 2 B) ──────────────────────────────────────────────────
1208
1209/// Files the `synaps daemon` keeps under `registry_dir()` (0700). One daemon
1210/// per profile: `daemon.{sock,lock,json,pid}` or `daemon-<P>.{…}`.
1211#[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
1220/// Daemon file paths for a profile, under `registry_dir()`.
1221pub fn daemon_paths(profile: Option<&str>) -> DaemonPaths {
1222    daemon_paths_in(&registry_dir(), profile)
1223}
1224
1225/// Same, rooted at an explicit dir (tests; `--socket` overrides only `.sock`).
1226pub 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}