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