Skip to main content

zshrs_daemon/
paths.rs

1// ~/.zshrs/* path resolution + permission enforcement.
2//
3// Layout (matches docs/DAEMON.md):
4//   ~/.zshrs/
5//   ├── index.rkyv
6//   ├── images/                  ← 0700 dir, 0600 files
7//   ├── catalog.db               ← 0600 (daemon-only writer)
8//   ├── history.db               ← 0600
9//   ├── zshrs.log                ← 0600 — shell (thin client) tracing
10//   ├── zshrs-daemon.log         ← 0600 — daemon tracing
11//   ├── zshrs-recorder.log       ← 0600 — recorder tracing
12//   ├── daemon.sock              ← 0600 Unix domain socket
13//   └── daemon.pid               ← 0600 singleton flock
14//
15// Cache directory is 0700 (user-only). Files inside are 0600. Verified by
16// `zcache verify` on every integrity scan; drift triggers WARN in
17// zshrs-daemon.log. All three log files are size-rotated together by the
18// daemon's ticker via `is_zshrs_log_file` — adding a fourth log file
19// (e.g. for a future `zshrs-test-runner` binary) only needs editing
20// that one helper.
21
22use std::os::unix::fs::PermissionsExt;
23use std::path::{Path, PathBuf};
24
25use super::{DaemonError, Result};
26
27/// Default `zshrs-daemon.toml` written on first daemon startup. HTTP
28/// is enabled on loopback so `zd health` / `zd ops` / `zd op …` work
29/// without the user having to edit anything. Loopback bind requires
30/// no auth (per `http::serve_http`); switching `listen` to a non-
31/// loopback address WILL refuse to start until `[http.tokens]` is
32/// populated.
33const DEFAULT_DAEMON_TOML: &str = "\
34# zshrs-daemon configuration. Lives at $ZSHRS_HOME/zshrs-daemon.toml
35# (~/.zshrs/zshrs-daemon.toml by default). Auto-seeded on first
36# start; edit freely — the daemon never overwrites it.
37
38[log]
39# Tracing filter directive. Same syntax as $ZSHRS_LOG (which
40# always wins when set). Accepts simple levels (info / debug /
41# trace / warn / error) or per-module overrides
42# (info,fsnotify=trace,ipc=debug). `zlog level <directive>`
43# overrides this at runtime without a daemon restart.
44#
45# Default `info` keeps the log file lean; bump to `trace` or
46# `debug,zshrs_daemon=trace` for first-pass diagnostics
47# (resolved root, shard count, db sizes, watch list, token
48# scopes, schedule rows etc — all gated to TRACE so they
49# don't flood at INFO).
50level = \"info\"
51
52[http]
53# HTTP listener for the `zd` CLI + curl + any HTTP client.
54# Loopback-only by default so no token is required. Set to
55# 0.0.0.0:7733 (or a non-loopback IP) to expose to your network —
56# the daemon will refuse that bind unless [http.tokens] has at
57# least one entry below.
58listen = \"127.0.0.1:7733\"
59
60# Bearer-token registry. Required for non-loopback listeners.
61# Two value shapes per key:
62#
63#   [http.tokens]
64#   # Legacy / unscoped — flat string. Token grants full access.
65#   mybox    = \"replace-with-32-byte-random-hex\"
66#
67#   # Scoped — only grants the listed scopes.
68#   # Wildcards: `*` (everything), `<area>.*`, `*.<verb>`.
69#   vim-lsp  = { token = \"…\", scopes = [\"defs.read\", \"snapshot.read\"] }
70#   ci-pipe  = { token = \"…\", scopes = [\"job.write\", \"cache.*\"] }
71[http.tokens]
72";
73
74/// Default `zshrs.toml` written on first daemon startup. The daemon
75/// owns the cache root, so seeding the shell-side file here too
76/// means a single `zshrs-daemon` invocation leaves both knobs
77/// discoverable + editable.
78const DEFAULT_SHELL_TOML: &str = "\
79# zshrs (shell) configuration. Lives at $ZSHRS_HOME/zshrs.toml
80# (~/.zshrs/zshrs.toml by default). Auto-seeded on first run of
81# any zshrs binary (zshrs / zshrs-daemon / zshrs-recorder / zd);
82# edit freely.
83
84[log]
85# Tracing filter directive for the SHELL side (writes to
86# zshrs.log + zshrs-recorder.log). Same syntax as $ZSHRS_LOG
87# (which always wins when set). Accepts simple levels (info /
88# debug / trace / warn / error) or per-module overrides
89# (info,zsh::exec=debug,zsh::recorder=trace).
90#
91# Default `info`. Bump to `trace` for first-pass diagnostics
92# of executor / parser / canonical_apply paths.
93level = \"info\"
94
95[daemon]
96# Whether the shell talks to zshrs-daemon at startup:
97#   auto     — connect if the socket is reachable, else go vanilla
98#   on       — require a daemon, fail loudly if missing
99#   off      — never connect, even if the daemon is running
100enabled = \"auto\"
101
102[shell]
103# Whether to skip sourcing /etc/zshenv + ~/.{zshenv,zprofile,zshrc,
104# zlogin} and rebuild executor state from the daemon's canonical
105# rkyv shard instead. Saves ~150ms on shells with heavy plugin
106# loads.
107#   auto — skip iff daemon is up AND has a recorded zshrs shard
108#   on   — always skip (config files become inert; recorder owns
109#          all state)
110#   off  — never skip (canonical state ignored even when present)
111skip_configs = \"auto\"
112
113# Optional: zsh script run once after dotfiles (or after canonical_apply
114# when skip_configs applies). Omit entirely if unused.
115# startup_config = \"~/.zshrs/shell-init.zsh\"
116";
117
118/// Default `zshrs-recorder.toml` written on first start. The recorder
119/// today reads only `[log] level` from this file; future runtime
120/// defaults (default `--shell-id`, default `--quiet`, default
121/// `--no-daemon`) land here as the surface grows.
122const DEFAULT_RECORDER_TOML: &str = "\
123# zshrs-recorder configuration. Lives at
124# $ZSHRS_HOME/zshrs-recorder.toml (~/.zshrs/zshrs-recorder.toml by
125# default). Auto-seeded on first run of any zshrs binary; edit freely.
126
127[log]
128# Tracing filter directive for the RECORDER log
129# (zshrs-recorder.log). Same syntax as $ZSHRS_LOG (which always
130# wins when set). Accepts simple levels (info / debug / trace /
131# warn / error) or per-module overrides
132# (info,zsh::recorder=trace).
133#
134# Default `info`. Bump to `trace` to see every captured event +
135# the source-resolver decisions for each captured file:line.
136level = \"info\"
137";
138
139/// All cache-related paths for the running user.
140#[derive(Clone, Debug)]
141pub struct CachePaths {
142    /// `root` field.
143    pub root: PathBuf,
144    /// `images` field.
145    pub images: PathBuf,
146    /// `catalog_db` field.
147    pub catalog_db: PathBuf,
148    /// `history_db` field.
149    pub history_db: PathBuf,
150    /// `log` field.
151    pub log: PathBuf,
152    /// `log_dir` field.
153    pub log_dir: PathBuf,
154    /// `log_file_name` field.
155    pub log_file_name: String,
156    /// `socket` field.
157    pub socket: PathBuf,
158    /// `pid_file` field.
159    pub pid_file: PathBuf,
160    /// `index_rkyv` field.
161    pub index_rkyv: PathBuf,
162    /// `replay/` — per-shell scripts holding the non-deterministic
163    /// fragments of `.zshrc` that the daemon couldn't bake into canonical
164    /// state. Per docs/DAEMON.md "Determinism boundary" (line 278).
165    pub replay_dir: PathBuf,
166    /// `cache.db` — daemon-as-service KV cache, namespaced. See
167    /// docs/DAEMON_AS_SERVICE.md `daemon.cache.*` ops + daemon/cache.rs.
168    pub cache_db: PathBuf,
169    /// `artifacts/` — content-addressed artifact cache. Files live at
170    /// `artifacts/<sha256_prefix>/<sha256_hex>`. See
171    /// docs/DAEMON_AS_SERVICE.md `daemon.artifact.*` ops.
172    pub artifacts_dir: PathBuf,
173    /// `snapshots/` — tag-based canonical-state snapshots. See
174    /// docs/DAEMON_AS_SERVICE.md `daemon.snapshot.*` ops.
175    pub snapshots_dir: PathBuf,
176}
177
178impl CachePaths {
179    /// Resolve to `$ZSHRS_HOME` or `$HOME/.zshrs`.
180    ///
181    /// Single top-level directory holds everything: rkyv shards, sqlite
182    /// caches, sockets, pid file, logs, AND config
183    /// (`zshrs.toml`, `zshrs-daemon.toml`, `zshrs-recorder.toml`).
184    /// The directory is NOT cache-semantic — it survives
185    /// OS cache eviction; that's why we don't use `XDG_CACHE_HOME`.
186    /// Matches the convention of `~/.zinit/`, `~/.zpwr/`, `~/.oh-my-zsh/`.
187    ///
188    /// Override via `$ZSHRS_HOME` for tests / hermetic harnesses /
189    /// users who want a non-default location.
190    pub fn resolve() -> Result<Self> {
191        let root = if let Some(custom) = std::env::var_os("ZSHRS_HOME") {
192            PathBuf::from(custom)
193        } else {
194            let home = std::env::var_os("HOME")
195                .or_else(|| dirs::home_dir().map(|p| p.into_os_string()))
196                .ok_or_else(|| DaemonError::other("could not resolve $HOME"))?;
197            PathBuf::from(home).join(".zshrs")
198        };
199        Ok(Self::with_root(root))
200    }
201
202    /// Build paths anchored at an explicit root. Useful for tests with tempdirs.
203    pub fn with_root<P: Into<PathBuf>>(root: P) -> Self {
204        let root = root.into();
205        let images = root.join("images");
206        let catalog_db = root.join("catalog.db");
207        let history_db = root.join("history.db");
208        let log = root.join("zshrs-daemon.log");
209        let log_dir = root.clone();
210        let log_file_name = "zshrs-daemon.log".to_string();
211        let socket = root.join("daemon.sock");
212        let pid_file = root.join("daemon.pid");
213        let index_rkyv = root.join("index.rkyv");
214        let replay_dir = root.join("replay");
215        let cache_db = root.join("cache.db");
216        let artifacts_dir = root.join("artifacts");
217        let snapshots_dir = root.join("snapshots");
218
219        Self {
220            root,
221            images,
222            catalog_db,
223            history_db,
224            log,
225            log_dir,
226            log_file_name,
227            socket,
228            pid_file,
229            index_rkyv,
230            replay_dir,
231            cache_db,
232            artifacts_dir,
233            snapshots_dir,
234        }
235    }
236
237    /// Create the cache directory tree with 0700 perms.
238    pub fn ensure_dirs(&self) -> Result<()> {
239        ensure_dir_700(&self.root)?;
240        ensure_dir_700(&self.images)?;
241        ensure_dir_700(&self.replay_dir)?;
242        ensure_dir_700(&self.artifacts_dir)?;
243        ensure_dir_700(&self.snapshots_dir)?;
244        Ok(())
245    }
246
247    /// Path to `zshrs-daemon.toml` — the daemon's HTTP/auth/log/cache
248    /// config. Renamed from the bare `daemon.toml` (briefly used during
249    /// the initial seeding work) so every config file in the dir
250    /// shares the `zshrs[-binary].toml` naming convention. Migration
251    /// from the old name is in `ensure_default_configs`.
252    pub fn daemon_config_path(&self) -> std::path::PathBuf {
253        self.root.join("zshrs-daemon.toml")
254    }
255
256    /// Path to `zshrs.toml` — shell-side daemon-presence + skip-configs
257    /// + log-level knobs (consumed by the `zshrs` binary).
258    pub fn shell_config_path(&self) -> std::path::PathBuf {
259        self.root.join("zshrs.toml")
260    }
261
262    /// Path to `zshrs-recorder.toml` — recorder-side log level + future
263    /// runtime knobs (e.g. default --shell-id, default --quiet, etc.).
264    pub fn recorder_config_path(&self) -> std::path::PathBuf {
265        self.root.join("zshrs-recorder.toml")
266    }
267
268    /// Seed every default config file under `~/.zshrs/` if absent:
269    ///   * `zshrs.toml`            — shell knobs
270    ///   * `zshrs-daemon.toml`     — daemon knobs (auto-migrates from
271    ///     the legacy `daemon.toml`)
272    ///   * `zshrs-recorder.toml`   — recorder knobs
273    ///
274    /// Idempotent — never overwrites a user-edited file. Files are
275    /// chmod'd 0600 because the same root holds secrets-bearing tokens
276    /// once the user opts in. Called by every binary
277    /// (`zshrs` / `zshrs-daemon` / `zshrs-recorder` / `zd`) at startup
278    /// so whichever runs first gets the user a fully-populated
279    /// `~/.zshrs/` tree without manual intervention.
280    pub fn ensure_default_configs(&self) -> Result<()> {
281        // Step 1: rename the legacy `daemon.toml` if present + the new
282        // path doesn't exist yet. Single-shot, never overwrites the new
283        // file. After this, the rest of the function treats the new
284        // path as authoritative.
285        let legacy_daemon = self.root.join("daemon.toml");
286        let daemon_cfg = self.daemon_config_path();
287        if legacy_daemon.exists() && !daemon_cfg.exists() {
288            match std::fs::rename(&legacy_daemon, &daemon_cfg) {
289                Ok(()) => tracing::info!(
290                    from = %legacy_daemon.display(),
291                    to = %daemon_cfg.display(),
292                    "renamed legacy daemon.toml -> zshrs-daemon.toml"
293                ),
294                Err(e) => tracing::warn!(?e, "rename legacy daemon.toml failed"),
295            }
296        }
297
298        if !daemon_cfg.exists() {
299            std::fs::write(&daemon_cfg, DEFAULT_DAEMON_TOML)?;
300            ensure_file_600(&daemon_cfg)?;
301            tracing::info!(path = %daemon_cfg.display(), "seeded default zshrs-daemon.toml");
302        }
303        let shell_cfg = self.shell_config_path();
304        if !shell_cfg.exists() {
305            std::fs::write(&shell_cfg, DEFAULT_SHELL_TOML)?;
306            ensure_file_600(&shell_cfg)?;
307            tracing::info!(path = %shell_cfg.display(), "seeded default zshrs.toml");
308        }
309        let recorder_cfg = self.recorder_config_path();
310        if !recorder_cfg.exists() {
311            std::fs::write(&recorder_cfg, DEFAULT_RECORDER_TOML)?;
312            ensure_file_600(&recorder_cfg)?;
313            tracing::info!(path = %recorder_cfg.display(), "seeded default zshrs-recorder.toml");
314        }
315        Ok(())
316    }
317
318    /// Has the daemon ever completed an init pass on this machine for this user?
319    /// First-run = no daemon.pid AND no index.rkyv AND no shards in images/.
320    pub fn is_first_run(&self) -> bool {
321        if self.pid_file.exists() {
322            return false;
323        }
324        if self.index_rkyv.exists() {
325            return false;
326        }
327        if let Ok(mut iter) = std::fs::read_dir(&self.images) {
328            if iter.next().is_some() {
329                return false;
330            }
331        }
332        true
333    }
334}
335
336fn ensure_dir_700(path: &Path) -> Result<()> {
337    if !path.exists() {
338        std::fs::create_dir_all(path)?;
339    }
340    let mut perms = std::fs::metadata(path)?.permissions();
341    if perms.mode() & 0o777 != 0o700 {
342        perms.set_mode(0o700);
343        std::fs::set_permissions(path, perms)?;
344    }
345    Ok(())
346}
347
348/// Resolve `$ZSHRS_HOME/zshrs-daemon.toml` (or
349/// `~/.zshrs/zshrs-daemon.toml`). Single-directory rule: every zshrs
350/// file — config, cache, sockets, rkyv shards, log — lives under one
351/// root. Returns the path even if the file does not exist; callers
352/// handle the not-present case as "no overrides" rather than as an
353/// error.
354pub fn daemon_config_file() -> Result<PathBuf> {
355    let root = if let Some(custom) = std::env::var_os("ZSHRS_HOME") {
356        PathBuf::from(custom)
357    } else {
358        dirs::home_dir()
359            .map(|h| h.join(".zshrs"))
360            .ok_or_else(|| DaemonError::other("no $HOME / $ZSHRS_HOME for zshrs-daemon.toml"))?
361    };
362    Ok(root.join("zshrs-daemon.toml"))
363}
364
365/// Load `[http]` section from `~/.zshrs/zshrs-daemon.toml` into the
366/// `HttpConfig` consumed by `daemon::http::serve_http`. The file is
367/// optional; a missing file or a missing `[http]` section both produce
368/// the default (HTTP listener disabled).
369///
370/// `[http.tokens]` accepts two value shapes per key:
371///
372/// ```toml
373/// [http]
374/// listen = "127.0.0.1:7733"
375///
376/// [http.tokens]
377/// # Legacy / unscoped — flat string. Token grants full access.
378/// mybox      = "0123abcd..."
379/// # Scoped — inline table. Token only grants the listed scopes.
380/// # See `daemon::auth::op_scope` for the area.verb namespace.
381/// vim-lsp    = { token = "feedface...", scopes = ["defs.read", "snapshot.read"] }
382/// ci-pipe    = { token = "deadbeef...", scopes = ["job.write", "cache.*"] }
383/// ```
384///
385/// Wildcards in `scopes`: `*` (everything), `<area>.*` (every verb in
386/// an area), `*.<verb>` (every area's `<verb>`).
387/// Read `[log] level` from `$ZSHRS_HOME/zshrs-daemon.toml`. Returns the
388/// directive string (e.g. `"info"`, `"debug,fsnotify=trace"`) so the
389/// caller can hand it straight to `EnvFilter::try_new`. Falls back
390/// to `"info"` on any error: missing file, missing section, missing
391/// key, parse error, non-string value. Errors aren't surfaced — the
392/// caller will hit them again via the EnvFilter parse if the
393/// directive is malformed, with a more useful "bad directive `xxx`"
394/// message at THAT layer.
395///
396/// Honors the same `$ZSHRS_HOME` override every other path resolver
397/// uses, and takes the resolved `CachePaths` so test harnesses can
398/// point at a tempdir without poking env.
399pub fn load_log_directive(paths: &CachePaths) -> String {
400    const DEFAULT: &str = "info";
401    let path = paths.daemon_config_path();
402    if !path.exists() {
403        return DEFAULT.into();
404    }
405    let body = match std::fs::read_to_string(&path) {
406        Ok(b) => b,
407        Err(_) => return DEFAULT.into(),
408    };
409    let parsed: toml::Value = match body.parse::<toml::Table>().map(toml::Value::Table) {
410        Ok(v) => v,
411        Err(_) => return DEFAULT.into(),
412    };
413    parsed
414        .get("log")
415        .and_then(|s| s.get("level"))
416        .and_then(toml::Value::as_str)
417        .filter(|s| !s.is_empty())
418        .map(str::to_string)
419        .unwrap_or_else(|| DEFAULT.into())
420}
421/// `load_http_config` — see implementation.
422pub fn load_http_config() -> Result<super::http::HttpConfig> {
423    let path = daemon_config_file()?;
424    if !path.exists() {
425        return Ok(super::http::HttpConfig::default());
426    }
427    let body = std::fs::read_to_string(&path)?;
428    // toml v1 reserves the FromStr impl on `Value` for SCALARS only;
429    // documents must go through `Table::from_str`. Routing through
430    // Value::from_str produces "unexpected content, expected nothing"
431    // the moment the parser sees a table header.
432    let parsed: toml::Value = body
433        .parse::<toml::Table>()
434        .map(toml::Value::Table)
435        .map_err(|e| DaemonError::other(format!("daemon.toml parse: {e}")))?;
436    let http_section = match parsed.get("http") {
437        Some(v) => v,
438        None => return Ok(super::http::HttpConfig::default()),
439    };
440    let listen = http_section
441        .get("listen")
442        .and_then(toml::Value::as_str)
443        .map(str::to_string);
444    let mut tokens: Vec<super::auth::Token> = Vec::new();
445    if let Some(tok_table) = http_section.get("tokens").and_then(toml::Value::as_table) {
446        for (label, val) in tok_table {
447            // Legacy form: `name = "secret"` (full access).
448            if let Some(secret) = val.as_str() {
449                if !secret.is_empty() {
450                    tokens.push(super::auth::Token {
451                        label: label.clone(),
452                        secret: secret.to_string(),
453                        scopes: super::auth::ScopeMatcher::default(),
454                    });
455                }
456                continue;
457            }
458            // Scoped form: `name = { token = "secret", scopes = [...] }`.
459            if let Some(inner) = val.as_table() {
460                let secret = match inner.get("token").and_then(toml::Value::as_str) {
461                    Some(s) if !s.is_empty() => s.to_string(),
462                    _ => {
463                        return Err(DaemonError::other(format!(
464                            "daemon.toml: [http.tokens].{label} missing or empty `token` field"
465                        )));
466                    }
467                };
468                let scopes = inner
469                    .get("scopes")
470                    .and_then(toml::Value::as_array)
471                    .map(|arr| {
472                        arr.iter()
473                            .filter_map(|v| v.as_str().map(str::to_string))
474                            .collect::<Vec<_>>()
475                    })
476                    .unwrap_or_default();
477                tokens.push(super::auth::Token {
478                    label: label.clone(),
479                    secret,
480                    scopes: super::auth::ScopeMatcher::from_strings(scopes),
481                });
482            }
483        }
484    }
485    Ok(super::http::HttpConfig {
486        listen,
487        tokens: super::auth::TokenRegistry::new(tokens),
488    })
489}
490
491/// True if `name` is the prefix of one of the three zshrs log files,
492/// or any of their `.N` rotation siblings:
493///
494///   * `zshrs.log` / `zshrs.log.1` / `zshrs.log.2` …  (shell)
495///   * `zshrs-daemon.log{,.N}`                       (daemon)
496///   * `zshrs-recorder.log{,.N}`                     (recorder)
497///
498/// Used by ticker rotation, `zlog` builtin scanners, and snapshot
499/// bundling. Centralized here so adding a fourth log file (e.g. for a
500/// future `zshrs-test-runner` binary) only needs editing this one
501/// function.
502pub fn is_zshrs_log_file(name: &str) -> bool {
503    name.starts_with("zshrs.log")
504        || name.starts_with("zshrs-daemon.log")
505        || name.starts_with("zshrs-recorder.log")
506}
507
508/// Set 0600 on a file path that already exists. Logs a warning on drift detection.
509pub fn ensure_file_600(path: &Path) -> Result<()> {
510    if !path.exists() {
511        return Ok(());
512    }
513    let mut perms = std::fs::metadata(path)?.permissions();
514    let mode = perms.mode() & 0o777;
515    if mode != 0o600 {
516        tracing::warn!(
517            path = %path.display(),
518            current_mode = format!("{:o}", mode),
519            "file mode drift; coercing to 0600"
520        );
521        perms.set_mode(0o600);
522        std::fs::set_permissions(path, perms)?;
523    }
524    Ok(())
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    use tempfile::TempDir;
531
532    #[test]
533    fn paths_relative_to_root() {
534        let tmp = TempDir::new().unwrap();
535        let p = CachePaths::with_root(tmp.path());
536        assert!(p.images.starts_with(tmp.path()));
537        assert_eq!(p.images.file_name().unwrap(), "images");
538        assert_eq!(p.socket.file_name().unwrap(), "daemon.sock");
539        assert_eq!(p.pid_file.file_name().unwrap(), "daemon.pid");
540    }
541
542    #[test]
543    fn ensure_dirs_sets_0700() {
544        let tmp = TempDir::new().unwrap();
545        let p = CachePaths::with_root(tmp.path().join("zshrs"));
546        p.ensure_dirs().unwrap();
547
548        let mode = std::fs::metadata(&p.root).unwrap().permissions().mode() & 0o777;
549        assert_eq!(mode, 0o700);
550
551        let mode = std::fs::metadata(&p.images).unwrap().permissions().mode() & 0o777;
552        assert_eq!(mode, 0o700);
553    }
554
555    #[test]
556    fn first_run_detected_on_empty_root() {
557        let tmp = TempDir::new().unwrap();
558        let p = CachePaths::with_root(tmp.path().join("zshrs"));
559        p.ensure_dirs().unwrap();
560        assert!(p.is_first_run());
561    }
562
563    #[test]
564    fn first_run_false_when_pid_exists() {
565        let tmp = TempDir::new().unwrap();
566        let p = CachePaths::with_root(tmp.path().join("zshrs"));
567        p.ensure_dirs().unwrap();
568        std::fs::write(&p.pid_file, "12345").unwrap();
569        assert!(!p.is_first_run());
570    }
571}