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