crates/signald/src/main.rs
309 lines · 11863 bytes
1//! # signald
2//!
3//! The one long-lived, user-level daemon that owns every collector and
4//! publishes a signal stream. Renderers are thin subscribers over a local Unix
5//! socket — no renderer ever touches a sensor.
6//!
7//! ```text
8//! zsh hooks ───▶ terminal collector ─┐ (aggregate counts from the spool)
9//! git/fsevents ▶ git collector ──────┼─▶ hub: last-value cache + fan-out
10//! IOKit/AppKit ▶ system+hw collector ┘ (macos-collector child, frames on stdout)
11//! ├─▶ publish: Unix socket (live)
12//! └─▶ SQLite WAL history
13//! ```
14//!
15//! The git and terminal collectors run in-process on a tick; the hardware
16//! collector is the sibling `macos-collector` binary, spawned as a child whose
17//! stdout frames are ingested into the same hub. The hub caches the latest
18//! value of every metric and streams updates to subscribers; every signal is
19//! persisted to a SQLite (WAL) history store. Collector internals live in the
20//! `signald` library crate; this binary is argument wiring, the producer loop,
21//! and self-attestation.
22//!
23//! Usage:
24//! ```text
25//! signald [--socket <path>] [--db <path>] [--spool <path>]
26//! [--collector <path>] [--interval-ms <n>] [--retention-days <n>]
27//! [<repo-path> ...]
28//! ```
29//! With no repo paths, `$XDG_CONFIG_HOME/signald/repos` (else
30//! `~/.config/signald/repos`) is read — one path per line, `#` comments
31//! ignored — and failing that the current directory is watched. The socket defaults to
32//! `$XDG_RUNTIME_DIR/signald.sock` (fallback `~/.local/state/signald/sock`); the
33//! history db and terminal spool default alongside it. `--collector` names the
34//! `macos-collector` binary; by default it is looked up on `PATH` and skipped,
35//! with a log line, when absent. History older than `--retention-days`
36//! (default 7) is pruned.
37
38use std::path::PathBuf;
39use std::thread;
40use std::time::Duration;
41
42use signal_schema::Source;
43use signald::collectors;
44use signald::history::History;
45use signald::hub::Hub;
46use signald::publish;
47
48struct Config {
49 socket: PathBuf,
50 db: PathBuf,
51 spool: PathBuf,
52 collector: Option<PathBuf>,
53 interval: Duration,
54 retention: Duration,
55 repos: Vec<PathBuf>,
56}
57
58fn main() {
59 let cfg = parse_args();
60 print_self_attestation(&cfg);
61
62 let history = match History::open_with_retention(&cfg.db, cfg.retention) {
63 Ok(h) => {
64 eprintln!(
65 "signald: history at {} (retention {} days)",
66 cfg.db.display(),
67 cfg.retention.as_secs() / 86_400
68 );
69 h
70 }
71 Err(e) => {
72 eprintln!("signald: fatal: cannot open history db {}: {e}", cfg.db.display());
73 std::process::exit(1);
74 }
75 };
76 let hub = Hub::with_history(history);
77
78 // Hardware: spawn the out-of-process collector and ingest its frames. Not
79 // having one (Linux, or a dev build not on PATH) is not fatal.
80 if let Some(collector) = &cfg.collector {
81 match collectors::hardware::spawn(collector, cfg.interval.as_millis() as u64, hub.clone()) {
82 Ok(_) => eprintln!("signald: hardware collector {}", collector.display()),
83 Err(e) => eprintln!("signald: cannot start hardware collector {}: {e}", collector.display()),
84 }
85 }
86
87 // Producer: poll the collectors on a tick and publish into the hub. Runs
88 // for the life of the daemon, independent of any subscriber.
89 let producer = hub.clone();
90 let repos = cfg.repos.clone();
91 let spool = cfg.spool.clone();
92 let interval = cfg.interval;
93 thread::spawn(move || {
94 let mut terminal = collectors::terminal::Collector::new();
95 loop {
96 for repo in &repos {
97 for sig in collectors::git::collect(repo) {
98 // The audited repo-path tag never leaves the watched roots.
99 if !signald::tag_within_roots(&sig, &repos) {
100 continue;
101 }
102 producer.publish(sig);
103 }
104 }
105 for sig in terminal.collect(&spool) {
106 producer.publish(sig);
107 }
108 thread::sleep(interval);
109 }
110 });
111
112 eprintln!("signald: watching {} repo(s), spool {}", cfg.repos.len(), cfg.spool.display());
113 if let Err(e) = publish::serve(&cfg.socket, hub) {
114 eprintln!("signald: fatal: {e}");
115 std::process::exit(1);
116 }
117}
118
119fn parse_args() -> Config {
120 let mut socket: Option<PathBuf> = None;
121 let mut db: Option<PathBuf> = None;
122 let mut spool: Option<PathBuf> = None;
123 let mut collector: Option<PathBuf> = None;
124 let mut interval_ms: u64 = 2000;
125 let mut retention_days: u64 = 7;
126 let mut repos: Vec<PathBuf> = Vec::new();
127
128 let mut args = std::env::args().skip(1);
129 while let Some(arg) = args.next() {
130 match arg.as_str() {
131 "--socket" => socket = args.next().map(PathBuf::from),
132 "--db" => db = args.next().map(PathBuf::from),
133 "--spool" => spool = args.next().map(PathBuf::from),
134 "--collector" => collector = args.next().map(PathBuf::from),
135 "--interval-ms" => {
136 interval_ms = args.next().and_then(|s| s.parse().ok()).unwrap_or(interval_ms)
137 }
138 "--retention-days" => {
139 retention_days = args.next().and_then(|s| s.parse().ok()).unwrap_or(retention_days)
140 }
141 _ => repos.push(PathBuf::from(arg)),
142 }
143 }
144
145 if repos.is_empty() {
146 repos = repos_from_config_file();
147 }
148 if repos.is_empty() {
149 repos.push(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
150 }
151 let socket = socket.unwrap_or_else(default_socket_path);
152 let base = socket.parent().map(PathBuf::from).unwrap_or_default();
153 Config {
154 db: db.unwrap_or_else(|| base.join("signald.sqlite")),
155 spool: spool.unwrap_or_else(|| base.join("terminal.spool")),
156 collector: collector.or_else(collectors::hardware::find_on_path),
157 interval: Duration::from_millis(interval_ms),
158 retention: Duration::from_secs(retention_days * 86_400),
159 repos,
160 socket,
161 }
162}
163
164/// `$XDG_RUNTIME_DIR/signald.sock`, else `$HOME/.local/state/signald/sock`.
165///
166/// Split from the environment so it can be tested without mutating it. The
167/// same resolution is duplicated in the other binary and in
168/// `shell-hooks/signald-hooks.zsh`; the README's "Paths" table is the one
169/// place they are written down, and the tests below pin them to it.
170fn socket_path_from(xdg_runtime_dir: Option<&str>, home: Option<&str>) -> PathBuf {
171 if let Some(dir) = xdg_runtime_dir {
172 return PathBuf::from(dir).join("signald.sock");
173 }
174 PathBuf::from(home.unwrap_or(".")).join(".local/state/signald/sock")
175}
176
177fn default_socket_path() -> PathBuf {
178 socket_path_from(
179 std::env::var("XDG_RUNTIME_DIR").ok().as_deref(),
180 std::env::var("HOME").ok().as_deref(),
181 )
182}
183
184/// `$XDG_CONFIG_HOME/signald/repos`, else `$HOME/.config/signald/repos`.
185fn repos_config_path(xdg_config_home: Option<&str>, home: Option<&str>) -> PathBuf {
186 match xdg_config_home {
187 Some(dir) => PathBuf::from(dir).join("signald/repos"),
188 None => PathBuf::from(home.unwrap_or(".")).join(".config/signald/repos"),
189 }
190}
191
192/// One repository path per line. Blank lines and `#` comments are ignored.
193fn parse_repos_file(contents: &str) -> Vec<PathBuf> {
194 contents
195 .lines()
196 .map(|l| l.trim())
197 .filter(|l| !l.is_empty() && !l.starts_with('#'))
198 .map(PathBuf::from)
199 .collect()
200}
201
202/// Repositories to watch when none were given on the command line.
203///
204/// A login agent has no useful working directory — launchd starts it in `/` —
205/// so falling straight through to the cwd would leave `brew services start
206/// signald` collecting no git signals at all. A missing file is not an error;
207/// the cwd fallback still applies.
208fn repos_from_config_file() -> Vec<PathBuf> {
209 let path = repos_config_path(
210 std::env::var("XDG_CONFIG_HOME").ok().as_deref(),
211 std::env::var("HOME").ok().as_deref(),
212 );
213 match std::fs::read_to_string(&path) {
214 Ok(contents) => {
215 let repos = parse_repos_file(&contents);
216 if !repos.is_empty() {
217 eprintln!("signald: watching {} repo(s) from {}", repos.len(), path.display());
218 }
219 repos
220 }
221 Err(_) => Vec::new(),
222 }
223}
224
225/// Log enabled collectors and assert none holds an input-tap capability. A real
226/// keylogger would need one of the forbidden APIs; their absence is the point,
227/// and this is the runtime half of that guarantee.
228fn print_self_attestation(cfg: &Config) {
229 eprintln!("signald {} — self-attestation", env!("CARGO_PKG_VERSION"));
230 eprintln!(" transport: unix socket (length-prefixed frames), live pub/sub");
231 eprintln!(" history: sqlite (WAL), aggregate scalars only");
232 let hardware = match &cfg.collector {
233 Some(p) => format!("ENABLED (out-of-process {}; IOKit only, no root)", p.display()),
234 None => format!(
235 "DISABLED ({} not on PATH; pass --collector <path>)",
236 collectors::hardware::PROGRAM
237 ),
238 };
239 for source in [Source::Terminal, Source::Git, Source::Macos, Source::Hardware] {
240 let state = match source {
241 Source::Git => "ENABLED (aggregate scalars only)",
242 Source::Terminal => "ENABLED (aggregate counts from zsh spool; no input tap)",
243 Source::Macos | Source::Hardware => hardware.as_str(),
244 };
245 eprintln!(" collector {source:?}: input-tap capability = NONE — {state}");
246 }
247}
248
249#[cfg(test)]
250mod path_tests {
251 use super::*;
252
253 /// Pinned to the README "Paths" table. If this changes, the table and
254 /// `shell-hooks/signald-hooks.zsh` change with it.
255 #[test]
256 fn socket_default_follows_xdg_then_home() {
257 assert_eq!(
258 socket_path_from(Some("/run/user/501"), Some("/Users/x")),
259 PathBuf::from("/run/user/501/signald.sock")
260 );
261 assert_eq!(
262 socket_path_from(None, Some("/Users/x")),
263 PathBuf::from("/Users/x/.local/state/signald/sock")
264 );
265 }
266
267 #[test]
268 fn repos_file_skips_blanks_and_comments() {
269 let repos = parse_repos_file(
270 "# what to watch\n\n/Users/x/git/one\n /Users/x/git/two \n\n# trailing\n",
271 );
272 assert_eq!(
273 repos,
274 vec![
275 PathBuf::from("/Users/x/git/one"),
276 PathBuf::from("/Users/x/git/two")
277 ]
278 );
279 }
280
281 #[test]
282 fn repos_config_follows_xdg_then_home() {
283 assert_eq!(
284 repos_config_path(Some("/Users/x/.config"), Some("/Users/x")),
285 PathBuf::from("/Users/x/.config/signald/repos")
286 );
287 assert_eq!(
288 repos_config_path(None, Some("/Users/x")),
289 PathBuf::from("/Users/x/.config/signald/repos")
290 );
291 }
292
293 /// The db and the spool are derived from the socket's directory, so all
294 /// three move together when --socket is given.
295 #[test]
296 fn db_and_spool_sit_beside_the_socket() {
297 let socket = socket_path_from(None, Some("/Users/x"));
298 let base = socket.parent().unwrap();
299 assert_eq!(
300 base.join("signald.sqlite"),
301 PathBuf::from("/Users/x/.local/state/signald/signald.sqlite")
302 );
303 assert_eq!(
304 base.join("terminal.spool"),
305 PathBuf::from("/Users/x/.local/state/signald/terminal.spool"),
306 "must equal SIGNALD_SPOOL's default in shell-hooks/signald-hooks.zsh"
307 );
308 }
309}