1use std::os::unix::fs::PermissionsExt;
23use std::path::{Path, PathBuf};
24
25use super::{DaemonError, Result};
26
27const 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
74const 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
118const 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#[derive(Clone, Debug)]
141pub struct CachePaths {
142 pub root: PathBuf,
144 pub images: PathBuf,
146 pub catalog_db: PathBuf,
148 pub history_db: PathBuf,
150 pub log: PathBuf,
152 pub log_dir: PathBuf,
154 pub log_file_name: String,
156 pub socket: PathBuf,
158 pub pid_file: PathBuf,
160 pub index_rkyv: PathBuf,
162 pub replay_dir: PathBuf,
166 pub cache_db: PathBuf,
169 pub artifacts_dir: PathBuf,
173 pub snapshots_dir: PathBuf,
176}
177
178impl CachePaths {
179 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 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 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 pub fn daemon_config_path(&self) -> std::path::PathBuf {
253 self.root.join("zshrs-daemon.toml")
254 }
255
256 pub fn shell_config_path(&self) -> std::path::PathBuf {
259 self.root.join("zshrs.toml")
260 }
261
262 pub fn recorder_config_path(&self) -> std::path::PathBuf {
265 self.root.join("zshrs-recorder.toml")
266 }
267
268 pub fn ensure_default_configs(&self) -> Result<()> {
281 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 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
348pub 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
365pub 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}
421pub 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 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 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 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
491pub 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
508pub 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}