kanade_shared/ipc/state.rs
1//! `state.*` method types — endpoint health snapshot + push notifications.
2//!
3//! Drives the Client App's "Health" tab (SPEC §2.1 use case 2):
4//! BitLocker / AV signature / OS-patch / cert-expiry / disk-free /
5//! agent-self-update + arbitrary additional compliance checks. The
6//! snapshot is computed agent-side on demand (`state.snapshot`) and
7//! pushed when underlying checks flip via `state.changed`.
8
9use serde::{Deserialize, Serialize};
10
11// ---------- state.snapshot ----------
12
13/// `state.snapshot` takes no params.
14#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
15pub struct StateSnapshotParams {}
16
17/// Full state bundle — the SPA renders this verbatim on the Health
18/// tab. SPEC §2.12.8's complete-conversation example pins the
19/// shape:
20///
21/// There is deliberately no dedicated `vpn` field: VPN posture is
22/// probe-able from the box and site-specific, so it belongs in
23/// [`checks`](StateSnapshot::checks) as an operator-defined `check:`
24/// job (same path as `disk_free` / `bitlocker`), not as a hard-coded
25/// snapshot field. A site that wants it ships a `check-vpn.yaml`.
26///
27/// ```jsonc
28/// {"pc_id":"PC1234","online":true,
29/// "checks":[{"name":"bitlocker","status":"ok"}],
30/// "agent_version":"0.4.0","target_version":"0.4.0"}
31/// ```
32#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
33pub struct StateSnapshot {
34 /// Agent's `pc_id` — duplicated here from the handshake so the
35 /// SPA can refresh the snapshot independently without
36 /// re-handshaking.
37 pub pc_id: String,
38 /// `true` when the agent currently has a NATS connection open.
39 /// Distinct from the OS-level network state — operators care
40 /// about "is fleet management reachable" specifically.
41 pub online: bool,
42 /// Ordered list of compliance check results. Each [`Check`]
43 /// item is rendered as a row on the Health tab; failing rows
44 /// surface a "修復する" button per SPEC §2.1.
45 pub checks: Vec<Check>,
46 /// Currently-running agent binary version
47 /// (`CARGO_PKG_VERSION`). Same value as
48 /// [`super::system::VersionResult::agent_version`].
49 pub agent_version: String,
50 /// Version the agent self-updater is targeting. When this
51 /// differs from `agent_version`, the SPA shows "restart pending"
52 /// on the Health tab.
53 pub target_version: String,
54}
55
56/// One compliance check result. `name` is the stable id (used as
57/// React key + analytics label); `label` is the optional human-facing
58/// title shown instead of the slug; `status` drives the row's color;
59/// `detail` is human-readable text for the row body. `troubleshoot`
60/// is the optional `Manifest.id` of the job whose execute button
61/// fixes this check — `None` means the check has no auto-remediation.
62#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
63pub struct Check {
64 pub name: String,
65 /// Optional human-facing row title. When set, the Client App's
66 /// Health tab renders this instead of [`name`](Check::name) — a
67 /// `defender_rtp` slug becomes e.g. "ウイルス対策のリアルタイム保護".
68 /// Sourced from the check job's
69 /// [`CheckHint.label`](crate::manifest::CheckHint::label); `None`
70 /// falls back to the slug, so it's purely additive on the wire.
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub label: Option<String>,
73 pub status: CheckStatus,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub detail: Option<String>,
76 /// Manifest id of a `category: troubleshoot` job that fixes
77 /// this check. The Client App renders a "修復する" button when
78 /// present (SPEC §2.1). The job MUST have `user_invokable:
79 /// true` — if not, `jobs.execute` returns `Unauthorized` when
80 /// the button is clicked.
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub troubleshoot: Option<String>,
83 /// When `true`, the Client App must NOT render this check on its
84 /// Health tab (nor count it in the health summary). Set by the agent
85 /// from a `check:` job's [`CheckHint.health`](crate::manifest::CheckHint::health)
86 /// `== false` — a **gate-only** check that exists purely to drive a
87 /// `client.show_when` display gate. The check is STILL carried in the
88 /// snapshot (so `show_when` evaluation, which reads the same
89 /// `StateSnapshot.checks`, keeps working); only the end-user Health
90 /// *rendering* skips it. New field ⇒ #492 wire rule: `default` (absent
91 /// ⇒ `false` ⇒ shown, unchanged for old readers) + `skip_serializing_if`
92 /// so the overwhelmingly-common shown case stays off the wire.
93 #[serde(default, skip_serializing_if = "is_false")]
94 pub health_hidden: bool,
95}
96
97/// `skip_serializing_if` predicate for a `bool` that defaults to `false`:
98/// keep the field off the wire in the common (`false`) case. Clearer than
99/// the equivalent `std::ops::Not::not` (which only type-checks via the
100/// blanket `impl Not for &bool`).
101fn is_false(b: &bool) -> bool {
102 !*b
103}
104
105/// Four-state result mirroring the SPA's color palette: ok = green,
106/// warn = yellow, fail = red, unknown = grey. Wire-encoded as
107/// snake_case (`"ok"` / `"warn"` / `"fail"` / `"unknown"`) — the
108/// PascalCase convention is reserved for [`super::error::ErrorKind`]
109/// where SPEC §2.12.9 specifically pins it.
110#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
111#[serde(rename_all = "snake_case")]
112pub enum CheckStatus {
113 /// Check passed.
114 Ok,
115 /// Non-blocking finding. SPA renders yellow; user can ignore.
116 Warn,
117 /// Failed — SPA renders red. If a `troubleshoot` manifest is
118 /// declared, the "修復する" button is enabled.
119 Fail,
120 /// Check couldn't run (agent timed out, WMI hang, …). SPA
121 /// renders grey "Unknown" — operator should investigate via
122 /// `system.log_tail`.
123 Unknown,
124}
125
126impl CheckStatus {
127 /// The wire-encoded snake_case name — the exact string the serde
128 /// encoding above produces and what the backend's `check_status`
129 /// SQLite column stores.
130 pub fn as_str(self) -> &'static str {
131 match self {
132 CheckStatus::Ok => "ok",
133 CheckStatus::Warn => "warn",
134 CheckStatus::Fail => "fail",
135 CheckStatus::Unknown => "unknown",
136 }
137 }
138}
139
140// ---------- state.subscribe ----------
141
142/// `state.subscribe` takes no params.
143#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
144pub struct StateSubscribeParams {}
145
146/// `state.subscribe` returns an opaque subscription handle. The
147/// client passes it back to `state.unsubscribe` to stop the push
148/// stream; SPEC §2.12.7 says subscriptions are auto-cleaned on
149/// disconnect, so a well-behaved client never needs to remember
150/// these across reconnects.
151#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
152pub struct StateSubscribeResult {
153 pub subscription: String,
154}
155
156/// `state.unsubscribe` params.
157#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
158pub struct StateUnsubscribeParams {
159 pub subscription: String,
160}
161
162// ---------- state.changed (push) ----------
163
164/// Push payload for `state.changed`. Pushed by the agent when one
165/// or more compliance checks flip status, or when `online` /
166/// `agent_version` change. A full [`StateSnapshot`] is included
167/// so the client doesn't need a second round-trip — the push is
168/// strictly idempotent: applying a `state.changed` payload onto the
169/// client's cached snapshot is a no-op replace, not a diff merge.
170#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
171pub struct StateChangedParams {
172 /// Full snapshot at the time of the change.
173 pub snapshot: StateSnapshot,
174 /// Wall-clock when the agent detected the change. Lets the
175 /// client surface "updated 3 s ago" without trusting its own
176 /// clock for the agent's processing time.
177 pub at: chrono::DateTime<chrono::Utc>,
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn check_status_serialises_snake_case() {
186 for (variant, expected) in [
187 (CheckStatus::Ok, "\"ok\""),
188 (CheckStatus::Warn, "\"warn\""),
189 (CheckStatus::Fail, "\"fail\""),
190 (CheckStatus::Unknown, "\"unknown\""),
191 ] {
192 let s = serde_json::to_string(&variant).unwrap();
193 assert_eq!(s, expected, "encode {variant:?}");
194 let back: CheckStatus = serde_json::from_str(expected).unwrap();
195 assert_eq!(back, variant, "round-trip {expected}");
196 }
197 }
198
199 #[test]
200 fn state_snapshot_spec_example_decodes() {
201 // SPEC §2.12.8 — pinned so a rename can't drift the
202 // documented contract.
203 let wire = r#"{
204 "pc_id":"PC1234","online":true,
205 "checks":[{"name":"bitlocker","status":"ok"},
206 {"name":"av_signature","status":"warn","detail":"3 日前"}],
207 "agent_version":"0.4.0","target_version":"0.4.0"
208 }"#;
209 let s: StateSnapshot = serde_json::from_str(wire).expect("decode");
210 assert_eq!(s.pc_id, "PC1234");
211 assert!(s.online);
212 assert_eq!(s.checks.len(), 2);
213 assert_eq!(s.checks[0].name, "bitlocker");
214 assert_eq!(s.checks[0].status, CheckStatus::Ok);
215 assert_eq!(s.checks[1].name, "av_signature");
216 assert_eq!(s.checks[1].status, CheckStatus::Warn);
217 assert_eq!(s.checks[1].detail.as_deref(), Some("3 日前"));
218 assert_eq!(s.agent_version, "0.4.0");
219 assert_eq!(s.target_version, "0.4.0");
220 }
221
222 #[test]
223 fn check_with_troubleshoot_round_trips() {
224 let c = Check {
225 name: "av_signature".into(),
226 label: Some("ウイルス対策の定義ファイル".into()),
227 status: CheckStatus::Fail,
228 detail: Some("Signatures > 7 days old".into()),
229 troubleshoot: Some("update-av-signatures".into()),
230 health_hidden: false,
231 };
232 let json = serde_json::to_string(&c).unwrap();
233 let back: Check = serde_json::from_str(&json).unwrap();
234 assert_eq!(back.name, c.name);
235 assert_eq!(back.label, c.label);
236 assert_eq!(back.status, c.status);
237 assert_eq!(back.detail, c.detail);
238 assert_eq!(back.troubleshoot, c.troubleshoot);
239 }
240
241 #[test]
242 fn check_without_optional_fields_decodes() {
243 // Minimal check — `label` + `detail` + `troubleshoot` should
244 // all be absent on the wire (not `null`) thanks to
245 // `skip_serializing_if`.
246 let c = Check {
247 name: "bitlocker".into(),
248 label: None,
249 status: CheckStatus::Ok,
250 detail: None,
251 troubleshoot: None,
252 health_hidden: false,
253 };
254 let v = serde_json::to_value(&c).unwrap();
255 assert!(v.get("label").is_none(), "wire: {v:?}");
256 assert!(v.get("detail").is_none(), "wire: {v:?}");
257 assert!(v.get("troubleshoot").is_none(), "wire: {v:?}");
258 // health_hidden = false is the common case → absent on the wire.
259 assert!(v.get("health_hidden").is_none(), "wire: {v:?}");
260 }
261
262 #[test]
263 fn check_status_parses_from_a_free_form_object_field() {
264 // The unified model: a check's stdout is an inventory-style
265 // object; the agent reads the `status_field` value (default
266 // "status") and parses it into a CheckStatus. Pin that the
267 // wire encoding the operator writes round-trips.
268 let obj: serde_json::Value = serde_json::from_str(
269 r#"{"status":"warn","detail":"D: unprotected","volumes":[{"drive":"C:","on":true}]}"#,
270 )
271 .expect("decode");
272 let status: CheckStatus =
273 serde_json::from_value(obj.get("status").unwrap().clone()).expect("status parses");
274 assert_eq!(status, CheckStatus::Warn);
275 assert_eq!(obj.get("detail").unwrap().as_str(), Some("D: unprotected"));
276 // The rest of the object is free-form (inventory projects it).
277 assert!(obj.get("volumes").unwrap().is_array());
278 }
279
280 #[test]
281 fn state_changed_push_round_trips() {
282 let p = StateChangedParams {
283 snapshot: StateSnapshot {
284 pc_id: "PC1234".into(),
285 online: true,
286 checks: vec![],
287 agent_version: "0.4.0".into(),
288 target_version: "0.4.0".into(),
289 },
290 at: chrono::Utc::now(),
291 };
292 let json = serde_json::to_string(&p).unwrap();
293 let back: StateChangedParams = serde_json::from_str(&json).unwrap();
294 assert_eq!(back.snapshot.pc_id, "PC1234");
295 }
296}