Skip to main content

omni_dev/daemon/
paths.rs

1//! Per-user runtime paths for the daemon (control socket, token file) and the
2//! filesystem-permission helpers that keep them owner-private.
3//!
4//! The runtime directory is resolved via [`dirs::data_dir`] —
5//! `~/Library/Application Support/omni-dev/` on macOS, `~/.local/share/omni-dev/`
6//! on Linux. This deliberately diverges from the `~/.omni-dev` config-file
7//! convention used by [`crate::claude::context`]: that governs user-editable
8//! configuration, whereas these are *runtime* artifacts of a long-lived
9//! menu-bar app, for which the platform data directory is the native home.
10//! See ADR-0039.
11
12use std::path::{Path, PathBuf};
13
14use anyhow::{bail, Context, Result};
15
16/// macOS caps a `sockaddr_un` path at 104 bytes (including the NUL
17/// terminator); Linux allows 108. Use the smaller, portable bound.
18pub const MAX_SOCKET_PATH_LEN: usize = 104;
19
20/// The per-user runtime directory holding the daemon's socket and token file.
21pub fn runtime_dir() -> Result<PathBuf> {
22    let base = dirs::data_dir().context("could not determine the user data directory")?;
23    Ok(base.join("omni-dev"))
24}
25
26/// Default control-socket path: `<runtime_dir>/daemon.sock`.
27pub fn socket_path() -> Result<PathBuf> {
28    Ok(runtime_dir()?.join("daemon.sock"))
29}
30
31/// Default bridge token-file path: `<runtime_dir>/bridge.token`. Thin clients
32/// (`request`/`harvest`) fall back to this when no `--token-file`/env is set.
33pub fn token_path() -> Result<PathBuf> {
34    Ok(runtime_dir()?.join("bridge.token"))
35}
36
37/// Default per-repo PR-poll preferences path: `<runtime_dir>/worktrees-polling.json`.
38///
39/// The worktrees service persists the set of GitHub repos whose PR badges it
40/// polls here (`0600`), so the enable/disable choice survives a daemon restart
41/// (#1376). Non-secret, but co-located with the other `0600` runtime state.
42pub fn worktrees_polling_path() -> Result<PathBuf> {
43    Ok(runtime_dir()?.join("worktrees-polling.json"))
44}
45
46/// Default resolved-PR-badge cache path: `<runtime_dir>/worktrees-pr-cache.json`.
47///
48/// The worktrees service persists the last polled PR badges (number / URL /
49/// check-state / draft flag) here (`0600`), so a daemon restart serves badges
50/// instantly and can skip its immediate re-poll when the verdicts are still fresh
51/// (#1389, fix 4). Non-secret — the same badge data already rides the tree wire —
52/// but co-located with the other `0600` runtime state for a consistent posture.
53pub fn worktrees_pr_cache_path() -> Result<PathBuf> {
54    Ok(runtime_dir()?.join("worktrees-pr-cache.json"))
55}
56
57/// The bridge token file co-located with a control socket
58/// (`<socket dir>/bridge.token`), so a custom `--socket` keeps its token beside
59/// it. For the default socket this equals [`token_path`].
60pub fn token_path_for_socket(socket: &Path) -> PathBuf {
61    socket.parent().map_or_else(
62        || PathBuf::from("bridge.token"),
63        |dir| dir.join("bridge.token"),
64    )
65}
66
67/// The daemon log file co-located with a control socket
68/// (`<socket dir>/daemon.log`).
69///
70/// A custom `--socket` keeps its log beside it. The non-macOS `daemon start`
71/// launcher appends the detached daemon's stdout/stderr here.
72pub fn log_path_for_socket(socket: &Path) -> PathBuf {
73    socket
74        .parent()
75        .map_or_else(|| PathBuf::from("daemon.log"), |dir| dir.join("daemon.log"))
76}
77
78/// The per-session hook journal directory beside a control socket
79/// (`<socket dir>/sessions`).
80///
81/// The hook sink and the daemon resolve the same one by construction, and a
82/// custom `--socket` keeps its journals beside it (#2108). For the default
83/// socket it is `<runtime_dir>/sessions`.
84pub fn sessions_journal_dir_for_socket(socket: &Path) -> PathBuf {
85    socket
86        .parent()
87        .map_or_else(|| PathBuf::from("sessions"), |dir| dir.join("sessions"))
88}
89
90/// Creates `dir` (and ancestors) if absent and tightens it to owner-only
91/// (`0700`) on Unix.
92///
93/// On Unix the mode is passed to `mkdir(2)` itself, so no directory is ever
94/// looser than `0700` even for an instant (the umask can only clear bits); the
95/// follow-up `chmod` re-tightens pre-existing directories and guarantees the
96/// exact mode under exotic umasks (#1139).
97pub fn ensure_dir_0700(dir: &Path) -> Result<()> {
98    let mut builder = std::fs::DirBuilder::new();
99    builder.recursive(true);
100    #[cfg(unix)]
101    {
102        use std::os::unix::fs::DirBuilderExt;
103        builder.mode(0o700);
104    }
105    builder
106        .create(dir)
107        .with_context(|| format!("failed to create directory {}", dir.display()))?;
108    #[cfg(unix)]
109    {
110        use std::os::unix::fs::PermissionsExt;
111        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
112            .with_context(|| format!("failed to set 0700 on {}", dir.display()))?;
113    }
114    Ok(())
115}
116
117/// Writes `contents` to `path`, owner read/write only (`0600`) from birth on
118/// Unix.
119///
120/// The mode is passed to `open(2)` at creation, so a fresh file is never
121/// group/world-readable even for an instant; a pre-existing looser-perm file
122/// is re-tightened via [`ensure_handle_0600`] *before* the contents land in it
123/// (#1132). On non-Unix platforms this is a plain truncating write.
124pub fn write_file_0600(path: &Path, contents: &[u8]) -> Result<()> {
125    use std::io::Write;
126
127    let mut options = std::fs::OpenOptions::new();
128    options.write(true).create(true).truncate(true);
129    #[cfg(unix)]
130    {
131        use std::os::unix::fs::OpenOptionsExt;
132        options.mode(0o600);
133    }
134    let mut file = options
135        .open(path)
136        .with_context(|| format!("failed to create file {}", path.display()))?;
137    ensure_handle_0600(&file)
138        .with_context(|| format!("failed to set 0600 on {}", path.display()))?;
139    file.write_all(contents)
140        .with_context(|| format!("failed to write file {}", path.display()))?;
141    Ok(())
142}
143
144/// Tightens an open file to owner read/write only (`0600`) on Unix if its
145/// current mode is any looser.
146///
147/// Operates on the handle (`fchmod(2)`), so there is no path race and the
148/// umask does not apply. No-op on non-Unix platforms.
149pub fn ensure_handle_0600(file: &std::fs::File) -> Result<()> {
150    #[cfg(unix)]
151    {
152        use std::os::unix::fs::PermissionsExt;
153        let mode = file
154            .metadata()
155            .context("failed to read file metadata")?
156            .permissions()
157            .mode();
158        if mode & 0o077 != 0 {
159            file.set_permissions(std::fs::Permissions::from_mode(0o600))
160                .context("failed to set 0600 on open file")?;
161        }
162    }
163    #[cfg(not(unix))]
164    {
165        let _ = file;
166    }
167    Ok(())
168}
169
170/// Creates `path`'s parent directory at `0700` if it doesn't already exist.
171///
172/// A no-op for a bare relative filename, whose "parent" is empty — the
173/// "make room for the file I'm about to create" step shared by every
174/// `0600`-file writer in the crate (the request log, the Drive lease ledger
175/// and its lock, the Drive lease backup directory).
176pub fn ensure_parent_dir_0700(path: &Path) -> Result<()> {
177    if let Some(dir) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
178        if !dir.exists() {
179            ensure_dir_0700(dir)?;
180        }
181    }
182    Ok(())
183}
184
185/// Creates `path` exclusively, owner read/write only (`0600`) from birth on
186/// Unix.
187///
188/// `create_new` (`O_EXCL`), so two callers racing to create the same path
189/// see exactly one winner and the other an error, never a silent overwrite
190/// — [`write_file_0600`]'s sibling for callers that need "either I created
191/// this file first, or I must fail" rather than a truncating write. Used by
192/// the Drive lease ledger's advisory lock file, its backup files, and the
193/// pre-existing `gmail insert` ledger lock.
194pub fn create_new_file_0600(path: &Path) -> Result<std::fs::File> {
195    let mut options = std::fs::OpenOptions::new();
196    options.write(true).create_new(true);
197    #[cfg(unix)]
198    {
199        use std::os::unix::fs::OpenOptionsExt;
200        options.mode(0o600);
201    }
202    let file = options
203        .open(path)
204        .with_context(|| format!("failed to exclusively create file {}", path.display()))?;
205    if let Err(err) = ensure_handle_0600(&file) {
206        // We just created this file exclusively; leaving it behind on this
207        // failure would be a phantom lock/marker indistinguishable from a
208        // genuine collision to the next caller (issue #1687 point 6).
209        // Best-effort: a failure to remove it doesn't change which error is
210        // reported.
211        if let Err(remove_err) = std::fs::remove_file(path) {
212            tracing::debug!(
213                "failed to remove {} after its fchmod failed: {remove_err}",
214                path.display()
215            );
216        }
217        return Err(err).with_context(|| format!("failed to set 0600 on {}", path.display()));
218    }
219    Ok(file)
220}
221
222/// Whether `err`'s cause chain includes an `io::Error` of kind
223/// `AlreadyExists`.
224///
225/// A caller of [`create_new_file_0600`] that wraps its `Result` in extra
226/// "this looks like a collision, retry" guidance must apply that guidance
227/// only when the underlying failure actually was the path already existing
228/// — not when `open(2)` itself succeeded and the follow-up `fchmod` safety
229/// net failed instead, which is an unrelated permissions/filesystem problem
230/// that "remove the stale lock and retry" or "may already exist" would
231/// misdiagnose (issue #1664 review finding).
232pub fn is_already_exists_error(err: &anyhow::Error) -> bool {
233    err.chain().any(|cause| {
234        cause
235            .downcast_ref::<std::io::Error>()
236            .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::AlreadyExists)
237    })
238}
239
240/// Why [`try_lock_file_exclusive`] failed.
241#[derive(Debug)]
242pub enum FileLockError {
243    /// The lock is already held by someone else — distinguishable from
244    /// [`Self::Io`] without string-sniffing a message, so a waiting caller
245    /// can poll on this variant specifically and treat any other error as
246    /// unconditionally fatal.
247    Busy,
248    /// Opening, locking, or verifying the lock file failed for a reason
249    /// other than contention.
250    Io(anyhow::Error),
251}
252
253/// An advisory exclusive lock on `path`, held for [`FileLock`]'s lifetime.
254///
255/// On Unix this is a `flock(2)` lock: kernel-released when every handle to
256/// it closes — including on process death — so unlike
257/// [`create_new_file_0600`]'s `O_EXCL` marker, a crashed or SIGKILLed
258/// holder never leaves a stale lock. The lock file itself is never deleted
259/// (`Drop` unlocks, it does not unlink): **no caller should ever tell an
260/// operator to delete this file by hand**, since that reopens the exact
261/// double-spend a naive marker-plus-unlink lock risks (issue #1687).
262///
263/// On non-Unix (`nix`'s `flock` wrapper is Unix-only) this falls back to
264/// an `O_EXCL` marker removed on drop, preserving today's semantics there.
265#[derive(Debug)]
266pub struct FileLock {
267    #[cfg(unix)]
268    #[allow(dead_code)] // Held only for its Drop (unlocks on drop); never read.
269    inner: nix::fcntl::Flock<std::fs::File>,
270    #[cfg(not(unix))]
271    path: PathBuf,
272}
273
274/// Bound on the replacement-check retries in [`try_lock_file_exclusive`] —
275/// see its doc comment.
276#[cfg(unix)]
277const LOCK_REPLACEMENT_RETRIES: u32 = 3;
278
279/// Acquires an advisory exclusive lock on `path` (non-blocking).
280///
281/// Creates the file at `0600` if absent, but not `path`'s parent directory
282/// — callers with an opinion on that call [`ensure_parent_dir_0700`] first.
283///
284/// `flock` locks the *open file description*, not the path: if the file at
285/// `path` is deleted and recreated while a lock is held (nothing in this
286/// crate does that, but a stray `rm` could), a second locker could lock the
287/// new inode while the first still holds the old one — both would then
288/// "hold the lock" without conflicting. After locking, this function
289/// re-`stat`s `path` and compares device/inode against the locked handle;
290/// on a mismatch it drops that lock and retries against the current path,
291/// bounded to [`LOCK_REPLACEMENT_RETRIES`] attempts. This narrows the race,
292/// it does not close it — the actual mitigation is that nothing in this
293/// crate ever instructs an operator to delete the file.
294#[cfg(unix)]
295pub fn try_lock_file_exclusive(path: &Path) -> std::result::Result<FileLock, FileLockError> {
296    use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
297
298    for _ in 0..LOCK_REPLACEMENT_RETRIES {
299        let mut options = std::fs::OpenOptions::new();
300        options.create(true).write(true).truncate(false).mode(0o600);
301        let file = options
302            .open(path)
303            .with_context(|| format!("failed to open lock file {}", path.display()))
304            .map_err(FileLockError::Io)?;
305        ensure_handle_0600(&file)
306            .with_context(|| format!("failed to set 0600 on {}", path.display()))
307            .map_err(FileLockError::Io)?;
308
309        let locked =
310            match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusiveNonblock) {
311                Ok(locked) => locked,
312                Err((_file, nix::errno::Errno::EWOULDBLOCK)) => return Err(FileLockError::Busy),
313                Err((_file, errno)) => {
314                    return Err(FileLockError::Io(anyhow::anyhow!(
315                        "failed to lock {}: {errno}",
316                        path.display()
317                    )))
318                }
319            };
320
321        let locked_meta = locked
322            .metadata()
323            .with_context(|| format!("failed to stat locked handle for {}", path.display()))
324            .map_err(FileLockError::Io)?;
325        match std::fs::metadata(path) {
326            Ok(current_meta)
327                if current_meta.dev() == locked_meta.dev()
328                    && current_meta.ino() == locked_meta.ino() =>
329            {
330                return Ok(FileLock { inner: locked });
331            }
332            // The path now names a different inode (or nothing) than the
333            // one we locked — someone deleted/recreated it out from under
334            // us. Drop this lock and retry against the current path.
335            _ => {}
336        }
337    }
338    Err(FileLockError::Io(anyhow::anyhow!(
339        "gave up acquiring the lock on {} after {LOCK_REPLACEMENT_RETRIES} attempts — the file \
340         kept being replaced out from under us",
341        path.display()
342    )))
343}
344
345#[cfg(not(unix))]
346pub fn try_lock_file_exclusive(path: &Path) -> std::result::Result<FileLock, FileLockError> {
347    match create_new_file_0600(path) {
348        Ok(_) => Ok(FileLock {
349            path: path.to_path_buf(),
350        }),
351        Err(err) if is_already_exists_error(&err) => Err(FileLockError::Busy),
352        Err(err) => Err(FileLockError::Io(err)),
353    }
354}
355
356#[cfg(not(unix))]
357impl Drop for FileLock {
358    fn drop(&mut self) {
359        // Best-effort: this is a marker, not the lock itself — the next
360        // caller's `create_new` collision check is what actually enforces
361        // exclusion, so a failed unlink here just means a future acquire
362        // will (incorrectly) see this as still held (STYLE-0018).
363        if let Err(err) = std::fs::remove_file(&self.path) {
364            tracing::debug!("failed to remove lock file {}: {err}", self.path.display());
365        }
366    }
367}
368
369/// [`try_lock_file_exclusive`], but folding [`FileLockError::Busy`] into
370/// `Ok(None)` instead of an `Err` variant — for callers that want to decide
371/// for themselves whether a busy lock is a wait-and-retry condition or an
372/// immediate failure, rather than that choice being baked into this
373/// function. An I/O failure is still `Err`, wrapped with `what` (a short
374/// noun phrase, e.g. `"the lease lock file"`) describing what was being
375/// locked, for a caller-appropriate error message.
376pub(crate) fn try_lock_or_busy(path: &Path, what: &str) -> Result<Option<FileLock>> {
377    match try_lock_file_exclusive(path) {
378        Ok(inner) => Ok(Some(inner)),
379        Err(FileLockError::Busy) => Ok(None),
380        Err(FileLockError::Io(err)) => {
381            Err(err.context(format!("failed to lock {what} at {}", path.display())))
382        }
383    }
384}
385
386/// Tightens an existing file to owner read/write only (`0600`) on Unix.
387pub fn set_file_0600(path: &Path) -> Result<()> {
388    #[cfg(unix)]
389    {
390        use std::os::unix::fs::PermissionsExt;
391        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
392            .with_context(|| format!("failed to set 0600 on {}", path.display()))?;
393    }
394    #[cfg(not(unix))]
395    {
396        let _ = path;
397    }
398    Ok(())
399}
400
401/// Fails closed if the socket path is too long for the platform's
402/// `sockaddr_un`, with an actionable message instead of an opaque bind error.
403pub fn check_socket_path_len(path: &Path) -> Result<()> {
404    let len = path.as_os_str().len();
405    if len >= MAX_SOCKET_PATH_LEN {
406        bail!(
407            "socket path is {len} bytes, exceeding the {MAX_SOCKET_PATH_LEN}-byte limit: {} — \
408             pass a shorter --socket path",
409            path.display()
410        );
411    }
412    Ok(())
413}
414
415#[cfg(test)]
416#[allow(clippy::unwrap_used, clippy::expect_used)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn default_paths_sit_under_the_runtime_dir() {
422        let rt = runtime_dir().unwrap();
423        assert!(socket_path().unwrap().starts_with(&rt));
424        assert!(token_path().unwrap().starts_with(&rt));
425        let polling = worktrees_polling_path().unwrap();
426        assert!(polling.starts_with(&rt));
427        assert_eq!(polling.file_name().unwrap(), "worktrees-polling.json");
428    }
429
430    #[test]
431    fn token_sits_beside_its_socket() {
432        assert_eq!(
433            token_path_for_socket(Path::new("/tmp/x/daemon.sock")),
434            Path::new("/tmp/x/bridge.token")
435        );
436        // A bare filename (parent is the empty path) still yields a token name.
437        assert_eq!(
438            token_path_for_socket(Path::new("daemon.sock")),
439            Path::new("bridge.token")
440        );
441    }
442
443    #[test]
444    fn log_sits_beside_its_socket() {
445        assert_eq!(
446            log_path_for_socket(Path::new("/tmp/x/daemon.sock")),
447            Path::new("/tmp/x/daemon.log")
448        );
449        // A bare filename (parent is the empty path) still yields a log name.
450        assert_eq!(
451            log_path_for_socket(Path::new("daemon.sock")),
452            Path::new("daemon.log")
453        );
454    }
455
456    #[test]
457    fn journals_sit_beside_their_socket() {
458        assert_eq!(
459            sessions_journal_dir_for_socket(Path::new("/tmp/x/daemon.sock")),
460            Path::new("/tmp/x/sessions")
461        );
462        assert_eq!(
463            sessions_journal_dir_for_socket(Path::new("daemon.sock")),
464            Path::new("sessions")
465        );
466        // The default socket's journals are under the runtime dir.
467        let socket = socket_path().unwrap();
468        assert_eq!(
469            sessions_journal_dir_for_socket(&socket),
470            runtime_dir().unwrap().join("sessions")
471        );
472    }
473
474    #[test]
475    fn socket_path_length_guard() {
476        check_socket_path_len(Path::new("/tmp/short.sock")).unwrap();
477        let too_long = PathBuf::from(format!("/{}", "a".repeat(MAX_SOCKET_PATH_LEN)));
478        assert!(check_socket_path_len(&too_long).is_err());
479    }
480
481    #[test]
482    fn write_file_0600_creates_owner_only() {
483        let dir = tempfile::tempdir().unwrap();
484        let file = dir.path().join("fresh.token");
485        write_file_0600(&file, b"secret").unwrap();
486        assert_eq!(std::fs::read(&file).unwrap(), b"secret");
487        #[cfg(unix)]
488        {
489            use std::os::unix::fs::PermissionsExt;
490            assert_eq!(
491                std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
492                0o600
493            );
494        }
495    }
496
497    #[cfg(unix)]
498    #[test]
499    fn write_file_0600_retightens_preexisting_loose_file() {
500        use std::os::unix::fs::PermissionsExt;
501        let dir = tempfile::tempdir().unwrap();
502        let file = dir.path().join("stale.token");
503        std::fs::write(&file, "old-secret-with-longer-content").unwrap();
504        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
505        write_file_0600(&file, b"new").unwrap();
506        assert_eq!(std::fs::read(&file).unwrap(), b"new");
507        assert_eq!(
508            std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
509            0o600
510        );
511    }
512
513    #[test]
514    fn ensure_dir_and_file_perms() {
515        let dir = tempfile::tempdir().unwrap();
516        let sub = dir.path().join("run");
517        ensure_dir_0700(&sub).unwrap();
518        assert!(sub.is_dir());
519        let file = sub.join("k");
520        std::fs::write(&file, "x").unwrap();
521        set_file_0600(&file).unwrap();
522        #[cfg(unix)]
523        {
524            use std::os::unix::fs::PermissionsExt;
525            assert_eq!(
526                std::fs::metadata(&sub).unwrap().permissions().mode() & 0o777,
527                0o700
528            );
529            assert_eq!(
530                std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
531                0o600
532            );
533        }
534    }
535
536    #[test]
537    fn ensure_parent_dir_0700_creates_a_missing_parent() {
538        let dir = tempfile::tempdir().unwrap();
539        let file = dir.path().join("run").join("k");
540        ensure_parent_dir_0700(&file).unwrap();
541        assert!(dir.path().join("run").is_dir());
542        #[cfg(unix)]
543        {
544            use std::os::unix::fs::PermissionsExt;
545            assert_eq!(
546                std::fs::metadata(dir.path().join("run"))
547                    .unwrap()
548                    .permissions()
549                    .mode()
550                    & 0o777,
551                0o700
552            );
553        }
554    }
555
556    #[test]
557    fn ensure_parent_dir_0700_is_a_noop_when_the_parent_already_exists() {
558        let dir = tempfile::tempdir().unwrap();
559        let file = dir.path().join("k");
560        // The parent (`dir`) already exists — this must not error or touch it.
561        ensure_parent_dir_0700(&file).unwrap();
562        assert!(dir.path().is_dir());
563    }
564
565    #[test]
566    fn ensure_parent_dir_0700_is_a_noop_for_a_bare_filename() {
567        // A bare relative filename has an empty parent — nothing to create.
568        ensure_parent_dir_0700(Path::new("bare.txt")).unwrap();
569    }
570
571    #[test]
572    fn is_already_exists_error_detects_the_wrapped_io_kind() {
573        let dir = tempfile::tempdir().unwrap();
574        let path = dir.path().join("collide");
575        create_new_file_0600(&path).unwrap();
576        let err = create_new_file_0600(&path).unwrap_err();
577        assert!(is_already_exists_error(&err), "{err:?}");
578    }
579
580    #[test]
581    fn is_already_exists_error_is_false_for_an_unrelated_failure() {
582        let dir = tempfile::tempdir().unwrap();
583        // A missing parent directory makes `open()` fail with `NotFound`,
584        // not `AlreadyExists`.
585        let path = dir.path().join("no-such-parent").join("child");
586        let err = create_new_file_0600(&path).unwrap_err();
587        assert!(!is_already_exists_error(&err), "{err:?}");
588    }
589}