Skip to main content

synaps_cli/events/
registry.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use chrono::{DateTime, Utc};
4
5use crate::core::config::base_dir;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct SessionRegistration {
9    pub session_id: String,
10    pub name: Option<String>,
11    pub socket_path: String,
12    pub pid: u32,
13    pub started_at: DateTime<Utc>,
14}
15
16/// Returns `~/.synaps-cli/run/`, creating it (mode 0700) if it doesn't exist.
17pub fn registry_dir() -> PathBuf {
18    let dir = base_dir().join("run");
19    if let Err(e) = std::fs::create_dir_all(&dir) {
20        tracing::warn!("registry: failed to create run dir {:?}: {}", dir, e);
21    }
22    #[cfg(unix)]
23    {
24        use std::os::unix::fs::PermissionsExt;
25        let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
26    }
27    dir
28}
29
30/// Sanitize a session ID for safe use in filenames and socket paths.
31/// Rejects path separators, `..`, and non-printable characters.
32/// Returns the sanitized string (replaces unsafe chars with `_`).
33pub fn sanitize_session_id(raw: &str) -> String {
34    // Only allow alphanumeric, hyphens, and underscores. Dots are not needed
35    // in session IDs (format is {name}-{timestamp}-{pid}) and allowing them
36    // complicates path traversal prevention (single-pass ".." replace is
37    // incomplete for "..." inputs).
38    raw.chars()
39        .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
40        .collect::<String>()
41}
42
43/// Returns the Unix domain socket path for a session.
44/// Sockets live in the registry dir (~/.synaps-cli/run/) which is user-owned
45/// and mode 0700, avoiding /tmp symlink squatting and TOCTOU races.
46pub fn socket_path_for_session(session_id: &str) -> String {
47    let safe_id = sanitize_session_id(session_id);
48    registry_dir().join(format!("{}.sock", safe_id))
49        .to_string_lossy()
50        .into_owned()
51}
52
53/// Write `{session_id}.json` atomically (tmp + rename). Chmod 0600 on Unix.
54pub fn register_session(reg: &SessionRegistration) -> Result<(), String> {
55    register_session_in(reg, &registry_dir())
56}
57
58fn register_session_in(reg: &SessionRegistration, dir: &std::path::Path) -> Result<(), String> {
59    let safe_id = sanitize_session_id(&reg.session_id);
60    let path = dir.join(format!("{}.json", safe_id));
61    let tmp = path.with_extension("tmp");
62
63    let json = serde_json::to_string(reg)
64        .map_err(|e| format!("serialize error: {}", e))?;
65
66    std::fs::write(&tmp, &json)
67        .map_err(|e| format!("write error: {}", e))?;
68
69    #[cfg(unix)]
70    {
71        use std::os::unix::fs::PermissionsExt;
72        let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
73    }
74
75    std::fs::rename(&tmp, &path)
76        .map_err(|e| format!("rename error: {}", e))?;
77
78    Ok(())
79}
80
81/// Remove the registration file. Best-effort — never panics.
82/// Also removes the socket file at `socket_path` if it exists.
83pub fn unregister_session(session_id: &str) {
84    unregister_session_in(session_id, &registry_dir());
85}
86
87fn unregister_session_in(session_id: &str, dir: &std::path::Path) {
88    let safe_id = sanitize_session_id(session_id);
89    let path = dir.join(format!("{}.json", safe_id));
90
91    // Load first so we can clean up the socket.
92    if let Ok(content) = std::fs::read_to_string(&path) {
93        if let Ok(reg) = serde_json::from_str::<SessionRegistration>(&content) {
94            let sock = std::path::Path::new(&reg.socket_path);
95            // Only delete if socket_path is inside the registry dir — prevents
96            // a crafted JSON from causing arbitrary file deletion.
97            if sock.starts_with(dir) && sock.extension().is_some_and(|e| e == "sock") {
98                let _ = std::fs::remove_file(sock);
99            }
100        }
101    }
102
103    let _ = std::fs::remove_file(&path);
104}
105
106/// Returns true if a process with `pid` is alive (Unix: `kill(pid, 0)`).
107fn pid_is_alive(pid: u32) -> bool {
108    #[cfg(unix)]
109    {
110        // SAFETY: kill with signal 0 never sends a signal; it only checks
111        // whether the process exists and we have permission to signal it.
112        let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
113        result == 0
114    }
115    #[cfg(not(unix))]
116    {
117        let _ = pid;
118        true
119    }
120}
121
122/// Read all registration files, prune stale ones (dead PID), return live set.
123pub fn list_active_sessions() -> Vec<SessionRegistration> {
124    list_active_sessions_in(&registry_dir())
125}
126
127fn list_active_sessions_in(dir: &std::path::Path) -> Vec<SessionRegistration> {
128    let Ok(entries) = std::fs::read_dir(dir) else {
129        return Vec::new();
130    };
131
132    let mut live = Vec::new();
133
134    for entry in entries.flatten() {
135        let path = entry.path();
136        if path.extension().is_some_and(|e| e == "json") {
137            let Ok(content) = std::fs::read_to_string(&path) else { continue };
138            let Ok(reg) = serde_json::from_str::<SessionRegistration>(&content) else {
139                let _ = std::fs::remove_file(&path);
140                continue;
141            };
142
143            if pid_is_alive(reg.pid) {
144                live.push(reg);
145            } else {
146                let _ = std::fs::remove_file(std::path::Path::new(&reg.socket_path));
147                let _ = std::fs::remove_file(&path);
148            }
149        }
150    }
151
152    live
153}
154
155/// Resolve a query to a registration. Resolution order:
156/// 1. Exact session_id
157/// 2. Name match
158/// 3. Partial session_id prefix (unambiguous)
159pub fn find_session_registration(query: &str) -> Option<SessionRegistration> {
160    find_session_registration_in(query, &registry_dir())
161}
162
163fn find_session_registration_in(query: &str, dir: &std::path::Path) -> Option<SessionRegistration> {
164    let sessions = list_active_sessions_in(dir);
165
166    // 1. Exact ID
167    if let Some(reg) = sessions.iter().find(|r| r.session_id == query) {
168        return Some(reg.clone());
169    }
170
171    // 2. Name match
172    if let Some(reg) = sessions.iter().find(|r| r.name.as_deref() == Some(query)) {
173        return Some(reg.clone());
174    }
175
176    // 3. Partial prefix — only if unambiguous
177    let matches: Vec<_> = sessions
178        .iter()
179        .filter(|r| r.session_id.starts_with(query))
180        .collect();
181
182    if matches.len() == 1 {
183        Some(matches[0].clone())
184    } else {
185        None
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use serial_test::serial;
193    use tempfile::TempDir;
194
195    fn tmp_registry() -> TempDir {
196        let dir = tempfile::tempdir().unwrap();
197        std::fs::create_dir_all(dir.path()).unwrap();
198        dir
199    }
200
201    fn make_reg(id: &str, name: Option<&str>, pid: u32) -> SessionRegistration {
202        SessionRegistration {
203            session_id: id.to_string(),
204            name: name.map(|s| s.to_string()),
205            socket_path: socket_path_for_session(id),
206            pid,
207            started_at: Utc::now(),
208        }
209    }
210
211    fn dir_buf(tmp: &TempDir) -> PathBuf {
212        tmp.path().to_path_buf()
213    }
214
215    #[test]
216    fn register_creates_file() {
217        let tmp = tmp_registry();
218        let dir = dir_buf(&tmp);
219        let reg = make_reg("abc-1234", None, std::process::id());
220        register_session_in(&reg, &dir).unwrap();
221        assert!(dir.join("abc-1234.json").exists());
222    }
223
224    #[test]
225    fn list_returns_live_sessions() {
226        let tmp = tmp_registry();
227        let dir = dir_buf(&tmp);
228        let pid = std::process::id();
229        let reg = make_reg("live-0001", Some("my-agent"), pid);
230        register_session_in(&reg, &dir).unwrap();
231
232        let sessions = list_active_sessions_in(&dir);
233        assert!(sessions.iter().any(|r| r.session_id == "live-0001"));
234    }
235
236    #[test]
237    fn find_by_exact_id() {
238        let tmp = tmp_registry();
239        let dir = dir_buf(&tmp);
240        let reg = make_reg("find-exact-01", None, std::process::id());
241        register_session_in(&reg, &dir).unwrap();
242
243        let found = find_session_registration_in("find-exact-01", &dir);
244        assert!(found.is_some());
245        assert_eq!(found.unwrap().session_id, "find-exact-01");
246    }
247
248    #[test]
249    fn find_by_name() {
250        let tmp = tmp_registry();
251        let dir = dir_buf(&tmp);
252        let reg = make_reg("named-session-01", Some("prod-agent"), std::process::id());
253        register_session_in(&reg, &dir).unwrap();
254
255        let found = find_session_registration_in("prod-agent", &dir);
256        assert!(found.is_some());
257        assert_eq!(found.unwrap().session_id, "named-session-01");
258    }
259
260    #[test]
261    fn find_by_partial_prefix() {
262        let tmp = tmp_registry();
263        let dir = dir_buf(&tmp);
264        let reg = make_reg("prefix-abcdef-01", None, std::process::id());
265        register_session_in(&reg, &dir).unwrap();
266
267        let found = find_session_registration_in("prefix-abc", &dir);
268        assert!(found.is_some());
269        assert_eq!(found.unwrap().session_id, "prefix-abcdef-01");
270    }
271
272    #[test]
273    fn ambiguous_prefix_returns_none() {
274        let tmp = tmp_registry();
275        let dir = dir_buf(&tmp);
276        let pid = std::process::id();
277        register_session_in(&make_reg("dup-aaaa-01", None, pid), &dir).unwrap();
278        register_session_in(&make_reg("dup-aaaa-02", None, pid), &dir).unwrap();
279
280        let found = find_session_registration_in("dup-aaaa", &dir);
281        assert!(found.is_none(), "ambiguous prefix should return None");
282    }
283
284    #[test]
285    fn unregister_removes_file() {
286        let tmp = tmp_registry();
287        let dir = dir_buf(&tmp);
288        let reg = make_reg("unreg-0001", None, std::process::id());
289        register_session_in(&reg, &dir).unwrap();
290
291        let path = dir.join("unreg-0001.json");
292        assert!(path.exists());
293
294        unregister_session_in("unreg-0001", &dir);
295        assert!(!path.exists());
296    }
297
298    #[test]
299    fn unregister_is_idempotent() {
300        let tmp = tmp_registry();
301        let dir = dir_buf(&tmp);
302        // Should not panic even if the file was never registered
303        unregister_session_in("ghost-session-99", &dir);
304    }
305
306    #[test]
307    fn stale_pid_pruned() {
308        let tmp = tmp_registry();
309        let dir = dir_buf(&tmp);
310        // PID 999999 is effectively guaranteed to not exist
311        let reg = make_reg("stale-dead-pid", None, 999999);
312        register_session_in(&reg, &dir).unwrap();
313
314        let sessions = list_active_sessions_in(&dir);
315        assert!(
316            !sessions.iter().any(|r| r.session_id == "stale-dead-pid"),
317            "stale registration should have been pruned"
318        );
319
320        // File should also be gone
321        assert!(!dir.join("stale-dead-pid.json").exists());
322    }
323
324    #[test]
325    #[serial]
326    fn socket_path_format() {
327        let path = socket_path_for_session("20240101-120000-ab12");
328        // Sockets now live in the registry dir, not /tmp
329        assert!(path.ends_with("/run/20240101-120000-ab12.sock"), "got: {}", path);
330        assert!(!path.contains("/tmp/"), "socket should not be in /tmp");
331    }
332
333    #[cfg(unix)]
334    #[test]
335    fn registration_file_is_0600() {
336        use std::os::unix::fs::PermissionsExt;
337        let tmp = tmp_registry();
338        let dir = dir_buf(&tmp);
339        let reg = make_reg("perms-check-01", None, std::process::id());
340        register_session_in(&reg, &dir).unwrap();
341
342        let path = dir.join("perms-check-01.json");
343        let perms = std::fs::metadata(&path).unwrap().permissions();
344        assert_eq!(perms.mode() & 0o777, 0o600, "registry file should be 0600");
345    }
346}