Commit 27835ca586
Verified · cmc
.gitignore added +1
| @@ -0,0 +1 @@ | ||
| 1 | /target | |
Cargo.lock added +306
| @@ -0,0 +1,306 @@ | ||
| 1 | # This file is automatically @generated by Cargo. | |
| 2 | # It is not intended for manual editing. | |
| 3 | version = 4 | |
| 4 | ||
| 5 | [[package]] | |
| 6 | name = "bitflags" | |
| 7 | version = "2.13.1" | |
| 8 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" | |
| 10 | ||
| 11 | [[package]] | |
| 12 | name = "bumpalo" | |
| 13 | version = "3.20.3" | |
| 14 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 15 | checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" | |
| 16 | ||
| 17 | [[package]] | |
| 18 | name = "cc" | |
| 19 | version = "1.4.2" | |
| 20 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 21 | checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" | |
| 22 | dependencies = [ | |
| 23 | "find-msvc-tools", | |
| 24 | "shlex", | |
| 25 | ] | |
| 26 | ||
| 27 | [[package]] | |
| 28 | name = "cfg-if" | |
| 29 | version = "1.0.4" | |
| 30 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 31 | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | |
| 32 | ||
| 33 | [[package]] | |
| 34 | name = "fallible-iterator" | |
| 35 | version = "0.3.0" | |
| 36 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 37 | checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" | |
| 38 | ||
| 39 | [[package]] | |
| 40 | name = "fallible-streaming-iterator" | |
| 41 | version = "0.1.9" | |
| 42 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 43 | checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" | |
| 44 | ||
| 45 | [[package]] | |
| 46 | name = "find-msvc-tools" | |
| 47 | version = "0.1.10" | |
| 48 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 49 | checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" | |
| 50 | ||
| 51 | [[package]] | |
| 52 | name = "foldhash" | |
| 53 | version = "0.2.0" | |
| 54 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 55 | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" | |
| 56 | ||
| 57 | [[package]] | |
| 58 | name = "hashbrown" | |
| 59 | version = "0.16.1" | |
| 60 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 61 | checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" | |
| 62 | dependencies = [ | |
| 63 | "foldhash", | |
| 64 | ] | |
| 65 | ||
| 66 | [[package]] | |
| 67 | name = "hashbrown" | |
| 68 | version = "0.17.1" | |
| 69 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 70 | checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" | |
| 71 | dependencies = [ | |
| 72 | "foldhash", | |
| 73 | ] | |
| 74 | ||
| 75 | [[package]] | |
| 76 | name = "hashlink" | |
| 77 | version = "0.12.1" | |
| 78 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 79 | checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" | |
| 80 | dependencies = [ | |
| 81 | "hashbrown 0.17.1", | |
| 82 | ] | |
| 83 | ||
| 84 | [[package]] | |
| 85 | name = "js-sys" | |
| 86 | version = "0.3.104" | |
| 87 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 88 | checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" | |
| 89 | dependencies = [ | |
| 90 | "cfg-if", | |
| 91 | "wasm-bindgen", | |
| 92 | ] | |
| 93 | ||
| 94 | [[package]] | |
| 95 | name = "libsqlite3-sys" | |
| 96 | version = "0.38.2" | |
| 97 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 98 | checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" | |
| 99 | dependencies = [ | |
| 100 | "cc", | |
| 101 | "pkg-config", | |
| 102 | "vcpkg", | |
| 103 | ] | |
| 104 | ||
| 105 | [[package]] | |
| 106 | name = "once_cell" | |
| 107 | version = "1.21.4" | |
| 108 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 109 | checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" | |
| 110 | ||
| 111 | [[package]] | |
| 112 | name = "pkg-config" | |
| 113 | version = "0.3.33" | |
| 114 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 115 | checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" | |
| 116 | ||
| 117 | [[package]] | |
| 118 | name = "proc-macro2" | |
| 119 | version = "1.0.107" | |
| 120 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 121 | checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" | |
| 122 | dependencies = [ | |
| 123 | "unicode-ident", | |
| 124 | ] | |
| 125 | ||
| 126 | [[package]] | |
| 127 | name = "quote" | |
| 128 | version = "1.0.47" | |
| 129 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 130 | checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" | |
| 131 | dependencies = [ | |
| 132 | "proc-macro2", | |
| 133 | ] | |
| 134 | ||
| 135 | [[package]] | |
| 136 | name = "rsqlite-vfs" | |
| 137 | version = "0.1.1" | |
| 138 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 139 | checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" | |
| 140 | dependencies = [ | |
| 141 | "hashbrown 0.16.1", | |
| 142 | "thiserror", | |
| 143 | ] | |
| 144 | ||
| 145 | [[package]] | |
| 146 | name = "rusqlite" | |
| 147 | version = "0.40.2" | |
| 148 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 149 | checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" | |
| 150 | dependencies = [ | |
| 151 | "bitflags", | |
| 152 | "fallible-iterator", | |
| 153 | "fallible-streaming-iterator", | |
| 154 | "hashlink", | |
| 155 | "libsqlite3-sys", | |
| 156 | "smallvec", | |
| 157 | "sqlite-wasm-rs", | |
| 158 | ] | |
| 159 | ||
| 160 | [[package]] | |
| 161 | name = "rustversion" | |
| 162 | version = "1.0.23" | |
| 163 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 164 | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" | |
| 165 | ||
| 166 | [[package]] | |
| 167 | name = "shlex" | |
| 168 | version = "2.0.1" | |
| 169 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 170 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" | |
| 171 | ||
| 172 | [[package]] | |
| 173 | name = "signal-schema" | |
| 174 | version = "0.3.0" | |
| 175 | ||
| 176 | [[package]] | |
| 177 | name = "signald" | |
| 178 | version = "0.3.0" | |
| 179 | dependencies = [ | |
| 180 | "rusqlite", | |
| 181 | "signal-schema", | |
| 182 | ] | |
| 183 | ||
| 184 | [[package]] | |
| 185 | name = "smallvec" | |
| 186 | version = "1.15.2" | |
| 187 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 188 | checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" | |
| 189 | ||
| 190 | [[package]] | |
| 191 | name = "sqlite-wasm-rs" | |
| 192 | version = "0.5.5" | |
| 193 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 194 | checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" | |
| 195 | dependencies = [ | |
| 196 | "cc", | |
| 197 | "js-sys", | |
| 198 | "rsqlite-vfs", | |
| 199 | "wasm-bindgen", | |
| 200 | ] | |
| 201 | ||
| 202 | [[package]] | |
| 203 | name = "syn" | |
| 204 | version = "2.0.119" | |
| 205 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 206 | checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" | |
| 207 | dependencies = [ | |
| 208 | "proc-macro2", | |
| 209 | "quote", | |
| 210 | "unicode-ident", | |
| 211 | ] | |
| 212 | ||
| 213 | [[package]] | |
| 214 | name = "syn" | |
| 215 | version = "3.0.3" | |
| 216 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 217 | checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" | |
| 218 | dependencies = [ | |
| 219 | "proc-macro2", | |
| 220 | "quote", | |
| 221 | "unicode-ident", | |
| 222 | ] | |
| 223 | ||
| 224 | [[package]] | |
| 225 | name = "terminal-garden" | |
| 226 | version = "0.3.0" | |
| 227 | dependencies = [ | |
| 228 | "signal-schema", | |
| 229 | ] | |
| 230 | ||
| 231 | [[package]] | |
| 232 | name = "thiserror" | |
| 233 | version = "2.0.20" | |
| 234 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 235 | checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" | |
| 236 | dependencies = [ | |
| 237 | "thiserror-impl", | |
| 238 | ] | |
| 239 | ||
| 240 | [[package]] | |
| 241 | name = "thiserror-impl" | |
| 242 | version = "2.0.20" | |
| 243 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 244 | checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" | |
| 245 | dependencies = [ | |
| 246 | "proc-macro2", | |
| 247 | "quote", | |
| 248 | "syn 3.0.3", | |
| 249 | ] | |
| 250 | ||
| 251 | [[package]] | |
| 252 | name = "unicode-ident" | |
| 253 | version = "1.0.24" | |
| 254 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 255 | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | |
| 256 | ||
| 257 | [[package]] | |
| 258 | name = "vcpkg" | |
| 259 | version = "0.2.15" | |
| 260 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 261 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" | |
| 262 | ||
| 263 | [[package]] | |
| 264 | name = "wasm-bindgen" | |
| 265 | version = "0.2.127" | |
| 266 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 267 | checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" | |
| 268 | dependencies = [ | |
| 269 | "cfg-if", | |
| 270 | "once_cell", | |
| 271 | "rustversion", | |
| 272 | "wasm-bindgen-macro", | |
| 273 | "wasm-bindgen-shared", | |
| 274 | ] | |
| 275 | ||
| 276 | [[package]] | |
| 277 | name = "wasm-bindgen-macro" | |
| 278 | version = "0.2.127" | |
| 279 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 280 | checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" | |
| 281 | dependencies = [ | |
| 282 | "quote", | |
| 283 | "wasm-bindgen-macro-support", | |
| 284 | ] | |
| 285 | ||
| 286 | [[package]] | |
| 287 | name = "wasm-bindgen-macro-support" | |
| 288 | version = "0.2.127" | |
| 289 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 290 | checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" | |
| 291 | dependencies = [ | |
| 292 | "bumpalo", | |
| 293 | "proc-macro2", | |
| 294 | "quote", | |
| 295 | "syn 2.0.119", | |
| 296 | "wasm-bindgen-shared", | |
| 297 | ] | |
| 298 | ||
| 299 | [[package]] | |
| 300 | name = "wasm-bindgen-shared" | |
| 301 | version = "0.2.127" | |
| 302 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 303 | checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" | |
| 304 | dependencies = [ | |
| 305 | "unicode-ident", | |
| 306 | ] | |
Cargo.toml added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | [workspace] | |
| 2 | resolver = "2" | |
| 3 | members = [ | |
| 4 | "crates/signal-schema", | |
| 5 | "crates/signald", | |
| 6 | "crates/terminal-garden", | |
| 7 | ] | |
| 8 | ||
| 9 | [workspace.package] | |
| 10 | version = "0.3.0" | |
| 11 | edition = "2021" | |
| 12 | license = "MIT" | |
README.md added +213
| @@ -0,0 +1,213 @@ | ||
| 1 | # ambient-companions | |
| 2 | ||
| 3 | *Five faces over one backend.* | |
| 4 | ||
| 5 | One signal-collection daemon; five thin renderers that subscribe to it. This is | |
| 6 | closer to **one project with five faces** than five projects: the backend is | |
| 7 | the project, and each face is thin, disposable, and deletable without touching | |
| 8 | the others. | |
| 9 | ||
| 10 | ## One backend, five faces | |
| 11 | ||
| 12 | ```text | |
| 13 | signald (user LaunchAgent) | |
| 14 | zsh hooks ───▶ terminal collector ─┐ | |
| 15 | git/fsevents ▶ git collector ──────┼─▶ normalizer ─▶ ring buffer | |
| 16 | IOKit/AppKit ▶ system+hw collector ┘ │ | |
| 17 | ▼ | |
| 18 | publish: Unix socket + SQLite WAL (history) | |
| 19 | ┌───────────┬───────────┼───────────┬───────────────┐ | |
| 20 | ▼ ▼ ▼ ▼ ▼ | |
| 21 | terminal-pet garden menubar-pet sonifier wallpaperd | |
| 22 | (TUI) (TUI) (SwiftUI) (AVAudio) (image/window) | |
| 23 | ``` | |
| 24 | ||
| 25 | All five draw from the **same signal bus**. Renderers never poll hardware, | |
| 26 | never read the shell — they subscribe over a local Unix socket | |
| 27 | (`$XDG_RUNTIME_DIR/signald.sock`). That buys one privacy boundary to defend | |
| 28 | instead of five, cheap renderers, and reuse of the collection layer into | |
| 29 | non-toy outputs (e-ink dashboards, printed posters). | |
| 30 | ||
| 31 | ## The privacy boundary, enforced by construction | |
| 32 | ||
| 33 | Reading typing during real work is a keylogger unless it is *structurally* | |
| 34 | content-free — and unless that is a **test**, not a sentence in a README. | |
| 35 | ||
| 36 | The invariant (spec §1.5): | |
| 37 | ||
| 38 | > No process persists, transmits, or exposes any representation from which the | |
| 39 | > content or identity of an individual keystroke, command argument, or typed | |
| 40 | > character can be recovered. Only order-free aggregates (counts, rates, | |
| 41 | > durations, codes) leave the terminal collector. | |
| 42 | ||
| 43 | Made true by construction: | |
| 44 | ||
| 45 | - **No content channel exists in the wire format.** The only payload channel is | |
| 46 | `Value`, a newtype over `f64`. There is no `text`, `bytes`, or `payload` | |
| 47 | field — a collector *cannot* emit typed content because the record has | |
| 48 | nowhere to put it. The two audited exceptions are non-content identifiers in | |
| 49 | `tag` (bundle id, repo path, ssh host), allow-listed per metric name. | |
| 50 | - **The key counter never stores the key**, there is **no input tap anywhere** | |
| 51 | (`CGEventTap`, `IOHIDManager` keyboard usage, accessibility observation are | |
| 52 | all forbidden), and **aggregation happens before transport** (the shell emits | |
| 53 | counts on `precmd`, never per-key events). | |
| 54 | ||
| 55 | And it is tested (`crates/signal-schema/tests/privacy_invariant.rs`): | |
| 56 | ||
| 57 | - `value_channel_is_exactly_f64` — the payload is an `f64`, nothing wider. | |
| 58 | - `wire_format_has_no_content_field` — the `Signal` type declares no | |
| 59 | content-carrying field beyond the audited `tag`. | |
| 60 | - `forbidden_symbol_scan` — the tree contains none of the banned keylogger APIs | |
| 61 | or shell line-buffer references (the static CI gate). | |
| 62 | - `differential_secret_typing` — **active (the ship gate).** It drives the real | |
| 63 | hook (`shell-hooks/signald-hooks.zsh`) through a real interactive zsh under a | |
| 64 | real pseudo-terminal (zsh's own `zsh/zpty` — no extra dependency), *typing a | |
| 65 | planted secret*, and asserts the secret never appears — plain, reversed, hex, | |
| 66 | or base64 — in the shell spool or the `f64`-only wire encoding. The **full | |
| 67 | pipeline** version (same real typing driven through the terminal collector, | |
| 68 | the SQLite history store, and the hub) is | |
| 69 | `crates/signald/tests/differential_secret_typing.rs`. Real `zle` keystroke | |
| 70 | counting increments a *number* per key and discards the key, so the only thing | |
| 71 | the pipeline ever receives about the typing is a count — the tests prove that | |
| 72 | empirically across every downstream artifact. | |
| 73 | ||
| 74 | ## Build order | |
| 75 | ||
| 76 | Build the backend to the point each renderer needs, then build the renderer | |
| 77 | that is cheapest *and* most valuable given what exists: | |
| 78 | ||
| 79 | 1. **garden** — git is the cleanest signal (discrete, no privacy questions), so | |
| 80 | it proves the bus first. *(recommended first face)* | |
| 81 | 2. **gated collector + pet** — add the sensitive terminal collector only after | |
| 82 | the privacy tests are green and extended to cover it. | |
| 83 | 3. **menu-bar permadeath pet** — first macOS-native face; ships the one-life | |
| 84 | variant (ages in wall-clock time, dies permanently) — the emotional hook. | |
| 85 | 4. **SSH-sonification** — unexpected access becomes *audible*; the utility | |
| 86 | sonification before the ambient one. | |
| 87 | 5. **homelab wallpaper** — desktop-as-status-board (Path A: render-to-image + | |
| 88 | `setDesktopImageURL`); the same frame pipeline later feeds e-ink and posters. | |
| 89 | ||
| 90 | ## macOS collector constraint | |
| 91 | ||
| 92 | The system + hardware collector is **IOKit-only: no root, no `powermetrics`.** | |
| 93 | `powermetrics` wants root and would make the suite un-shippable as a plain user | |
| 94 | agent. Signals come from IOKit (`IOPMPowerSource` / power sources, IORegistry | |
| 95 | `AppleSmartBattery`), `ProcessInfo`, and Mach `host_processor_info`. Where a | |
| 96 | metric can't be reached without root, it is simply **absent from the schema** | |
| 97 | rather than gated behind sudo. Accessibility permission is never requested. | |
| 98 | ||
| 99 | It ships in v0.3 as **`macos-collector/`**, a sibling **Swift** package (built | |
| 100 | with `swift build`, kept out of the cargo workspace — SwiftPM and cargo do not | |
| 101 | share a build system). It reads aggregate CPU load, battery %, charging, battery | |
| 102 | draw (W), and thermal state, and emits them as `signal-schema` wire frames — the | |
| 103 | same byte format `signald` parses. GPU/fan are deliberately omitted (no clean | |
| 104 | root-free IOKit channel). See `macos-collector/README.md` for the full **Swift ↔ | |
| 105 | Rust wire contract** (the byte layout) and the shared canonical-frame test that | |
| 106 | pins both sides to the same bytes. | |
| 107 | ||
| 108 | ## Workspace layout | |
| 109 | ||
| 110 | ```text | |
| 111 | ambient-companions/ | |
| 112 | ├── Cargo.toml # Rust workspace | |
| 113 | ├── crates/ | |
| 114 | │ ├── signal-schema/ # shared wire format (the privacy boundary) | |
| 115 | │ │ ├── src/lib.rs | |
| 116 | │ │ └── tests/privacy_invariant.rs | |
| 117 | │ ├── signald/ # the daemon (collectors + live fan-out) | |
| 118 | │ │ ├── src/lib.rs # git + terminal collectors, publish | |
| 119 | │ │ ├── src/history.rs # SQLite (WAL) history store + recent() query | |
| 120 | │ │ ├── src/hub.rs # last-value cache + live fan-out | |
| 121 | │ │ ├── src/main.rs # CLI, producer loop, self-attestation | |
| 122 | │ │ ├── tests/git_collector.rs # aggregates vs a temp git repo | |
| 123 | │ │ ├── tests/streaming.rs # last-value cache + live update | |
| 124 | │ │ └── tests/differential_secret_typing.rs # the full privacy ship-gate | |
| 125 | │ └── terminal-garden/ # first renderer: a socket subscriber | |
| 126 | │ ├── src/lib.rs # signals → plots → render (unit-tested) | |
| 127 | │ └── src/main.rs # live subscribe + redraw loop | |
| 128 | ├── shell-hooks/ # zsh hooks: aggregate-only terminal collector | |
| 129 | │ ├── signald-hooks.zsh | |
| 130 | │ └── README.md | |
| 131 | └── macos-collector/ # SwiftPM sibling (NOT in the cargo workspace): | |
| 132 | ├── Package.swift # macOS IOKit hardware collector (Phase 3) | |
| 133 | ├── README.md # the Swift↔Rust wire contract (byte layout) | |
| 134 | ├── Sources/CollectorCore/ # wire encoder + IOKit reads (host_processor_ | |
| 135 | │ # info, power sources, AppleSmartBattery) | |
| 136 | ├── Sources/macos-collector/ # thin CLI: --once / stream / --hex / --out | |
| 137 | └── Tests/ # shared canonical-frame wire-contract test | |
| 138 | ``` | |
| 139 | ||
| 140 | ### Language note | |
| 141 | ||
| 142 | The daemon is written in **Rust**. The spec allows Rust or Go; Rust is chosen | |
| 143 | because it makes the "no content field exists" guarantee enforceable in the | |
| 144 | type system (the `f64`-only `Value` payload), which is the whole point of the | |
| 145 | privacy boundary. | |
| 146 | ||
| 147 | ### Dependencies | |
| 148 | ||
| 149 | `signal-schema` is **dependency-free** by design — the wire format is the | |
| 150 | privacy boundary and carries no third-party code. `signald` has **one** | |
| 151 | dependency, `rusqlite` (with the `bundled` feature, so SQLite is compiled | |
| 152 | in-tree and there is no system-library requirement), for the WAL history store. | |
| 153 | The terminal collector's shell side needs only `zsh` (the `zle`, `datetime`, | |
| 154 | and — for the differential test — `zpty` modules ship with zsh). | |
| 155 | ||
| 156 | ## Phase plan | |
| 157 | ||
| 158 | - **M0 — backend skeleton + schema + privacy invariant test. ✅ Done.** | |
| 159 | Workspace builds; the structural privacy tests pass. | |
| 160 | - **Phase 1 — git collector + garden. ✅ Done (v0.1).** | |
| 161 | The wire format (`encode`/`decode` + length-prefixed framing) is real and | |
| 162 | `f64`-only. `signald`'s git collector shells out to `git` for aggregate | |
| 163 | scalars — commits-in-window, commits-today, branch count, days-since-last- | |
| 164 | commit — tagged by the audited repo path. `terminal-garden` subscribes and | |
| 165 | renders each repo as a plot: growth (🌱→🌿→🌳) tracks recent commits, wilt | |
| 166 | (🥀→🍂) tracks staleness. | |
| 167 | - **Persistence + live streaming. ✅ Done (v0.2).** | |
| 168 | - **SQLite (WAL) history** (`signald/src/history.rs`): every published signal | |
| 169 | is persisted (aggregate scalars only — same privacy constraints as the | |
| 170 | wire), and `recent()` / `recent_named()` let a renderer read recent history, | |
| 171 | not just the live snapshot. | |
| 172 | - **Live streaming + last-value cache** (`signald/src/hub.rs`, | |
| 173 | `publish::serve`): the daemon runs a producer loop and continuously | |
| 174 | publishes. On connect a subscriber is handed the current value of every | |
| 175 | metric immediately, then streams updates as they change (no longer | |
| 176 | snapshot-then-close). `terminal-garden` now redraws live. | |
| 177 | - **Phase 2 — terminal collector. ✅ Done (v0.2).** | |
| 178 | The aggregate-only terminal path is real: `shell-hooks/signald-hooks.zsh` | |
| 179 | counts keystrokes with a `zle` widget that increments a number and discards | |
| 180 | the key, and appends `<epoch_ms> <keys> <session_seconds>` count records | |
| 181 | (numbers only) to a spool; `collectors::terminal` reads the spool and derives | |
| 182 | `keys_per_min` and `session_seconds`. The **differential secret-typing test is | |
| 183 | active and passing** — the privacy ship-gate. *The Terminal Pet renderer is | |
| 184 | still todo.* | |
| 185 | - **Phase 3 — macOS IOKit hardware collector. ✅ Done (v0.3).** | |
| 186 | `macos-collector/` (a sibling Swift package, `swift build`) reads aggregate | |
| 187 | hardware scalars via **IOKit only — no `powermetrics`, no root**: CPU load | |
| 188 | (Mach `host_processor_info`), battery %/charging (IOKit power sources), battery | |
| 189 | draw in watts (IORegistry `AppleSmartBattery`), and thermal state | |
| 190 | (`ProcessInfo`). It emits `signal-schema` wire frames — the schema gained the | |
| 191 | aggregate `cpu_load` metric and bumped to `SCHEMA_VERSION = 3`. GPU/fan are | |
| 192 | omitted (no clean root-free channel). The **Swift ↔ Rust byte contract** is | |
| 193 | documented and pinned by a shared canonical-frame test on both sides | |
| 194 | (`crates/signal-schema/tests/hardware_wire.rs` decodes the exact bytes the | |
| 195 | Swift encoder commits to in `macos-collector/Tests/.../WireTests.swift`). | |
| 196 | *Still todo:* the menu-bar permadeath pet, and the live socket handshake by | |
| 197 | which `signald` ingests the collector's frames (minimal/documented in v0.3). | |
| 198 | - **Phase 4** — sonification (SSH-utility first, then continuous). *Out of scope.* | |
| 199 | - **Phase 5** — live wallpaper (homelab, Path A) + e-ink/poster reuse. | |
| 200 | *Out of scope.* | |
| 201 | ||
| 202 | ## Build & test | |
| 203 | ||
| 204 | ```sh | |
| 205 | cargo build # whole Rust workspace | |
| 206 | cargo test # includes the privacy invariant suite (must be green) | |
| 207 | ||
| 208 | # The macOS IOKit hardware collector is a sibling Swift package (macOS only): | |
| 209 | cd macos-collector | |
| 210 | swift build # builds the collector | |
| 211 | swift test # the Swift↔Rust wire-contract test | |
| 212 | swift run macos-collector --once # one real IOKit read (no root) | |
| 213 | ``` | |
crates/signal-schema/Cargo.toml added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | [package] | |
| 2 | name = "signal-schema" | |
| 3 | version.workspace = true | |
| 4 | edition.workspace = true | |
| 5 | license.workspace = true | |
| 6 | description = "Shared, versioned wire format for the ambient-companions signal bus. The privacy boundary is structural: the payload channel is an f64 and there is no content-carrying field." | |
| 7 | ||
| 8 | [dependencies] | |
crates/signal-schema/src/lib.rs added +465
| @@ -0,0 +1,465 @@ | ||
| 1 | //! # signal-schema | |
| 2 | //! | |
| 3 | //! The shared, versioned wire format for the ambient-companions signal bus. | |
| 4 | //! | |
| 5 | //! ## The privacy boundary, made structural | |
| 6 | //! | |
| 7 | //! Everything in this suite is a footnote to one invariant (spec §1.5): | |
| 8 | //! | |
| 9 | //! > No process persists, transmits, or exposes any representation from which | |
| 10 | //! > the content or identity of an individual keystroke, command argument, or | |
| 11 | //! > typed character can be recovered. Only order-free aggregates (counts, | |
| 12 | //! > rates, durations, codes) leave the terminal collector. | |
| 13 | //! | |
| 14 | //! This crate makes the *schema-level half* of that invariant true **by | |
| 15 | //! construction**: the only payload channel is [`Value`], a newtype over | |
| 16 | //! `f64`. There is no `text`, `bytes`, `payload`, or `content` field. A | |
| 17 | //! collector *cannot* emit typed content because the wire format has nowhere | |
| 18 | //! to put it. | |
| 19 | //! | |
| 20 | //! The two audited exceptions are non-content identifiers carried in [`Tag`] | |
| 21 | //! (a bundle id, an absolute repo path, or an SSH host) and are allow-listed | |
| 22 | //! per [`SignalName`]. Everything else is `tag == None`, enforced at the | |
| 23 | //! daemon boundary. | |
| 24 | //! | |
| 25 | //! The structural guarantee is not asserted in prose alone — see | |
| 26 | //! `tests/privacy_invariant.rs`, which fails the build if a content-carrying | |
| 27 | //! field is ever added. | |
| 28 | ||
| 29 | /// Bump on **any** field change to [`Signal`]. Renderers negotiate on connect | |
| 30 | /// and ignore records they do not understand. | |
| 31 | /// | |
| 32 | /// v3 (0.3.0): added the aggregate [`SignalName::CpuLoad`] emitted by the macOS | |
| 33 | /// IOKit hardware collector (`macos-collector/`, a sibling Swift package). | |
| 34 | pub const SCHEMA_VERSION: u16 = 3; | |
| 35 | ||
| 36 | /// The collector domain a signal originated from. | |
| 37 | /// | |
| 38 | /// A small closed enum — never free text. | |
| 39 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 40 | pub enum Source { | |
| 41 | Terminal, | |
| 42 | Git, | |
| 43 | Macos, | |
| 44 | Hardware, | |
| 45 | } | |
| 46 | ||
| 47 | impl Source { | |
| 48 | /// Stable wire discriminant. | |
| 49 | pub fn to_u8(self) -> u8 { | |
| 50 | match self { | |
| 51 | Source::Terminal => 0, | |
| 52 | Source::Git => 1, | |
| 53 | Source::Macos => 2, | |
| 54 | Source::Hardware => 3, | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | /// Inverse of [`Source::to_u8`]; `None` for an unknown discriminant. | |
| 59 | pub fn from_u8(v: u8) -> Option<Source> { | |
| 60 | Some(match v { | |
| 61 | 0 => Source::Terminal, | |
| 62 | 1 => Source::Git, | |
| 63 | 2 => Source::Macos, | |
| 64 | 3 => Source::Hardware, | |
| 65 | _ => return None, | |
| 66 | }) | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | /// The **enum-constrained** metric name. | |
| 71 | /// | |
| 72 | /// A fixed allow-list. Unknown names are dropped at the daemon boundary. This | |
| 73 | /// prevents a future careless collector from inventing `last_command` as a | |
| 74 | /// name and shoving a string through the tag. New metrics require a new | |
| 75 | /// variant here (and a schema-version bump), which is a reviewed change. | |
| 76 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 77 | pub enum SignalName { | |
| 78 | // --- terminal (aggregates only) --- | |
| 79 | KeysPerMin, | |
| 80 | InterArrivalVariance, | |
| 81 | SessionSeconds, | |
| 82 | CommandsPerMin, | |
| 83 | ExitCodeRatio, | |
| 84 | TodBucket, | |
| 85 | // --- git --- | |
| 86 | Commits5m, | |
| 87 | LinesAdded, | |
| 88 | LinesRemoved, | |
| 89 | BranchLastCommitAge, | |
| 90 | DirtyWorktree, | |
| 91 | /// Commits within the collector's rolling window (last N days). | |
| 92 | CommitsWindow, | |
| 93 | /// Commits since local midnight. | |
| 94 | CommitsToday, | |
| 95 | /// Number of local branches in the repo. | |
| 96 | BranchCount, | |
| 97 | /// Whole days since the most recent commit on `HEAD`. | |
| 98 | DaysSinceLastCommit, | |
| 99 | // --- macos system --- | |
| 100 | AppForegroundSeconds, | |
| 101 | IdleSeconds, | |
| 102 | BatteryPct, | |
| 103 | Charging, | |
| 104 | ThermalState, | |
| 105 | // --- hardware --- | |
| 106 | /// Aggregate CPU busy fraction across all cores in `[0.0, 1.0]`, from Mach | |
| 107 | /// `host_processor_info` tick deltas. Emitted by the macOS IOKit collector | |
| 108 | /// as an untagged scalar (the per-core, tagged variant is [`CpuCoreLoad`]). | |
| 109 | CpuLoad, | |
| 110 | CpuCoreLoad, | |
| 111 | NetRxBps, | |
| 112 | NetTxBps, | |
| 113 | FanRpm, | |
| 114 | DiskReadBps, | |
| 115 | DiskWriteBps, | |
| 116 | GpuUtil, | |
| 117 | BatteryDrawW, | |
| 118 | } | |
| 119 | ||
| 120 | impl SignalName { | |
| 121 | /// Whether this metric is permitted to carry a [`Tag`]. Only a small, | |
| 122 | /// audited set may — everything else must have `tag == None`. | |
| 123 | pub fn allows_tag(self) -> bool { | |
| 124 | matches!( | |
| 125 | self, | |
| 126 | SignalName::BranchLastCommitAge // tag: repo/branch identifier | |
| 127 | | SignalName::CommitsWindow // tag: repo path (audited, spec §1.2) | |
| 128 | | SignalName::CommitsToday // tag: repo path | |
| 129 | | SignalName::BranchCount // tag: repo path | |
| 130 | | SignalName::DaysSinceLastCommit // tag: repo path | |
| 131 | | SignalName::AppForegroundSeconds // tag: bundle id | |
| 132 | | SignalName::CpuCoreLoad // tag: core index | |
| 133 | ) | |
| 134 | } | |
| 135 | ||
| 136 | /// Stable wire discriminant. New metrics append here (a reviewed change | |
| 137 | /// that also bumps [`SCHEMA_VERSION`]). | |
| 138 | pub fn to_u8(self) -> u8 { | |
| 139 | match self { | |
| 140 | SignalName::KeysPerMin => 0, | |
| 141 | SignalName::InterArrivalVariance => 1, | |
| 142 | SignalName::SessionSeconds => 2, | |
| 143 | SignalName::CommandsPerMin => 3, | |
| 144 | SignalName::ExitCodeRatio => 4, | |
| 145 | SignalName::TodBucket => 5, | |
| 146 | SignalName::Commits5m => 6, | |
| 147 | SignalName::LinesAdded => 7, | |
| 148 | SignalName::LinesRemoved => 8, | |
| 149 | SignalName::BranchLastCommitAge => 9, | |
| 150 | SignalName::DirtyWorktree => 10, | |
| 151 | SignalName::CommitsWindow => 11, | |
| 152 | SignalName::CommitsToday => 12, | |
| 153 | SignalName::BranchCount => 13, | |
| 154 | SignalName::DaysSinceLastCommit => 14, | |
| 155 | SignalName::AppForegroundSeconds => 15, | |
| 156 | SignalName::IdleSeconds => 16, | |
| 157 | SignalName::BatteryPct => 17, | |
| 158 | SignalName::Charging => 18, | |
| 159 | SignalName::ThermalState => 19, | |
| 160 | SignalName::CpuCoreLoad => 20, | |
| 161 | SignalName::NetRxBps => 21, | |
| 162 | SignalName::NetTxBps => 22, | |
| 163 | SignalName::FanRpm => 23, | |
| 164 | SignalName::DiskReadBps => 24, | |
| 165 | SignalName::DiskWriteBps => 25, | |
| 166 | SignalName::GpuUtil => 26, | |
| 167 | SignalName::BatteryDrawW => 27, | |
| 168 | // Appended in v3 (0.3.0); discriminants are append-only. | |
| 169 | SignalName::CpuLoad => 28, | |
| 170 | } | |
| 171 | } | |
| 172 | ||
| 173 | /// Inverse of [`SignalName::to_u8`]. An unknown discriminant returns `None` | |
| 174 | /// so the daemon can drop names it does not understand at the boundary. | |
| 175 | pub fn from_u8(v: u8) -> Option<SignalName> { | |
| 176 | Some(match v { | |
| 177 | 0 => SignalName::KeysPerMin, | |
| 178 | 1 => SignalName::InterArrivalVariance, | |
| 179 | 2 => SignalName::SessionSeconds, | |
| 180 | 3 => SignalName::CommandsPerMin, | |
| 181 | 4 => SignalName::ExitCodeRatio, | |
| 182 | 5 => SignalName::TodBucket, | |
| 183 | 6 => SignalName::Commits5m, | |
| 184 | 7 => SignalName::LinesAdded, | |
| 185 | 8 => SignalName::LinesRemoved, | |
| 186 | 9 => SignalName::BranchLastCommitAge, | |
| 187 | 10 => SignalName::DirtyWorktree, | |
| 188 | 11 => SignalName::CommitsWindow, | |
| 189 | 12 => SignalName::CommitsToday, | |
| 190 | 13 => SignalName::BranchCount, | |
| 191 | 14 => SignalName::DaysSinceLastCommit, | |
| 192 | 15 => SignalName::AppForegroundSeconds, | |
| 193 | 16 => SignalName::IdleSeconds, | |
| 194 | 17 => SignalName::BatteryPct, | |
| 195 | 18 => SignalName::Charging, | |
| 196 | 19 => SignalName::ThermalState, | |
| 197 | 20 => SignalName::CpuCoreLoad, | |
| 198 | 21 => SignalName::NetRxBps, | |
| 199 | 22 => SignalName::NetTxBps, | |
| 200 | 23 => SignalName::FanRpm, | |
| 201 | 24 => SignalName::DiskReadBps, | |
| 202 | 25 => SignalName::DiskWriteBps, | |
| 203 | 26 => SignalName::GpuUtil, | |
| 204 | 27 => SignalName::BatteryDrawW, | |
| 205 | 28 => SignalName::CpuLoad, | |
| 206 | _ => return None, | |
| 207 | }) | |
| 208 | } | |
| 209 | } | |
| 210 | ||
| 211 | /// The **only** payload channel: a single `f64`. | |
| 212 | /// | |
| 213 | /// This newtype is the load-bearing privacy primitive. There is deliberately | |
| 214 | /// no constructor that accepts a `String`, `&[u8]`, or any content-shaped | |
| 215 | /// type. If you find yourself wanting to widen this, stop: that is the | |
| 216 | /// privacy boundary you would be dismantling. | |
| 217 | #[derive(Debug, Clone, Copy, PartialEq)] | |
| 218 | pub struct Value(pub f64); | |
| 219 | ||
| 220 | /// A structurally validated, non-content identifier (spec §1.2). | |
| 221 | /// | |
| 222 | /// Never free text. Constructed only via [`Tag::bundle_id`], | |
| 223 | /// [`Tag::repo_path`], or [`Tag::ssh_host`], each of which validates shape. | |
| 224 | /// M0: validation is stubbed. | |
| 225 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 226 | pub struct Tag(String); | |
| 227 | ||
| 228 | impl Tag { | |
| 229 | /// A macOS bundle id, e.g. `com.apple.Terminal`. Validated against a | |
| 230 | /// bundle-id shape at the daemon boundary. | |
| 231 | pub fn bundle_id(_s: &str) -> Option<Tag> { | |
| 232 | todo!("validate bundle-id shape (reverse-DNS); reject anything else") | |
| 233 | } | |
| 234 | ||
| 235 | /// An absolute repo path. Validated as a shape (absolute, non-empty, no | |
| 236 | /// interior NUL) — a structural identifier, never free text. Per-watched- | |
| 237 | /// root prefix enforcement is layered on at the daemon boundary later. | |
| 238 | pub fn repo_path(s: &str) -> Option<Tag> { | |
| 239 | if s.starts_with('/') && !s.is_empty() && !s.contains('\0') { | |
| 240 | Some(Tag(s.to_string())) | |
| 241 | } else { | |
| 242 | None | |
| 243 | } | |
| 244 | } | |
| 245 | ||
| 246 | /// An SSH host present in `known_hosts`. | |
| 247 | pub fn ssh_host(_s: &str) -> Option<Tag> { | |
| 248 | todo!("validate against ~/.ssh/known_hosts") | |
| 249 | } | |
| 250 | ||
| 251 | /// Read-only view of the validated identifier. | |
| 252 | pub fn as_str(&self) -> &str { | |
| 253 | &self.0 | |
| 254 | } | |
| 255 | } | |
| 256 | ||
| 257 | /// One flat, versioned record. Every field is a named scalar, a small enum, or | |
| 258 | /// an audited non-content identifier. | |
| 259 | /// | |
| 260 | /// Note what is absent: there is no field capable of carrying a character or a | |
| 261 | /// string of typed content. That absence is the point. | |
| 262 | #[derive(Debug, Clone, PartialEq)] | |
| 263 | pub struct Signal { | |
| 264 | /// Bump on any field change; see [`SCHEMA_VERSION`]. | |
| 265 | pub schema_version: u16, | |
| 266 | /// Unix millis, monotonic-corrected. | |
| 267 | pub ts: u64, | |
| 268 | /// Originating collector domain. | |
| 269 | pub source: Source, | |
| 270 | /// Enum-constrained metric name. | |
| 271 | pub name: SignalName, | |
| 272 | /// The only payload channel. | |
| 273 | pub value: Value, | |
| 274 | /// Optional, audited, non-content identifier. `Some` only for names where | |
| 275 | /// [`SignalName::allows_tag`] is true. | |
| 276 | pub tag: Option<Tag>, | |
| 277 | } | |
| 278 | ||
| 279 | impl Signal { | |
| 280 | /// Validate a record against the schema's structural rules: a tag is | |
| 281 | /// present only where the name allows it. This is the daemon-boundary | |
| 282 | /// check; renderers can trust records that pass it. | |
| 283 | pub fn is_well_formed(&self) -> bool { | |
| 284 | self.schema_version == SCHEMA_VERSION | |
| 285 | && (self.tag.is_none() || self.name.allows_tag()) | |
| 286 | } | |
| 287 | } | |
| 288 | ||
| 289 | /// Length-prefixed wire encoding (spec §1.3: "length-prefixed framing over the | |
| 290 | /// Unix socket"). | |
| 291 | /// | |
| 292 | /// The frame is a little-endian `u32` body length followed by the body: | |
| 293 | /// | |
| 294 | /// ```text | |
| 295 | /// [u32 body_len] [u16 schema_version] [u64 ts] [u8 source] [u8 name] | |
| 296 | /// [f64 value] [u8 tag_present] [ (u16 tag_len) (tag_len bytes utf8) ]? | |
| 297 | /// ``` | |
| 298 | /// | |
| 299 | /// The payload channel is still exactly the `f64` `value` — the framing adds | |
| 300 | /// nowhere to put typed content. The one string on the wire is the audited | |
| 301 | /// `tag` identifier, and only when [`SignalName::allows_tag`] permits it. | |
| 302 | pub mod wire { | |
| 303 | use super::{Signal, SignalName, Source, Tag, Value, SCHEMA_VERSION}; | |
| 304 | ||
| 305 | /// Encode a signal to its length-prefixed wire bytes. | |
| 306 | pub fn encode(s: &Signal) -> Vec<u8> { | |
| 307 | let mut body = Vec::with_capacity(24); | |
| 308 | body.extend_from_slice(&s.schema_version.to_le_bytes()); | |
| 309 | body.extend_from_slice(&s.ts.to_le_bytes()); | |
| 310 | body.push(s.source.to_u8()); | |
| 311 | body.push(s.name.to_u8()); | |
| 312 | body.extend_from_slice(&s.value.0.to_le_bytes()); | |
| 313 | match &s.tag { | |
| 314 | None => body.push(0), | |
| 315 | Some(tag) => { | |
| 316 | body.push(1); | |
| 317 | let bytes = tag.as_str().as_bytes(); | |
| 318 | body.extend_from_slice(&(bytes.len() as u16).to_le_bytes()); | |
| 319 | body.extend_from_slice(bytes); | |
| 320 | } | |
| 321 | } | |
| 322 | ||
| 323 | let mut frame = Vec::with_capacity(4 + body.len()); | |
| 324 | frame.extend_from_slice(&(body.len() as u32).to_le_bytes()); | |
| 325 | frame.extend_from_slice(&body); | |
| 326 | frame | |
| 327 | } | |
| 328 | ||
| 329 | /// Decode one length-prefixed frame from the front of `buf`. Returns `None` | |
| 330 | /// if the buffer is short or the frame is malformed. Trailing bytes are | |
| 331 | /// ignored, so this is safe to call on a read buffer holding one frame. | |
| 332 | pub fn decode(buf: &[u8]) -> Option<Signal> { | |
| 333 | if buf.len() < 4 { | |
| 334 | return None; | |
| 335 | } | |
| 336 | let body_len = u32::from_le_bytes(buf[0..4].try_into().ok()?) as usize; | |
| 337 | let body = buf.get(4..4 + body_len)?; | |
| 338 | decode_body(body) | |
| 339 | } | |
| 340 | ||
| 341 | /// Read exactly one frame from a stream. `Ok(None)` signals a clean EOF at a | |
| 342 | /// frame boundary; a partial or malformed frame is an error. | |
| 343 | pub fn read_frame(r: &mut impl std::io::Read) -> std::io::Result<Option<Signal>> { | |
| 344 | use std::io::{Error, ErrorKind}; | |
| 345 | ||
| 346 | let mut len_buf = [0u8; 4]; | |
| 347 | match r.read_exact(&mut len_buf) { | |
| 348 | Ok(()) => {} | |
| 349 | Err(e) if e.kind() == ErrorKind::UnexpectedEof => return Ok(None), | |
| 350 | Err(e) => return Err(e), | |
| 351 | } | |
| 352 | let body_len = u32::from_le_bytes(len_buf) as usize; | |
| 353 | let mut body = vec![0u8; body_len]; | |
| 354 | r.read_exact(&mut body)?; | |
| 355 | decode_body(&body) | |
| 356 | .map(Some) | |
| 357 | .ok_or_else(|| Error::new(ErrorKind::InvalidData, "malformed signal frame")) | |
| 358 | } | |
| 359 | ||
| 360 | /// Encode `s` and write the whole frame to a stream. | |
| 361 | pub fn write_frame(w: &mut impl std::io::Write, s: &Signal) -> std::io::Result<()> { | |
| 362 | w.write_all(&encode(s)) | |
| 363 | } | |
| 364 | ||
| 365 | fn decode_body(body: &[u8]) -> Option<Signal> { | |
| 366 | // schema_version(2) + ts(8) + source(1) + name(1) + value(8) + tag_flag(1) | |
| 367 | if body.len() < 21 { | |
| 368 | return None; | |
| 369 | } | |
| 370 | let schema_version = u16::from_le_bytes(body[0..2].try_into().ok()?); | |
| 371 | let ts = u64::from_le_bytes(body[2..10].try_into().ok()?); | |
| 372 | let source = Source::from_u8(body[10])?; | |
| 373 | let name = SignalName::from_u8(body[11])?; | |
| 374 | let value = Value(f64::from_le_bytes(body[12..20].try_into().ok()?)); | |
| 375 | ||
| 376 | let tag = match body[20] { | |
| 377 | 0 => None, | |
| 378 | 1 => { | |
| 379 | let len = u16::from_le_bytes(body.get(21..23)?.try_into().ok()?) as usize; | |
| 380 | let bytes = body.get(23..23 + len)?; | |
| 381 | Some(Tag(std::str::from_utf8(bytes).ok()?.to_string())) | |
| 382 | } | |
| 383 | _ => return None, | |
| 384 | }; | |
| 385 | ||
| 386 | let signal = Signal { | |
| 387 | schema_version, | |
| 388 | ts, | |
| 389 | source, | |
| 390 | name, | |
| 391 | value, | |
| 392 | tag, | |
| 393 | }; | |
| 394 | // Only accept records this build understands and that obey the tag rule. | |
| 395 | if signal.schema_version != SCHEMA_VERSION || !signal.is_well_formed() { | |
| 396 | return None; | |
| 397 | } | |
| 398 | Some(signal) | |
| 399 | } | |
| 400 | } | |
| 401 | ||
| 402 | #[cfg(test)] | |
| 403 | mod wire_tests { | |
| 404 | use super::*; | |
| 405 | ||
| 406 | fn sig(name: SignalName, value: f64, tag: Option<Tag>) -> Signal { | |
| 407 | Signal { | |
| 408 | schema_version: SCHEMA_VERSION, | |
| 409 | ts: 1_723_100_000_000, | |
| 410 | source: Source::Git, | |
| 411 | name, | |
| 412 | value: Value(value), | |
| 413 | tag, | |
| 414 | } | |
| 415 | } | |
| 416 | ||
| 417 | #[test] | |
| 418 | fn round_trip_no_tag() { | |
| 419 | let s = sig(SignalName::CommitsToday, 7.0, None); | |
| 420 | let bytes = wire::encode(&s); | |
| 421 | let back = wire::decode(&bytes).expect("decodes"); | |
| 422 | assert_eq!(s, back); | |
| 423 | } | |
| 424 | ||
| 425 | #[test] | |
| 426 | fn round_trip_with_tag() { | |
| 427 | let tag = Tag::repo_path("/Users/x/git/repo").expect("valid repo path"); | |
| 428 | let s = Signal { | |
| 429 | source: Source::Git, | |
| 430 | ..sig(SignalName::BranchLastCommitAge, 3.0, Some(tag)) | |
| 431 | }; | |
| 432 | let bytes = wire::encode(&s); | |
| 433 | let back = wire::decode(&bytes).expect("decodes"); | |
| 434 | assert_eq!(s, back); | |
| 435 | } | |
| 436 | ||
| 437 | #[test] | |
| 438 | fn round_trip_preserves_float_payload() { | |
| 439 | let s = sig(SignalName::DaysSinceLastCommit, 12.5, None); | |
| 440 | let back = wire::decode(&wire::encode(&s)).expect("decodes"); | |
| 441 | assert_eq!(back.value, Value(12.5)); | |
| 442 | } | |
| 443 | ||
| 444 | #[test] | |
| 445 | fn stream_read_frame_round_trips_multiple() { | |
| 446 | let a = sig(SignalName::CommitsWindow, 4.0, None); | |
| 447 | let b = sig(SignalName::BranchCount, 2.0, None); | |
| 448 | let mut buf = Vec::new(); | |
| 449 | wire::write_frame(&mut buf, &a).unwrap(); | |
| 450 | wire::write_frame(&mut buf, &b).unwrap(); | |
| 451 | ||
| 452 | let mut cursor = std::io::Cursor::new(buf); | |
| 453 | let ra = wire::read_frame(&mut cursor).unwrap().unwrap(); | |
| 454 | let rb = wire::read_frame(&mut cursor).unwrap().unwrap(); | |
| 455 | let end = wire::read_frame(&mut cursor).unwrap(); | |
| 456 | assert_eq!(ra, a); | |
| 457 | assert_eq!(rb, b); | |
| 458 | assert!(end.is_none(), "clean EOF at frame boundary"); | |
| 459 | } | |
| 460 | ||
| 461 | #[test] | |
| 462 | fn decode_rejects_short_buffer() { | |
| 463 | assert!(wire::decode(&[0, 1, 2]).is_none()); | |
| 464 | } | |
| 465 | } | |
crates/signal-schema/tests/hardware_wire.rs added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | //! # The Swift↔Rust hardware wire contract (v0.3) | |
| 2 | //! | |
| 3 | //! The macOS IOKit collector is a sibling Swift package (`macos-collector/`) | |
| 4 | //! that speaks this crate's wire format. Swift and Rust are two independent | |
| 5 | //! implementations of the same byte layout, so the contract needs a test that | |
| 6 | //! pins the *exact bytes* both sides must agree on — not just a Rust round trip. | |
| 7 | //! | |
| 8 | //! This test hand-encodes, per the documented frame layout | |
| 9 | //! (`signal_schema::wire`), the canonical hardware frame the Swift collector | |
| 10 | //! emits, and asserts: | |
| 11 | //! 1. `wire::decode` parses those exact bytes into the expected [`Signal`] | |
| 12 | //! (this is signald's parse path — signald decodes hardware frames with the | |
| 13 | //! same function), and | |
| 14 | //! 2. `wire::encode` reproduces those exact bytes from the [`Signal`]. | |
| 15 | //! | |
| 16 | //! The identical byte array is asserted on the Swift side in | |
| 17 | //! `macos-collector/Tests/CollectorCoreTests/WireTests.swift`, so both languages | |
| 18 | //! independently commit to the same wire. Cross-*process* execution of the Swift | |
| 19 | //! binary is not required to prove agreement — the shared literal is the proof. | |
| 20 | //! | |
| 21 | //! Privacy note: a hardware frame carries the same `f64`-only payload as every | |
| 22 | //! other signal. `CpuLoad` is untagged (`tag_present == 0`); there is no content | |
| 23 | //! field. The privacy invariant is intact for hardware signals by construction. | |
| 24 | ||
| 25 | use signal_schema::{wire, Signal, SignalName, Source, Value, SCHEMA_VERSION}; | |
| 26 | ||
| 27 | /// The canonical hardware test vector, shared verbatim with the Swift side. | |
| 28 | /// | |
| 29 | /// Signal: schema_version=3, ts=0, source=Hardware(3), name=CpuLoad(28), | |
| 30 | /// value=0.5 (`f64`), tag=None. | |
| 31 | /// | |
| 32 | /// Frame bytes (little-endian throughout): | |
| 33 | /// ```text | |
| 34 | /// 15 00 00 00 body_len = 21 (u32) | |
| 35 | /// 03 00 schema_version = 3 (u16) | |
| 36 | /// 00 00 00 00 00 00 00 00 ts = 0 (u64) | |
| 37 | /// 03 source = Hardware (u8) | |
| 38 | /// 1C name = CpuLoad = 28 (u8) | |
| 39 | /// 00 00 00 00 00 00 E0 3F value = 0.5 (f64) | |
| 40 | /// 00 tag_present = 0 (u8) | |
| 41 | /// ``` | |
| 42 | const CANONICAL_FRAME: [u8; 25] = [ | |
| 43 | 0x15, 0x00, 0x00, 0x00, // body_len = 21 | |
| 44 | 0x03, 0x00, // schema_version = 3 | |
| 45 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // ts = 0 | |
| 46 | 0x03, // source = Hardware | |
| 47 | 0x1C, // name = CpuLoad (28) | |
| 48 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x3F, // value = 0.5 | |
| 49 | 0x00, // tag_present = 0 | |
| 50 | ]; | |
| 51 | ||
| 52 | fn canonical_signal() -> Signal { | |
| 53 | Signal { | |
| 54 | schema_version: SCHEMA_VERSION, | |
| 55 | ts: 0, | |
| 56 | source: Source::Hardware, | |
| 57 | name: SignalName::CpuLoad, | |
| 58 | value: Value(0.5), | |
| 59 | tag: None, | |
| 60 | } | |
| 61 | } | |
| 62 | ||
| 63 | /// signald parses a hardware frame produced to the documented byte layout. | |
| 64 | #[test] | |
| 65 | fn signald_decodes_the_swift_hardware_frame() { | |
| 66 | let decoded = wire::decode(&CANONICAL_FRAME).expect("signald must decode the hardware frame"); | |
| 67 | assert_eq!(decoded, canonical_signal()); | |
| 68 | // The privacy boundary holds for hardware signals too: f64 payload, no tag. | |
| 69 | assert_eq!(decoded.value, Value(0.5)); | |
| 70 | assert!(decoded.tag.is_none(), "aggregate hardware scalar carries no tag"); | |
| 71 | } | |
| 72 | ||
| 73 | /// The Rust encoder reproduces the exact bytes the Swift encoder commits to. | |
| 74 | #[test] | |
| 75 | fn rust_encoder_matches_the_shared_byte_vector() { | |
| 76 | assert_eq!(wire::encode(&canonical_signal()), CANONICAL_FRAME.to_vec()); | |
| 77 | } | |
| 78 | ||
| 79 | /// The new aggregate CPU metric is untagged and stable at discriminant 28. | |
| 80 | #[test] | |
| 81 | fn cpu_load_is_untagged_and_stable() { | |
| 82 | assert_eq!(SignalName::CpuLoad.to_u8(), 28); | |
| 83 | assert_eq!(SignalName::from_u8(28), Some(SignalName::CpuLoad)); | |
| 84 | assert!(!SignalName::CpuLoad.allows_tag(), "aggregate CPU load is never tagged"); | |
| 85 | } | |
crates/signal-schema/tests/privacy_invariant.rs added +309
| @@ -0,0 +1,309 @@ | ||
| 1 | //! # The privacy invariant test (spec §1.5) | |
| 2 | //! | |
| 3 | //! This is the single most important test in the project. It exists from day | |
| 4 | //! one, before the sensitive terminal collector does, so the guardrail is in | |
| 5 | //! place before the code it guards. | |
| 6 | //! | |
| 7 | //! The invariant (spec §1.5): | |
| 8 | //! | |
| 9 | //! > No process persists, transmits, or exposes any representation from which | |
| 10 | //! > the content or identity of an individual keystroke, command argument, or | |
| 11 | //! > typed character can be recovered. Only order-free aggregates leave the | |
| 12 | //! > terminal collector. | |
| 13 | //! | |
| 14 | //! At M0 we encode the *structural* half as real, passing tests: | |
| 15 | //! 1. `value_channel_is_exactly_f64` — the payload is an `f64`, nothing wider. | |
| 16 | //! 2. `wire_format_has_no_content_field` — the `Signal` type declares no | |
| 17 | //! content-carrying field (only `tag: Option<Tag>` is a string, and it is | |
| 18 | //! the audited identifier exception). | |
| 19 | //! 3. `forbidden_symbol_scan` — the tree contains none of the banned | |
| 20 | //! keylogger APIs / shell patterns (the static CI gate). | |
| 21 | //! | |
| 22 | //! The runtime *differential secret-typing* test (drive the hooks with a | |
| 23 | //! planted secret, assert it never reaches the socket or SQLite in any | |
| 24 | //! encoding) is stubbed `#[ignore]` below and lands with the terminal | |
| 25 | //! collector in Phase 2. | |
| 26 | ||
| 27 | use std::mem::size_of; | |
| 28 | use std::path::PathBuf; | |
| 29 | ||
| 30 | use signal_schema::Value; | |
| 31 | ||
| 32 | /// The payload channel is exactly an `f64` — no room for a character, a string, | |
| 33 | /// or a byte buffer. This is the load-bearing structural guarantee. | |
| 34 | #[test] | |
| 35 | fn value_channel_is_exactly_f64() { | |
| 36 | assert_eq!( | |
| 37 | size_of::<Value>(), | |
| 38 | size_of::<f64>(), | |
| 39 | "Value must be a transparent f64 payload; anything wider is a content channel" | |
| 40 | ); | |
| 41 | // Construction only accepts an f64. If someone adds a String-accepting | |
| 42 | // constructor, this file is where the review happens. | |
| 43 | let _v = Value(3.14_f64); | |
| 44 | } | |
| 45 | ||
| 46 | /// The `Signal` type must declare no content-carrying field. We assert this | |
| 47 | /// structurally by scanning the schema source: within the `struct Signal` | |
| 48 | /// declaration, the only `String`-typed payload permitted is the audited | |
| 49 | /// `tag`. Any `content` / `text` / `bytes` / `payload` field fails the build. | |
| 50 | #[test] | |
| 51 | fn wire_format_has_no_content_field() { | |
| 52 | let lib = repo_root().join("crates/signal-schema/src/lib.rs"); | |
| 53 | let src = std::fs::read_to_string(&lib).expect("read signal-schema/src/lib.rs"); | |
| 54 | ||
| 55 | let start = src.find("pub struct Signal {").expect("Signal struct present"); | |
| 56 | let body = &src[start..]; | |
| 57 | let end = body.find('}').expect("Signal struct closes"); | |
| 58 | // Field declarations only — strip doc/comment lines so prose like | |
| 59 | // "non-content identifier" doesn't false-positive. | |
| 60 | let body: String = body[..end] | |
| 61 | .lines() | |
| 62 | .filter(|l| !l.trim_start().starts_with("//")) | |
| 63 | .collect::<Vec<_>>() | |
| 64 | .join("\n"); | |
| 65 | ||
| 66 | for banned in ["content", "text", "bytes", "payload", "keystroke", "command"] { | |
| 67 | assert!( | |
| 68 | !body.contains(banned), | |
| 69 | "Signal declares a forbidden content field containing `{banned}`; \ | |
| 70 | the wire format must have nowhere to put typed content" | |
| 71 | ); | |
| 72 | } | |
| 73 | // The one audited string exception is the identifier tag. | |
| 74 | assert!( | |
| 75 | body.contains("tag: Option<Tag>"), | |
| 76 | "the only string in Signal must be the audited `tag` identifier" | |
| 77 | ); | |
| 78 | } | |
| 79 | ||
| 80 | /// The static CI gate (spec §1.5): the tree must contain none of the APIs a | |
| 81 | /// real keylogger would use, nor any shell hook that touches the line buffer. | |
| 82 | /// Any hit fails the build. The banned list itself lives here and is reviewed. | |
| 83 | #[test] | |
| 84 | fn forbidden_symbol_scan() { | |
| 85 | // APIs for input taps / global key monitoring, and shell line-buffer refs. | |
| 86 | const BANNED: &[&str] = &[ | |
| 87 | "CGEventTap", | |
| 88 | "IOHIDManager", | |
| 89 | "kAXTrusted", | |
| 90 | "addGlobalMonitorForEvents", | |
| 91 | "$BUFFER", | |
| 92 | "$LBUFFER", | |
| 93 | ]; | |
| 94 | const CODE_EXTS: &[&str] = &["rs", "zsh", "sh", "swift", "m", "c", "h"]; | |
| 95 | ||
| 96 | let this_file = PathBuf::from(file!()); | |
| 97 | let this_name = this_file.file_name().unwrap(); | |
| 98 | ||
| 99 | let mut offenders = Vec::new(); | |
| 100 | for path in walk(&repo_root()) { | |
| 101 | // Skip build artifacts and this test (which names the banned tokens). | |
| 102 | // `target` is Rust's; `.build` is SwiftPM's (macos-collector/.build). | |
| 103 | if path | |
| 104 | .components() | |
| 105 | .any(|c| matches!(c.as_os_str().to_str(), Some("target") | Some(".build"))) | |
| 106 | { | |
| 107 | continue; | |
| 108 | } | |
| 109 | if path.file_name() == Some(this_name) { | |
| 110 | continue; | |
| 111 | } | |
| 112 | let ext_ok = path | |
| 113 | .extension() | |
| 114 | .and_then(|e| e.to_str()) | |
| 115 | .map(|e| CODE_EXTS.contains(&e)) | |
| 116 | .unwrap_or(false); | |
| 117 | if !ext_ok { | |
| 118 | continue; | |
| 119 | } | |
| 120 | let Ok(text) = std::fs::read_to_string(&path) else { | |
| 121 | continue; | |
| 122 | }; | |
| 123 | for banned in BANNED { | |
| 124 | if text.contains(banned) { | |
| 125 | offenders.push(format!("{}: {banned}", path.display())); | |
| 126 | } | |
| 127 | } | |
| 128 | } | |
| 129 | ||
| 130 | assert!( | |
| 131 | offenders.is_empty(), | |
| 132 | "forbidden keylogger symbol(s) found:\n{}", | |
| 133 | offenders.join("\n") | |
| 134 | ); | |
| 135 | } | |
| 136 | ||
| 137 | /// The differential secret-typing acceptance test (spec §1.5, §5 Phase 2) — | |
| 138 | /// the ship gate for the terminal collector, now **active**. | |
| 139 | /// | |
| 140 | /// This drives the **real production hook** (`shell-hooks/signald-hooks.zsh`) | |
| 141 | /// through a **real interactive zsh under a real pseudo-terminal** (zsh's own | |
| 142 | /// `zsh/zpty` module — no external dependency), *typing a planted secret* the | |
| 143 | /// way a person would at a prompt. Real `zle` keystroke counting increments a | |
| 144 | /// number per key and discards the key, so the only thing the shell emits is a | |
| 145 | /// count. This test proves that empirically: the secret must never appear — | |
| 146 | /// plain, reversed, hex, or base64 — in the spool the shell writes, nor in the | |
| 147 | /// `f64`-only wire encoding of the signals derived from those counts. | |
| 148 | /// | |
| 149 | /// This crate is the privacy boundary and stays dependency-free, so it exercises | |
| 150 | /// the schema-reachable half (spool + wire). The **full pipeline** gate — the | |
| 151 | /// same real typing driven through the terminal collector, the SQLite history | |
| 152 | /// store, and the hub — lives in `crates/signald/tests/differential_secret_typing.rs` | |
| 153 | /// (that crate owns the collector and the store). | |
| 154 | #[test] | |
| 155 | fn differential_secret_typing() { | |
| 156 | use std::process::Command; | |
| 157 | ||
| 158 | // A distinctive, non-dictionary secret so an incidental byte-collision is | |
| 159 | // not credible. | |
| 160 | const SECRET: &str = "hunter2-CorrectHorseBatteryStaple-9f3a-SUPERSECRET"; | |
| 161 | ||
| 162 | let root = repo_root(); | |
| 163 | let hooks = root.join("shell-hooks/signald-hooks.zsh"); | |
| 164 | assert!(hooks.exists(), "production hook missing at {}", hooks.display()); | |
| 165 | ||
| 166 | let nanos = std::time::SystemTime::now() | |
| 167 | .duration_since(std::time::UNIX_EPOCH) | |
| 168 | .unwrap() | |
| 169 | .as_nanos(); | |
| 170 | let tmp = std::env::temp_dir().join(format!("ambient-diff-schema-{}-{nanos}", std::process::id())); | |
| 171 | std::fs::create_dir_all(&tmp).unwrap(); | |
| 172 | let spool = tmp.join("terminal.spool"); | |
| 173 | let driver = tmp.join("driver.zsh"); | |
| 174 | std::fs::write(&spool, b"").unwrap(); | |
| 175 | ||
| 176 | // pty driver: spawn interactive zsh under zpty, source the real hook, and | |
| 177 | // type the secret as ordinary `echo` arguments — real keystrokes, real zle. | |
| 178 | const DRIVER: &str = r#" | |
| 179 | zmodload zsh/zpty || exit 3 | |
| 180 | zpty SH zsh -f -i || exit 4 | |
| 181 | drain() { local x; while zpty -r -t SH x 2>/dev/null; do :; done } | |
| 182 | sleep 0.4; drain | |
| 183 | zpty -w SH "source $HOOKS" | |
| 184 | sleep 0.3; drain | |
| 185 | zpty -w SH "echo $SECRET" | |
| 186 | sleep 0.5; drain | |
| 187 | zpty -w SH "echo typed $SECRET twice $SECRET" | |
| 188 | sleep 0.5; drain | |
| 189 | zpty -w SH "exit" | |
| 190 | sleep 0.3 | |
| 191 | zpty -d SH 2>/dev/null | |
| 192 | "#; | |
| 193 | std::fs::write(&driver, DRIVER).unwrap(); | |
| 194 | ||
| 195 | let status = Command::new("zsh") | |
| 196 | .arg("-f") | |
| 197 | .arg(&driver) | |
| 198 | .env("SIGNALD_SPOOL", &spool) | |
| 199 | .env("HOOKS", &hooks) | |
| 200 | .env("SECRET", SECRET) | |
| 201 | .status() | |
| 202 | .expect("run zsh/zpty harness (the privacy ship-gate must run)"); | |
| 203 | assert!(status.success(), "zsh/zpty harness failed"); | |
| 204 | ||
| 205 | let spool_bytes = std::fs::read(&spool).unwrap(); | |
| 206 | let _ = std::fs::remove_dir_all(&tmp); | |
| 207 | ||
| 208 | // Parse count records (three integers per line) and confirm the typing was | |
| 209 | // genuinely counted — otherwise we'd be "proving" absence over an empty run. | |
| 210 | let mut max_keys = 0u64; | |
| 211 | let mut wire_bytes: Vec<u8> = Vec::new(); | |
| 212 | for line in String::from_utf8_lossy(&spool_bytes).lines() { | |
| 213 | let nums: Vec<u64> = line.split_whitespace().filter_map(|t| t.parse().ok()).collect(); | |
| 214 | if nums.len() != 3 { | |
| 215 | continue; // not a well-formed count record | |
| 216 | } | |
| 217 | max_keys = max_keys.max(nums[1]); | |
| 218 | // Build a signal from the count alone — the only thing available — and | |
| 219 | // encode it to the real wire format. | |
| 220 | let sig = signal_schema::Signal { | |
| 221 | schema_version: signal_schema::SCHEMA_VERSION, | |
| 222 | ts: nums[0], | |
| 223 | source: signal_schema::Source::Terminal, | |
| 224 | name: signal_schema::SignalName::KeysPerMin, | |
| 225 | value: Value(nums[1] as f64), | |
| 226 | tag: None, | |
| 227 | }; | |
| 228 | wire_bytes.extend_from_slice(&signal_schema::wire::encode(&sig)); | |
| 229 | } | |
| 230 | assert!( | |
| 231 | max_keys >= SECRET.chars().count() as u64, | |
| 232 | "secret does not appear to have been typed through the hook \ | |
| 233 | (max keys counted = {max_keys}, secret len = {})", | |
| 234 | SECRET.chars().count() | |
| 235 | ); | |
| 236 | ||
| 237 | // The secret must be absent from the spool and the wire, in any encoding. | |
| 238 | let b = SECRET.as_bytes(); | |
| 239 | let reversed: Vec<u8> = b.iter().rev().copied().collect(); | |
| 240 | let needles: [(&str, Vec<u8>); 4] = [ | |
| 241 | ("plain", b.to_vec()), | |
| 242 | ("reversed", reversed), | |
| 243 | ("hex", to_hex(b).into_bytes()), | |
| 244 | ("base64", to_base64(b).into_bytes()), | |
| 245 | ]; | |
| 246 | for (label, artifact) in [("spool", &spool_bytes), ("wire", &wire_bytes)] { | |
| 247 | for (enc, needle) in &needles { | |
| 248 | assert!( | |
| 249 | !byte_contains(artifact, needle), | |
| 250 | "SECRET LEAK: found the secret ({enc}) in the {label} — only \ | |
| 251 | aggregate counts may leave the terminal collector" | |
| 252 | ); | |
| 253 | } | |
| 254 | } | |
| 255 | } | |
| 256 | ||
| 257 | fn byte_contains(hay: &[u8], needle: &[u8]) -> bool { | |
| 258 | !needle.is_empty() && hay.len() >= needle.len() && hay.windows(needle.len()).any(|w| w == needle) | |
| 259 | } | |
| 260 | ||
| 261 | fn to_hex(bytes: &[u8]) -> String { | |
| 262 | let mut s = String::with_capacity(bytes.len() * 2); | |
| 263 | for byte in bytes { | |
| 264 | s.push_str(&format!("{byte:02x}")); | |
| 265 | } | |
| 266 | s | |
| 267 | } | |
| 268 | ||
| 269 | fn to_base64(bytes: &[u8]) -> String { | |
| 270 | const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; | |
| 271 | let mut out = String::new(); | |
| 272 | for chunk in bytes.chunks(3) { | |
| 273 | let b0 = chunk[0] as u32; | |
| 274 | let b1 = *chunk.get(1).unwrap_or(&0) as u32; | |
| 275 | let b2 = *chunk.get(2).unwrap_or(&0) as u32; | |
| 276 | let n = (b0 << 16) | (b1 << 8) | b2; | |
| 277 | out.push(T[(n >> 18 & 63) as usize] as char); | |
| 278 | out.push(T[(n >> 12 & 63) as usize] as char); | |
| 279 | out.push(if chunk.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' }); | |
| 280 | out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' }); | |
| 281 | } | |
| 282 | out | |
| 283 | } | |
| 284 | ||
| 285 | // --- helpers --- | |
| 286 | ||
| 287 | fn repo_root() -> PathBuf { | |
| 288 | // CARGO_MANIFEST_DIR = <root>/crates/signal-schema | |
| 289 | PathBuf::from(env!("CARGO_MANIFEST_DIR")) | |
| 290 | .join("../..") | |
| 291 | .canonicalize() | |
| 292 | .expect("canonicalize repo root") | |
| 293 | } | |
| 294 | ||
| 295 | fn walk(dir: &PathBuf) -> Vec<PathBuf> { | |
| 296 | let mut out = Vec::new(); | |
| 297 | let Ok(entries) = std::fs::read_dir(dir) else { | |
| 298 | return out; | |
| 299 | }; | |
| 300 | for entry in entries.flatten() { | |
| 301 | let path = entry.path(); | |
| 302 | if path.is_dir() { | |
| 303 | out.extend(walk(&path)); | |
| 304 | } else { | |
| 305 | out.push(path); | |
| 306 | } | |
| 307 | } | |
| 308 | out | |
| 309 | } | |
crates/signald/Cargo.toml added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | [package] | |
| 2 | name = "signald" | |
| 3 | version.workspace = true | |
| 4 | edition.workspace = true | |
| 5 | license.workspace = true | |
| 6 | description = "The ambient-companions signal-collection daemon: owns all collectors, normalizes to the shared schema, and publishes a pub/sub stream over a local Unix socket. One backend, five faces." | |
| 7 | ||
| 8 | [[bin]] | |
| 9 | name = "signald" | |
| 10 | path = "src/main.rs" | |
| 11 | ||
| 12 | [dependencies] | |
| 13 | signal-schema = { path = "../signal-schema" } | |
| 14 | # History store (spec §1.3): SQLite in WAL mode. `bundled` compiles SQLite in- | |
| 15 | # tree so there is no system-library dependency. This is the one new dependency | |
| 16 | # added in v0.2; signal-schema stays dependency-free by design. | |
| 17 | rusqlite = { version = "0.40", features = ["bundled"] } | |
crates/signald/src/history.rs added +176
| @@ -0,0 +1,176 @@ | ||
| 1 | //! # History store (spec §1.3, §4) | |
| 2 | //! | |
| 3 | //! A local SQLite (WAL) store of a rolling history of signals. History is what | |
| 4 | //! lets a renderer read *recent aggregates* rather than only a live snapshot — | |
| 5 | //! it is what lets the garden survive a restart and (later) the aging pet know | |
| 6 | //! its true age. | |
| 7 | //! | |
| 8 | //! ## Same privacy constraints as the wire | |
| 9 | //! | |
| 10 | //! The store holds **aggregate scalars only**. Each row carries the schema | |
| 11 | //! discriminants, the `f64` `value`, and the audited non-content `tag` — exactly | |
| 12 | //! the fields of a [`Signal`], nothing wider. There is no content column. As a | |
| 13 | //! belt-and-braces measure the audited wire frame is also stored as a BLOB and | |
| 14 | //! is the source of truth on read, so reconstruction reuses the same validated | |
| 15 | //! [`wire::decode`] the socket uses — a row can decode to nothing but a | |
| 16 | //! well-formed, `f64`-only signal. The differential secret-typing test writes a | |
| 17 | //! planted secret through the collector into this store and asserts it never | |
| 18 | //! appears here in any encoding. | |
| 19 | ||
| 20 | use std::path::Path; | |
| 21 | ||
| 22 | use rusqlite::{params, Connection}; | |
| 23 | use signal_schema::{wire, Signal}; | |
| 24 | ||
| 25 | /// A handle to the SQLite history database. | |
| 26 | pub struct History { | |
| 27 | conn: Connection, | |
| 28 | } | |
| 29 | ||
| 30 | impl History { | |
| 31 | /// Open (creating if needed) the history database at `path` in WAL mode. | |
| 32 | pub fn open(path: &Path) -> rusqlite::Result<History> { | |
| 33 | let conn = Connection::open(path)?; | |
| 34 | // WAL: concurrent reads while the daemon writes; survives restarts. | |
| 35 | conn.pragma_update(None, "journal_mode", "WAL")?; | |
| 36 | Self::init(conn) | |
| 37 | } | |
| 38 | ||
| 39 | /// An ephemeral in-memory store (used by tests). | |
| 40 | pub fn open_in_memory() -> rusqlite::Result<History> { | |
| 41 | Self::init(Connection::open_in_memory()?) | |
| 42 | } | |
| 43 | ||
| 44 | fn init(conn: Connection) -> rusqlite::Result<History> { | |
| 45 | // Columns for querying (name, ts, value, tag); `frame` is the audited | |
| 46 | // wire encoding, reused for faithful, validated reconstruction on read. | |
| 47 | conn.execute( | |
| 48 | "CREATE TABLE IF NOT EXISTS signals ( | |
| 49 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 50 | schema_version INTEGER NOT NULL, | |
| 51 | ts INTEGER NOT NULL, | |
| 52 | source INTEGER NOT NULL, | |
| 53 | name INTEGER NOT NULL, | |
| 54 | value REAL NOT NULL, | |
| 55 | tag TEXT, | |
| 56 | frame BLOB NOT NULL | |
| 57 | )", | |
| 58 | [], | |
| 59 | )?; | |
| 60 | conn.execute( | |
| 61 | "CREATE INDEX IF NOT EXISTS idx_signals_name_ts ON signals(name, ts)", | |
| 62 | [], | |
| 63 | )?; | |
| 64 | Ok(History { conn }) | |
| 65 | } | |
| 66 | ||
| 67 | /// Append one signal to the history. | |
| 68 | pub fn record(&self, s: &Signal) -> rusqlite::Result<()> { | |
| 69 | self.conn.execute( | |
| 70 | "INSERT INTO signals (schema_version, ts, source, name, value, tag, frame) | |
| 71 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", | |
| 72 | params![ | |
| 73 | s.schema_version, | |
| 74 | s.ts as i64, // SQLite integers are i64; unix-millis fits | |
| 75 | s.source.to_u8(), | |
| 76 | s.name.to_u8(), | |
| 77 | s.value.0, | |
| 78 | s.tag.as_ref().map(|t| t.as_str()), | |
| 79 | wire::encode(s), | |
| 80 | ], | |
| 81 | )?; | |
| 82 | Ok(()) | |
| 83 | } | |
| 84 | ||
| 85 | /// The most recent `limit` signals, newest first. This is the query that | |
| 86 | /// lets a renderer read recent history instead of only the live snapshot. | |
| 87 | pub fn recent(&self, limit: usize) -> rusqlite::Result<Vec<Signal>> { | |
| 88 | self.query("SELECT frame FROM signals ORDER BY id DESC LIMIT ?1", limit) | |
| 89 | } | |
| 90 | ||
| 91 | /// The most recent `limit` signals for one metric name, newest first. | |
| 92 | pub fn recent_named( | |
| 93 | &self, | |
| 94 | name: signal_schema::SignalName, | |
| 95 | limit: usize, | |
| 96 | ) -> rusqlite::Result<Vec<Signal>> { | |
| 97 | let mut stmt = self.conn.prepare( | |
| 98 | "SELECT frame FROM signals WHERE name = ?1 ORDER BY id DESC LIMIT ?2", | |
| 99 | )?; | |
| 100 | let rows = stmt.query_map(params![name.to_u8(), limit as i64], |row| { | |
| 101 | row.get::<_, Vec<u8>>(0) | |
| 102 | })?; | |
| 103 | Self::decode_rows(rows) | |
| 104 | } | |
| 105 | ||
| 106 | fn query(&self, sql: &str, limit: usize) -> rusqlite::Result<Vec<Signal>> { | |
| 107 | let mut stmt = self.conn.prepare(sql)?; | |
| 108 | let rows = stmt.query_map(params![limit as i64], |row| row.get::<_, Vec<u8>>(0))?; | |
| 109 | Self::decode_rows(rows) | |
| 110 | } | |
| 111 | ||
| 112 | fn decode_rows( | |
| 113 | rows: impl Iterator<Item = rusqlite::Result<Vec<u8>>>, | |
| 114 | ) -> rusqlite::Result<Vec<Signal>> { | |
| 115 | let mut out = Vec::new(); | |
| 116 | for frame in rows { | |
| 117 | let frame = frame?; | |
| 118 | if let Some(sig) = wire::decode(&frame) { | |
| 119 | out.push(sig); | |
| 120 | } | |
| 121 | } | |
| 122 | Ok(out) | |
| 123 | } | |
| 124 | } | |
| 125 | ||
| 126 | #[cfg(test)] | |
| 127 | mod tests { | |
| 128 | use super::*; | |
| 129 | use signal_schema::{SignalName, Source, Tag, Value, SCHEMA_VERSION}; | |
| 130 | ||
| 131 | fn sig(name: SignalName, value: f64, tag: Option<Tag>) -> Signal { | |
| 132 | Signal { | |
| 133 | schema_version: SCHEMA_VERSION, | |
| 134 | ts: 1_723_100_000_000, | |
| 135 | source: Source::Git, | |
| 136 | name, | |
| 137 | value: Value(value), | |
| 138 | tag, | |
| 139 | } | |
| 140 | } | |
| 141 | ||
| 142 | #[test] | |
| 143 | fn round_trip_write_then_query_recent() { | |
| 144 | let h = History::open_in_memory().unwrap(); | |
| 145 | let repo = Tag::repo_path("/x/repo").unwrap(); | |
| 146 | h.record(&sig(SignalName::CommitsWindow, 5.0, Some(repo.clone()))) | |
| 147 | .unwrap(); | |
| 148 | h.record(&sig(SignalName::CommitsToday, 2.0, Some(repo.clone()))) | |
| 149 | .unwrap(); | |
| 150 | h.record(&sig(SignalName::KeysPerMin, 88.0, None)).unwrap(); | |
| 151 | ||
| 152 | let recent = h.recent(10).unwrap(); | |
| 153 | assert_eq!(recent.len(), 3); | |
| 154 | // Newest first, and the aggregates survive the round trip exactly. | |
| 155 | assert_eq!(recent[0].name, SignalName::KeysPerMin); | |
| 156 | assert_eq!(recent[0].value, Value(88.0)); | |
| 157 | assert_eq!(recent[2].name, SignalName::CommitsWindow); | |
| 158 | assert_eq!(recent[2].value, Value(5.0)); | |
| 159 | assert_eq!(recent[2].tag.as_ref().unwrap().as_str(), "/x/repo"); | |
| 160 | } | |
| 161 | ||
| 162 | #[test] | |
| 163 | fn recent_named_filters_by_metric() { | |
| 164 | let h = History::open_in_memory().unwrap(); | |
| 165 | for v in [1.0, 2.0, 3.0] { | |
| 166 | h.record(&sig(SignalName::KeysPerMin, v, None)).unwrap(); | |
| 167 | } | |
| 168 | h.record(&sig(SignalName::SessionSeconds, 42.0, None)) | |
| 169 | .unwrap(); | |
| 170 | ||
| 171 | let keys = h.recent_named(SignalName::KeysPerMin, 10).unwrap(); | |
| 172 | assert_eq!(keys.len(), 3); | |
| 173 | assert!(keys.iter().all(|s| s.name == SignalName::KeysPerMin)); | |
| 174 | assert_eq!(keys[0].value, Value(3.0)); // newest first | |
| 175 | } | |
| 176 | } | |
crates/signald/src/hub.rs added +149
| @@ -0,0 +1,149 @@ | ||
| 1 | //! # The signal hub — last-value cache + live fan-out (spec §1.3) | |
| 2 | //! | |
| 3 | //! The in-memory heart of the publish side. Collectors [`Hub::publish`] signals; | |
| 4 | //! subscribers [`Hub::subscribe`] and receive the current value of every cached | |
| 5 | //! metric immediately, then a live stream of updates. | |
| 6 | //! | |
| 7 | //! Subscription semantics (spec §1.3): *last-value-cache + live stream.* On | |
| 8 | //! subscribe, the caller is handed the latest value of each `name` (keyed by | |
| 9 | //! name + audited tag), so a renderer paints correct state at once; thereafter | |
| 10 | //! it receives updates as they are published. Channels are unbounded, so a slow | |
| 11 | //! subscriber never blocks a publisher. | |
| 12 | //! | |
| 13 | //! When a `Hub` is built [`Hub::with_history`], every published signal is also | |
| 14 | //! persisted to the SQLite history store, so live and persisted paths share one | |
| 15 | //! write point. | |
| 16 | ||
| 17 | use std::collections::BTreeMap; | |
| 18 | use std::sync::mpsc::{channel, Receiver, Sender}; | |
| 19 | use std::sync::{Arc, Mutex}; | |
| 20 | ||
| 21 | use signal_schema::Signal; | |
| 22 | ||
| 23 | use crate::history::History; | |
| 24 | ||
| 25 | /// Cache key: metric name discriminant + optional audited tag. Distinct | |
| 26 | /// per-repo / per-core signals coexist; a new value for the same key replaces | |
| 27 | /// the old one (keep-latest). | |
| 28 | type Key = (u8, Option<String>); | |
| 29 | ||
| 30 | /// A cloneable handle to the shared hub. Clones share one cache + subscriber | |
| 31 | /// set behind a mutex. | |
| 32 | #[derive(Clone)] | |
| 33 | pub struct Hub { | |
| 34 | inner: Arc<Mutex<Inner>>, | |
| 35 | } | |
| 36 | ||
| 37 | struct Inner { | |
| 38 | cache: BTreeMap<Key, Signal>, | |
| 39 | subs: Vec<Sender<Signal>>, | |
| 40 | history: Option<History>, | |
| 41 | } | |
| 42 | ||
| 43 | impl Hub { | |
| 44 | /// A hub with no history (live-only). Useful for tests and for a renderer | |
| 45 | /// that never persists. | |
| 46 | pub fn new() -> Hub { | |
| 47 | Hub::build(None) | |
| 48 | } | |
| 49 | ||
| 50 | /// A hub that also persists every published signal to `history`. | |
| 51 | pub fn with_history(history: History) -> Hub { | |
| 52 | Hub::build(Some(history)) | |
| 53 | } | |
| 54 | ||
| 55 | fn build(history: Option<History>) -> Hub { | |
| 56 | Hub { | |
| 57 | inner: Arc::new(Mutex::new(Inner { | |
| 58 | cache: BTreeMap::new(), | |
| 59 | subs: Vec::new(), | |
| 60 | history, | |
| 61 | })), | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | /// Publish one signal: persist it (if a history is attached), update the | |
| 66 | /// last-value cache, and fan it out to every live subscriber. Dead | |
| 67 | /// subscribers (whose receiver was dropped) are pruned here. | |
| 68 | pub fn publish(&self, s: Signal) { | |
| 69 | let mut guard = self.inner.lock().unwrap(); | |
| 70 | let Inner { | |
| 71 | cache, | |
| 72 | subs, | |
| 73 | history, | |
| 74 | } = &mut *guard; | |
| 75 | ||
| 76 | if let Some(h) = history { | |
| 77 | // A history write failing must not stop the live stream. | |
| 78 | let _ = h.record(&s); | |
| 79 | } | |
| 80 | let key = (s.name.to_u8(), s.tag.as_ref().map(|t| t.as_str().to_string())); | |
| 81 | cache.insert(key, s.clone()); | |
| 82 | subs.retain(|tx| tx.send(s.clone()).is_ok()); | |
| 83 | } | |
| 84 | ||
| 85 | /// Subscribe: return the current last-value snapshot plus a receiver that | |
| 86 | /// yields every signal published after this call. | |
| 87 | pub fn subscribe(&self) -> (Vec<Signal>, Receiver<Signal>) { | |
| 88 | let mut guard = self.inner.lock().unwrap(); | |
| 89 | let snapshot = guard.cache.values().cloned().collect(); | |
| 90 | let (tx, rx) = channel(); | |
| 91 | guard.subs.push(tx); | |
| 92 | (snapshot, rx) | |
| 93 | } | |
| 94 | ||
| 95 | /// The current last-value snapshot without subscribing. | |
| 96 | pub fn snapshot(&self) -> Vec<Signal> { | |
| 97 | self.inner.lock().unwrap().cache.values().cloned().collect() | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | impl Default for Hub { | |
| 102 | fn default() -> Self { | |
| 103 | Hub::new() | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 107 | #[cfg(test)] | |
| 108 | mod tests { | |
| 109 | use super::*; | |
| 110 | use signal_schema::{SignalName, Source, Value, SCHEMA_VERSION}; | |
| 111 | ||
| 112 | fn sig(name: SignalName, value: f64) -> Signal { | |
| 113 | Signal { | |
| 114 | schema_version: SCHEMA_VERSION, | |
| 115 | ts: 1, | |
| 116 | source: Source::Terminal, | |
| 117 | name, | |
| 118 | value: Value(value), | |
| 119 | tag: None, | |
| 120 | } | |
| 121 | } | |
| 122 | ||
| 123 | #[test] | |
| 124 | fn subscriber_gets_cached_value_then_live_update() { | |
| 125 | let hub = Hub::new(); | |
| 126 | // A value published before anyone subscribes must still be seen (cache). | |
| 127 | hub.publish(sig(SignalName::KeysPerMin, 10.0)); | |
| 128 | ||
| 129 | let (snapshot, rx) = hub.subscribe(); | |
| 130 | assert_eq!(snapshot.len(), 1); | |
| 131 | assert_eq!(snapshot[0].value, Value(10.0)); | |
| 132 | ||
| 133 | // A change after subscribing arrives live. | |
| 134 | hub.publish(sig(SignalName::KeysPerMin, 25.0)); | |
| 135 | let update = rx.recv().expect("live update"); | |
| 136 | assert_eq!(update.name, SignalName::KeysPerMin); | |
| 137 | assert_eq!(update.value, Value(25.0)); | |
| 138 | } | |
| 139 | ||
| 140 | #[test] | |
| 141 | fn cache_keeps_latest_per_name() { | |
| 142 | let hub = Hub::new(); | |
| 143 | hub.publish(sig(SignalName::KeysPerMin, 1.0)); | |
| 144 | hub.publish(sig(SignalName::KeysPerMin, 2.0)); | |
| 145 | let snap = hub.snapshot(); | |
| 146 | assert_eq!(snap.len(), 1, "same name collapses to keep-latest"); | |
| 147 | assert_eq!(snap[0].value, Value(2.0)); | |
| 148 | } | |
| 149 | } | |
crates/signald/src/lib.rs added +296
| @@ -0,0 +1,296 @@ | ||
| 1 | //! # signald (library) | |
| 2 | //! | |
| 3 | //! The collector + transport internals of the ambient-companions daemon, split | |
| 4 | //! into a library so the collectors are unit-testable and the thin `signald` | |
| 5 | //! binary (`src/main.rs`) just wires arguments to the runtime. | |
| 6 | //! | |
| 7 | //! v0.2 implements the **git path** and the **terminal path** end to end, adds a | |
| 8 | //! **SQLite (WAL) history store** ([`history`]), and turns the socket into a | |
| 9 | //! **live pub/sub bus with a last-value cache** ([`hub`], [`publish`]). The | |
| 10 | //! sensitive terminal collector is aggregate-only and ships behind the now-active | |
| 11 | //! differential secret-typing test (see `signal-schema/tests/privacy_invariant.rs`). | |
| 12 | //! The system/hardware collector remains a stub (spec phase 3). | |
| 13 | ||
| 14 | use std::time::{SystemTime, UNIX_EPOCH}; | |
| 15 | ||
| 16 | pub mod history; | |
| 17 | pub mod hub; | |
| 18 | ||
| 19 | /// Collectors: each reduces its domain to schema scalars (spec §1.1, §1.4). | |
| 20 | pub mod collectors { | |
| 21 | /// Git collector: shell out to `git` for counts and ages. Counts and | |
| 22 | /// branch/age scalars only — never diff content. This is the cleanest | |
| 23 | /// signal in the suite and the first collector wired (spec §2.2, §3). | |
| 24 | pub mod git { | |
| 25 | use std::path::Path; | |
| 26 | use std::process::Command; | |
| 27 | ||
| 28 | use signal_schema::{Signal, SignalName, Source, Tag, Value, SCHEMA_VERSION}; | |
| 29 | ||
| 30 | use super::super::now_millis; | |
| 31 | ||
| 32 | /// Rolling window (days) for [`SignalName::CommitsWindow`]. | |
| 33 | pub const WINDOW_DAYS: u64 = 7; | |
| 34 | ||
| 35 | /// Derive the aggregate git signals for one repo. Every value is a | |
| 36 | /// scalar `f64`; the only string on the wire is the repo path carried | |
| 37 | /// in the audited `tag` (spec §1.2). Returns an empty vec if `repo` is | |
| 38 | /// not a git repo. | |
| 39 | pub fn collect(repo: &Path) -> Vec<Signal> { | |
| 40 | if !is_git_repo(repo) { | |
| 41 | return Vec::new(); | |
| 42 | } | |
| 43 | let ts = now_millis(); | |
| 44 | let now_secs = ts / 1000; | |
| 45 | let tag = Tag::repo_path(&repo.to_string_lossy()); | |
| 46 | ||
| 47 | let mk = |name: SignalName, value: f64| Signal { | |
| 48 | schema_version: SCHEMA_VERSION, | |
| 49 | ts, | |
| 50 | source: Source::Git, | |
| 51 | name, | |
| 52 | value: Value(value), | |
| 53 | tag: tag.clone(), | |
| 54 | }; | |
| 55 | ||
| 56 | vec![ | |
| 57 | mk( | |
| 58 | SignalName::CommitsWindow, | |
| 59 | commits_since(repo, &format!("{WINDOW_DAYS} days ago")), | |
| 60 | ), | |
| 61 | mk(SignalName::CommitsToday, commits_since(repo, "midnight")), | |
| 62 | mk(SignalName::BranchCount, branch_count(repo)), | |
| 63 | mk( | |
| 64 | SignalName::DaysSinceLastCommit, | |
| 65 | days_since_last_commit(repo, now_secs), | |
| 66 | ), | |
| 67 | ] | |
| 68 | } | |
| 69 | ||
| 70 | fn is_git_repo(repo: &Path) -> bool { | |
| 71 | run_git(repo, &["rev-parse", "--is-inside-work-tree"]) | |
| 72 | .map(|s| s == "true") | |
| 73 | .unwrap_or(false) | |
| 74 | } | |
| 75 | ||
| 76 | /// Count commits reachable from `HEAD` newer than `since` (a git | |
| 77 | /// approxidate, e.g. "midnight" or "7 days ago"). | |
| 78 | fn commits_since(repo: &Path, since: &str) -> f64 { | |
| 79 | run_git( | |
| 80 | repo, | |
| 81 | &["rev-list", "--count", &format!("--since={since}"), "HEAD"], | |
| 82 | ) | |
| 83 | .and_then(|s| s.parse::<f64>().ok()) | |
| 84 | .unwrap_or(0.0) | |
| 85 | } | |
| 86 | ||
| 87 | fn branch_count(repo: &Path) -> f64 { | |
| 88 | run_git(repo, &["for-each-ref", "--format=%(refname)", "refs/heads/"]) | |
| 89 | .map(|s| if s.is_empty() { 0 } else { s.lines().count() }) | |
| 90 | .unwrap_or(0) as f64 | |
| 91 | } | |
| 92 | ||
| 93 | /// Whole days between the most recent `HEAD` commit and now. `0.0` when | |
| 94 | /// the repo has no commits yet. | |
| 95 | fn days_since_last_commit(repo: &Path, now_secs: u64) -> f64 { | |
| 96 | match run_git(repo, &["log", "-1", "--format=%ct", "HEAD"]) | |
| 97 | .and_then(|s| s.parse::<u64>().ok()) | |
| 98 | { | |
| 99 | Some(last) => (now_secs.saturating_sub(last) / 86_400) as f64, | |
| 100 | None => 0.0, | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | /// Run `git -C <repo> <args>` and return trimmed stdout on success. | |
| 105 | fn run_git(repo: &Path, args: &[&str]) -> Option<String> { | |
| 106 | let out = Command::new("git") | |
| 107 | .arg("-C") | |
| 108 | .arg(repo) | |
| 109 | .args(args) | |
| 110 | .output() | |
| 111 | .ok()?; | |
| 112 | if !out.status.success() { | |
| 113 | return None; | |
| 114 | } | |
| 115 | Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) | |
| 116 | } | |
| 117 | } | |
| 118 | ||
| 119 | /// Terminal collector (the sensitive one): **no input tap, no PTY sniff.** | |
| 120 | /// | |
| 121 | /// The shell side (`shell-hooks/signald-hooks.zsh`) counts keystrokes with a | |
| 122 | /// `zle` widget that increments a *number* and discards the key, and appends | |
| 123 | /// aggregate count records — `<epoch_ms> <keys> <session_seconds>`, numbers | |
| 124 | /// only — to a spool file on each `precmd`. This collector reads that spool | |
| 125 | /// and derives [`SignalName::KeysPerMin`] and [`SignalName::SessionSeconds`]. | |
| 126 | /// It parses numbers; a line that is not three integers is dropped, so the | |
| 127 | /// spool can carry nothing but counts (the privacy contract, enforced by the | |
| 128 | /// differential secret-typing test). | |
| 129 | pub mod terminal { | |
| 130 | use std::path::Path; | |
| 131 | ||
| 132 | use signal_schema::{Signal, SignalName, Source, Value, SCHEMA_VERSION}; | |
| 133 | ||
| 134 | use super::super::now_millis; | |
| 135 | ||
| 136 | /// One aggregate flush parsed from the spool: a timestamp and counts. | |
| 137 | /// Every field is a number — there is nowhere to put content. | |
| 138 | #[derive(Debug, Clone, Copy, PartialEq)] | |
| 139 | pub struct Flush { | |
| 140 | pub ts_ms: u64, | |
| 141 | pub keys: u64, | |
| 142 | pub session_s: u64, | |
| 143 | } | |
| 144 | ||
| 145 | /// Parse one spool line. Returns `None` for anything that is not exactly | |
| 146 | /// three integers, so non-count lines can never survive into a signal. | |
| 147 | pub fn parse_flush(line: &str) -> Option<Flush> { | |
| 148 | let mut it = line.split_whitespace(); | |
| 149 | let ts_ms = it.next()?.parse().ok()?; | |
| 150 | let keys = it.next()?.parse().ok()?; | |
| 151 | let session_s = it.next()?.parse().ok()?; | |
| 152 | if it.next().is_some() { | |
| 153 | return None; // extra fields => not a well-formed count record | |
| 154 | } | |
| 155 | Some(Flush { | |
| 156 | ts_ms, | |
| 157 | keys, | |
| 158 | session_s, | |
| 159 | }) | |
| 160 | } | |
| 161 | ||
| 162 | /// Read the spool and derive the current aggregate terminal signals. | |
| 163 | /// Returns an empty vec if the spool is missing or holds no count lines. | |
| 164 | pub fn collect(spool: &Path) -> Vec<Signal> { | |
| 165 | let text = match std::fs::read_to_string(spool) { | |
| 166 | Ok(t) => t, | |
| 167 | Err(_) => return Vec::new(), | |
| 168 | }; | |
| 169 | let flushes: Vec<Flush> = text.lines().filter_map(parse_flush).collect(); | |
| 170 | signals_from_flushes(&flushes) | |
| 171 | } | |
| 172 | ||
| 173 | /// Pure mapping from parsed flushes to schema signals (unit-testable). | |
| 174 | pub fn signals_from_flushes(flushes: &[Flush]) -> Vec<Signal> { | |
| 175 | let Some(last) = flushes.last() else { | |
| 176 | return Vec::new(); | |
| 177 | }; | |
| 178 | let ts = now_millis(); | |
| 179 | let mk = |name: SignalName, value: f64| Signal { | |
| 180 | schema_version: SCHEMA_VERSION, | |
| 181 | ts, | |
| 182 | source: Source::Terminal, | |
| 183 | name, | |
| 184 | value: Value(value), | |
| 185 | tag: None, // terminal aggregates are never tagged (spec §1.2) | |
| 186 | }; | |
| 187 | vec![ | |
| 188 | mk(SignalName::KeysPerMin, keys_per_min(flushes)), | |
| 189 | mk(SignalName::SessionSeconds, last.session_s as f64), | |
| 190 | ] | |
| 191 | } | |
| 192 | ||
| 193 | /// Keys/min from the most recent flush interval; falls back to the raw | |
| 194 | /// per-flush count when only one flush (or a zero interval) is available. | |
| 195 | fn keys_per_min(f: &[Flush]) -> f64 { | |
| 196 | if f.len() >= 2 { | |
| 197 | let a = &f[f.len() - 2]; | |
| 198 | let b = &f[f.len() - 1]; | |
| 199 | let dt_ms = b.ts_ms.saturating_sub(a.ts_ms); | |
| 200 | if dt_ms > 0 { | |
| 201 | return b.keys as f64 * 60_000.0 / dt_ms as f64; | |
| 202 | } | |
| 203 | } | |
| 204 | f.last().map(|x| x.keys as f64).unwrap_or(0.0) | |
| 205 | } | |
| 206 | } | |
| 207 | ||
| 208 | use signal_schema::Signal; | |
| 209 | ||
| 210 | /// System + hardware collector: **IOKit-only, no powermetrics, no root** | |
| 211 | /// (spec §1.4). Native macOS. | |
| 212 | /// | |
| 213 | /// v0.3: the real collector ships out-of-process as the sibling Swift package | |
| 214 | /// `macos-collector/` (SwiftPM, not in this cargo workspace), which reads | |
| 215 | /// aggregate hardware scalars via IOKit and emits `signal-schema` wire frames | |
| 216 | /// this daemon can parse (see `signal-schema/tests/hardware_wire.rs`). This | |
| 217 | /// in-process hook stays a stub: signald's live frame-ingest handshake for | |
| 218 | /// those frames is the next increment. | |
| 219 | pub fn system_hw_tick() -> Vec<Signal> { | |
| 220 | todo!("v0.3: hardware signals come from the sibling macos-collector Swift package") | |
| 221 | } | |
| 222 | } | |
| 223 | ||
| 224 | /// The publish side (spec §1.3): a live pub/sub fan-out with a last-value cache. | |
| 225 | /// | |
| 226 | /// On connect a subscriber is handed the current value of every cached `name` | |
| 227 | /// immediately (so a renderer paints correct state at once), then streams | |
| 228 | /// updates as they change. Each subscriber runs on its own thread with its own | |
| 229 | /// unbounded channel, so a slow subscriber never blocks the daemon (spec §1.3: | |
| 230 | /// "a stuck wallpaper process must not stall the audio thread's feed"). | |
| 231 | pub mod publish { | |
| 232 | use std::io; | |
| 233 | use std::io::Write; | |
| 234 | use std::os::unix::net::{UnixListener, UnixStream}; | |
| 235 | use std::path::Path; | |
| 236 | use std::thread; | |
| 237 | ||
| 238 | use signal_schema::wire; | |
| 239 | ||
| 240 | use crate::hub::Hub; | |
| 241 | ||
| 242 | /// Bind `socket_path` and serve the live stream from `hub` to each | |
| 243 | /// subscriber. Blocks, accepting connections until the listener errors. | |
| 244 | pub fn serve(socket_path: &Path, hub: Hub) -> io::Result<()> { | |
| 245 | // Clear any stale socket file from a previous run. | |
| 246 | let _ = std::fs::remove_file(socket_path); | |
| 247 | if let Some(parent) = socket_path.parent() { | |
| 248 | std::fs::create_dir_all(parent)?; | |
| 249 | } | |
| 250 | let listener = UnixListener::bind(socket_path)?; | |
| 251 | eprintln!("signald: listening on {}", socket_path.display()); | |
| 252 | ||
| 253 | for stream in listener.incoming() { | |
| 254 | match stream { | |
| 255 | Ok(stream) => { | |
| 256 | let hub = hub.clone(); | |
| 257 | // One subscriber per thread; a misbehaving one cannot take | |
| 258 | // the daemon or the other subscribers down. | |
| 259 | thread::spawn(move || { | |
| 260 | if let Err(e) = serve_subscriber(stream, hub) { | |
| 261 | eprintln!("signald: subscriber dropped: {e}"); | |
| 262 | } | |
| 263 | }); | |
| 264 | } | |
| 265 | Err(e) => eprintln!("signald: accept error: {e}"), | |
| 266 | } | |
| 267 | } | |
| 268 | Ok(()) | |
| 269 | } | |
| 270 | ||
| 271 | /// Replay the last-value cache to a freshly connected subscriber, then | |
| 272 | /// stream live updates until it disconnects. | |
| 273 | fn serve_subscriber(mut stream: UnixStream, hub: Hub) -> io::Result<()> { | |
| 274 | let (snapshot, rx) = hub.subscribe(); | |
| 275 | for sig in snapshot { | |
| 276 | wire::write_frame(&mut stream, &sig)?; | |
| 277 | } | |
| 278 | stream.flush()?; | |
| 279 | // `rx` yields every signal published after we subscribed. A write error | |
| 280 | // means the subscriber went away; returning drops `rx`, and the hub | |
| 281 | // prunes the dead sender on its next publish. | |
| 282 | for sig in rx.iter() { | |
| 283 | wire::write_frame(&mut stream, &sig)?; | |
| 284 | stream.flush()?; | |
| 285 | } | |
| 286 | Ok(()) | |
| 287 | } | |
| 288 | } | |
| 289 | ||
| 290 | /// Unix millis, best-effort (spec: monotonic-corrected later). | |
| 291 | pub fn now_millis() -> u64 { | |
| 292 | SystemTime::now() | |
| 293 | .duration_since(UNIX_EPOCH) | |
| 294 | .map(|d| d.as_millis() as u64) | |
| 295 | .unwrap_or(0) | |
| 296 | } | |
crates/signald/src/main.rs added +147
| @@ -0,0 +1,147 @@ | ||
| 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 (spec §1.3). | |
| 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 ┘ (stub, phase 3) | |
| 11 | //! ├─▶ publish: Unix socket (live) | |
| 12 | //! └─▶ SQLite WAL history | |
| 13 | //! ``` | |
| 14 | //! | |
| 15 | //! v0.2: the git and terminal collectors are live; the hub caches the latest | |
| 16 | //! value of every metric and streams updates to subscribers; every signal is | |
| 17 | //! persisted to a SQLite (WAL) history store. Collector internals live in the | |
| 18 | //! `signald` library crate; this binary is argument wiring, the producer loop, | |
| 19 | //! and self-attestation. | |
| 20 | //! | |
| 21 | //! Usage: | |
| 22 | //! ```text | |
| 23 | //! signald [--socket <path>] [--db <path>] [--spool <path>] | |
| 24 | //! [--interval-ms <n>] [<repo-path> ...] | |
| 25 | //! ``` | |
| 26 | //! With no repo paths, the current directory is watched. The socket defaults to | |
| 27 | //! `$XDG_RUNTIME_DIR/signald.sock` (fallback `~/.local/state/signald/sock`); the | |
| 28 | //! history db and terminal spool default alongside it. | |
| 29 | ||
| 30 | use std::path::PathBuf; | |
| 31 | use std::thread; | |
| 32 | use std::time::Duration; | |
| 33 | ||
| 34 | use signal_schema::Source; | |
| 35 | use signald::collectors; | |
| 36 | use signald::history::History; | |
| 37 | use signald::hub::Hub; | |
| 38 | use signald::publish; | |
| 39 | ||
| 40 | struct Config { | |
| 41 | socket: PathBuf, | |
| 42 | db: PathBuf, | |
| 43 | spool: PathBuf, | |
| 44 | interval: Duration, | |
| 45 | repos: Vec<PathBuf>, | |
| 46 | } | |
| 47 | ||
| 48 | fn main() { | |
| 49 | print_self_attestation(); | |
| 50 | let cfg = parse_args(); | |
| 51 | ||
| 52 | let history = match History::open(&cfg.db) { | |
| 53 | Ok(h) => { | |
| 54 | eprintln!("signald: history at {}", cfg.db.display()); | |
| 55 | h | |
| 56 | } | |
| 57 | Err(e) => { | |
| 58 | eprintln!("signald: fatal: cannot open history db {}: {e}", cfg.db.display()); | |
| 59 | std::process::exit(1); | |
| 60 | } | |
| 61 | }; | |
| 62 | let hub = Hub::with_history(history); | |
| 63 | ||
| 64 | // Producer: poll the collectors on a tick and publish into the hub. Runs | |
| 65 | // for the life of the daemon, independent of any subscriber. | |
| 66 | let producer = hub.clone(); | |
| 67 | let repos = cfg.repos.clone(); | |
| 68 | let spool = cfg.spool.clone(); | |
| 69 | let interval = cfg.interval; | |
| 70 | thread::spawn(move || loop { | |
| 71 | for repo in &repos { | |
| 72 | for sig in collectors::git::collect(repo) { | |
| 73 | producer.publish(sig); | |
| 74 | } | |
| 75 | } | |
| 76 | for sig in collectors::terminal::collect(&spool) { | |
| 77 | producer.publish(sig); | |
| 78 | } | |
| 79 | thread::sleep(interval); | |
| 80 | }); | |
| 81 | ||
| 82 | eprintln!("signald: watching {} repo(s), spool {}", cfg.repos.len(), cfg.spool.display()); | |
| 83 | if let Err(e) = publish::serve(&cfg.socket, hub) { | |
| 84 | eprintln!("signald: fatal: {e}"); | |
| 85 | std::process::exit(1); | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | fn parse_args() -> Config { | |
| 90 | let mut socket: Option<PathBuf> = None; | |
| 91 | let mut db: Option<PathBuf> = None; | |
| 92 | let mut spool: Option<PathBuf> = None; | |
| 93 | let mut interval_ms: u64 = 2000; | |
| 94 | let mut repos: Vec<PathBuf> = Vec::new(); | |
| 95 | ||
| 96 | let mut args = std::env::args().skip(1); | |
| 97 | while let Some(arg) = args.next() { | |
| 98 | match arg.as_str() { | |
| 99 | "--socket" => socket = args.next().map(PathBuf::from), | |
| 100 | "--db" => db = args.next().map(PathBuf::from), | |
| 101 | "--spool" => spool = args.next().map(PathBuf::from), | |
| 102 | "--interval-ms" => { | |
| 103 | interval_ms = args.next().and_then(|s| s.parse().ok()).unwrap_or(interval_ms) | |
| 104 | } | |
| 105 | _ => repos.push(PathBuf::from(arg)), | |
| 106 | } | |
| 107 | } | |
| 108 | ||
| 109 | if repos.is_empty() { | |
| 110 | repos.push(std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); | |
| 111 | } | |
| 112 | let socket = socket.unwrap_or_else(default_socket_path); | |
| 113 | let base = socket.parent().map(PathBuf::from).unwrap_or_default(); | |
| 114 | Config { | |
| 115 | db: db.unwrap_or_else(|| base.join("signald.sqlite")), | |
| 116 | spool: spool.unwrap_or_else(|| base.join("terminal.spool")), | |
| 117 | interval: Duration::from_millis(interval_ms), | |
| 118 | repos, | |
| 119 | socket, | |
| 120 | } | |
| 121 | } | |
| 122 | ||
| 123 | /// `$XDG_RUNTIME_DIR/signald.sock`, else `~/.local/state/signald/sock`. | |
| 124 | fn default_socket_path() -> PathBuf { | |
| 125 | if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { | |
| 126 | return PathBuf::from(dir).join("signald.sock"); | |
| 127 | } | |
| 128 | let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); | |
| 129 | PathBuf::from(home).join(".local/state/signald/sock") | |
| 130 | } | |
| 131 | ||
| 132 | /// Log enabled collectors and assert none holds an input-tap capability. A real | |
| 133 | /// keylogger would need one of the forbidden APIs; their absence is the point, | |
| 134 | /// and this is the runtime half of that guarantee (spec §1.5). | |
| 135 | fn print_self_attestation() { | |
| 136 | eprintln!("signald {} — self-attestation", env!("CARGO_PKG_VERSION")); | |
| 137 | eprintln!(" transport: unix socket (length-prefixed frames), live pub/sub"); | |
| 138 | eprintln!(" history: sqlite (WAL), aggregate scalars only"); | |
| 139 | for source in [Source::Terminal, Source::Git, Source::Macos, Source::Hardware] { | |
| 140 | let state = match source { | |
| 141 | Source::Git => "ENABLED (aggregate scalars only)", | |
| 142 | Source::Terminal => "ENABLED (aggregate counts from zsh spool; no input tap)", | |
| 143 | Source::Macos | Source::Hardware => "stub (phase 3)", | |
| 144 | }; | |
| 145 | eprintln!(" collector {source:?}: input-tap capability = NONE — {state}"); | |
| 146 | } | |
| 147 | } | |
crates/signald/tests/differential_secret_typing.rs added +240
| @@ -0,0 +1,240 @@ | ||
| 1 | //! # The differential secret-typing test — the privacy ship-gate (spec §1.5, §5) | |
| 2 | //! | |
| 3 | //! This is the acceptance test for the whole terminal collector, and the single | |
| 4 | //! most important test in the project. It drives the **real production hook** | |
| 5 | //! (`shell-hooks/signald-hooks.zsh`) through a **real interactive zsh under a | |
| 6 | //! real pseudo-terminal** (using zsh's own `zsh/zpty` module, so there is no | |
| 7 | //! Python/libc dependency), *typing a known secret string* the way a person | |
| 8 | //! would type it at a prompt. | |
| 9 | //! | |
| 10 | //! It then runs the exact daemon write path — the terminal [`collect`]or, the | |
| 11 | //! [`Hub`] (last-value cache + fan-out), and the SQLite [`History`] store — and | |
| 12 | //! asserts the secret **never appears**, in any of {plain, reversed, hex, | |
| 13 | //! base64}, in: | |
| 14 | //! * the shell spool the hook writes, | |
| 15 | //! * the wire encoding of every emitted signal, | |
| 16 | //! * the SQLite history files and the `recent()` query result, | |
| 17 | //! * the in-memory hub snapshot. | |
| 18 | //! | |
| 19 | //! Why this proves no content leaks: real `zle` keystroke counting increments a | |
| 20 | //! *number* per key and discards the key; the hook flushes only that number. | |
| 21 | //! So the only thing the whole pipeline ever receives about the typing is a | |
| 22 | //! count — there is no channel through which the characters could travel. The | |
| 23 | //! test types the secret for real and checks every downstream artifact to prove | |
| 24 | //! that empirically, not just by inspection. | |
| 25 | ||
| 26 | use std::path::{Path, PathBuf}; | |
| 27 | use std::process::Command; | |
| 28 | ||
| 29 | use signal_schema::{wire, SignalName}; | |
| 30 | use signald::collectors::terminal; | |
| 31 | use signald::history::History; | |
| 32 | use signald::hub::Hub; | |
| 33 | ||
| 34 | /// A distinctive planted secret — long and non-dictionary so an incidental | |
| 35 | /// byte-collision in a binary artifact is not credible. | |
| 36 | const SECRET: &str = "hunter2-CorrectHorseBatteryStaple-9f3a-SUPERSECRET"; | |
| 37 | ||
| 38 | #[test] | |
| 39 | fn secret_typed_at_prompt_never_reaches_any_output() { | |
| 40 | let harness = TempDir::new("ambient-differential"); | |
| 41 | let spool = harness.path().join("terminal.spool"); | |
| 42 | let db = harness.path().join("signald.sqlite"); | |
| 43 | ||
| 44 | // 1. Type the secret through the real hook + real zle under a real pty. | |
| 45 | let spool_bytes = drive_real_hook(&harness, &spool) | |
| 46 | .expect("zsh/zpty harness must run (this is the privacy ship-gate)"); | |
| 47 | ||
| 48 | // The path must genuinely have processed the typing: at least one flush | |
| 49 | // must have counted as many keys as the secret is long (it was typed after | |
| 50 | // `echo `). Otherwise we'd be "proving" absence over an empty run. | |
| 51 | let flushes: Vec<terminal::Flush> = String::from_utf8_lossy(&spool_bytes) | |
| 52 | .lines() | |
| 53 | .filter_map(terminal::parse_flush) | |
| 54 | .collect(); | |
| 55 | let max_keys = flushes.iter().map(|f| f.keys).max().unwrap_or(0); | |
| 56 | assert!( | |
| 57 | max_keys >= SECRET.chars().count() as u64, | |
| 58 | "the secret does not appear to have been typed through the collector \ | |
| 59 | (max keys counted = {max_keys}, secret length = {})", | |
| 60 | SECRET.chars().count() | |
| 61 | ); | |
| 62 | ||
| 63 | // 2. Run the exact daemon write path: collector -> hub(+history). | |
| 64 | let signals = terminal::collect(&spool); | |
| 65 | assert!(!signals.is_empty(), "terminal collector produced no signals"); | |
| 66 | assert!(signals.iter().any(|s| s.name == SignalName::KeysPerMin)); | |
| 67 | assert!(signals.iter().any(|s| s.name == SignalName::SessionSeconds)); | |
| 68 | ||
| 69 | let hub = Hub::with_history(History::open(&db).unwrap()); | |
| 70 | let mut wire_bytes = Vec::new(); | |
| 71 | for s in &signals { | |
| 72 | wire_bytes.extend_from_slice(&wire::encode(s)); | |
| 73 | hub.publish(s.clone()); | |
| 74 | } | |
| 75 | ||
| 76 | // 3. Read history back through the real query a renderer would use. | |
| 77 | let read_back = History::open(&db).unwrap().recent(1000).unwrap(); | |
| 78 | assert!(!read_back.is_empty(), "history query returned nothing"); | |
| 79 | ||
| 80 | // 4. Gather every artifact and scan them all for the secret, in any encoding. | |
| 81 | let mut artifacts: Vec<(&str, Vec<u8>)> = Vec::new(); | |
| 82 | artifacts.push(("shell spool", spool_bytes.clone())); | |
| 83 | artifacts.push(("wire encoding", wire_bytes)); | |
| 84 | artifacts.push(("collected signals (debug)", format!("{signals:?}").into_bytes())); | |
| 85 | artifacts.push(("hub snapshot (debug)", format!("{:?}", hub.snapshot()).into_bytes())); | |
| 86 | artifacts.push(("history recent() (debug)", format!("{read_back:?}").into_bytes())); | |
| 87 | // Every on-disk SQLite file (main db + -wal + -shm). | |
| 88 | for f in sqlite_files(&db) { | |
| 89 | let bytes = std::fs::read(&f).unwrap_or_default(); | |
| 90 | artifacts.push(("sqlite file", bytes)); | |
| 91 | } | |
| 92 | ||
| 93 | let needles = secret_encodings(SECRET); | |
| 94 | for (label, bytes) in &artifacts { | |
| 95 | for (enc, needle) in &needles { | |
| 96 | assert!( | |
| 97 | !contains(bytes, needle), | |
| 98 | "SECRET LEAK: found the secret ({enc}) in `{label}` — the terminal \ | |
| 99 | collector must emit aggregate counts only" | |
| 100 | ); | |
| 101 | } | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | // --- the zsh/zpty typing harness --- | |
| 106 | ||
| 107 | /// Write a zpty driver, run it under `zsh`, and return the spool it produced. | |
| 108 | /// The driver spawns an interactive zsh under a pty, sources the real hook, and | |
| 109 | /// types the secret as `echo` arguments — real keystrokes through real `zle`. | |
| 110 | fn drive_real_hook(harness: &TempDir, spool: &Path) -> Option<Vec<u8>> { | |
| 111 | let hooks = repo_root().join("shell-hooks/signald-hooks.zsh"); | |
| 112 | assert!(hooks.exists(), "production hook missing at {}", hooks.display()); | |
| 113 | let driver = harness.path().join("driver.zsh"); | |
| 114 | std::fs::write(&driver, DRIVER).ok()?; | |
| 115 | // Ensure a clean spool. | |
| 116 | let _ = std::fs::write(spool, b""); | |
| 117 | ||
| 118 | let status = Command::new("zsh") | |
| 119 | .arg("-f") | |
| 120 | .arg(&driver) | |
| 121 | .env("SIGNALD_SPOOL", spool) | |
| 122 | .env("HOOKS", &hooks) | |
| 123 | .env("SECRET", SECRET) | |
| 124 | .status() | |
| 125 | .ok()?; | |
| 126 | if !status.success() { | |
| 127 | return None; | |
| 128 | } | |
| 129 | std::fs::read(spool).ok().filter(|b| !b.is_empty()) | |
| 130 | } | |
| 131 | ||
| 132 | /// The pty driver. `zsh/zpty` gives us a real pseudo-terminal from within zsh — | |
| 133 | /// no external dependency. We drain output between writes so the inner shell | |
| 134 | /// makes progress, and type the secret as ordinary command-line input. | |
| 135 | const DRIVER: &str = r#" | |
| 136 | zmodload zsh/zpty || exit 3 | |
| 137 | zpty SH zsh -f -i || exit 4 | |
| 138 | drain() { local x; while zpty -r -t SH x 2>/dev/null; do :; done } | |
| 139 | sleep 0.4; drain | |
| 140 | zpty -w SH "source $HOOKS" | |
| 141 | sleep 0.3; drain | |
| 142 | zpty -w SH "echo $SECRET" | |
| 143 | sleep 0.5; drain | |
| 144 | zpty -w SH "echo typed $SECRET twice $SECRET" | |
| 145 | sleep 0.5; drain | |
| 146 | zpty -w SH "exit" | |
| 147 | sleep 0.3 | |
| 148 | zpty -d SH 2>/dev/null | |
| 149 | "#; | |
| 150 | ||
| 151 | // --- encodings + scanning --- | |
| 152 | ||
| 153 | /// The secret in every representation the spec calls out (plain, reversed, hex, | |
| 154 | /// base64), as raw byte needles to search for. | |
| 155 | fn secret_encodings(secret: &str) -> Vec<(&'static str, Vec<u8>)> { | |
| 156 | let b = secret.as_bytes(); | |
| 157 | let reversed: Vec<u8> = b.iter().rev().copied().collect(); | |
| 158 | vec![ | |
| 159 | ("plain", b.to_vec()), | |
| 160 | ("reversed", reversed), | |
| 161 | ("hex", hex(b).into_bytes()), | |
| 162 | ("base64", base64(b).into_bytes()), | |
| 163 | ] | |
| 164 | } | |
| 165 | ||
| 166 | fn contains(hay: &[u8], needle: &[u8]) -> bool { | |
| 167 | if needle.is_empty() || hay.len() < needle.len() { | |
| 168 | return false; | |
| 169 | } | |
| 170 | hay.windows(needle.len()).any(|w| w == needle) | |
| 171 | } | |
| 172 | ||
| 173 | fn hex(bytes: &[u8]) -> String { | |
| 174 | let mut s = String::with_capacity(bytes.len() * 2); | |
| 175 | for b in bytes { | |
| 176 | s.push_str(&format!("{b:02x}")); | |
| 177 | } | |
| 178 | s | |
| 179 | } | |
| 180 | ||
| 181 | fn base64(bytes: &[u8]) -> String { | |
| 182 | const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; | |
| 183 | let mut out = String::new(); | |
| 184 | for chunk in bytes.chunks(3) { | |
| 185 | let b0 = chunk[0] as u32; | |
| 186 | let b1 = *chunk.get(1).unwrap_or(&0) as u32; | |
| 187 | let b2 = *chunk.get(2).unwrap_or(&0) as u32; | |
| 188 | let n = (b0 << 16) | (b1 << 8) | b2; | |
| 189 | out.push(T[(n >> 18 & 63) as usize] as char); | |
| 190 | out.push(T[(n >> 12 & 63) as usize] as char); | |
| 191 | out.push(if chunk.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' }); | |
| 192 | out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' }); | |
| 193 | } | |
| 194 | out | |
| 195 | } | |
| 196 | ||
| 197 | // --- helpers --- | |
| 198 | ||
| 199 | fn sqlite_files(db: &Path) -> Vec<PathBuf> { | |
| 200 | let mut v = vec![db.to_path_buf()]; | |
| 201 | for suffix in ["-wal", "-shm"] { | |
| 202 | let mut p = db.as_os_str().to_os_string(); | |
| 203 | p.push(suffix); | |
| 204 | v.push(PathBuf::from(p)); | |
| 205 | } | |
| 206 | v.into_iter().filter(|p| p.exists()).collect() | |
| 207 | } | |
| 208 | ||
| 209 | fn repo_root() -> PathBuf { | |
| 210 | // CARGO_MANIFEST_DIR = <root>/crates/signald | |
| 211 | PathBuf::from(env!("CARGO_MANIFEST_DIR")) | |
| 212 | .join("../..") | |
| 213 | .canonicalize() | |
| 214 | .expect("canonicalize repo root") | |
| 215 | } | |
| 216 | ||
| 217 | struct TempDir { | |
| 218 | dir: PathBuf, | |
| 219 | } | |
| 220 | ||
| 221 | impl TempDir { | |
| 222 | fn new(prefix: &str) -> TempDir { | |
| 223 | let nanos = std::time::SystemTime::now() | |
| 224 | .duration_since(std::time::UNIX_EPOCH) | |
| 225 | .unwrap() | |
| 226 | .as_nanos(); | |
| 227 | let dir = std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id())); | |
| 228 | std::fs::create_dir_all(&dir).unwrap(); | |
| 229 | TempDir { dir } | |
| 230 | } | |
| 231 | fn path(&self) -> &Path { | |
| 232 | &self.dir | |
| 233 | } | |
| 234 | } | |
| 235 | ||
| 236 | impl Drop for TempDir { | |
| 237 | fn drop(&mut self) { | |
| 238 | let _ = std::fs::remove_dir_all(&self.dir); | |
| 239 | } | |
| 240 | } | |
crates/signald/tests/git_collector.rs added +126
| @@ -0,0 +1,126 @@ | ||
| 1 | //! Git collector acceptance test (spec §5 Phase 1). | |
| 2 | //! | |
| 3 | //! Builds a real temp git repo, drives the collector against it, and asserts the | |
| 4 | //! aggregate scalars — commits-in-window, commits-today, branch count, and | |
| 5 | //! days-since-last-commit — match the repo's true state. Also covers the stale | |
| 6 | //! repo (an old commit) that the garden renders as wilt. | |
| 7 | ||
| 8 | use std::path::{Path, PathBuf}; | |
| 9 | use std::process::Command; | |
| 10 | ||
| 11 | use signal_schema::{Signal, SignalName}; | |
| 12 | use signald::collectors::git; | |
| 13 | ||
| 14 | #[test] | |
| 15 | fn fresh_repo_reports_today_commits_and_no_staleness() { | |
| 16 | let repo = TempRepo::init(); | |
| 17 | repo.commit_now("first"); | |
| 18 | repo.commit_now("second"); | |
| 19 | repo.commit_now("third"); | |
| 20 | ||
| 21 | let signals = git::collect(repo.path()); | |
| 22 | ||
| 23 | assert_eq!(value(&signals, SignalName::CommitsToday), 3.0); | |
| 24 | assert_eq!(value(&signals, SignalName::CommitsWindow), 3.0); | |
| 25 | assert_eq!(value(&signals, SignalName::BranchCount), 1.0); | |
| 26 | assert_eq!(value(&signals, SignalName::DaysSinceLastCommit), 0.0); | |
| 27 | } | |
| 28 | ||
| 29 | #[test] | |
| 30 | fn stale_repo_reports_zero_recent_commits_and_high_staleness() { | |
| 31 | let repo = TempRepo::init(); | |
| 32 | repo.commit_days_ago("ancient", 40); | |
| 33 | ||
| 34 | let signals = git::collect(repo.path()); | |
| 35 | ||
| 36 | assert_eq!(value(&signals, SignalName::CommitsToday), 0.0); | |
| 37 | assert_eq!(value(&signals, SignalName::CommitsWindow), 0.0); | |
| 38 | let stale = value(&signals, SignalName::DaysSinceLastCommit); | |
| 39 | assert!(stale >= 39.0, "expected ~40 days stale, got {stale}"); | |
| 40 | } | |
| 41 | ||
| 42 | #[test] | |
| 43 | fn non_git_directory_yields_no_signals() { | |
| 44 | let dir = unique_dir("ambient-nongit"); | |
| 45 | std::fs::create_dir_all(&dir).unwrap(); | |
| 46 | let signals = git::collect(&dir); | |
| 47 | assert!(signals.is_empty()); | |
| 48 | let _ = std::fs::remove_dir_all(&dir); | |
| 49 | } | |
| 50 | ||
| 51 | // --- helpers --- | |
| 52 | ||
| 53 | fn value(signals: &[Signal], name: SignalName) -> f64 { | |
| 54 | signals | |
| 55 | .iter() | |
| 56 | .find(|s| s.name == name) | |
| 57 | .unwrap_or_else(|| panic!("signal {name:?} missing")) | |
| 58 | .value | |
| 59 | .0 | |
| 60 | } | |
| 61 | ||
| 62 | struct TempRepo { | |
| 63 | dir: PathBuf, | |
| 64 | } | |
| 65 | ||
| 66 | impl TempRepo { | |
| 67 | fn init() -> TempRepo { | |
| 68 | let dir = unique_dir("ambient-gitcollector"); | |
| 69 | std::fs::create_dir_all(&dir).unwrap(); | |
| 70 | run(&dir, &["init", "-q"], &[]); | |
| 71 | // Local identity so commits work without global git config. | |
| 72 | run(&dir, &["config", "user.email", "test@example.com"], &[]); | |
| 73 | run(&dir, &["config", "user.name", "Test"], &[]); | |
| 74 | TempRepo { dir } | |
| 75 | } | |
| 76 | ||
| 77 | fn path(&self) -> &Path { | |
| 78 | &self.dir | |
| 79 | } | |
| 80 | ||
| 81 | fn commit_now(&self, msg: &str) { | |
| 82 | run(&self.dir, &["commit", "--allow-empty", "-q", "-m", msg], &[]); | |
| 83 | } | |
| 84 | ||
| 85 | fn commit_days_ago(&self, msg: &str, days: u64) { | |
| 86 | let secs = std::time::SystemTime::now() | |
| 87 | .duration_since(std::time::UNIX_EPOCH) | |
| 88 | .unwrap() | |
| 89 | .as_secs() | |
| 90 | - days * 86_400; | |
| 91 | let date = format!("{secs} +0000"); | |
| 92 | run( | |
| 93 | &self.dir, | |
| 94 | &["commit", "--allow-empty", "-q", "-m", msg], | |
| 95 | &[("GIT_AUTHOR_DATE", &date), ("GIT_COMMITTER_DATE", &date)], | |
| 96 | ); | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | impl Drop for TempRepo { | |
| 101 | fn drop(&mut self) { | |
| 102 | let _ = std::fs::remove_dir_all(&self.dir); | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | fn run(repo: &Path, args: &[&str], envs: &[(&str, &str)]) { | |
| 107 | let mut cmd = Command::new("git"); | |
| 108 | cmd.arg("-C").arg(repo).args(args); | |
| 109 | for (k, v) in envs { | |
| 110 | cmd.env(k, v); | |
| 111 | } | |
| 112 | let out = cmd.output().expect("run git"); | |
| 113 | assert!( | |
| 114 | out.status.success(), | |
| 115 | "git {args:?} failed: {}", | |
| 116 | String::from_utf8_lossy(&out.stderr) | |
| 117 | ); | |
| 118 | } | |
| 119 | ||
| 120 | fn unique_dir(prefix: &str) -> PathBuf { | |
| 121 | let nanos = std::time::SystemTime::now() | |
| 122 | .duration_since(std::time::UNIX_EPOCH) | |
| 123 | .unwrap() | |
| 124 | .as_nanos(); | |
| 125 | std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id())) | |
| 126 | } | |
crates/signald/tests/streaming.rs added +75
| @@ -0,0 +1,75 @@ | ||
| 1 | //! Live-streaming acceptance test (spec §1.3): a subscriber gets the current | |
| 2 | //! value of each metric immediately on connect (last-value cache), then receives | |
| 3 | //! updates as values change — over the real Unix socket, not just the hub. | |
| 4 | ||
| 5 | use std::io::BufReader; | |
| 6 | use std::os::unix::net::UnixStream; | |
| 7 | use std::path::{Path, PathBuf}; | |
| 8 | use std::thread; | |
| 9 | use std::time::{Duration, Instant}; | |
| 10 | ||
| 11 | use signal_schema::{wire, Signal, SignalName, Source, Value, SCHEMA_VERSION}; | |
| 12 | use signald::hub::Hub; | |
| 13 | use signald::publish; | |
| 14 | ||
| 15 | fn sig(name: SignalName, value: f64) -> Signal { | |
| 16 | Signal { | |
| 17 | schema_version: SCHEMA_VERSION, | |
| 18 | ts: 1, | |
| 19 | source: Source::Terminal, | |
| 20 | name, | |
| 21 | value: Value(value), | |
| 22 | tag: None, | |
| 23 | } | |
| 24 | } | |
| 25 | ||
| 26 | #[test] | |
| 27 | fn subscriber_sees_cached_value_then_a_live_update() { | |
| 28 | let socket = unique_socket(); | |
| 29 | let hub = Hub::new(); | |
| 30 | ||
| 31 | // A value published before anyone connects must still reach a subscriber | |
| 32 | // (the last-value cache). | |
| 33 | hub.publish(sig(SignalName::KeysPerMin, 12.0)); | |
| 34 | ||
| 35 | let serve_hub = hub.clone(); | |
| 36 | let serve_socket = socket.clone(); | |
| 37 | thread::spawn(move || { | |
| 38 | let _ = publish::serve(&serve_socket, serve_hub); | |
| 39 | }); | |
| 40 | ||
| 41 | let stream = connect(&socket); | |
| 42 | let mut reader = BufReader::new(stream); | |
| 43 | ||
| 44 | // 1. On connect: the cached snapshot arrives immediately. | |
| 45 | let first = wire::read_frame(&mut reader).unwrap().expect("snapshot frame"); | |
| 46 | assert_eq!(first.name, SignalName::KeysPerMin); | |
| 47 | assert_eq!(first.value, Value(12.0)); | |
| 48 | ||
| 49 | // 2. A change after subscribing is streamed live. | |
| 50 | hub.publish(sig(SignalName::KeysPerMin, 99.0)); | |
| 51 | let update = wire::read_frame(&mut reader).unwrap().expect("live update frame"); | |
| 52 | assert_eq!(update.name, SignalName::KeysPerMin); | |
| 53 | assert_eq!(update.value, Value(99.0)); | |
| 54 | ||
| 55 | let _ = std::fs::remove_file(&socket); | |
| 56 | } | |
| 57 | ||
| 58 | fn connect(socket: &Path) -> UnixStream { | |
| 59 | let deadline = Instant::now() + Duration::from_secs(5); | |
| 60 | loop { | |
| 61 | if let Ok(s) = UnixStream::connect(socket) { | |
| 62 | return s; | |
| 63 | } | |
| 64 | assert!(Instant::now() < deadline, "signald socket never came up"); | |
| 65 | thread::sleep(Duration::from_millis(20)); | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | fn unique_socket() -> PathBuf { | |
| 70 | let nanos = std::time::SystemTime::now() | |
| 71 | .duration_since(std::time::UNIX_EPOCH) | |
| 72 | .unwrap() | |
| 73 | .as_nanos(); | |
| 74 | std::env::temp_dir().join(format!("signald-stream-{}-{nanos}.sock", std::process::id())) | |
| 75 | } | |
crates/terminal-garden/Cargo.toml added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | [package] | |
| 2 | name = "terminal-garden" | |
| 3 | version.workspace = true | |
| 4 | edition.workspace = true | |
| 5 | license.workspace = true | |
| 6 | description = "First renderer (spec build order §3): a TUI garden that grows with commits and wilts on stale branches. A thin subscriber to signald — it reads git aggregates off the socket and never touches a sensor." | |
| 7 | ||
| 8 | [[bin]] | |
| 9 | name = "terminal-garden" | |
| 10 | path = "src/main.rs" | |
| 11 | ||
| 12 | [dependencies] | |
| 13 | signal-schema = { path = "../signal-schema" } | |
crates/terminal-garden/src/lib.rs added +237
| @@ -0,0 +1,237 @@ | ||
| 1 | //! # terminal-garden (library) | |
| 2 | //! | |
| 3 | //! The pure garden model: signals in, a rendered garden out. Kept as a library | |
| 4 | //! so the mapping is unit-testable independently of the socket subscriber in | |
| 5 | //! `src/main.rs`. | |
| 6 | //! | |
| 7 | //! Each watched repo is a plot; growth stage is a function of recent commit | |
| 8 | //! activity and health (wilt) is a function of days-since-last-commit | |
| 9 | //! (spec §2.2). A plant advances 🌱→🌿→🌳 as commits land, and droops then | |
| 10 | //! browns as a repo goes stale. | |
| 11 | ||
| 12 | use std::collections::BTreeMap; | |
| 13 | ||
| 14 | use signal_schema::{Signal, SignalName}; | |
| 15 | ||
| 16 | /// Growth stage, driven by recent commit volume. `Ord` is meaningful: more | |
| 17 | /// commits never yields a lower stage. | |
| 18 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] | |
| 19 | pub enum Stage { | |
| 20 | Seed, | |
| 21 | Sprout, | |
| 22 | Sapling, | |
| 23 | Tree, | |
| 24 | } | |
| 25 | ||
| 26 | impl Stage { | |
| 27 | /// Stage from commits within the collector window (spec: growth tracks | |
| 28 | /// cumulative recent commit activity). | |
| 29 | pub fn from_commits(commits_window: f64) -> Stage { | |
| 30 | match commits_window as u64 { | |
| 31 | 0 => Stage::Seed, | |
| 32 | 1..=2 => Stage::Sprout, | |
| 33 | 3..=6 => Stage::Sapling, | |
| 34 | _ => Stage::Tree, | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | fn glyph(self) -> &'static str { | |
| 39 | match self { | |
| 40 | Stage::Seed => ".", | |
| 41 | Stage::Sprout => "\u{1F331}", // 🌱 | |
| 42 | Stage::Sapling => "\u{1F33F}", // 🌿 | |
| 43 | Stage::Tree => "\u{1F333}", // 🌳 | |
| 44 | } | |
| 45 | } | |
| 46 | } | |
| 47 | ||
| 48 | /// Health (wilt), driven by staleness. `Ord` is meaningful: more days stale | |
| 49 | /// never yields a healthier state. | |
| 50 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] | |
| 51 | pub enum Health { | |
| 52 | Fresh, | |
| 53 | Dry, | |
| 54 | Wilting, | |
| 55 | Withered, | |
| 56 | } | |
| 57 | ||
| 58 | impl Health { | |
| 59 | /// Health from whole days since the last commit. | |
| 60 | pub fn from_days_since(days_since: f64) -> Health { | |
| 61 | match days_since as u64 { | |
| 62 | 0..=1 => Health::Fresh, | |
| 63 | 2..=6 => Health::Dry, | |
| 64 | 7..=13 => Health::Wilting, | |
| 65 | _ => Health::Withered, | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | fn label(self) -> &'static str { | |
| 70 | match self { | |
| 71 | Health::Fresh => "fresh", | |
| 72 | Health::Dry => "dry", | |
| 73 | Health::Wilting => "wilting", | |
| 74 | Health::Withered => "withered", | |
| 75 | } | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | /// One repo's rendered plot. | |
| 80 | #[derive(Debug, Clone, PartialEq)] | |
| 81 | pub struct Plot { | |
| 82 | pub repo: String, | |
| 83 | pub commits_window: f64, | |
| 84 | pub commits_today: f64, | |
| 85 | pub branch_count: f64, | |
| 86 | pub days_since: f64, | |
| 87 | } | |
| 88 | ||
| 89 | impl Plot { | |
| 90 | pub fn stage(&self) -> Stage { | |
| 91 | Stage::from_commits(self.commits_window) | |
| 92 | } | |
| 93 | ||
| 94 | pub fn health(&self) -> Health { | |
| 95 | Health::from_days_since(self.days_since) | |
| 96 | } | |
| 97 | ||
| 98 | /// The glyph shown for the plant: a withered plot browns regardless of how | |
| 99 | /// tall it once grew; a wilting one droops; otherwise it shows its stage. | |
| 100 | pub fn glyph(&self) -> &'static str { | |
| 101 | match self.health() { | |
| 102 | Health::Withered => "\u{1F342}", // 🍂 | |
| 103 | Health::Wilting => "\u{1F940}", // 🥀 | |
| 104 | _ => self.stage().glyph(), | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | fn render_line(&self) -> String { | |
| 109 | format!( | |
| 110 | " {glyph} {repo:<24} stage={stage:<7} health={health:<8} \ | |
| 111 | (commits: {win} in window, {today} today | branches: {branches} | last commit {days}d ago)", | |
| 112 | glyph = self.glyph(), | |
| 113 | repo = short_name(&self.repo), | |
| 114 | stage = format!("{:?}", self.stage()).to_lowercase(), | |
| 115 | health = self.health().label(), | |
| 116 | win = self.commits_window as u64, | |
| 117 | today = self.commits_today as u64, | |
| 118 | branches = self.branch_count as u64, | |
| 119 | days = self.days_since as u64, | |
| 120 | ) | |
| 121 | } | |
| 122 | } | |
| 123 | ||
| 124 | /// Fold a snapshot of signals into one plot per repo (keyed by the audited repo | |
| 125 | /// tag; untagged signals collapse into a single unnamed plot). Later git names | |
| 126 | /// (`Commits5m`, diff-line counts, dirty-worktree) are simply ignored here. | |
| 127 | pub fn plots_from_signals(signals: &[Signal]) -> Vec<Plot> { | |
| 128 | let mut by_repo: BTreeMap<String, Plot> = BTreeMap::new(); | |
| 129 | ||
| 130 | for s in signals { | |
| 131 | let key = s.tag.as_ref().map(|t| t.as_str().to_string()).unwrap_or_default(); | |
| 132 | let plot = by_repo.entry(key.clone()).or_insert_with(|| Plot { | |
| 133 | repo: key.clone(), | |
| 134 | commits_window: 0.0, | |
| 135 | commits_today: 0.0, | |
| 136 | branch_count: 0.0, | |
| 137 | days_since: 0.0, | |
| 138 | }); | |
| 139 | match s.name { | |
| 140 | SignalName::CommitsWindow => plot.commits_window = s.value.0, | |
| 141 | SignalName::CommitsToday => plot.commits_today = s.value.0, | |
| 142 | SignalName::BranchCount => plot.branch_count = s.value.0, | |
| 143 | SignalName::DaysSinceLastCommit => plot.days_since = s.value.0, | |
| 144 | _ => {} | |
| 145 | } | |
| 146 | } | |
| 147 | ||
| 148 | by_repo.into_values().collect() | |
| 149 | } | |
| 150 | ||
| 151 | /// Render the whole garden to a text block (spec §2.2: a text render is fine for | |
| 152 | /// v0.1). | |
| 153 | pub fn render(plots: &[Plot]) -> String { | |
| 154 | let mut out = String::new(); | |
| 155 | out.push_str("terminal-garden\n"); | |
| 156 | out.push_str("===============\n"); | |
| 157 | if plots.is_empty() { | |
| 158 | out.push_str(" (no plots yet — waiting for git signals)\n"); | |
| 159 | return out; | |
| 160 | } | |
| 161 | for plot in plots { | |
| 162 | out.push_str(&plot.render_line()); | |
| 163 | out.push('\n'); | |
| 164 | } | |
| 165 | out | |
| 166 | } | |
| 167 | ||
| 168 | fn short_name(repo: &str) -> String { | |
| 169 | if repo.is_empty() { | |
| 170 | return "(repo)".to_string(); | |
| 171 | } | |
| 172 | repo.rsplit('/').next().unwrap_or(repo).to_string() | |
| 173 | } | |
| 174 | ||
| 175 | #[cfg(test)] | |
| 176 | mod tests { | |
| 177 | use super::*; | |
| 178 | use signal_schema::{Source, Tag, Value, SCHEMA_VERSION}; | |
| 179 | ||
| 180 | #[test] | |
| 181 | fn more_commits_grow_a_taller_plant() { | |
| 182 | assert!(Stage::from_commits(0.0) < Stage::from_commits(2.0)); | |
| 183 | assert!(Stage::from_commits(2.0) < Stage::from_commits(5.0)); | |
| 184 | assert!(Stage::from_commits(5.0) < Stage::from_commits(20.0)); | |
| 185 | assert_eq!(Stage::from_commits(0.0), Stage::Seed); | |
| 186 | assert_eq!(Stage::from_commits(20.0), Stage::Tree); | |
| 187 | } | |
| 188 | ||
| 189 | #[test] | |
| 190 | fn staleness_makes_a_plant_wilt() { | |
| 191 | assert_eq!(Health::from_days_since(0.0), Health::Fresh); | |
| 192 | assert!(Health::from_days_since(0.0) < Health::from_days_since(3.0)); | |
| 193 | assert!(Health::from_days_since(3.0) < Health::from_days_since(10.0)); | |
| 194 | assert!(Health::from_days_since(10.0) < Health::from_days_since(40.0)); | |
| 195 | assert_eq!(Health::from_days_since(40.0), Health::Withered); | |
| 196 | } | |
| 197 | ||
| 198 | #[test] | |
| 199 | fn withered_plot_shows_the_brown_glyph_over_its_stage() { | |
| 200 | let plot = Plot { | |
| 201 | repo: "/x/big-repo".to_string(), | |
| 202 | commits_window: 50.0, // would be a Tree | |
| 203 | commits_today: 0.0, | |
| 204 | branch_count: 1.0, | |
| 205 | days_since: 30.0, // but it's withered | |
| 206 | }; | |
| 207 | assert_eq!(plot.stage(), Stage::Tree); | |
| 208 | assert_eq!(plot.health(), Health::Withered); | |
| 209 | assert_eq!(plot.glyph(), "\u{1F342}"); | |
| 210 | } | |
| 211 | ||
| 212 | #[test] | |
| 213 | fn plots_group_by_repo_tag() { | |
| 214 | let mk = |name, value, repo: &str| Signal { | |
| 215 | schema_version: SCHEMA_VERSION, | |
| 216 | ts: 1, | |
| 217 | source: Source::Git, | |
| 218 | name, | |
| 219 | value: Value(value), | |
| 220 | tag: Some(Tag::repo_path(repo).unwrap()), | |
| 221 | }; | |
| 222 | let signals = vec![ | |
| 223 | mk(SignalName::CommitsWindow, 5.0, "/a/alpha"), | |
| 224 | mk(SignalName::DaysSinceLastCommit, 0.0, "/a/alpha"), | |
| 225 | mk(SignalName::CommitsWindow, 0.0, "/b/beta"), | |
| 226 | mk(SignalName::DaysSinceLastCommit, 20.0, "/b/beta"), | |
| 227 | ]; | |
| 228 | let plots = plots_from_signals(&signals); | |
| 229 | assert_eq!(plots.len(), 2); | |
| 230 | let alpha = plots.iter().find(|p| p.repo == "/a/alpha").unwrap(); | |
| 231 | let beta = plots.iter().find(|p| p.repo == "/b/beta").unwrap(); | |
| 232 | assert_eq!(alpha.stage(), Stage::Sapling); | |
| 233 | assert_eq!(alpha.health(), Health::Fresh); | |
| 234 | assert_eq!(beta.stage(), Stage::Seed); | |
| 235 | assert_eq!(beta.health(), Health::Withered); | |
| 236 | } | |
| 237 | } | |
crates/terminal-garden/src/main.rs added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | //! # terminal-garden | |
| 2 | //! | |
| 3 | //! The first face (spec §3: "the garden is the recommended first build"). | |
| 4 | //! Git is the cleanest signal in the suite — discrete, unambiguous, no privacy | |
| 5 | //! questions — so the garden proves the bus before anything reads the shell. | |
| 6 | //! | |
| 7 | //! Like every renderer, the garden is thin: connect to the socket → read the | |
| 8 | //! git aggregates → fold them into per-repo plots → render. It never touches a | |
| 9 | //! sensor. The mapping (commits → growth, staleness → wilt) lives in the | |
| 10 | //! `terminal-garden` library so it can be unit-tested; this binary is just the | |
| 11 | //! subscriber loop. | |
| 12 | //! | |
| 13 | //! Usage: | |
| 14 | //! ```text | |
| 15 | //! terminal-garden [--socket <path>] | |
| 16 | //! ``` | |
| 17 | //! Socket defaults to `$XDG_RUNTIME_DIR/signald.sock`, falling back to | |
| 18 | //! `~/.local/state/signald/sock`. | |
| 19 | ||
| 20 | use std::collections::BTreeMap; | |
| 21 | use std::io::BufReader; | |
| 22 | use std::os::unix::net::UnixStream; | |
| 23 | use std::path::PathBuf; | |
| 24 | ||
| 25 | use signal_schema::{wire, Signal, SignalName}; | |
| 26 | use terminal_garden::{plots_from_signals, render}; | |
| 27 | ||
| 28 | /// The `name`s the garden cares about (spec §2.2 — the git aggregates). | |
| 29 | const SUBSCRIBE: &[SignalName] = &[ | |
| 30 | SignalName::CommitsWindow, | |
| 31 | SignalName::CommitsToday, | |
| 32 | SignalName::BranchCount, | |
| 33 | SignalName::DaysSinceLastCommit, | |
| 34 | ]; | |
| 35 | ||
| 36 | fn main() { | |
| 37 | let socket = parse_socket(); | |
| 38 | if let Err(e) = run(&socket) { | |
| 39 | eprintln!("terminal-garden: could not read signald at {}: {e}", socket.display()); | |
| 40 | eprintln!("terminal-garden: is signald running? (signald --socket {})", socket.display()); | |
| 41 | std::process::exit(1); | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | /// Connect and render live: the daemon replays the last-value cache on connect, | |
| 46 | /// then streams updates. We keep the latest value of each metric (keyed by name | |
| 47 | /// + repo tag) and re-render the garden on every frame. `Ok(())` is a clean EOF | |
| 48 | /// (the daemon closed the stream). | |
| 49 | fn run(socket: &PathBuf) -> std::io::Result<()> { | |
| 50 | let stream = UnixStream::connect(socket)?; | |
| 51 | let mut reader = BufReader::new(stream); | |
| 52 | ||
| 53 | // Keyed by (metric name, repo tag) so per-repo signals coexist and updates | |
| 54 | // replace prior values rather than accumulating. | |
| 55 | let mut latest: BTreeMap<(u8, Option<String>), Signal> = BTreeMap::new(); | |
| 56 | while let Some(sig) = wire::read_frame(&mut reader)? { | |
| 57 | if !SUBSCRIBE.contains(&sig.name) { | |
| 58 | continue; | |
| 59 | } | |
| 60 | let key = (sig.name.to_u8(), sig.tag.as_ref().map(|t| t.as_str().to_string())); | |
| 61 | latest.insert(key, sig); | |
| 62 | ||
| 63 | let signals: Vec<Signal> = latest.values().cloned().collect(); | |
| 64 | let plots = plots_from_signals(&signals); | |
| 65 | // Clear + home so the garden redraws in place as signals change. | |
| 66 | print!("\x1b[2J\x1b[H{}", render(&plots)); | |
| 67 | } | |
| 68 | Ok(()) | |
| 69 | } | |
| 70 | ||
| 71 | fn parse_socket() -> PathBuf { | |
| 72 | let mut args = std::env::args().skip(1); | |
| 73 | while let Some(arg) = args.next() { | |
| 74 | if arg == "--socket" { | |
| 75 | if let Some(path) = args.next() { | |
| 76 | return PathBuf::from(path); | |
| 77 | } | |
| 78 | } | |
| 79 | } | |
| 80 | default_socket_path() | |
| 81 | } | |
| 82 | ||
| 83 | fn default_socket_path() -> PathBuf { | |
| 84 | if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { | |
| 85 | return PathBuf::from(dir).join("signald.sock"); | |
| 86 | } | |
| 87 | let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); | |
| 88 | PathBuf::from(home).join(".local/state/signald/sock") | |
| 89 | } | |
macos-collector/.gitignore added +1
| @@ -0,0 +1 @@ | ||
| 1 | .build/ | |
macos-collector/Package.swift added +22
| @@ -0,0 +1,22 @@ | ||
| 1 | // swift-tools-version:5.9 | |
| 2 | import PackageDescription | |
| 3 | ||
| 4 | // The macOS IOKit hardware collector (spec Phase 3). A *sibling* of the Rust | |
| 5 | // cargo workspace, deliberately kept OUT of it: SwiftPM and cargo do not share a | |
| 6 | // build system. This package speaks the shared wire protocol (signal-schema) and | |
| 7 | // nothing else — it is one more producer for the same daemon/schema. | |
| 8 | let package = Package( | |
| 9 | name: "macos-collector", | |
| 10 | platforms: [.macOS(.v13)], | |
| 11 | products: [ | |
| 12 | .executable(name: "macos-collector", targets: ["macos-collector"]), | |
| 13 | ], | |
| 14 | targets: [ | |
| 15 | // Wire encoder + IOKit reads, in a library so they are unit-testable. | |
| 16 | .target(name: "CollectorCore"), | |
| 17 | // Thin CLI: flags -> collect loop -> emit frames. | |
| 18 | .executableTarget(name: "macos-collector", dependencies: ["CollectorCore"]), | |
| 19 | // The cross-language wire-contract test (canonical frame bytes). | |
| 20 | .testTarget(name: "CollectorCoreTests", dependencies: ["CollectorCore"]), | |
| 21 | ] | |
| 22 | ) | |
macos-collector/README.md added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | # macos-collector | |
| 2 | ||
| 3 | *The macOS IOKit hardware collector (spec Phase 3) — a sibling of the Rust | |
| 4 | workspace that speaks the shared wire protocol.* | |
| 5 | ||
| 6 | This is a **SwiftPM** package, kept deliberately **out** of the cargo workspace | |
| 7 | (SwiftPM and cargo do not share a build system). It is not a fifth face; it is | |
| 8 | one more **producer** for the same daemon and the same schema. It reads | |
| 9 | aggregate hardware scalars via **IOKit only — no `powermetrics`, no root** — and | |
| 10 | emits them as `signal-schema` wire frames, the exact byte format `signald` | |
| 11 | already parses. | |
| 12 | ||
| 13 | ```sh | |
| 14 | swift build # builds the collector | |
| 15 | swift test # the cross-language wire-contract test | |
| 16 | swift run macos-collector --once # one real IOKit read (summary on stderr) | |
| 17 | ``` | |
| 18 | ||
| 19 | ## What it reads (IOKit-only, no root) | |
| 20 | ||
| 21 | | Metric | `SignalName` | Source | How | | |
| 22 | |---|---|---|---| | |
| 23 | | Aggregate CPU load `[0,1]` | `cpu_load` (28) | hardware | Mach `host_processor_info(PROCESSOR_CPU_LOAD_INFO)` tick deltas | | |
| 24 | | Battery percentage `[0,100]` | `battery_pct` (17) | macos | IOKit power sources (`IOPSCopyPowerSourcesInfo`) | | |
| 25 | | Charging (1/0) | `charging` (18) | macos | IOKit power sources | | |
| 26 | | Battery draw (W) | `battery_draw_w` (27) | hardware | IORegistry `AppleSmartBattery` Amperage×Voltage | | |
| 27 | | Thermal state `0..3` | `thermal_state` (19) | macos | `ProcessInfo.thermalState` | | |
| 28 | ||
| 29 | **Intentionally omitted:** GPU utilization and fan RPM. They are reachable in | |
| 30 | principle via `IOReport`/AppleSMC, but only through chip-generation-specific, | |
| 31 | undocumented channels. Per spec §1.4/§6, a metric that cannot be read cleanly | |
| 32 | without root is **omitted from the schema rather than gated behind sudo**. | |
| 33 | Battery signals are omitted on a machine with no internal battery (a desktop). | |
| 34 | ||
| 35 | Every value is an **aggregate scalar**. This collector has no code path that can | |
| 36 | read user content: it touches only hardware counters and power registers. The | |
| 37 | `f64`-only payload and the absence of any input-tap API mean the privacy | |
| 38 | invariant (spec §1.5) holds for hardware signals exactly as it does for the | |
| 39 | terminal collector — and the repo's `forbidden_symbol_scan` gate scans these | |
| 40 | `.swift` files too. | |
| 41 | ||
| 42 | ## Modes | |
| 43 | ||
| 44 | ``` | |
| 45 | macos-collector [--once] [--hex] [--interval-ms <n>] [--out <path>] | |
| 46 | ``` | |
| 47 | ||
| 48 | - *(default)* stream raw wire frames to stdout every `--interval-ms` (default 1000). | |
| 49 | - `--once` — one tick: a human-readable summary on **stderr**, the frames on **stdout**. | |
| 50 | - `--hex` — emit each frame as a hex line (a bridge decodes; also handy to eyeball). | |
| 51 | - `--out <path>` — append raw frames to a file (a spool the daemon can tail) instead of stdout. | |
| 52 | ||
| 53 | ## The Swift ↔ Rust wire contract (the byte format) | |
| 54 | ||
| 55 | Both languages implement the **same** length-prefixed frame. The authoritative | |
| 56 | definition lives in Rust (`crates/signal-schema/src/lib.rs`, module `wire`); the | |
| 57 | Swift encoder (`Sources/CollectorCore/Wire.swift`) mirrors it exactly. All | |
| 58 | multi-byte integers and the `f64` (via its IEEE-754 bit pattern) are | |
| 59 | **little-endian**. | |
| 60 | ||
| 61 | ```text | |
| 62 | frame: | |
| 63 | [u32 body_len] little-endian length of body | |
| 64 | body: | |
| 65 | [u16 schema_version] must equal 3 (v0.3); a daemon drops other versions | |
| 66 | [u64 ts] unix milliseconds | |
| 67 | [u8 source] 0=terminal 1=git 2=macos 3=hardware | |
| 68 | [u8 name] SignalName discriminant (see table above) | |
| 69 | [f64 value] the ONLY payload channel — no content field exists | |
| 70 | [u8 tag_present] 0 = no tag, 1 = tag follows | |
| 71 | [u16 tag_len] present only if tag_present == 1 | |
| 72 | [tag_len bytes] UTF-8 audited identifier; hardware signals never tag | |
| 73 | ``` | |
| 74 | ||
| 75 | ### Canonical frame (pinned by tests on both sides) | |
| 76 | ||
| 77 | `schema_version=3, ts=0, source=Hardware(3), name=CpuLoad(28), value=0.5, | |
| 78 | tag=none` encodes to these exact 25 bytes: | |
| 79 | ||
| 80 | ```text | |
| 81 | 15 00 00 00 body_len = 21 | |
| 82 | 03 00 schema_version = 3 | |
| 83 | 00 00 00 00 00 00 00 00 ts = 0 | |
| 84 | 03 source = Hardware | |
| 85 | 1C name = CpuLoad (28) | |
| 86 | 00 00 00 00 00 00 E0 3F value = 0.5 (f64 LE) | |
| 87 | 00 tag_present = 0 | |
| 88 | ``` | |
| 89 | ||
| 90 | This identical literal is asserted in **both**: | |
| 91 | - Rust — `crates/signal-schema/tests/hardware_wire.rs` (`wire::decode` parses it, | |
| 92 | `wire::encode` reproduces it — this is signald's own parse path), and | |
| 93 | - Swift — `Tests/CollectorCoreTests/WireTests.swift` (`encode` produces it). | |
| 94 | ||
| 95 | Because both sides independently commit to the same bytes, cross-*process* | |
| 96 | execution is not required to prove they agree — the shared literal is the proof. | |
| 97 | (A live `swift run macos-collector --once --hex` produces frames that decode | |
| 98 | byte-for-byte to this layout; see the project README for a captured run.) | |
| 99 | ||
| 100 | ## Wiring into signald (Phase 3 scope note) | |
| 101 | ||
| 102 | `signald`'s in-process `system_hw_tick` collector is a stub; this out-of-process | |
| 103 | Swift collector is its real realization. The **live socket handshake** by which | |
| 104 | `signald` ingests these frames is intentionally minimal in v0.3 (spec's stated | |
| 105 | Phase 3 scope): the collector emits frames to stdout / `--out` today, and the | |
| 106 | guarantee that `signald` parses them is the shared wire-contract test above | |
| 107 | (which exercises the identical `wire::decode` the socket path uses). A full | |
| 108 | cross-process ingest handshake is the next increment. | |
macos-collector/Sources/CollectorCore/Hardware.swift added +166
| @@ -0,0 +1,166 @@ | ||
| 1 | import Foundation | |
| 2 | import IOKit | |
| 3 | import IOKit.ps | |
| 4 | ||
| 5 | // The hardware reads. IOKit-only, no `powermetrics`, no root (spec §1.4, §6): | |
| 6 | // * CPU load — Mach `host_processor_info(PROCESSOR_CPU_LOAD_INFO)` deltas | |
| 7 | // * battery % — IOKit power sources (`IOPSCopyPowerSourcesInfo`) | |
| 8 | // * charging — IOKit power sources | |
| 9 | // * battery draw— IORegistry `AppleSmartBattery` (Amperage * Voltage) | |
| 10 | // * thermal — `ProcessInfo.thermalState` (aggregate enum, no root) | |
| 11 | // | |
| 12 | // GPU utilization and fan RPM are reachable in principle via `IOReport`/AppleSMC | |
| 13 | // but only with chip-generation-specific, undocumented channel spelunking; per | |
| 14 | // spec §1.4 a metric that cannot be read cleanly without root is *omitted from | |
| 15 | // the schema* rather than gated behind sudo. They are intentionally absent here. | |
| 16 | // | |
| 17 | // Every value produced is an aggregate scalar. There is no code path here that | |
| 18 | // reads user content — this file cannot, because it only ever touches hardware | |
| 19 | // counters and power registers. | |
| 20 | ||
| 21 | /// Cumulative CPU ticks summed across all cores. | |
| 22 | private struct CPUTicks { | |
| 23 | var used: UInt64 | |
| 24 | var total: UInt64 | |
| 25 | } | |
| 26 | ||
| 27 | /// Read cumulative CPU ticks via Mach `host_processor_info`. Non-privileged. | |
| 28 | private func readCPUTicks() -> CPUTicks? { | |
| 29 | var info: processor_info_array_t? | |
| 30 | var infoCount: mach_msg_type_number_t = 0 | |
| 31 | var cpuCount: natural_t = 0 | |
| 32 | ||
| 33 | let result = host_processor_info( | |
| 34 | mach_host_self(), | |
| 35 | PROCESSOR_CPU_LOAD_INFO, | |
| 36 | &cpuCount, | |
| 37 | &info, | |
| 38 | &infoCount | |
| 39 | ) | |
| 40 | guard result == KERN_SUCCESS, let info else { return nil } | |
| 41 | defer { | |
| 42 | let addr = vm_address_t(UInt(bitPattern: UnsafeRawPointer(info))) | |
| 43 | vm_deallocate(mach_task_self_, addr, vm_size_t(infoCount) * vm_size_t(MemoryLayout<integer_t>.stride)) | |
| 44 | } | |
| 45 | ||
| 46 | let states = Int(CPU_STATE_MAX) | |
| 47 | var used: UInt64 = 0 | |
| 48 | var total: UInt64 = 0 | |
| 49 | for cpu in 0..<Int(cpuCount) { | |
| 50 | let base = cpu * states | |
| 51 | let user = UInt64(info[base + Int(CPU_STATE_USER)]) | |
| 52 | let system = UInt64(info[base + Int(CPU_STATE_SYSTEM)]) | |
| 53 | let idle = UInt64(info[base + Int(CPU_STATE_IDLE)]) | |
| 54 | let nice = UInt64(info[base + Int(CPU_STATE_NICE)]) | |
| 55 | used += user + system + nice | |
| 56 | total += user + system + nice + idle | |
| 57 | } | |
| 58 | return CPUTicks(used: used, total: total) | |
| 59 | } | |
| 60 | ||
| 61 | /// Aggregate CPU load sampler. Load is `Δbusy / Δtotal` between successive | |
| 62 | /// samples, so the first `sample()` primes and returns `nil`. | |
| 63 | public final class CPUSampler { | |
| 64 | private var prev: CPUTicks? | |
| 65 | ||
| 66 | public init() {} | |
| 67 | ||
| 68 | /// Busy fraction in `[0, 1]` since the previous sample, or `nil` on the | |
| 69 | /// priming call / if the counters are unavailable. | |
| 70 | public func sample() -> Double? { | |
| 71 | guard let cur = readCPUTicks() else { return nil } | |
| 72 | defer { prev = cur } | |
| 73 | guard let prev else { return nil } | |
| 74 | let dTotal = cur.total >= prev.total ? cur.total - prev.total : 0 | |
| 75 | let dUsed = cur.used >= prev.used ? cur.used - prev.used : 0 | |
| 76 | guard dTotal > 0 else { return nil } | |
| 77 | return min(1.0, Double(dUsed) / Double(dTotal)) | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | /// A snapshot of the internal battery, if present. | |
| 82 | public struct BatteryInfo { | |
| 83 | public var pct: Double? | |
| 84 | public var charging: Bool? | |
| 85 | } | |
| 86 | ||
| 87 | /// Read battery percentage and charging state via IOKit power sources. No root. | |
| 88 | /// Returns empty fields on a machine with no internal battery (e.g. a desktop). | |
| 89 | public func readBattery() -> BatteryInfo { | |
| 90 | guard let blob = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(), | |
| 91 | let list = IOPSCopyPowerSourcesList(blob)?.takeRetainedValue() as? [CFTypeRef] | |
| 92 | else { | |
| 93 | return BatteryInfo(pct: nil, charging: nil) | |
| 94 | } | |
| 95 | ||
| 96 | for source in list { | |
| 97 | guard let desc = IOPSGetPowerSourceDescription(blob, source)?.takeUnretainedValue() as? [String: Any] | |
| 98 | else { continue } | |
| 99 | if let type = desc[kIOPSTypeKey] as? String, type != kIOPSInternalBatteryType { | |
| 100 | continue | |
| 101 | } | |
| 102 | var pct: Double? | |
| 103 | if let cur = (desc[kIOPSCurrentCapacityKey] as? NSNumber)?.doubleValue, | |
| 104 | let max = (desc[kIOPSMaxCapacityKey] as? NSNumber)?.doubleValue, max > 0 { | |
| 105 | pct = cur / max * 100.0 | |
| 106 | } | |
| 107 | let charging = (desc[kIOPSIsChargingKey] as? NSNumber)?.boolValue | |
| 108 | return BatteryInfo(pct: pct, charging: charging) | |
| 109 | } | |
| 110 | return BatteryInfo(pct: nil, charging: nil) | |
| 111 | } | |
| 112 | ||
| 113 | /// Instantaneous battery draw in watts from the IORegistry `AppleSmartBattery` | |
| 114 | /// node (Amperage in mA, Voltage in mV). No root. `nil` on a machine with no | |
| 115 | /// smart battery. | |
| 116 | public func readBatteryDrawW() -> Double? { | |
| 117 | let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBattery")) | |
| 118 | guard service != 0 else { return nil } | |
| 119 | defer { IOObjectRelease(service) } | |
| 120 | ||
| 121 | func number(_ key: String) -> Double? { | |
| 122 | guard let cf = IORegistryEntryCreateCFProperty(service, key as CFString, kCFAllocatorDefault, 0)? | |
| 123 | .takeRetainedValue() as? NSNumber | |
| 124 | else { return nil } | |
| 125 | return cf.doubleValue | |
| 126 | } | |
| 127 | guard let amperage = number("Amperage"), let voltage = number("Voltage") else { return nil } | |
| 128 | // Amperage is signed (negative = discharging); draw magnitude is what we map. | |
| 129 | return abs(amperage / 1000.0 * voltage / 1000.0) | |
| 130 | } | |
| 131 | ||
| 132 | /// Aggregate thermal state as `0..3` (nominal/fair/serious/critical). Sourced | |
| 133 | /// from `ProcessInfo`, not `powermetrics`; no root, no per-sensor detail. | |
| 134 | public func readThermalState() -> Double { | |
| 135 | switch ProcessInfo.processInfo.thermalState { | |
| 136 | case .nominal: return 0 | |
| 137 | case .fair: return 1 | |
| 138 | case .serious: return 2 | |
| 139 | case .critical: return 3 | |
| 140 | @unknown default: return 0 | |
| 141 | } | |
| 142 | } | |
| 143 | ||
| 144 | /// Collect one tick of hardware signals. Signals whose sensor is absent (no | |
| 145 | /// battery, priming CPU sample) are simply omitted — never faked. | |
| 146 | public func collectHardwareSignals(cpu: CPUSampler, now: UInt64) -> [Signal] { | |
| 147 | var out: [Signal] = [] | |
| 148 | ||
| 149 | if let load = cpu.sample() { | |
| 150 | out.append(Signal(ts: now, source: .hardware, name: .cpuLoad, value: load)) | |
| 151 | } | |
| 152 | ||
| 153 | let battery = readBattery() | |
| 154 | if let pct = battery.pct { | |
| 155 | out.append(Signal(ts: now, source: .macos, name: .batteryPct, value: pct)) | |
| 156 | } | |
| 157 | if let charging = battery.charging { | |
| 158 | out.append(Signal(ts: now, source: .macos, name: .charging, value: charging ? 1.0 : 0.0)) | |
| 159 | } | |
| 160 | if let watts = readBatteryDrawW() { | |
| 161 | out.append(Signal(ts: now, source: .hardware, name: .batteryDrawW, value: watts)) | |
| 162 | } | |
| 163 | ||
| 164 | out.append(Signal(ts: now, source: .macos, name: .thermalState, value: readThermalState())) | |
| 165 | return out | |
| 166 | } | |
macos-collector/Sources/CollectorCore/Wire.swift added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// The wire schema version. **Must equal** `signal_schema::SCHEMA_VERSION` on the | |
| 4 | /// Rust side (v3 as of 0.3.0). A daemon rejects frames whose version it does not | |
| 5 | /// understand, so this is a hard cross-language contract. | |
| 6 | public let SCHEMA_VERSION: UInt16 = 3 | |
| 7 | ||
| 8 | /// The collector domain. Discriminants **must match** Rust `Source::to_u8`. | |
| 9 | public enum Source: UInt8 { | |
| 10 | case terminal = 0 | |
| 11 | case git = 1 | |
| 12 | case macos = 2 | |
| 13 | case hardware = 3 | |
| 14 | } | |
| 15 | ||
| 16 | /// The enum-constrained metric name. Only the variants this collector emits are | |
| 17 | /// declared here; every discriminant **must match** Rust `SignalName::to_u8`. | |
| 18 | /// | |
| 19 | /// The privacy boundary is unchanged for hardware: each of these is an aggregate | |
| 20 | /// `f64` scalar, and none of them carries a tag (all are emitted with | |
| 21 | /// `tag == nil`), exactly as `SignalName::allows_tag` is `false` for them. | |
| 22 | public enum SignalName: UInt8 { | |
| 23 | case batteryPct = 17 // macos: battery charge percentage [0, 100] | |
| 24 | case charging = 18 // macos: 1.0 charging, 0.0 not | |
| 25 | case thermalState = 19 // macos: ProcessInfo thermal state 0..3 | |
| 26 | case batteryDrawW = 27 // hardware: instantaneous battery draw, watts | |
| 27 | case cpuLoad = 28 // hardware: aggregate CPU busy fraction [0, 1] | |
| 28 | ||
| 29 | /// Human-readable name, matching the Rust metric names (for `--once`). | |
| 30 | public var label: String { | |
| 31 | switch self { | |
| 32 | case .batteryPct: return "battery_pct" | |
| 33 | case .charging: return "charging" | |
| 34 | case .thermalState: return "thermal_state" | |
| 35 | case .batteryDrawW: return "battery_draw_w" | |
| 36 | case .cpuLoad: return "cpu_load" | |
| 37 | } | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | /// One flat signal record — the Swift mirror of Rust `Signal`. The only payload | |
| 42 | /// channel is `value: Double` (`f64`); there is deliberately no content field. | |
| 43 | public struct Signal { | |
| 44 | public let schemaVersion: UInt16 | |
| 45 | public let ts: UInt64 | |
| 46 | public let source: Source | |
| 47 | public let name: SignalName | |
| 48 | public let value: Double | |
| 49 | public let tag: String? | |
| 50 | ||
| 51 | public init(ts: UInt64, source: Source, name: SignalName, value: Double, tag: String? = nil) { | |
| 52 | self.schemaVersion = SCHEMA_VERSION | |
| 53 | self.ts = ts | |
| 54 | self.source = source | |
| 55 | self.name = name | |
| 56 | self.value = value | |
| 57 | self.tag = tag | |
| 58 | } | |
| 59 | } | |
| 60 | ||
| 61 | private extension Array where Element == UInt8 { | |
| 62 | /// Append a fixed-width integer in little-endian order (matches Rust | |
| 63 | /// `to_le_bytes`). | |
| 64 | mutating func appendLE<T: FixedWidthInteger>(_ v: T) { | |
| 65 | var le = v.littleEndian | |
| 66 | Swift.withUnsafeBytes(of: &le) { append(contentsOf: $0) } | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | /// Encode a signal to its length-prefixed wire frame, byte-for-byte identical to | |
| 71 | /// Rust `signal_schema::wire::encode`. | |
| 72 | /// | |
| 73 | /// ```text | |
| 74 | /// [u32 body_len] | |
| 75 | /// body = [u16 schema_version][u64 ts][u8 source][u8 name][f64 value] | |
| 76 | /// [u8 tag_present] ( [u16 tag_len][tag_len bytes utf8] )? | |
| 77 | /// ``` | |
| 78 | /// All multi-byte integers and the `f64` (via its IEEE-754 bit pattern) are | |
| 79 | /// little-endian. | |
| 80 | public func encode(_ s: Signal) -> [UInt8] { | |
| 81 | var body: [UInt8] = [] | |
| 82 | body.appendLE(s.schemaVersion) // u16 | |
| 83 | body.appendLE(s.ts) // u64 | |
| 84 | body.append(s.source.rawValue) // u8 | |
| 85 | body.append(s.name.rawValue) // u8 | |
| 86 | body.appendLE(s.value.bitPattern) // f64 as its u64 bit pattern, LE | |
| 87 | if let tag = s.tag { | |
| 88 | body.append(1) | |
| 89 | let bytes = Array(tag.utf8) | |
| 90 | body.appendLE(UInt16(bytes.count)) | |
| 91 | body.append(contentsOf: bytes) | |
| 92 | } else { | |
| 93 | body.append(0) | |
| 94 | } | |
| 95 | ||
| 96 | var frame: [UInt8] = [] | |
| 97 | frame.appendLE(UInt32(body.count)) | |
| 98 | frame.append(contentsOf: body) | |
| 99 | return frame | |
| 100 | } | |
| 101 | ||
| 102 | /// Unix milliseconds, best-effort (matches Rust `now_millis`). | |
| 103 | public func nowMillis() -> UInt64 { | |
| 104 | UInt64(Date().timeIntervalSince1970 * 1000.0) | |
| 105 | } | |
macos-collector/Sources/macos-collector/main.swift added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | import Foundation | |
| 2 | import CollectorCore | |
| 3 | ||
| 4 | // The macOS IOKit hardware collector CLI. It reads aggregate hardware scalars | |
| 5 | // (IOKit-only, no root) and emits them as signal-schema wire frames — the same | |
| 6 | // byte format signald already parses. It is one more *producer* for the shared | |
| 7 | // bus; it never subscribes and never reads user content. | |
| 8 | // | |
| 9 | // Modes: | |
| 10 | // (default) stream frames forever at --interval-ms (default 1000) | |
| 11 | // --once read one tick, print a human-readable summary to stderr, | |
| 12 | // and emit that tick's frames once | |
| 13 | // --hex emit each frame as a hex line (a bridge decodes; also handy | |
| 14 | // for eyeballing) instead of raw binary | |
| 15 | // --out <path> append raw frames to <path> (a spool the daemon can tail) | |
| 16 | // instead of stdout | |
| 17 | // | |
| 18 | // Wiring into signald: signald's live frame-ingest handshake is documented in | |
| 19 | // README.md and is intentionally minimal here (spec Phase 3 scope note). The | |
| 20 | // authoritative proof that signald parses these frames is the shared-byte | |
| 21 | // contract test on both sides (see WIRE section of the README). | |
| 22 | ||
| 23 | func fail(_ msg: String) -> Never { | |
| 24 | FileHandle.standardError.write(Data("macos-collector: \(msg)\n".utf8)) | |
| 25 | exit(2) | |
| 26 | } | |
| 27 | ||
| 28 | func printUsage() { | |
| 29 | let usage = """ | |
| 30 | macos-collector — macOS IOKit hardware collector (no root) | |
| 31 | ||
| 32 | USAGE: | |
| 33 | macos-collector [--once] [--hex] [--interval-ms <n>] [--out <path>] | |
| 34 | ||
| 35 | Emits signal-schema wire frames for aggregate hardware signals: | |
| 36 | cpu_load, battery_pct, charging, battery_draw_w, thermal_state | |
| 37 | """ | |
| 38 | print(usage) | |
| 39 | } | |
| 40 | ||
| 41 | var once = false | |
| 42 | var hex = false | |
| 43 | var intervalMs: UInt64 = 1000 | |
| 44 | var outPath: String? | |
| 45 | ||
| 46 | var args = CommandLine.arguments.dropFirst().makeIterator() | |
| 47 | while let arg = args.next() { | |
| 48 | switch arg { | |
| 49 | case "--once": once = true | |
| 50 | case "--hex": hex = true | |
| 51 | case "--interval-ms": | |
| 52 | guard let v = args.next(), let n = UInt64(v) else { fail("--interval-ms needs a number") } | |
| 53 | intervalMs = n | |
| 54 | case "--out": | |
| 55 | guard let p = args.next() else { fail("--out needs a path") } | |
| 56 | outPath = p | |
| 57 | case "-h", "--help": | |
| 58 | printUsage() | |
| 59 | exit(0) | |
| 60 | default: | |
| 61 | fail("unknown argument \(arg)") | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | /// Where frames go: a file (append) if --out was given, else stdout. | |
| 66 | func openOutput() -> FileHandle { | |
| 67 | guard let path = outPath else { return FileHandle.standardOutput } | |
| 68 | if !FileManager.default.fileExists(atPath: path) { | |
| 69 | FileManager.default.createFile(atPath: path, contents: nil) | |
| 70 | } | |
| 71 | guard let handle = FileHandle(forWritingAtPath: path) else { fail("cannot open --out \(path)") } | |
| 72 | handle.seekToEndOfFile() | |
| 73 | return handle | |
| 74 | } | |
| 75 | ||
| 76 | func emit(_ signals: [Signal], to handle: FileHandle) { | |
| 77 | for signal in signals { | |
| 78 | let frame = encode(signal) | |
| 79 | if hex { | |
| 80 | let line = frame.map { String(format: "%02x", $0) }.joined() + "\n" | |
| 81 | handle.write(Data(line.utf8)) | |
| 82 | } else { | |
| 83 | handle.write(Data(frame)) | |
| 84 | } | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | func printSummary(_ signals: [Signal]) { | |
| 89 | var lines = ["macos-collector --once (IOKit, no root):"] | |
| 90 | for signal in signals { | |
| 91 | let label = signal.name.label.padding(toLength: 16, withPad: " ", startingAt: 0) | |
| 92 | lines.append(String(format: " %@ %.4f", label, signal.value)) | |
| 93 | } | |
| 94 | if signals.first(where: { $0.name == .batteryPct }) == nil { | |
| 95 | lines.append(" (no internal battery detected — battery signals omitted)") | |
| 96 | } | |
| 97 | FileHandle.standardError.write(Data((lines.joined(separator: "\n") + "\n").utf8)) | |
| 98 | } | |
| 99 | ||
| 100 | let sampler = CPUSampler() | |
| 101 | let output = openOutput() | |
| 102 | ||
| 103 | if once { | |
| 104 | _ = sampler.sample() // prime the CPU delta | |
| 105 | Thread.sleep(forTimeInterval: 0.25) | |
| 106 | let signals = collectHardwareSignals(cpu: sampler, now: nowMillis()) | |
| 107 | printSummary(signals) | |
| 108 | emit(signals, to: output) | |
| 109 | exit(0) | |
| 110 | } | |
| 111 | ||
| 112 | // Stream mode: prime, then emit a tick every interval, forever. | |
| 113 | _ = sampler.sample() | |
| 114 | FileHandle.standardError.write(Data("macos-collector: streaming every \(intervalMs) ms (IOKit, no root)\n".utf8)) | |
| 115 | while true { | |
| 116 | Thread.sleep(forTimeInterval: Double(intervalMs) / 1000.0) | |
| 117 | let signals = collectHardwareSignals(cpu: sampler, now: nowMillis()) | |
| 118 | emit(signals, to: output) | |
| 119 | } | |
macos-collector/Tests/CollectorCoreTests/WireTests.swift added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | import XCTest | |
| 2 | @testable import CollectorCore | |
| 3 | ||
| 4 | final class WireTests: XCTestCase { | |
| 5 | /// The canonical hardware frame, shared **verbatim** with the Rust decode | |
| 6 | /// test (`crates/signal-schema/tests/hardware_wire.rs`). Both languages | |
| 7 | /// independently commit to these exact bytes — that shared literal *is* the | |
| 8 | /// cross-language wire contract, so no cross-process execution is needed to | |
| 9 | /// prove Swift and Rust agree. | |
| 10 | /// | |
| 11 | /// Signal: schema_version=3, ts=0, source=Hardware(3), name=CpuLoad(28), | |
| 12 | /// value=0.5, tag=none. | |
| 13 | func testCanonicalHardwareFrameMatchesRust() { | |
| 14 | let signal = Signal(ts: 0, source: .hardware, name: .cpuLoad, value: 0.5) | |
| 15 | let expected: [UInt8] = [ | |
| 16 | 0x15, 0x00, 0x00, 0x00, // body_len = 21 | |
| 17 | 0x03, 0x00, // schema_version = 3 | |
| 18 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // ts = 0 | |
| 19 | 0x03, // source = Hardware | |
| 20 | 0x1C, // name = CpuLoad (28) | |
| 21 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x3F, // value = 0.5 (f64 LE) | |
| 22 | 0x00, // tag_present = 0 | |
| 23 | ] | |
| 24 | XCTAssertEqual(encode(signal), expected) | |
| 25 | } | |
| 26 | ||
| 27 | /// The schema version must equal the Rust `SCHEMA_VERSION`, or the daemon | |
| 28 | /// drops every frame this collector sends. | |
| 29 | func testSchemaVersionMatchesRust() { | |
| 30 | XCTAssertEqual(SCHEMA_VERSION, 3) | |
| 31 | } | |
| 32 | ||
| 33 | /// The tagged-frame layout matches Rust too (hardware signals are untagged, | |
| 34 | /// but the encoder must still agree on the audited-tag case). | |
| 35 | func testTaggedFrameLayout() { | |
| 36 | let signal = Signal(ts: 0, source: .macos, name: .batteryPct, value: 1.0, tag: "ab") | |
| 37 | let frame = encode(signal) | |
| 38 | // body = 2+8+1+1+8+1 + (2 + 2) = 25 ; frame = 4 + 25 = 29 | |
| 39 | XCTAssertEqual(frame.count, 29) | |
| 40 | XCTAssertEqual(Array(frame.prefix(4)), [0x19, 0x00, 0x00, 0x00]) // body_len = 25 | |
| 41 | XCTAssertEqual(frame[24], 0x01) // tag_present | |
| 42 | XCTAssertEqual([frame[25], frame[26]], [0x02, 0x00]) // tag_len = 2 | |
| 43 | XCTAssertEqual([frame[27], frame[28]], Array("ab".utf8)) | |
| 44 | } | |
| 45 | ||
| 46 | /// Hardware signals never carry a tag and never carry content — the payload | |
| 47 | /// is a single `Double`. This mirrors the Rust privacy invariant. | |
| 48 | func testHardwareSignalsAreUntaggedScalars() { | |
| 49 | for name in [SignalName.cpuLoad, .batteryPct, .charging, .batteryDrawW, .thermalState] { | |
| 50 | let s = Signal(ts: 1, source: .hardware, name: name, value: 0.0) | |
| 51 | XCTAssertNil(s.tag) | |
| 52 | } | |
| 53 | } | |
| 54 | ||
| 55 | /// The IOKit CPU read yields a plausible fraction in `[0, 1]` (or nil while | |
| 56 | /// unavailable) — a real read against real hardware. | |
| 57 | func testCPUSamplerInRange() { | |
| 58 | let sampler = CPUSampler() | |
| 59 | _ = sampler.sample() | |
| 60 | Thread.sleep(forTimeInterval: 0.1) | |
| 61 | if let load = sampler.sample() { | |
| 62 | XCTAssertGreaterThanOrEqual(load, 0.0) | |
| 63 | XCTAssertLessThanOrEqual(load, 1.0) | |
| 64 | } | |
| 65 | } | |
| 66 | } | |
shell-hooks/README.md added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | # shell-hooks | |
| 2 | ||
| 3 | Shell side of the terminal collector (spec §1.4). **Live as of v0.2.** These | |
| 4 | snippets belong in the dotfiles repo and are sourced by the user's `.zshrc`; | |
| 5 | they are kept here so the privacy contract lives next to the code it constrains | |
| 6 | (and so the differential secret-typing test can drive the real file). | |
| 7 | ||
| 8 | ## The one rule | |
| 9 | ||
| 10 | **Aggregate-only.** The hooks emit counts, durations, and exit codes. They | |
| 11 | never read, store, or transmit the content of a command or a keystroke. | |
| 12 | ||
| 13 | - No input tap, no `CGEventTap`, no PTY sniffing. | |
| 14 | - Never reference the `zle` line buffer or capture argv. | |
| 15 | - What leaves the shell is a number, flushed on `precmd`. | |
| 16 | ||
| 17 | v0.2 ships the `zle` keypress counter: a widget wraps `self-insert`, does | |
| 18 | `(( _SIGNALD_KEYS++ ))`, then calls the built-in insert. It receives the key in | |
| 19 | the editor and discards it — the character is never assigned to a variable that | |
| 20 | outlives the widget and never leaves the shell. On each `precmd` the hook | |
| 21 | appends one count record — `<epoch_ms> <keys> <session_seconds>`, numbers only | |
| 22 | — to `$SIGNALD_SPOOL`, which `signald` reads. | |
| 23 | ||
| 24 | This contract is enforced by the forbidden-symbol scan **and** the differential | |
| 25 | secret-typing test in `crates/signal-schema/tests/privacy_invariant.rs` (plus | |
| 26 | the full-pipeline gate in `crates/signald/tests/`), which drive *this file* with | |
| 27 | a planted secret and fail the build if it — in any encoding — reaches the spool, | |
| 28 | the wire, or SQLite. | |
| 29 | ||
| 30 | ## Files | |
| 31 | ||
| 32 | - `signald-hooks.zsh` — the `zle` keystroke counter + `precmd` count flush | |
| 33 | (aggregate-only). | |
shell-hooks/signald-hooks.zsh added +60
| @@ -0,0 +1,60 @@ | ||
| 1 | # signald-hooks.zsh — terminal collector, shell side (spec §1.4). | |
| 2 | # | |
| 3 | # A small sourced script that lives in the dotfiles repo and is sourced by the | |
| 4 | # user's .zshrc. It talks to signald by appending newline-delimited AGGREGATE | |
| 5 | # COUNT records to a spool file that signald reads. It holds no history. | |
| 6 | # | |
| 7 | # ============================ PRIVACY CONTRACT ============================ | |
| 8 | # AGGREGATE-ONLY. This script emits COUNTS, DURATIONS, and EXIT CODES. | |
| 9 | # It NEVER reads, stores, or transmits the content of a command or a keystroke. | |
| 10 | # | |
| 11 | # - No input tap (none of the global event-tap / HID keyboard APIs). No | |
| 12 | # PTY sniffing. | |
| 13 | # - The keypress counter is a zle widget that increments a NUMBER and then | |
| 14 | # calls the built-in insert. It receives the key in the editor and discards | |
| 15 | # it; the character is never assigned to a variable that outlives the widget | |
| 16 | # and never leaves the shell. What leaves is a count. | |
| 17 | # - It NEVER references the zle line buffer (the BUFFER/LBUFFER/RBUFFER zle | |
| 18 | # parameters) and NEVER captures argv. The forbidden-symbol CI scan | |
| 19 | # (crates/signal-schema/tests/privacy_invariant.rs) fails the build if it | |
| 20 | # ever does. That test — plus the differential secret-typing test — drives | |
| 21 | # THIS FILE with a planted secret and asserts the secret never reaches the | |
| 22 | # spool, the wire, or SQLite. | |
| 23 | # | |
| 24 | # Spool record format (all fields are NUMBERS, space-separated): | |
| 25 | # | |
| 26 | # <epoch_ms> <keys_since_last_flush> <session_seconds> | |
| 27 | # | |
| 28 | # One record is appended on each precmd (i.e. after each command line). There is | |
| 29 | # no field capable of carrying typed content. | |
| 30 | # ========================================================================= | |
| 31 | ||
| 32 | zmodload zsh/datetime 2>/dev/null | |
| 33 | ||
| 34 | # Spool the daemon reads. Override SIGNALD_SPOOL to point elsewhere. | |
| 35 | : ${SIGNALD_SPOOL:=${XDG_RUNTIME_DIR:-$HOME/.local/state/signald}/terminal.spool} | |
| 36 | ||
| 37 | # --- session start: a timestamp only --- | |
| 38 | typeset -g _SIGNALD_SESSION_START=${EPOCHSECONDS:-0} | |
| 39 | typeset -g _SIGNALD_KEYS=0 | |
| 40 | ||
| 41 | # Keypress counter: increment a number, then perform the normal insert. The key | |
| 42 | # is handled by `.self-insert` and is never captured here. No BUFFER/LBUFFER. | |
| 43 | _signald_self_insert() { | |
| 44 | (( _SIGNALD_KEYS++ )) | |
| 45 | zle .self-insert | |
| 46 | } | |
| 47 | zle -N self-insert _signald_self_insert | |
| 48 | ||
| 49 | # precmd: the previous command finished. Append ONE aggregate record (numbers | |
| 50 | # only) and reset the per-flush key counter. | |
| 51 | _signald_precmd() { | |
| 52 | local now_ms=$(( ${EPOCHREALTIME:-$EPOCHSECONDS} * 1000 )) | |
| 53 | local session=$(( ${EPOCHSECONDS:-0} - _SIGNALD_SESSION_START )) | |
| 54 | mkdir -p ${SIGNALD_SPOOL:h} 2>/dev/null | |
| 55 | print -r -- "${now_ms%.*} ${_SIGNALD_KEYS} ${session}" >> $SIGNALD_SPOOL | |
| 56 | _SIGNALD_KEYS=0 | |
| 57 | } | |
| 58 | ||
| 59 | autoload -Uz add-zsh-hook | |
| 60 | add-zsh-hook precmd _signald_precmd | |