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