Skip to main content

room_daemon/
paths.rs

1//! Room filesystem path resolution.
2//!
3//! All persistent state lives under `~/.room/`:
4//! - `~/.room/state/` — tokens, cursors, subscriptions (0700)
5//! - `~/.room/data/`  — chat files (default, overridable via `--data-dir`)
6//!
7//! Ephemeral runtime files (sockets, PID, meta) use the platform-native
8//! temporary directory:
9//! - macOS: `$TMPDIR` (per-user, e.g. `/var/folders/...`)
10//! - Linux: `$XDG_RUNTIME_DIR/room/` or `/tmp/` fallback
11
12use std::path::{Path, PathBuf};
13
14#[cfg(unix)]
15use std::os::unix::fs::DirBuilderExt;
16
17// ── Public path accessors ─────────────────────────────────────────────────────
18
19/// Root of all persistent room state: `~/.room/`.
20pub fn room_home() -> PathBuf {
21    home_dir().join(".room")
22}
23
24/// Directory for persistent state files (tokens, cursors, subscriptions).
25///
26/// Returns `~/.room/state/`.
27pub fn room_state_dir() -> PathBuf {
28    room_home().join("state")
29}
30
31/// Default directory for chat files: `~/.room/data/`.
32///
33/// Overridable at daemon startup with `--data-dir`.
34pub fn room_data_dir() -> PathBuf {
35    room_home().join("data")
36}
37
38/// Platform-native runtime directory for ephemeral room files (sockets,
39/// PID, meta).
40///
41/// - macOS: `$TMPDIR` (per-user, e.g. `/var/folders/...`)
42/// - Linux: `$XDG_RUNTIME_DIR/room/` or `/tmp/` fallback
43pub fn room_runtime_dir() -> PathBuf {
44    runtime_dir()
45}
46
47/// Platform-native socket path for the multi-room daemon.
48///
49/// - macOS: `$TMPDIR/roomd.sock`
50/// - Linux: `$XDG_RUNTIME_DIR/room/roomd.sock` (falls back to `/tmp/roomd.sock`)
51pub fn room_socket_path() -> PathBuf {
52    runtime_dir().join("roomd.sock")
53}
54
55/// Resolve the effective daemon socket path.
56///
57/// Resolution order:
58/// 1. `explicit` — caller-supplied path (e.g. from `--socket` flag).
59/// 2. `ROOM_SOCKET` environment variable.
60/// 3. Platform-native default (`room_socket_path()`).
61pub fn effective_socket_path(explicit: Option<&std::path::Path>) -> PathBuf {
62    if let Some(p) = explicit {
63        return p.to_owned();
64    }
65    if let Ok(p) = std::env::var("ROOM_SOCKET") {
66        if !p.is_empty() {
67            return PathBuf::from(p);
68        }
69    }
70    room_socket_path()
71}
72
73/// Platform-native socket path for a single-room broker.
74pub fn room_single_socket_path(room_id: &str) -> PathBuf {
75    runtime_dir().join(format!("room-{room_id}.sock"))
76}
77
78/// Platform-native meta file path for a single-room broker.
79pub fn room_meta_path(room_id: &str) -> PathBuf {
80    runtime_dir().join(format!("room-{room_id}.meta"))
81}
82
83/// Token file path for a given room/user pair (legacy, per-room tokens).
84///
85/// Returns `~/.room/state/room-<room_id>-<username>.token`.
86pub fn token_path(room_id: &str, username: &str) -> PathBuf {
87    room_state_dir().join(format!("room-{room_id}-{username}.token"))
88}
89
90/// Global token file path for a user (room-independent).
91///
92/// Returns `~/.room/state/room-<username>.token`.
93/// Used by `room join <username>` which issues a global token not tied to any room.
94pub fn global_token_path(username: &str) -> PathBuf {
95    room_state_dir().join(format!("room-{username}.token"))
96}
97
98/// Cursor file path for a given room/user pair.
99///
100/// Returns `~/.room/state/room-<room_id>-<username>.cursor`.
101pub fn cursor_path(room_id: &str, username: &str) -> PathBuf {
102    room_state_dir().join(format!("room-{room_id}-{username}.cursor"))
103}
104
105/// Broker token-map file path: `<state_dir>/<room_id>.tokens`.
106///
107/// The broker persists its in-memory `TokenMap` here on every token issuance.
108pub fn broker_tokens_path(state_dir: &Path, room_id: &str) -> PathBuf {
109    state_dir.join(format!("{room_id}.tokens"))
110}
111
112/// PID file for the daemon process: `~/.room/roomd.pid`.
113///
114/// Written by `ensure_daemon_running` when it auto-starts the daemon.
115/// Ephemeral — deleted on clean daemon shutdown, may linger after a crash.
116pub fn room_pid_path() -> PathBuf {
117    room_home().join("roomd.pid")
118}
119
120/// System-level token persistence path: `~/.room/state/tokens.json`.
121///
122/// Tokens in a daemon are system-level — a single token issued by `room join`
123/// in any room is valid in all rooms managed by the same daemon. This file
124/// stores the complete token → username mapping across all rooms.
125pub fn system_tokens_path() -> PathBuf {
126    room_state_dir().join("tokens.json")
127}
128
129/// Directory that contained per-room token files in older daemon versions.
130///
131/// Before `~/.room/state/` was introduced, `room join` wrote token files to
132/// the platform-native runtime directory (`$TMPDIR` on macOS,
133/// `$XDG_RUNTIME_DIR/room/` or `/tmp/` on Linux). The daemon scans this
134/// directory on every startup to import any tokens that pre-date the
135/// `~/.room/state/` migration, so existing clients do not need to re-join.
136pub fn legacy_token_dir() -> PathBuf {
137    runtime_dir()
138}
139
140/// Broker subscription-map file path: `<state_dir>/<room_id>.subscriptions`.
141///
142/// The broker persists per-user subscription tiers here on every mutation
143/// (subscribe, unsubscribe, auto-subscribe on @mention).
144pub fn broker_subscriptions_path(state_dir: &Path, room_id: &str) -> PathBuf {
145    state_dir.join(format!("{room_id}.subscriptions"))
146}
147
148/// Broker event-filter-map file path: `<state_dir>/<room_id>.event_filters`.
149///
150/// Persists per-user event type filters on every mutation. Used alongside
151/// the subscription tier to control which [`EventType`]s appear in poll results.
152pub fn broker_event_filters_path(state_dir: &Path, room_id: &str) -> PathBuf {
153    state_dir.join(format!("{room_id}.event_filters"))
154}
155
156// ── Directory initialisation ──────────────────────────────────────────────────
157
158/// Ensure `~/.room/state/` and `~/.room/data/` exist.
159///
160/// Both directories are created with mode `0700` on Unix to protect token
161/// files from other users on the same machine. `recursive(true)` means the
162/// call is idempotent — safe to call on every daemon/broker start.
163pub fn ensure_room_dirs() -> std::io::Result<()> {
164    create_dir_0700(&room_state_dir())?;
165    create_dir_0700(&room_data_dir())?;
166    // Ensure the runtime directory for the daemon socket exists.
167    // On Linux with $XDG_RUNTIME_DIR, this creates the `room/` subdirectory
168    // (e.g. /run/user/1002/room/) which is not created by the OS.
169    let rt = runtime_dir();
170    if rt != std::path::Path::new("/tmp") {
171        create_dir_0700(&rt)?;
172    }
173    Ok(())
174}
175
176// ── Internals ────────────────────────────────────────────────────────────────
177
178fn home_dir() -> PathBuf {
179    std::env::var("HOME")
180        .map(PathBuf::from)
181        .unwrap_or_else(|_| PathBuf::from("/tmp"))
182}
183
184fn runtime_dir() -> PathBuf {
185    // macOS: $TMPDIR is per-user and secure (/var/folders/...)
186    // Linux: prefer $XDG_RUNTIME_DIR if set, fall back to /tmp
187    #[cfg(target_os = "macos")]
188    {
189        std::env::var("TMPDIR")
190            .map(PathBuf::from)
191            .unwrap_or_else(|_| PathBuf::from("/tmp"))
192    }
193    #[cfg(not(target_os = "macos"))]
194    {
195        std::env::var("XDG_RUNTIME_DIR")
196            .map(|d| PathBuf::from(d).join("room"))
197            .unwrap_or_else(|_| PathBuf::from("/tmp"))
198    }
199}
200
201fn create_dir_0700(path: &Path) -> std::io::Result<()> {
202    #[cfg(unix)]
203    {
204        std::fs::DirBuilder::new()
205            .recursive(true)
206            .mode(0o700)
207            .create(path)
208    }
209    #[cfg(not(unix))]
210    {
211        std::fs::create_dir_all(path)
212    }
213}
214
215// ── Tests ─────────────────────────────────────────────────────────────────────
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use std::sync::Mutex;
221
222    /// Serialises tests that read or write the `ROOM_SOCKET` environment
223    /// variable.  Env vars are process-global state — without this lock,
224    /// `cargo test` runs these tests in parallel and they race.
225    static ENV_LOCK: Mutex<()> = Mutex::new(());
226
227    #[test]
228    fn room_home_ends_with_dot_room() {
229        let h = room_home();
230        assert!(
231            h.ends_with(".room"),
232            "expected path ending in .room, got: {h:?}"
233        );
234    }
235
236    #[test]
237    fn room_state_dir_under_room_home() {
238        assert!(room_state_dir().starts_with(room_home()));
239        assert!(room_state_dir().ends_with("state"));
240    }
241
242    #[test]
243    fn room_data_dir_under_room_home() {
244        assert!(room_data_dir().starts_with(room_home()));
245        assert!(room_data_dir().ends_with("data"));
246    }
247
248    #[test]
249    fn token_path_is_per_room_and_user() {
250        let alice_r1 = token_path("room1", "alice");
251        let bob_r1 = token_path("room1", "bob");
252        let alice_r2 = token_path("room2", "alice");
253        assert_ne!(alice_r1, bob_r1);
254        assert_ne!(alice_r1, alice_r2);
255        assert!(alice_r1.to_str().unwrap().contains("alice"));
256        assert!(alice_r1.to_str().unwrap().contains("room1"));
257    }
258
259    #[test]
260    fn cursor_path_is_per_room_and_user() {
261        let p = cursor_path("myroom", "bob");
262        assert!(p.to_str().unwrap().contains("bob"));
263        assert!(p.to_str().unwrap().contains("myroom"));
264        assert!(p.to_str().unwrap().ends_with(".cursor"));
265    }
266
267    #[test]
268    fn broker_tokens_path_contains_room_id() {
269        let base = PathBuf::from("/tmp/state");
270        let p = broker_tokens_path(&base, "test-room");
271        assert_eq!(p, base.join("test-room.tokens"));
272    }
273
274    #[test]
275    fn broker_subscriptions_path_contains_room_id() {
276        let base = PathBuf::from("/tmp/state");
277        let p = broker_subscriptions_path(&base, "test-room");
278        assert_eq!(p, base.join("test-room.subscriptions"));
279    }
280
281    #[test]
282    fn broker_event_filters_path_contains_room_id() {
283        let base = PathBuf::from("/tmp/state");
284        let p = broker_event_filters_path(&base, "test-room");
285        assert_eq!(p, base.join("test-room.event_filters"));
286    }
287
288    #[test]
289    fn create_dir_0700_is_idempotent() {
290        let dir = tempfile::TempDir::new().unwrap();
291        let target = dir.path().join("nested").join("deep");
292        create_dir_0700(&target).unwrap();
293        // Second call must not error (recursive=true).
294        create_dir_0700(&target).unwrap();
295        assert!(target.exists());
296    }
297
298    #[cfg(unix)]
299    #[test]
300    fn create_dir_0700_sets_correct_permissions() {
301        use std::os::unix::fs::PermissionsExt;
302        let dir = tempfile::TempDir::new().unwrap();
303        let target = dir.path().join("secret");
304        create_dir_0700(&target).unwrap();
305        let perms = std::fs::metadata(&target).unwrap().permissions();
306        assert_eq!(
307            perms.mode() & 0o777,
308            0o700,
309            "expected 0700, got {:o}",
310            perms.mode() & 0o777
311        );
312    }
313
314    // ── effective_socket_path ─────────────────────────────────────────────
315
316    #[test]
317    fn effective_socket_path_uses_env_var() {
318        let _lock = ENV_LOCK.lock().unwrap();
319        let key = "ROOM_SOCKET";
320        let prev = std::env::var(key).ok();
321        std::env::set_var(key, "/tmp/test-roomd.sock");
322        let result = effective_socket_path(None);
323        match prev {
324            Some(v) => std::env::set_var(key, v),
325            None => std::env::remove_var(key),
326        }
327        assert_eq!(result, PathBuf::from("/tmp/test-roomd.sock"));
328    }
329
330    #[test]
331    fn effective_socket_path_explicit_overrides_env() {
332        let _lock = ENV_LOCK.lock().unwrap();
333        let key = "ROOM_SOCKET";
334        let prev = std::env::var(key).ok();
335        std::env::set_var(key, "/tmp/env-roomd.sock");
336        let explicit = PathBuf::from("/tmp/explicit.sock");
337        let result = effective_socket_path(Some(&explicit));
338        match prev {
339            Some(v) => std::env::set_var(key, v),
340            None => std::env::remove_var(key),
341        }
342        assert_eq!(result, explicit);
343    }
344
345    #[test]
346    fn effective_socket_path_default_without_env() {
347        let _lock = ENV_LOCK.lock().unwrap();
348        let key = "ROOM_SOCKET";
349        let prev = std::env::var(key).ok();
350        std::env::remove_var(key);
351        let result = effective_socket_path(None);
352        match prev {
353            Some(v) => std::env::set_var(key, v),
354            None => std::env::remove_var(key),
355        }
356        assert_eq!(result, room_socket_path());
357    }
358
359    #[test]
360    fn room_runtime_dir_returns_absolute_path() {
361        let p = room_runtime_dir();
362        assert!(p.is_absolute(), "expected absolute path, got: {p:?}");
363    }
364
365    #[test]
366    fn legacy_token_dir_returns_valid_path() {
367        let p = legacy_token_dir();
368        // Must be absolute and non-empty.
369        assert!(p.is_absolute(), "expected absolute path, got: {p:?}");
370    }
371
372    #[test]
373    fn ensure_room_dirs_creates_state_and_data() {
374        // We cannot call ensure_room_dirs() directly without writing to ~/.room,
375        // so test the underlying create_dir_0700 with a temp directory.
376        let dir = tempfile::TempDir::new().unwrap();
377        let state = dir.path().join("state");
378        let data = dir.path().join("data");
379        create_dir_0700(&state).unwrap();
380        create_dir_0700(&data).unwrap();
381        assert!(state.exists());
382        assert!(data.exists());
383    }
384}