Skip to main content

kanade_shared/
check_eval.rs

1//! Derive a [`Check`] from a `check:` job's run output — the single
2//! source of truth shared by the agent's Health-tab cache
3//! (`kanade-agent::check_cache`) and the backend's fleet-wide
4//! `check_status` projector, so the end user's Client App and the
5//! operator SPA can never disagree about the same run (#908).
6//!
7//! Pure functions (no I/O) on purpose: both consumers feed them the
8//! already-captured stdout/stderr of an ExecResult.
9
10use crate::ipc::state::{Check, CheckStatus};
11use crate::manifest::CheckHint;
12
13/// Map a check job's stdout JSON object + its [`CheckHint`] into a KLP
14/// [`Check`]. Pure (no I/O) so it's unit-testable. Non-JSON / non-object
15/// stdout, or a missing / unrecognised `status_field`, degrades to
16/// [`CheckStatus::Unknown`] with a diagnostic detail — everything else
17/// is up to the operator's PowerShell.
18pub fn build_check(hint: &CheckHint, stdout: &str) -> Check {
19    // #821: read check's own fenced block so a job can compose check with
20    // inventory / collect (and/or a user message) on one stdout. No fence
21    // ⇒ the whole stdout (back-compat for a check-only job). `fenced_
22    // payload` already trims.
23    let payload = crate::manifest::fenced_payload(
24        stdout,
25        crate::manifest::CHECK_BLOCK_BEGIN,
26        crate::manifest::CHECK_BLOCK_END,
27    );
28    let value: serde_json::Value = match serde_json::from_str(payload) {
29        Ok(v) => v,
30        Err(e) => return unknown(hint, format!("check stdout was not JSON: {e}")),
31    };
32    let Some(obj) = value.as_object() else {
33        return unknown(hint, "check stdout was not a JSON object".to_string());
34    };
35    let status = match obj.get(&hint.status_field) {
36        Some(v) => match serde_json::from_value::<CheckStatus>(v.clone()) {
37            Ok(s) => s,
38            Err(_) => {
39                return unknown(
40                    hint,
41                    format!(
42                        "`{}` = {v} is not one of ok/warn/fail/unknown",
43                        hint.status_field
44                    ),
45                );
46            }
47        },
48        None => {
49            return unknown(hint, format!("stdout has no `{}` field", hint.status_field));
50        }
51    };
52    let detail = obj.get(&hint.detail_field).and_then(json_to_detail);
53    Check {
54        name: hint.name.clone(),
55        label: hint.label.clone(),
56        status,
57        detail,
58        troubleshoot: hint.troubleshoot.clone(),
59        // gate-only checks (`health: false`) stay in the snapshot for
60        // show_when but are hidden from the Client App's Health tab.
61        health_hidden: !hint.health,
62    }
63}
64
65/// Build the [`Check`] for a `check:` job that exited non-zero. The
66/// script crashed before it could report a status, so we surface
67/// `Unknown` with the exit code + a stderr snippet rather than
68/// leaving a stale `Ok` on the Health tab (a persistently-crashing
69/// check must not read as healthy).
70pub fn build_check_failed(hint: &CheckHint, exit_code: i32, stderr: &str) -> Check {
71    let snippet: String = stderr.trim().chars().take(200).collect();
72    let detail = if snippet.is_empty() {
73        format!("check script exited {exit_code}")
74    } else {
75        format!("check script exited {exit_code}: {snippet}")
76    };
77    unknown(hint, detail)
78}
79
80fn unknown(hint: &CheckHint, detail: String) -> Check {
81    Check {
82        name: hint.name.clone(),
83        label: hint.label.clone(),
84        status: CheckStatus::Unknown,
85        detail: Some(detail),
86        troubleshoot: hint.troubleshoot.clone(),
87        // gate-only checks (`health: false`) stay in the snapshot for
88        // show_when but are hidden from the Client App's Health tab.
89        health_hidden: !hint.health,
90    }
91}
92
93/// Render a detail field value as a string: pass non-empty strings
94/// through, stringify scalars, and compact-JSON-encode arrays /
95/// objects (so an operator who puts structured data in `detail` sees
96/// *something* on the row instead of a silently-blank column). Only
97/// `null` / empty-string yield `None`.
98pub fn json_to_detail(v: &serde_json::Value) -> Option<String> {
99    match v {
100        serde_json::Value::Null => None,
101        serde_json::Value::String(s) if s.is_empty() => None,
102        serde_json::Value::String(s) => Some(s.clone()),
103        serde_json::Value::Number(n) => Some(n.to_string()),
104        serde_json::Value::Bool(b) => Some(b.to_string()),
105        // Array / Object → compact JSON, e.g. `["C:","D:"]`.
106        other => Some(other.to_string()),
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    fn hint(name: &str) -> CheckHint {
115        CheckHint {
116            name: name.into(),
117            label: None,
118            status_field: "status".into(),
119            detail_field: "detail".into(),
120            troubleshoot: None,
121            fleet: true,
122            health: true,
123            alert: None,
124        }
125    }
126
127    #[test]
128    fn build_check_reads_status_and_detail() {
129        let c = build_check(
130            &hint("bitlocker"),
131            r#"{"status":"warn","detail":"D: unprotected"}"#,
132        );
133        assert_eq!(c.name, "bitlocker");
134        assert_eq!(c.status, CheckStatus::Warn);
135        assert_eq!(c.detail.as_deref(), Some("D: unprotected"));
136    }
137
138    #[test]
139    fn build_check_honours_custom_fields_and_troubleshoot() {
140        let h = CheckHint {
141            name: "patch".into(),
142            label: Some("Windows 更新プログラム".into()),
143            status_field: "compliance".into(),
144            detail_field: "summary".into(),
145            troubleshoot: Some("fix-patch".into()),
146            fleet: true,
147            health: true,
148            alert: None,
149        };
150        let c = build_check(
151            &h,
152            r#"{"compliance":"fail","summary":"12 missing","extra":[1,2]}"#,
153        );
154        assert_eq!(c.status, CheckStatus::Fail);
155        assert_eq!(c.label.as_deref(), Some("Windows 更新プログラム"));
156        assert_eq!(c.detail.as_deref(), Some("12 missing"));
157        assert_eq!(c.troubleshoot.as_deref(), Some("fix-patch"));
158    }
159
160    #[test]
161    fn build_check_detail_optional() {
162        let c = build_check(&hint("x"), r#"{"status":"ok"}"#);
163        assert_eq!(c.status, CheckStatus::Ok);
164        assert!(c.detail.is_none());
165    }
166
167    #[test]
168    fn build_check_degrades_on_bad_input() {
169        // Not JSON.
170        assert_eq!(
171            build_check(&hint("x"), "not json").status,
172            CheckStatus::Unknown
173        );
174        // Not an object.
175        assert_eq!(
176            build_check(&hint("x"), "[1,2,3]").status,
177            CheckStatus::Unknown
178        );
179        // Missing status field.
180        assert_eq!(
181            build_check(&hint("x"), r#"{"detail":"hi"}"#).status,
182            CheckStatus::Unknown
183        );
184        // Unrecognised status value — and the reason is surfaced.
185        let bad = build_check(&hint("x"), r#"{"status":"green"}"#);
186        assert_eq!(bad.status, CheckStatus::Unknown);
187        assert!(bad.detail.unwrap().contains("green"));
188    }
189
190    #[test]
191    fn build_check_reads_fenced_payload() {
192        // #821: a composed job fences check's payload so it can share
193        // stdout with inventory / a human-readable message.
194        let stdout = "updating...\n#KANADE-CHECK-BEGIN\n{\"status\":\"fail\",\"detail\":\"D: unprotected\"}\n#KANADE-CHECK-END\ndone.\n";
195        let c = build_check(&hint("bitlocker"), stdout);
196        assert_eq!(c.status, CheckStatus::Fail);
197        assert_eq!(c.detail.as_deref(), Some("D: unprotected"));
198    }
199
200    #[test]
201    fn build_check_sets_health_hidden_from_hint() {
202        // Default hint (health = true) → shown on the Health tab.
203        let shown = build_check(&hint("bitlocker"), r#"{"status":"ok"}"#);
204        assert!(!shown.health_hidden);
205
206        // A gate-only check (health = false) → recorded but hidden from
207        // the Health tab; a crashed run (build_check_failed) keeps the flag.
208        let mut h = hint("myapp-up-to-date");
209        h.health = false;
210        assert!(build_check(&h, r#"{"status":"fail"}"#).health_hidden);
211        assert!(build_check_failed(&h, 1, "boom").health_hidden);
212    }
213
214    #[test]
215    fn build_check_failed_surfaces_exit_and_stderr() {
216        let c = build_check_failed(&hint("av"), 1, "  Get-MpComputerStatus: access denied  ");
217        assert_eq!(c.status, CheckStatus::Unknown);
218        let d = c.detail.unwrap();
219        assert!(d.contains("exited 1"));
220        assert!(d.contains("access denied"));
221    }
222
223    #[test]
224    fn json_to_detail_renders_arrays_as_compact_json() {
225        let v: serde_json::Value = serde_json::json!(["C:", "D:"]);
226        assert_eq!(json_to_detail(&v).as_deref(), Some(r#"["C:","D:"]"#));
227        assert!(json_to_detail(&serde_json::Value::Null).is_none());
228    }
229}