Freeze the schema as v4 !7
10 files changed, +256 −144
README.md +3 −2
| @@ -45,8 +45,9 @@ Made true by construction: | ||
| 45 | 45 | - **No content channel exists in the wire format.** The only payload channel is |
| 46 | 46 | `Value`, a newtype over `f64`. There is no `text`, `bytes`, or `payload` |
| 47 | 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. | |
| 48 | nowhere to put it. The one audited exception is a non-content identifier in | |
| 49 | `tag` — an absolute repo path, allow-listed to the four git metric names, | |
| 50 | and confined by the daemon to the roots it was told to watch. | |
| 50 | 51 | - **The key counter never stores the key**, there is **no input tap anywhere** |
| 51 | 52 | (`CGEventTap`, `IOHIDManager` keyboard usage, accessibility observation are |
| 52 | 53 | all forbidden), and **aggregation happens before transport** (the shell emits |
crates/signal-schema/src/lib.rs +109 −111
| @@ -26,12 +26,18 @@ | ||
| 26 | 26 | //! `tests/privacy_invariant.rs`, which fails the build if a content-carrying |
| 27 | 27 | //! field is ever added. |
| 28 | 28 | |
| 29 | /// Bump on **any** field change to [`Signal`]. Renderers negotiate on connect | |
| 30 | /// and ignore records they do not understand. | |
| 29 | /// Bump on **any** field change to [`Signal`]. A reader skips records it does | |
| 30 | /// not understand rather than failing on them; see [`wire::Frame::Skipped`]. | |
| 31 | 31 | /// |
| 32 | 32 | /// v3 (0.3.0): added the aggregate [`SignalName::CpuLoad`] emitted by the macOS |
| 33 | 33 | /// IOKit hardware collector (`macos-collector/`, a sibling Swift package). |
| 34 | pub const SCHEMA_VERSION: u16 = 3; | |
| 34 | /// | |
| 35 | /// **v4 (0.5.0) is the 1.0 contract.** [`SignalName`] was cut to the eleven | |
| 36 | /// metrics that have a producer and its discriminants renumbered from zero. | |
| 37 | /// From 1.0 onward discriminants are append-only and removing one is a | |
| 38 | /// breaking change, so this is the last version in which renumbering is | |
| 39 | /// possible. | |
| 40 | pub const SCHEMA_VERSION: u16 = 4; | |
| 35 | 41 | |
| 36 | 42 | /// The collector domain a signal originated from. |
| 37 | 43 | /// |
| @@ -76,18 +82,11 @@ impl Source { | ||
| 76 | 82 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 77 | 83 | pub enum SignalName { |
| 78 | 84 | // --- terminal (aggregates only) --- |
| 85 | /// Keystrokes per minute, summed across active shell sessions. | |
| 79 | 86 | KeysPerMin, |
| 80 | InterArrivalVariance, | |
| 87 | /// Seconds of the longest currently active shell session. | |
| 81 | 88 | SessionSeconds, |
| 82 | CommandsPerMin, | |
| 83 | ExitCodeRatio, | |
| 84 | TodBucket, | |
| 85 | 89 | // --- git --- |
| 86 | Commits5m, | |
| 87 | LinesAdded, | |
| 88 | LinesRemoved, | |
| 89 | BranchLastCommitAge, | |
| 90 | DirtyWorktree, | |
| 91 | 90 | /// Commits within the collector's rolling window (last N days). |
| 92 | 91 | CommitsWindow, |
| 93 | 92 | /// Commits since local midnight. |
| @@ -96,25 +95,19 @@ pub enum SignalName { | ||
| 96 | 95 | BranchCount, |
| 97 | 96 | /// Whole days since the most recent commit on `HEAD`. |
| 98 | 97 | DaysSinceLastCommit, |
| 99 | // --- macos system --- | |
| 100 | AppForegroundSeconds, | |
| 101 | IdleSeconds, | |
| 98 | // --- macos --- | |
| 99 | /// Battery charge percentage in `[0.0, 100.0]`. | |
| 102 | 100 | BatteryPct, |
| 101 | /// `1.0` charging, `0.0` not. | |
| 103 | 102 | Charging, |
| 103 | /// `ProcessInfo` thermal state, `0.0` nominal through `3.0` critical. | |
| 104 | 104 | ThermalState, |
| 105 | 105 | // --- hardware --- |
| 106 | /// Instantaneous battery draw in watts, from IORegistry `AppleSmartBattery`. | |
| 107 | BatteryDrawW, | |
| 106 | 108 | /// 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 | /// `host_processor_info` tick deltas. | |
| 109 | 110 | CpuLoad, |
| 110 | CpuCoreLoad, | |
| 111 | NetRxBps, | |
| 112 | NetTxBps, | |
| 113 | FanRpm, | |
| 114 | DiskReadBps, | |
| 115 | DiskWriteBps, | |
| 116 | GpuUtil, | |
| 117 | BatteryDrawW, | |
| 118 | 111 | } |
| 119 | 112 | |
| 120 | 113 | impl SignalName { |
| @@ -123,86 +116,49 @@ impl SignalName { | ||
| 123 | 116 | pub fn allows_tag(self) -> bool { |
| 124 | 117 | matches!( |
| 125 | 118 | 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 | |
| 119 | // All four carry a repo path, the only audited identifier left in | |
| 120 | // the v4 contract. | |
| 121 | SignalName::CommitsWindow | |
| 122 | | SignalName::CommitsToday | |
| 123 | | SignalName::BranchCount | |
| 124 | | SignalName::DaysSinceLastCommit | |
| 133 | 125 | ) |
| 134 | 126 | } |
| 135 | 127 | |
| 136 | /// Stable wire discriminant. New metrics append here (a reviewed change | |
| 137 | /// that also bumps [`SCHEMA_VERSION`]). | |
| 128 | /// Stable wire discriminant. Renumbered from zero for v4; from 1.0 onward | |
| 129 | /// new metrics append here (a reviewed change that also bumps | |
| 130 | /// [`SCHEMA_VERSION`]) and no discriminant is ever reused. | |
| 138 | 131 | pub fn to_u8(self) -> u8 { |
| 139 | 132 | match self { |
| 140 | 133 | 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, | |
| 134 | SignalName::SessionSeconds => 1, | |
| 135 | SignalName::CommitsWindow => 2, | |
| 136 | SignalName::CommitsToday => 3, | |
| 137 | SignalName::BranchCount => 4, | |
| 138 | SignalName::DaysSinceLastCommit => 5, | |
| 139 | SignalName::BatteryPct => 6, | |
| 140 | SignalName::Charging => 7, | |
| 141 | SignalName::ThermalState => 8, | |
| 142 | SignalName::BatteryDrawW => 9, | |
| 143 | SignalName::CpuLoad => 10, | |
| 170 | 144 | } |
| 171 | 145 | } |
| 172 | 146 | |
| 173 | 147 | /// Inverse of [`SignalName::to_u8`]. An unknown discriminant returns `None` |
| 174 | /// so the daemon can drop names it does not understand at the boundary. | |
| 148 | /// so a reader can skip names it does not understand. | |
| 175 | 149 | pub fn from_u8(v: u8) -> Option<SignalName> { |
| 176 | 150 | Some(match v { |
| 177 | 151 | 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, | |
| 152 | 1 => SignalName::SessionSeconds, | |
| 153 | 2 => SignalName::CommitsWindow, | |
| 154 | 3 => SignalName::CommitsToday, | |
| 155 | 4 => SignalName::BranchCount, | |
| 156 | 5 => SignalName::DaysSinceLastCommit, | |
| 157 | 6 => SignalName::BatteryPct, | |
| 158 | 7 => SignalName::Charging, | |
| 159 | 8 => SignalName::ThermalState, | |
| 160 | 9 => SignalName::BatteryDrawW, | |
| 161 | 10 => SignalName::CpuLoad, | |
| 206 | 162 | _ => return None, |
| 207 | 163 | }) |
| 208 | 164 | } |
| @@ -217,24 +173,22 @@ impl SignalName { | ||
| 217 | 173 | #[derive(Debug, Clone, Copy, PartialEq)] |
| 218 | 174 | pub struct Value(pub f64); |
| 219 | 175 | |
| 220 | /// A structurally validated, non-content identifier (spec §1.2). | |
| 176 | /// A structurally validated, non-content identifier. | |
| 221 | 177 | /// |
| 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. | |
| 178 | /// Never free text. In the v4 contract the only identifier on the wire is an | |
| 179 | /// absolute repo path, so [`Tag::repo_path`] is the only constructor. Adding | |
| 180 | /// another means a validating constructor here, a [`SignalName`] that | |
| 181 | /// [`SignalName::allows_tag`] permits, and a schema bump. | |
| 225 | 182 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 226 | 183 | pub struct Tag(String); |
| 227 | 184 | |
| 228 | 185 | 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. | |
| 186 | /// An absolute repo path. Validated as a shape — absolute, non-empty, no | |
| 187 | /// interior NUL — a structural identifier, never free text. | |
| 188 | /// | |
| 189 | /// Shape is all this crate can check. Confining a tag to the roots the | |
| 190 | /// daemon was told to watch needs those roots, which only the daemon | |
| 191 | /// knows; `signald` enforces that as signals are collected. | |
| 238 | 192 | pub fn repo_path(s: &str) -> Option<Tag> { |
| 239 | 193 | if s.starts_with('/') && !s.is_empty() && !s.contains('\0') { |
| 240 | 194 | Some(Tag(s.to_string())) |
| @@ -243,11 +197,6 @@ impl Tag { | ||
| 243 | 197 | } |
| 244 | 198 | } |
| 245 | 199 | |
| 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 | 200 | /// Read-only view of the validated identifier. |
| 252 | 201 | pub fn as_str(&self) -> &str { |
| 253 | 202 | &self.0 |
| @@ -453,7 +402,7 @@ mod wire_tests { | ||
| 453 | 402 | let tag = Tag::repo_path("/Users/x/git/repo").expect("valid repo path"); |
| 454 | 403 | let s = Signal { |
| 455 | 404 | source: Source::Git, |
| 456 | ..sig(SignalName::BranchLastCommitAge, 3.0, Some(tag)) | |
| 405 | ..sig(SignalName::CommitsWindow, 3.0, Some(tag)) | |
| 457 | 406 | }; |
| 458 | 407 | let bytes = wire::encode(&s); |
| 459 | 408 | let back = wire::decode(&bytes).expect("decodes"); |
| @@ -541,4 +490,53 @@ mod wire_tests { | ||
| 541 | 490 | fn decode_rejects_short_buffer() { |
| 542 | 491 | assert!(wire::decode(&[0, 1, 2]).is_none()); |
| 543 | 492 | } |
| 493 | ||
| 494 | /// The v4 contract: eleven names, discriminants 0..=10, each round-tripping | |
| 495 | /// through the wire byte. Adding a variant without a discriminant, or | |
| 496 | /// reusing one, fails here. | |
| 497 | #[test] | |
| 498 | fn v4_names_are_exactly_zero_through_ten() { | |
| 499 | const NAMES: [SignalName; 11] = [ | |
| 500 | SignalName::KeysPerMin, | |
| 501 | SignalName::SessionSeconds, | |
| 502 | SignalName::CommitsWindow, | |
| 503 | SignalName::CommitsToday, | |
| 504 | SignalName::BranchCount, | |
| 505 | SignalName::DaysSinceLastCommit, | |
| 506 | SignalName::BatteryPct, | |
| 507 | SignalName::Charging, | |
| 508 | SignalName::ThermalState, | |
| 509 | SignalName::BatteryDrawW, | |
| 510 | SignalName::CpuLoad, | |
| 511 | ]; | |
| 512 | for (i, name) in NAMES.iter().enumerate() { | |
| 513 | assert_eq!(name.to_u8(), i as u8, "{name:?} discriminant"); | |
| 514 | assert_eq!(SignalName::from_u8(i as u8), Some(*name)); | |
| 515 | } | |
| 516 | assert_eq!(SignalName::from_u8(11), None, "11 is past the frozen set"); | |
| 517 | } | |
| 518 | ||
| 519 | /// Only the four git metrics may carry a tag in v4. | |
| 520 | #[test] | |
| 521 | fn only_git_repo_path_names_allow_a_tag() { | |
| 522 | for name in [ | |
| 523 | SignalName::CommitsWindow, | |
| 524 | SignalName::CommitsToday, | |
| 525 | SignalName::BranchCount, | |
| 526 | SignalName::DaysSinceLastCommit, | |
| 527 | ] { | |
| 528 | assert!(name.allows_tag(), "{name:?} should allow a tag"); | |
| 529 | } | |
| 530 | for name in [ | |
| 531 | SignalName::KeysPerMin, | |
| 532 | SignalName::SessionSeconds, | |
| 533 | SignalName::BatteryPct, | |
| 534 | SignalName::Charging, | |
| 535 | SignalName::ThermalState, | |
| 536 | SignalName::BatteryDrawW, | |
| 537 | SignalName::CpuLoad, | |
| 538 | ] { | |
| 539 | assert!(!name.allows_tag(), "{name:?} must not allow a tag"); | |
| 540 | } | |
| 541 | } | |
| 544 | 542 | } |
crates/signal-schema/tests/hardware_wire.rs +9 −9
| @@ -1,4 +1,4 @@ | ||
| 1 | //! # The Swift↔Rust hardware wire contract (v0.3) | |
| 1 | //! # The Swift↔Rust hardware wire contract (v4) | |
| 2 | 2 | //! |
| 3 | 3 | //! The macOS IOKit collector is a sibling Swift package (`macos-collector/`) |
| 4 | 4 | //! that speaks this crate's wire format. Swift and Rust are two independent |
| @@ -26,25 +26,25 @@ use signal_schema::{wire, Signal, SignalName, Source, Value, SCHEMA_VERSION}; | ||
| 26 | 26 | |
| 27 | 27 | /// The canonical hardware test vector, shared verbatim with the Swift side. |
| 28 | 28 | /// |
| 29 | /// Signal: schema_version=3, ts=0, source=Hardware(3), name=CpuLoad(28), | |
| 29 | /// Signal: schema_version=4, ts=0, source=Hardware(3), name=CpuLoad(10), | |
| 30 | 30 | /// value=0.5 (`f64`), tag=None. |
| 31 | 31 | /// |
| 32 | 32 | /// Frame bytes (little-endian throughout): |
| 33 | 33 | /// ```text |
| 34 | 34 | /// 15 00 00 00 body_len = 21 (u32) |
| 35 | /// 03 00 schema_version = 3 (u16) | |
| 35 | /// 04 00 schema_version = 4 (u16) | |
| 36 | 36 | /// 00 00 00 00 00 00 00 00 ts = 0 (u64) |
| 37 | 37 | /// 03 source = Hardware (u8) |
| 38 | /// 1C name = CpuLoad = 28 (u8) | |
| 38 | /// 0A name = CpuLoad = 10 (u8) | |
| 39 | 39 | /// 00 00 00 00 00 00 E0 3F value = 0.5 (f64) |
| 40 | 40 | /// 00 tag_present = 0 (u8) |
| 41 | 41 | /// ``` |
| 42 | 42 | const CANONICAL_FRAME: [u8; 25] = [ |
| 43 | 43 | 0x15, 0x00, 0x00, 0x00, // body_len = 21 |
| 44 | 0x03, 0x00, // schema_version = 3 | |
| 44 | 0x04, 0x00, // schema_version = 4 | |
| 45 | 45 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // ts = 0 |
| 46 | 46 | 0x03, // source = Hardware |
| 47 | 0x1C, // name = CpuLoad (28) | |
| 47 | 0x0A, // name = CpuLoad (10) | |
| 48 | 48 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x3F, // value = 0.5 |
| 49 | 49 | 0x00, // tag_present = 0 |
| 50 | 50 | ]; |
| @@ -76,10 +76,10 @@ fn rust_encoder_matches_the_shared_byte_vector() { | ||
| 76 | 76 | assert_eq!(wire::encode(&canonical_signal()), CANONICAL_FRAME.to_vec()); |
| 77 | 77 | } |
| 78 | 78 | |
| 79 | /// The new aggregate CPU metric is untagged and stable at discriminant 28. | |
| 79 | /// The aggregate CPU metric is untagged and frozen at discriminant 10. | |
| 80 | 80 | #[test] |
| 81 | 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)); | |
| 82 | assert_eq!(SignalName::CpuLoad.to_u8(), 10); | |
| 83 | assert_eq!(SignalName::from_u8(10), Some(SignalName::CpuLoad)); | |
| 84 | 84 | assert!(!SignalName::CpuLoad.allows_tag(), "aggregate CPU load is never tagged"); |
| 85 | 85 | } |
crates/signald/src/history.rs +53
| @@ -59,6 +59,7 @@ impl History { | ||
| 59 | 59 | // WAL: concurrent reads while the daemon writes; survives restarts. |
| 60 | 60 | conn.pragma_update(None, "journal_mode", "WAL")?; |
| 61 | 61 | let h = Self::init(conn, Some(retention.as_millis() as u64))?; |
| 62 | h.drop_foreign_schema_rows()?; | |
| 62 | 63 | h.prune()?; |
| 63 | 64 | Ok(h) |
| 64 | 65 | } |
| @@ -97,6 +98,21 @@ impl History { | ||
| 97 | 98 | }) |
| 98 | 99 | } |
| 99 | 100 | |
| 101 | /// Delete rows written by a different schema version. Returns the number | |
| 102 | /// deleted. | |
| 103 | /// | |
| 104 | /// `query` rebuilds each row from its stored `frame`, and `wire::decode` | |
| 105 | /// refuses a frame whose version is not [`signal_schema::SCHEMA_VERSION`]. | |
| 106 | /// Rows from an older daemon would therefore linger, unreadable and | |
| 107 | /// silently skipped, until retention caught up with them. Dropping them on | |
| 108 | /// open keeps the store to rows it can actually return. | |
| 109 | pub fn drop_foreign_schema_rows(&self) -> rusqlite::Result<usize> { | |
| 110 | self.conn.execute( | |
| 111 | "DELETE FROM signals WHERE schema_version != ?1", | |
| 112 | params![signal_schema::SCHEMA_VERSION], | |
| 113 | ) | |
| 114 | } | |
| 115 | ||
| 100 | 116 | /// Delete rows older than the retention. Returns the number deleted. |
| 101 | 117 | pub fn prune(&self) -> rusqlite::Result<usize> { |
| 102 | 118 | let Some(retention_ms) = self.retention_ms else { |
| @@ -252,4 +268,41 @@ mod tests { | ||
| 252 | 268 | assert_eq!(rows[0].value, Value(2.0)); |
| 253 | 269 | let _ = std::fs::remove_dir_all(&dir); |
| 254 | 270 | } |
| 271 | ||
| 272 | /// A row written under an older schema cannot be rebuilt from its frame, so | |
| 273 | /// reopening drops it rather than leaving it to be skipped silently. | |
| 274 | #[test] | |
| 275 | fn reopen_drops_rows_from_a_foreign_schema_version() { | |
| 276 | let dir = std::env::temp_dir().join(format!("signald-schema-{}", std::process::id())); | |
| 277 | std::fs::create_dir_all(&dir).unwrap(); | |
| 278 | let db = dir.join("history.db"); | |
| 279 | ||
| 280 | let old_version = signal_schema::SCHEMA_VERSION - 1; | |
| 281 | { | |
| 282 | let h = History::open(&db).unwrap(); | |
| 283 | // A row exactly as an older daemon left it: the version in the | |
| 284 | // column and in the stored frame both predate this build. | |
| 285 | let s = sig(SignalName::KeysPerMin, 1.0, None); | |
| 286 | let mut frame = wire::encode(&s); | |
| 287 | frame[4..6].copy_from_slice(&old_version.to_le_bytes()); | |
| 288 | h.conn | |
| 289 | .execute( | |
| 290 | "INSERT INTO signals (schema_version, ts, source, name, value, tag, frame) | |
| 291 | VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6)", | |
| 292 | params![old_version, s.ts as i64, s.source.to_u8(), s.name.to_u8(), s.value.0, frame], | |
| 293 | ) | |
| 294 | .unwrap(); | |
| 295 | assert_eq!(h.recent(10).unwrap().len(), 0, "unreadable while present"); | |
| 296 | } | |
| 297 | ||
| 298 | let h = History::open(&db).unwrap(); | |
| 299 | assert_eq!( | |
| 300 | h.conn | |
| 301 | .query_row("SELECT COUNT(*) FROM signals", [], |r| r.get::<_, i64>(0)) | |
| 302 | .unwrap(), | |
| 303 | 0, | |
| 304 | "the foreign-schema row is gone, not merely unreadable" | |
| 305 | ); | |
| 306 | let _ = std::fs::remove_dir_all(&dir); | |
| 307 | } | |
| 255 | 308 | } |
crates/signald/src/lib.rs +56
| @@ -12,11 +12,28 @@ | ||
| 12 | 12 | //! v0.4 adds the **hardware path**: the daemon spawns the sibling |
| 13 | 13 | //! `macos-collector` and ingests its wire frames ([`collectors::hardware`]). |
| 14 | 14 | |
| 15 | use std::path::{Path, PathBuf}; | |
| 15 | 16 | use std::time::{SystemTime, UNIX_EPOCH}; |
| 16 | 17 | |
| 18 | use signal_schema::Signal; | |
| 19 | ||
| 17 | 20 | pub mod history; |
| 18 | 21 | pub mod hub; |
| 19 | 22 | |
| 23 | /// Whether `sig` may be published: any tag it carries must name a path under | |
| 24 | /// one of the roots the daemon was told to watch. | |
| 25 | /// | |
| 26 | /// `Tag::repo_path` validates shape and nothing else — the watched roots live | |
| 27 | /// here, not in the schema crate. Comparison is by path component, so `/a/bc` | |
| 28 | /// is not under `/a/b`. | |
| 29 | pub fn tag_within_roots(sig: &Signal, roots: &[PathBuf]) -> bool { | |
| 30 | let Some(tag) = &sig.tag else { | |
| 31 | return true; | |
| 32 | }; | |
| 33 | let path = Path::new(tag.as_str()); | |
| 34 | roots.iter().any(|root| path.starts_with(root)) | |
| 35 | } | |
| 36 | ||
| 20 | 37 | /// Collectors: each reduces its domain to schema scalars (spec §1.1, §1.4). |
| 21 | 38 | pub mod collectors { |
| 22 | 39 | /// Git collector: shell out to `git` for counts and ages. Counts and |
| @@ -492,3 +509,42 @@ pub fn now_millis() -> u64 { | ||
| 492 | 509 | .map(|d| d.as_millis() as u64) |
| 493 | 510 | .unwrap_or(0) |
| 494 | 511 | } |
| 512 | ||
| 513 | #[cfg(test)] | |
| 514 | mod root_tests { | |
| 515 | use super::*; | |
| 516 | use signal_schema::{SignalName, Source, Tag, Value, SCHEMA_VERSION}; | |
| 517 | ||
| 518 | fn tagged(path: &str) -> Signal { | |
| 519 | Signal { | |
| 520 | schema_version: SCHEMA_VERSION, | |
| 521 | ts: 0, | |
| 522 | source: Source::Git, | |
| 523 | name: SignalName::CommitsToday, | |
| 524 | value: Value(1.0), | |
| 525 | tag: Tag::repo_path(path), | |
| 526 | } | |
| 527 | } | |
| 528 | ||
| 529 | #[test] | |
| 530 | fn a_tag_under_a_watched_root_is_allowed() { | |
| 531 | let roots = vec![PathBuf::from("/home/x/git")]; | |
| 532 | assert!(tag_within_roots(&tagged("/home/x/git/repo"), &roots)); | |
| 533 | assert!(tag_within_roots(&tagged("/home/x/git"), &roots)); | |
| 534 | } | |
| 535 | ||
| 536 | #[test] | |
| 537 | fn a_tag_outside_every_watched_root_is_refused() { | |
| 538 | let roots = vec![PathBuf::from("/home/x/git")]; | |
| 539 | assert!(!tag_within_roots(&tagged("/etc/passwd"), &roots)); | |
| 540 | // Component-wise, so a shared string prefix is not a shared path. | |
| 541 | assert!(!tag_within_roots(&tagged("/home/x/gitsecrets"), &roots)); | |
| 542 | } | |
| 543 | ||
| 544 | #[test] | |
| 545 | fn an_untagged_signal_needs_no_root() { | |
| 546 | let mut s = tagged("/home/x/git/repo"); | |
| 547 | s.tag = None; | |
| 548 | assert!(tag_within_roots(&s, &[])); | |
| 549 | } | |
| 550 | } | |
crates/signald/src/main.rs +4
| @@ -93,6 +93,10 @@ fn main() { | ||
| 93 | 93 | loop { |
| 94 | 94 | for repo in &repos { |
| 95 | 95 | for sig in collectors::git::collect(repo) { |
| 96 | // The audited repo-path tag never leaves the watched roots. | |
| 97 | if !signald::tag_within_roots(&sig, &repos) { | |
| 98 | continue; | |
| 99 | } | |
| 96 | 100 | producer.publish(sig); |
| 97 | 101 | } |
| 98 | 102 | } |
crates/terminal-garden/src/lib.rs +2 −2
| @@ -122,8 +122,8 @@ impl Plot { | ||
| 122 | 122 | } |
| 123 | 123 | |
| 124 | 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. | |
| 125 | /// tag; untagged signals collapse into a single unnamed plot). Names this | |
| 126 | /// renderer does not draw are ignored here. | |
| 127 | 127 | pub fn plots_from_signals(signals: &[Signal]) -> Vec<Plot> { |
| 128 | 128 | let mut by_repo: BTreeMap<String, Plot> = BTreeMap::new(); |
| 129 | 129 | |
macos-collector/README.md +9 −9
| @@ -20,11 +20,11 @@ swift run macos-collector --once # one real IOKit read (summary on stderr) | ||
| 20 | 20 | |
| 21 | 21 | | Metric | `SignalName` | Source | How | |
| 22 | 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` | | |
| 23 | | Aggregate CPU load `[0,1]` | `cpu_load` (10) | hardware | Mach `host_processor_info(PROCESSOR_CPU_LOAD_INFO)` tick deltas | | |
| 24 | | Battery percentage `[0,100]` | `battery_pct` (6) | macos | IOKit power sources (`IOPSCopyPowerSourcesInfo`) | | |
| 25 | | Charging (1/0) | `charging` (7) | macos | IOKit power sources | | |
| 26 | | Battery draw (W) | `battery_draw_w` (9) | hardware | IORegistry `AppleSmartBattery` Amperage×Voltage | | |
| 27 | | Thermal state `0..3` | `thermal_state` (8) | macos | `ProcessInfo.thermalState` | | |
| 28 | 28 | |
| 29 | 29 | **Intentionally omitted:** GPU utilization and fan RPM. They are reachable in |
| 30 | 30 | principle via `IOReport`/AppleSMC, but only through chip-generation-specific, |
| @@ -62,7 +62,7 @@ multi-byte integers and the `f64` (via its IEEE-754 bit pattern) are | ||
| 62 | 62 | frame: |
| 63 | 63 | [u32 body_len] little-endian length of body |
| 64 | 64 | body: |
| 65 | [u16 schema_version] must equal 3 (v0.3); a daemon drops other versions | |
| 65 | [u16 schema_version] must equal 4 (v4); a reader skips other versions | |
| 66 | 66 | [u64 ts] unix milliseconds |
| 67 | 67 | [u8 source] 0=terminal 1=git 2=macos 3=hardware |
| 68 | 68 | [u8 name] SignalName discriminant (see table above) |
| @@ -74,15 +74,15 @@ frame: | ||
| 74 | 74 | |
| 75 | 75 | ### Canonical frame (pinned by tests on both sides) |
| 76 | 76 | |
| 77 | `schema_version=3, ts=0, source=Hardware(3), name=CpuLoad(28), value=0.5, | |
| 77 | `schema_version=4, ts=0, source=Hardware(3), name=CpuLoad(10), value=0.5, | |
| 78 | 78 | tag=none` encodes to these exact 25 bytes: |
| 79 | 79 | |
| 80 | 80 | ```text |
| 81 | 81 | 15 00 00 00 body_len = 21 |
| 82 | 03 00 schema_version = 3 | |
| 82 | 04 00 schema_version = 4 | |
| 83 | 83 | 00 00 00 00 00 00 00 00 ts = 0 |
| 84 | 84 | 03 source = Hardware |
| 85 | 1C name = CpuLoad (28) | |
| 85 | 0A name = CpuLoad (10) | |
| 86 | 86 | 00 00 00 00 00 00 E0 3F value = 0.5 (f64 LE) |
| 87 | 87 | 00 tag_present = 0 |
| 88 | 88 | ``` |
macos-collector/Sources/CollectorCore/Wire.swift +8 −8
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 3 | 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 | |
| 4 | /// Rust side (v4 as of 0.5.0, the 1.0 contract). A reader skips frames whose | |
| 5 | /// version it does not understand, so this is a hard cross-language contract. | |
| 6 | public let SCHEMA_VERSION: UInt16 = 4 | |
| 7 | 7 | |
| 8 | 8 | /// The collector domain. Discriminants **must match** Rust `Source::to_u8`. |
| 9 | 9 | public enum Source: UInt8 { |
| @@ -20,11 +20,11 @@ public enum Source: UInt8 { | ||
| 20 | 20 | /// `f64` scalar, and none of them carries a tag (all are emitted with |
| 21 | 21 | /// `tag == nil`), exactly as `SignalName::allows_tag` is `false` for them. |
| 22 | 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] | |
| 23 | case batteryPct = 6 // macos: battery charge percentage [0, 100] | |
| 24 | case charging = 7 // macos: 1.0 charging, 0.0 not | |
| 25 | case thermalState = 8 // macos: ProcessInfo thermal state 0..3 | |
| 26 | case batteryDrawW = 9 // hardware: instantaneous battery draw, watts | |
| 27 | case cpuLoad = 10 // hardware: aggregate CPU busy fraction [0, 1] | |
| 28 | 28 | |
| 29 | 29 | /// Human-readable name, matching the Rust metric names (for `--once`). |
| 30 | 30 | public var label: String { |
macos-collector/Tests/CollectorCoreTests/WireTests.swift +3 −3
| @@ -14,10 +14,10 @@ final class WireTests: XCTestCase { | ||
| 14 | 14 | let signal = Signal(ts: 0, source: .hardware, name: .cpuLoad, value: 0.5) |
| 15 | 15 | let expected: [UInt8] = [ |
| 16 | 16 | 0x15, 0x00, 0x00, 0x00, // body_len = 21 |
| 17 | 0x03, 0x00, // schema_version = 3 | |
| 17 | 0x04, 0x00, // schema_version = 4 | |
| 18 | 18 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // ts = 0 |
| 19 | 19 | 0x03, // source = Hardware |
| 20 | 0x1C, // name = CpuLoad (28) | |
| 20 | 0x0A, // name = CpuLoad (10) | |
| 21 | 21 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x3F, // value = 0.5 (f64 LE) |
| 22 | 22 | 0x00, // tag_present = 0 |
| 23 | 23 | ] |
| @@ -27,7 +27,7 @@ final class WireTests: XCTestCase { | ||
| 27 | 27 | /// The schema version must equal the Rust `SCHEMA_VERSION`, or the daemon |
| 28 | 28 | /// drops every frame this collector sends. |
| 29 | 29 | func testSchemaVersionMatchesRust() { |
| 30 | XCTAssertEqual(SCHEMA_VERSION, 3) | |
| 30 | XCTAssertEqual(SCHEMA_VERSION, 4) | |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | 33 | /// The tagged-frame layout matches Rust too (hardware signals are untagged, |