Skip to main content

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/// ```jsonc
22/// {"pc_id":"PC1234","online":true,"vpn":"connected",
23///  "checks":[{"name":"bitlocker","status":"ok"}],
24///  "agent_version":"0.4.0","target_version":"0.4.0"}
25/// ```
26#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
27pub struct StateSnapshot {
28    /// Agent's `pc_id` — duplicated here from the handshake so the
29    /// SPA can refresh the snapshot independently without
30    /// re-handshaking.
31    pub pc_id: String,
32    /// `true` when the agent currently has a NATS connection open.
33    /// Distinct from the OS-level network state — operators care
34    /// about "is fleet management reachable" specifically.
35    pub online: bool,
36    /// VPN posture. Free-form string today (`"connected"` /
37    /// `"disconnected"` / `"unknown"` / a vendor-specific status)
38    /// because SPEC §2.1's compliance checks are
39    /// site-specific. Future SPEC version may tighten this into an
40    /// enum.
41    pub vpn: String,
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}
84
85/// Four-state result mirroring the SPA's color palette: ok = green,
86/// warn = yellow, fail = red, unknown = grey. Wire-encoded as
87/// snake_case (`"ok"` / `"warn"` / `"fail"` / `"unknown"`) — the
88/// PascalCase convention is reserved for [`super::error::ErrorKind`]
89/// where SPEC §2.12.9 specifically pins it.
90#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
91#[serde(rename_all = "snake_case")]
92pub enum CheckStatus {
93    /// Check passed.
94    Ok,
95    /// Non-blocking finding. SPA renders yellow; user can ignore.
96    Warn,
97    /// Failed — SPA renders red. If a `troubleshoot` manifest is
98    /// declared, the "修復する" button is enabled.
99    Fail,
100    /// Check couldn't run (agent timed out, WMI hang, …). SPA
101    /// renders grey "Unknown" — operator should investigate via
102    /// `system.log_tail`.
103    Unknown,
104}
105
106// ---------- state.subscribe ----------
107
108/// `state.subscribe` takes no params.
109#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
110pub struct StateSubscribeParams {}
111
112/// `state.subscribe` returns an opaque subscription handle. The
113/// client passes it back to `state.unsubscribe` to stop the push
114/// stream; SPEC §2.12.7 says subscriptions are auto-cleaned on
115/// disconnect, so a well-behaved client never needs to remember
116/// these across reconnects.
117#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
118pub struct StateSubscribeResult {
119    pub subscription: String,
120}
121
122/// `state.unsubscribe` params.
123#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
124pub struct StateUnsubscribeParams {
125    pub subscription: String,
126}
127
128// ---------- state.changed (push) ----------
129
130/// Push payload for `state.changed`. Pushed by the agent when one
131/// or more compliance checks flip status, or when `online` / `vpn`
132/// / `agent_version` change. A full [`StateSnapshot`] is included
133/// so the client doesn't need a second round-trip — the push is
134/// strictly idempotent: applying a `state.changed` payload onto the
135/// client's cached snapshot is a no-op replace, not a diff merge.
136#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
137pub struct StateChangedParams {
138    /// Full snapshot at the time of the change.
139    pub snapshot: StateSnapshot,
140    /// Wall-clock when the agent detected the change. Lets the
141    /// client surface "updated 3 s ago" without trusting its own
142    /// clock for the agent's processing time.
143    pub at: chrono::DateTime<chrono::Utc>,
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn check_status_serialises_snake_case() {
152        for (variant, expected) in [
153            (CheckStatus::Ok, "\"ok\""),
154            (CheckStatus::Warn, "\"warn\""),
155            (CheckStatus::Fail, "\"fail\""),
156            (CheckStatus::Unknown, "\"unknown\""),
157        ] {
158            let s = serde_json::to_string(&variant).unwrap();
159            assert_eq!(s, expected, "encode {variant:?}");
160            let back: CheckStatus = serde_json::from_str(expected).unwrap();
161            assert_eq!(back, variant, "round-trip {expected}");
162        }
163    }
164
165    #[test]
166    fn state_snapshot_spec_example_decodes() {
167        // SPEC §2.12.8 — pinned so a rename can't drift the
168        // documented contract.
169        let wire = r#"{
170            "pc_id":"PC1234","online":true,"vpn":"connected",
171            "checks":[{"name":"bitlocker","status":"ok"},
172                      {"name":"av_signature","status":"warn","detail":"3 日前"}],
173            "agent_version":"0.4.0","target_version":"0.4.0"
174        }"#;
175        let s: StateSnapshot = serde_json::from_str(wire).expect("decode");
176        assert_eq!(s.pc_id, "PC1234");
177        assert!(s.online);
178        assert_eq!(s.vpn, "connected");
179        assert_eq!(s.checks.len(), 2);
180        assert_eq!(s.checks[0].name, "bitlocker");
181        assert_eq!(s.checks[0].status, CheckStatus::Ok);
182        assert_eq!(s.checks[1].name, "av_signature");
183        assert_eq!(s.checks[1].status, CheckStatus::Warn);
184        assert_eq!(s.checks[1].detail.as_deref(), Some("3 日前"));
185        assert_eq!(s.agent_version, "0.4.0");
186        assert_eq!(s.target_version, "0.4.0");
187    }
188
189    #[test]
190    fn check_with_troubleshoot_round_trips() {
191        let c = Check {
192            name: "av_signature".into(),
193            label: Some("ウイルス対策の定義ファイル".into()),
194            status: CheckStatus::Fail,
195            detail: Some("Signatures > 7 days old".into()),
196            troubleshoot: Some("update-av-signatures".into()),
197        };
198        let json = serde_json::to_string(&c).unwrap();
199        let back: Check = serde_json::from_str(&json).unwrap();
200        assert_eq!(back.name, c.name);
201        assert_eq!(back.label, c.label);
202        assert_eq!(back.status, c.status);
203        assert_eq!(back.detail, c.detail);
204        assert_eq!(back.troubleshoot, c.troubleshoot);
205    }
206
207    #[test]
208    fn check_without_optional_fields_decodes() {
209        // Minimal check — `label` + `detail` + `troubleshoot` should
210        // all be absent on the wire (not `null`) thanks to
211        // `skip_serializing_if`.
212        let c = Check {
213            name: "bitlocker".into(),
214            label: None,
215            status: CheckStatus::Ok,
216            detail: None,
217            troubleshoot: None,
218        };
219        let v = serde_json::to_value(&c).unwrap();
220        assert!(v.get("label").is_none(), "wire: {v:?}");
221        assert!(v.get("detail").is_none(), "wire: {v:?}");
222        assert!(v.get("troubleshoot").is_none(), "wire: {v:?}");
223    }
224
225    #[test]
226    fn check_status_parses_from_a_free_form_object_field() {
227        // The unified model: a check's stdout is an inventory-style
228        // object; the agent reads the `status_field` value (default
229        // "status") and parses it into a CheckStatus. Pin that the
230        // wire encoding the operator writes round-trips.
231        let obj: serde_json::Value = serde_json::from_str(
232            r#"{"status":"warn","detail":"D: unprotected","volumes":[{"drive":"C:","on":true}]}"#,
233        )
234        .expect("decode");
235        let status: CheckStatus =
236            serde_json::from_value(obj.get("status").unwrap().clone()).expect("status parses");
237        assert_eq!(status, CheckStatus::Warn);
238        assert_eq!(obj.get("detail").unwrap().as_str(), Some("D: unprotected"));
239        // The rest of the object is free-form (inventory projects it).
240        assert!(obj.get("volumes").unwrap().is_array());
241    }
242
243    #[test]
244    fn state_changed_push_round_trips() {
245        let p = StateChangedParams {
246            snapshot: StateSnapshot {
247                pc_id: "PC1234".into(),
248                online: true,
249                vpn: "connected".into(),
250                checks: vec![],
251                agent_version: "0.4.0".into(),
252                target_version: "0.4.0".into(),
253            },
254            at: chrono::Utc::now(),
255        };
256        let json = serde_json::to_string(&p).unwrap();
257        let back: StateChangedParams = serde_json::from_str(&json).unwrap();
258        assert_eq!(back.snapshot.pc_id, "PC1234");
259    }
260}