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/// The bridge token file co-located with a control socket
38/// (`<socket dir>/bridge.token`), so a custom `--socket` keeps its token beside
39/// it. For the default socket this equals [`token_path`].
40pub fn token_path_for_socket(socket: &Path) -> PathBuf {
41    socket.parent().map_or_else(
42        || PathBuf::from("bridge.token"),
43        |dir| dir.join("bridge.token"),
44    )
45}
46
47/// The daemon log file co-located with a control socket
48/// (`<socket dir>/daemon.log`).
49///
50/// A custom `--socket` keeps its log beside it. The non-macOS `daemon start`
51/// launcher appends the detached daemon's stdout/stderr here.
52pub fn log_path_for_socket(socket: &Path) -> PathBuf {
53    socket
54        .parent()
55        .map_or_else(|| PathBuf::from("daemon.log"), |dir| dir.join("daemon.log"))
56}
57
58/// Creates `dir` (and ancestors) if absent and tightens it to owner-only
59/// (`0700`) on Unix.
60///
61/// On Unix the mode is passed to `mkdir(2)` itself, so no directory is ever
62/// looser than `0700` even for an instant (the umask can only clear bits); the
63/// follow-up `chmod` re-tightens pre-existing directories and guarantees the
64/// exact mode under exotic umasks (#1139).
65pub fn ensure_dir_0700(dir: &Path) -> Result<()> {
66    let mut builder = std::fs::DirBuilder::new();
67    builder.recursive(true);
68    #[cfg(unix)]
69    {
70        use std::os::unix::fs::DirBuilderExt;
71        builder.mode(0o700);
72    }
73    builder
74        .create(dir)
75        .with_context(|| format!("failed to create directory {}", dir.display()))?;
76    #[cfg(unix)]
77    {
78        use std::os::unix::fs::PermissionsExt;
79        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
80            .with_context(|| format!("failed to set 0700 on {}", dir.display()))?;
81    }
82    Ok(())
83}
84
85/// Writes `contents` to `path`, owner read/write only (`0600`) from birth on
86/// Unix.
87///
88/// The mode is passed to `open(2)` at creation, so a fresh file is never
89/// group/world-readable even for an instant; a pre-existing looser-perm file
90/// is re-tightened via [`ensure_handle_0600`] *before* the contents land in it
91/// (#1132). On non-Unix platforms this is a plain truncating write.
92pub fn write_file_0600(path: &Path, contents: &[u8]) -> Result<()> {
93    use std::io::Write;
94
95    let mut options = std::fs::OpenOptions::new();
96    options.write(true).create(true).truncate(true);
97    #[cfg(unix)]
98    {
99        use std::os::unix::fs::OpenOptionsExt;
100        options.mode(0o600);
101    }
102    let mut file = options
103        .open(path)
104        .with_context(|| format!("failed to create file {}", path.display()))?;
105    ensure_handle_0600(&file)
106        .with_context(|| format!("failed to set 0600 on {}", path.display()))?;
107    file.write_all(contents)
108        .with_context(|| format!("failed to write file {}", path.display()))?;
109    Ok(())
110}
111
112/// Tightens an open file to owner read/write only (`0600`) on Unix if its
113/// current mode is any looser.
114///
115/// Operates on the handle (`fchmod(2)`), so there is no path race and the
116/// umask does not apply. No-op on non-Unix platforms.
117pub fn ensure_handle_0600(file: &std::fs::File) -> Result<()> {
118    #[cfg(unix)]
119    {
120        use std::os::unix::fs::PermissionsExt;
121        let mode = file
122            .metadata()
123            .context("failed to read file metadata")?
124            .permissions()
125            .mode();
126        if mode & 0o077 != 0 {
127            file.set_permissions(std::fs::Permissions::from_mode(0o600))
128                .context("failed to set 0600 on open file")?;
129        }
130    }
131    #[cfg(not(unix))]
132    {
133        let _ = file;
134    }
135    Ok(())
136}
137
138/// Tightens an existing file to owner read/write only (`0600`) on Unix.
139pub fn set_file_0600(path: &Path) -> Result<()> {
140    #[cfg(unix)]
141    {
142        use std::os::unix::fs::PermissionsExt;
143        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
144            .with_context(|| format!("failed to set 0600 on {}", path.display()))?;
145    }
146    #[cfg(not(unix))]
147    {
148        let _ = path;
149    }
150    Ok(())
151}
152
153/// Fails closed if the socket path is too long for the platform's
154/// `sockaddr_un`, with an actionable message instead of an opaque bind error.
155pub fn check_socket_path_len(path: &Path) -> Result<()> {
156    let len = path.as_os_str().len();
157    if len >= MAX_SOCKET_PATH_LEN {
158        bail!(
159            "socket path is {len} bytes, exceeding the {MAX_SOCKET_PATH_LEN}-byte limit: {} — \
160             pass a shorter --socket path",
161            path.display()
162        );
163    }
164    Ok(())
165}
166
167#[cfg(test)]
168#[allow(clippy::unwrap_used, clippy::expect_used)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn default_paths_sit_under_the_runtime_dir() {
174        let rt = runtime_dir().unwrap();
175        assert!(socket_path().unwrap().starts_with(&rt));
176        assert!(token_path().unwrap().starts_with(&rt));
177    }
178
179    #[test]
180    fn token_sits_beside_its_socket() {
181        assert_eq!(
182            token_path_for_socket(Path::new("/tmp/x/daemon.sock")),
183            Path::new("/tmp/x/bridge.token")
184        );
185        // A bare filename (parent is the empty path) still yields a token name.
186        assert_eq!(
187            token_path_for_socket(Path::new("daemon.sock")),
188            Path::new("bridge.token")
189        );
190    }
191
192    #[test]
193    fn log_sits_beside_its_socket() {
194        assert_eq!(
195            log_path_for_socket(Path::new("/tmp/x/daemon.sock")),
196            Path::new("/tmp/x/daemon.log")
197        );
198        // A bare filename (parent is the empty path) still yields a log name.
199        assert_eq!(
200            log_path_for_socket(Path::new("daemon.sock")),
201            Path::new("daemon.log")
202        );
203    }
204
205    #[test]
206    fn socket_path_length_guard() {
207        check_socket_path_len(Path::new("/tmp/short.sock")).unwrap();
208        let too_long = PathBuf::from(format!("/{}", "a".repeat(MAX_SOCKET_PATH_LEN)));
209        assert!(check_socket_path_len(&too_long).is_err());
210    }
211
212    #[test]
213    fn write_file_0600_creates_owner_only() {
214        let dir = tempfile::tempdir().unwrap();
215        let file = dir.path().join("fresh.token");
216        write_file_0600(&file, b"secret").unwrap();
217        assert_eq!(std::fs::read(&file).unwrap(), b"secret");
218        #[cfg(unix)]
219        {
220            use std::os::unix::fs::PermissionsExt;
221            assert_eq!(
222                std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
223                0o600
224            );
225        }
226    }
227
228    #[cfg(unix)]
229    #[test]
230    fn write_file_0600_retightens_preexisting_loose_file() {
231        use std::os::unix::fs::PermissionsExt;
232        let dir = tempfile::tempdir().unwrap();
233        let file = dir.path().join("stale.token");
234        std::fs::write(&file, "old-secret-with-longer-content").unwrap();
235        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
236        write_file_0600(&file, b"new").unwrap();
237        assert_eq!(std::fs::read(&file).unwrap(), b"new");
238        assert_eq!(
239            std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
240            0o600
241        );
242    }
243
244    #[test]
245    fn ensure_dir_and_file_perms() {
246        let dir = tempfile::tempdir().unwrap();
247        let sub = dir.path().join("run");
248        ensure_dir_0700(&sub).unwrap();
249        assert!(sub.is_dir());
250        let file = sub.join("k");
251        std::fs::write(&file, "x").unwrap();
252        set_file_0600(&file).unwrap();
253        #[cfg(unix)]
254        {
255            use std::os::unix::fs::PermissionsExt;
256            assert_eq!(
257                std::fs::metadata(&sub).unwrap().permissions().mode() & 0o777,
258                0o700
259            );
260            assert_eq!(
261                std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
262                0o600
263            );
264        }
265    }
266}