//! # signald //! //! The one long-lived, user-level daemon that owns every collector and //! publishes a signal stream. Renderers are thin subscribers over a local Unix //! socket — no renderer ever touches a sensor. //! //! ```text //! zsh hooks ───▶ terminal collector ─┐ (aggregate counts from the spool) //! git/fsevents ▶ git collector ──────┼─▶ hub: last-value cache + fan-out //! IOKit/AppKit ▶ system+hw collector ┘ (macos-collector child, frames on stdout) //! ├─▶ publish: Unix socket (live) //! └─▶ SQLite WAL history //! ``` //! //! The git and terminal collectors run in-process on a tick; the hardware //! collector is the sibling `macos-collector` binary, spawned as a child whose //! stdout frames are ingested into the same hub. The hub caches the latest //! value of every metric and streams updates to subscribers; every signal is //! persisted to a SQLite (WAL) history store. Collector internals live in the //! `signald` library crate; this binary is argument wiring, the producer loop, //! and self-attestation. //! //! Usage: //! ```text //! signald [--socket ] [--db ] [--spool ] //! [--collector ] [--interval-ms ] [--retention-days ] //! [ ...] //! ``` //! With no repo paths, `$XDG_CONFIG_HOME/signald/repos` (else //! `~/.config/signald/repos`) is read — one path per line, `#` comments //! ignored — and failing that the current directory is watched. The socket defaults to //! `$XDG_RUNTIME_DIR/signald.sock` (fallback `~/.local/state/signald/sock`); the //! history db and terminal spool default alongside it. `--collector` names the //! `macos-collector` binary; by default it is looked up on `PATH` and skipped, //! with a log line, when absent. History older than `--retention-days` //! (default 7) is pruned. use std::path::PathBuf; use std::thread; use std::time::Duration; use signal_schema::Source; use signald::collectors; use signald::history::History; use signald::hub::Hub; use signald::publish; struct Config { socket: PathBuf, db: PathBuf, spool: PathBuf, collector: Option, interval: Duration, retention: Duration, repos: Vec, } fn main() { let cfg = parse_args(); print_self_attestation(&cfg); let history = match History::open_with_retention(&cfg.db, cfg.retention) { Ok(h) => { eprintln!( "signald: history at {} (retention {} days)", cfg.db.display(), cfg.retention.as_secs() / 86_400 ); h } Err(e) => { eprintln!("signald: fatal: cannot open history db {}: {e}", cfg.db.display()); std::process::exit(1); } }; let hub = Hub::with_history(history); // Hardware: spawn the out-of-process collector and ingest its frames. Not // having one (Linux, or a dev build not on PATH) is not fatal. if let Some(collector) = &cfg.collector { match collectors::hardware::spawn(collector, cfg.interval.as_millis() as u64, hub.clone()) { Ok(_) => eprintln!("signald: hardware collector {}", collector.display()), Err(e) => eprintln!("signald: cannot start hardware collector {}: {e}", collector.display()), } } // Producer: poll the collectors on a tick and publish into the hub. Runs // for the life of the daemon, independent of any subscriber. let producer = hub.clone(); let repos = cfg.repos.clone(); let spool = cfg.spool.clone(); let interval = cfg.interval; thread::spawn(move || { let mut terminal = collectors::terminal::Collector::new(); loop { for repo in &repos { for sig in collectors::git::collect(repo) { // The audited repo-path tag never leaves the watched roots. if !signald::tag_within_roots(&sig, &repos) { continue; } producer.publish(sig); } } for sig in terminal.collect(&spool) { producer.publish(sig); } thread::sleep(interval); } }); eprintln!("signald: watching {} repo(s), spool {}", cfg.repos.len(), cfg.spool.display()); if let Err(e) = publish::serve(&cfg.socket, hub) { eprintln!("signald: fatal: {e}"); std::process::exit(1); } } fn parse_args() -> Config { let mut socket: Option = None; let mut db: Option = None; let mut spool: Option = None; let mut collector: Option = None; let mut interval_ms: u64 = 2000; let mut retention_days: u64 = 7; let mut repos: Vec = Vec::new(); let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { match arg.as_str() { "--socket" => socket = args.next().map(PathBuf::from), "--db" => db = args.next().map(PathBuf::from), "--spool" => spool = args.next().map(PathBuf::from), "--collector" => collector = args.next().map(PathBuf::from), "--interval-ms" => { interval_ms = args.next().and_then(|s| s.parse().ok()).unwrap_or(interval_ms) } "--retention-days" => { retention_days = args.next().and_then(|s| s.parse().ok()).unwrap_or(retention_days) } _ => repos.push(PathBuf::from(arg)), } } if repos.is_empty() { repos = repos_from_config_file(); } if repos.is_empty() { repos.push(std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); } let socket = socket.unwrap_or_else(default_socket_path); let base = socket.parent().map(PathBuf::from).unwrap_or_default(); Config { db: db.unwrap_or_else(|| base.join("signald.sqlite")), spool: spool.unwrap_or_else(|| base.join("terminal.spool")), collector: collector.or_else(collectors::hardware::find_on_path), interval: Duration::from_millis(interval_ms), retention: Duration::from_secs(retention_days * 86_400), repos, socket, } } /// `$XDG_RUNTIME_DIR/signald.sock`, else `$HOME/.local/state/signald/sock`. /// /// Split from the environment so it can be tested without mutating it. The /// same resolution is duplicated in the other binary and in /// `shell-hooks/signald-hooks.zsh`; the README's "Paths" table is the one /// place they are written down, and the tests below pin them to it. fn socket_path_from(xdg_runtime_dir: Option<&str>, home: Option<&str>) -> PathBuf { if let Some(dir) = xdg_runtime_dir { return PathBuf::from(dir).join("signald.sock"); } PathBuf::from(home.unwrap_or(".")).join(".local/state/signald/sock") } fn default_socket_path() -> PathBuf { socket_path_from( std::env::var("XDG_RUNTIME_DIR").ok().as_deref(), std::env::var("HOME").ok().as_deref(), ) } /// `$XDG_CONFIG_HOME/signald/repos`, else `$HOME/.config/signald/repos`. fn repos_config_path(xdg_config_home: Option<&str>, home: Option<&str>) -> PathBuf { match xdg_config_home { Some(dir) => PathBuf::from(dir).join("signald/repos"), None => PathBuf::from(home.unwrap_or(".")).join(".config/signald/repos"), } } /// One repository path per line. Blank lines and `#` comments are ignored. fn parse_repos_file(contents: &str) -> Vec { contents .lines() .map(|l| l.trim()) .filter(|l| !l.is_empty() && !l.starts_with('#')) .map(PathBuf::from) .collect() } /// Repositories to watch when none were given on the command line. /// /// A login agent has no useful working directory — launchd starts it in `/` — /// so falling straight through to the cwd would leave `brew services start /// signald` collecting no git signals at all. A missing file is not an error; /// the cwd fallback still applies. fn repos_from_config_file() -> Vec { let path = repos_config_path( std::env::var("XDG_CONFIG_HOME").ok().as_deref(), std::env::var("HOME").ok().as_deref(), ); match std::fs::read_to_string(&path) { Ok(contents) => { let repos = parse_repos_file(&contents); if !repos.is_empty() { eprintln!("signald: watching {} repo(s) from {}", repos.len(), path.display()); } repos } Err(_) => Vec::new(), } } /// Log enabled collectors and assert none holds an input-tap capability. A real /// keylogger would need one of the forbidden APIs; their absence is the point, /// and this is the runtime half of that guarantee. fn print_self_attestation(cfg: &Config) { eprintln!("signald {} — self-attestation", env!("CARGO_PKG_VERSION")); eprintln!(" transport: unix socket (length-prefixed frames), live pub/sub"); eprintln!(" history: sqlite (WAL), aggregate scalars only"); let hardware = match &cfg.collector { Some(p) => format!("ENABLED (out-of-process {}; IOKit only, no root)", p.display()), None => format!( "DISABLED ({} not on PATH; pass --collector )", collectors::hardware::PROGRAM ), }; for source in [Source::Terminal, Source::Git, Source::Macos, Source::Hardware] { let state = match source { Source::Git => "ENABLED (aggregate scalars only)", Source::Terminal => "ENABLED (aggregate counts from zsh spool; no input tap)", Source::Macos | Source::Hardware => hardware.as_str(), }; eprintln!(" collector {source:?}: input-tap capability = NONE — {state}"); } } #[cfg(test)] mod path_tests { use super::*; /// Pinned to the README "Paths" table. If this changes, the table and /// `shell-hooks/signald-hooks.zsh` change with it. #[test] fn socket_default_follows_xdg_then_home() { assert_eq!( socket_path_from(Some("/run/user/501"), Some("/Users/x")), PathBuf::from("/run/user/501/signald.sock") ); assert_eq!( socket_path_from(None, Some("/Users/x")), PathBuf::from("/Users/x/.local/state/signald/sock") ); } #[test] fn repos_file_skips_blanks_and_comments() { let repos = parse_repos_file( "# what to watch\n\n/Users/x/git/one\n /Users/x/git/two \n\n# trailing\n", ); assert_eq!( repos, vec![ PathBuf::from("/Users/x/git/one"), PathBuf::from("/Users/x/git/two") ] ); } #[test] fn repos_config_follows_xdg_then_home() { assert_eq!( repos_config_path(Some("/Users/x/.config"), Some("/Users/x")), PathBuf::from("/Users/x/.config/signald/repos") ); assert_eq!( repos_config_path(None, Some("/Users/x")), PathBuf::from("/Users/x/.config/signald/repos") ); } /// The db and the spool are derived from the socket's directory, so all /// three move together when --socket is given. #[test] fn db_and_spool_sit_beside_the_socket() { let socket = socket_path_from(None, Some("/Users/x")); let base = socket.parent().unwrap(); assert_eq!( base.join("signald.sqlite"), PathBuf::from("/Users/x/.local/state/signald/signald.sqlite") ); assert_eq!( base.join("terminal.spool"), PathBuf::from("/Users/x/.local/state/signald/terminal.spool"), "must equal SIGNALD_SPOOL's default in shell-hooks/signald-hooks.zsh" ); } }