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