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/// Tightens an existing file to owner read/write only (`0600`) on Unix.
159pub fn set_file_0600(path: &Path) -> Result<()> {
160    #[cfg(unix)]
161    {
162        use std::os::unix::fs::PermissionsExt;
163        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
164            .with_context(|| format!("failed to set 0600 on {}", path.display()))?;
165    }
166    #[cfg(not(unix))]
167    {
168        let _ = path;
169    }
170    Ok(())
171}
172
173/// Fails closed if the socket path is too long for the platform's
174/// `sockaddr_un`, with an actionable message instead of an opaque bind error.
175pub fn check_socket_path_len(path: &Path) -> Result<()> {
176    let len = path.as_os_str().len();
177    if len >= MAX_SOCKET_PATH_LEN {
178        bail!(
179            "socket path is {len} bytes, exceeding the {MAX_SOCKET_PATH_LEN}-byte limit: {} — \
180             pass a shorter --socket path",
181            path.display()
182        );
183    }
184    Ok(())
185}
186
187#[cfg(test)]
188#[allow(clippy::unwrap_used, clippy::expect_used)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn default_paths_sit_under_the_runtime_dir() {
194        let rt = runtime_dir().unwrap();
195        assert!(socket_path().unwrap().starts_with(&rt));
196        assert!(token_path().unwrap().starts_with(&rt));
197        let polling = worktrees_polling_path().unwrap();
198        assert!(polling.starts_with(&rt));
199        assert_eq!(polling.file_name().unwrap(), "worktrees-polling.json");
200    }
201
202    #[test]
203    fn token_sits_beside_its_socket() {
204        assert_eq!(
205            token_path_for_socket(Path::new("/tmp/x/daemon.sock")),
206            Path::new("/tmp/x/bridge.token")
207        );
208        // A bare filename (parent is the empty path) still yields a token name.
209        assert_eq!(
210            token_path_for_socket(Path::new("daemon.sock")),
211            Path::new("bridge.token")
212        );
213    }
214
215    #[test]
216    fn log_sits_beside_its_socket() {
217        assert_eq!(
218            log_path_for_socket(Path::new("/tmp/x/daemon.sock")),
219            Path::new("/tmp/x/daemon.log")
220        );
221        // A bare filename (parent is the empty path) still yields a log name.
222        assert_eq!(
223            log_path_for_socket(Path::new("daemon.sock")),
224            Path::new("daemon.log")
225        );
226    }
227
228    #[test]
229    fn socket_path_length_guard() {
230        check_socket_path_len(Path::new("/tmp/short.sock")).unwrap();
231        let too_long = PathBuf::from(format!("/{}", "a".repeat(MAX_SOCKET_PATH_LEN)));
232        assert!(check_socket_path_len(&too_long).is_err());
233    }
234
235    #[test]
236    fn write_file_0600_creates_owner_only() {
237        let dir = tempfile::tempdir().unwrap();
238        let file = dir.path().join("fresh.token");
239        write_file_0600(&file, b"secret").unwrap();
240        assert_eq!(std::fs::read(&file).unwrap(), b"secret");
241        #[cfg(unix)]
242        {
243            use std::os::unix::fs::PermissionsExt;
244            assert_eq!(
245                std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
246                0o600
247            );
248        }
249    }
250
251    #[cfg(unix)]
252    #[test]
253    fn write_file_0600_retightens_preexisting_loose_file() {
254        use std::os::unix::fs::PermissionsExt;
255        let dir = tempfile::tempdir().unwrap();
256        let file = dir.path().join("stale.token");
257        std::fs::write(&file, "old-secret-with-longer-content").unwrap();
258        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
259        write_file_0600(&file, b"new").unwrap();
260        assert_eq!(std::fs::read(&file).unwrap(), b"new");
261        assert_eq!(
262            std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
263            0o600
264        );
265    }
266
267    #[test]
268    fn ensure_dir_and_file_perms() {
269        let dir = tempfile::tempdir().unwrap();
270        let sub = dir.path().join("run");
271        ensure_dir_0700(&sub).unwrap();
272        assert!(sub.is_dir());
273        let file = sub.join("k");
274        std::fs::write(&file, "x").unwrap();
275        set_file_0600(&file).unwrap();
276        #[cfg(unix)]
277        {
278            use std::os::unix::fs::PermissionsExt;
279            assert_eq!(
280                std::fs::metadata(&sub).unwrap().permissions().mode() & 0o777,
281                0o700
282            );
283            assert_eq!(
284                std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
285                0o600
286            );
287        }
288    }
289}