use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use kanade_shared::ipc::state::{Check, CheckStatus};
use kanade_shared::manifest::CheckHint;
use tokio::sync::Notify;
#[derive(Clone)]
pub struct CheckSink {
inner: Arc<Inner>,
}
struct Inner {
results: Mutex<HashMap<String, Check>>,
updated: Notify,
path: Option<PathBuf>,
}
impl CheckSink {
pub fn new() -> Self {
Self::with_inner(HashMap::new(), None)
}
pub fn load(path: PathBuf) -> Self {
let results = match std::fs::read(&path) {
Ok(bytes) => serde_json::from_slice::<HashMap<String, Check>>(&bytes)
.unwrap_or_else(|e| {
tracing::warn!(error = %e, path = %path.display(), "check_cache: ignoring unreadable persisted cache");
HashMap::new()
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => HashMap::new(),
Err(e) => {
tracing::warn!(error = %e, path = %path.display(), "check_cache: failed to read persisted cache");
HashMap::new()
}
};
Self::with_inner(results, Some(path))
}
fn with_inner(results: HashMap<String, Check>, path: Option<PathBuf>) -> Self {
Self {
inner: Arc::new(Inner {
results: Mutex::new(results),
updated: Notify::new(),
path,
}),
}
}
pub fn record(&self, check: Check) {
self.guarded(|map| {
map.insert(check.name.clone(), check);
self.persist(map);
});
self.inner.updated.notify_one();
}
fn persist(&self, results: &HashMap<String, Check>) {
let Some(path) = &self.inner.path else { return };
let json = match serde_json::to_vec_pretty(results) {
Ok(j) => j,
Err(e) => {
tracing::warn!(error = %e, "check_cache: serialize failed");
return;
}
};
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
tracing::warn!(error = %e, path = %parent.display(), "check_cache: create data dir failed");
return;
}
}
let tmp = path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, &json) {
tracing::warn!(error = %e, path = %tmp.display(), "check_cache: temp write failed");
return;
}
if let Err(e) = std::fs::rename(&tmp, path) {
tracing::warn!(error = %e, path = %path.display(), "check_cache: atomic rename failed");
}
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub fn checks(&self) -> Vec<Check> {
self.guarded(|map| map.values().cloned().collect())
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub async fn wait(&self) {
self.inner.updated.notified().await;
}
fn guarded<R>(&self, f: impl FnOnce(&mut HashMap<String, Check>) -> R) -> R {
let mut map = self.inner.results.lock().unwrap_or_else(|poisoned| {
tracing::warn!("check_cache: results mutex poisoned — recovering");
poisoned.into_inner()
});
f(&mut map)
}
}
impl Default for CheckSink {
fn default() -> Self {
Self::new()
}
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub fn merge_checks(base: Vec<Check>, extra: &[Check]) -> Vec<Check> {
let overridden: HashSet<&str> = extra.iter().map(|c| c.name.as_str()).collect();
let mut merged: Vec<Check> = base
.into_iter()
.filter(|c| !overridden.contains(c.name.as_str()))
.collect();
merged.extend(extra.iter().cloned());
merged
}
pub fn build_check(hint: &CheckHint, stdout: &str) -> Check {
let value: serde_json::Value = match serde_json::from_str(stdout.trim()) {
Ok(v) => v,
Err(e) => return unknown(hint, format!("check stdout was not JSON: {e}")),
};
let Some(obj) = value.as_object() else {
return unknown(hint, "check stdout was not a JSON object".to_string());
};
let status = match obj.get(&hint.status_field) {
Some(v) => match serde_json::from_value::<CheckStatus>(v.clone()) {
Ok(s) => s,
Err(_) => {
return unknown(
hint,
format!(
"`{}` = {v} is not one of ok/warn/fail/unknown",
hint.status_field
),
);
}
},
None => {
return unknown(hint, format!("stdout has no `{}` field", hint.status_field));
}
};
let detail = obj.get(&hint.detail_field).and_then(json_to_detail);
Check {
name: hint.name.clone(),
status,
detail,
troubleshoot: hint.troubleshoot.clone(),
}
}
pub fn build_check_failed(hint: &CheckHint, exit_code: i32, stderr: &str) -> Check {
let snippet: String = stderr.trim().chars().take(200).collect();
let detail = if snippet.is_empty() {
format!("check script exited {exit_code}")
} else {
format!("check script exited {exit_code}: {snippet}")
};
unknown(hint, detail)
}
fn unknown(hint: &CheckHint, detail: String) -> Check {
Check {
name: hint.name.clone(),
status: CheckStatus::Unknown,
detail: Some(detail),
troubleshoot: hint.troubleshoot.clone(),
}
}
fn json_to_detail(v: &serde_json::Value) -> Option<String> {
match v {
serde_json::Value::Null => None,
serde_json::Value::String(s) if s.is_empty() => None,
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
other => Some(other.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn hint(name: &str) -> CheckHint {
CheckHint {
name: name.into(),
status_field: "status".into(),
detail_field: "detail".into(),
troubleshoot: None,
}
}
#[test]
fn build_check_reads_status_and_detail() {
let c = build_check(
&hint("bitlocker"),
r#"{"status":"warn","detail":"D: unprotected"}"#,
);
assert_eq!(c.name, "bitlocker");
assert_eq!(c.status, CheckStatus::Warn);
assert_eq!(c.detail.as_deref(), Some("D: unprotected"));
}
#[test]
fn build_check_honours_custom_fields_and_troubleshoot() {
let h = CheckHint {
name: "patch".into(),
status_field: "compliance".into(),
detail_field: "summary".into(),
troubleshoot: Some("fix-patch".into()),
};
let c = build_check(
&h,
r#"{"compliance":"fail","summary":"12 missing","extra":[1,2]}"#,
);
assert_eq!(c.status, CheckStatus::Fail);
assert_eq!(c.detail.as_deref(), Some("12 missing"));
assert_eq!(c.troubleshoot.as_deref(), Some("fix-patch"));
}
#[test]
fn build_check_detail_optional() {
let c = build_check(&hint("x"), r#"{"status":"ok"}"#);
assert_eq!(c.status, CheckStatus::Ok);
assert!(c.detail.is_none());
}
#[test]
fn build_check_degrades_on_bad_input() {
assert_eq!(
build_check(&hint("x"), "not json").status,
CheckStatus::Unknown
);
assert_eq!(
build_check(&hint("x"), "[1,2,3]").status,
CheckStatus::Unknown
);
assert_eq!(
build_check(&hint("x"), r#"{"detail":"hi"}"#).status,
CheckStatus::Unknown
);
let bad = build_check(&hint("x"), r#"{"status":"green"}"#);
assert_eq!(bad.status, CheckStatus::Unknown);
assert!(bad.detail.unwrap().contains("green"));
}
#[test]
fn merge_checks_overrides_builtin_by_name() {
let base = vec![
Check {
name: "agent_self_update".into(),
status: CheckStatus::Ok,
detail: None,
troubleshoot: None,
},
Check {
name: "disk_free".into(),
status: CheckStatus::Ok,
detail: None,
troubleshoot: None,
},
];
let extra = vec![
Check {
name: "disk_free".into(),
status: CheckStatus::Warn,
detail: Some("90%".into()),
troubleshoot: None,
},
Check {
name: "bitlocker".into(),
status: CheckStatus::Ok,
detail: None,
troubleshoot: None,
},
];
let merged = merge_checks(base, &extra);
assert_eq!(merged.len(), 3);
let disk = merged.iter().find(|c| c.name == "disk_free").unwrap();
assert_eq!(disk.status, CheckStatus::Warn);
assert!(merged.iter().any(|c| c.name == "bitlocker"));
assert_eq!(merged.iter().filter(|c| c.name == "disk_free").count(), 1);
}
#[test]
fn build_check_failed_surfaces_exit_and_stderr() {
let c = build_check_failed(&hint("av"), 1, " Get-MpComputerStatus: access denied ");
assert_eq!(c.status, CheckStatus::Unknown);
let d = c.detail.unwrap();
assert!(d.contains("exited 1"), "detail: {d}");
assert!(d.contains("access denied"), "detail: {d}");
}
#[test]
fn json_to_detail_renders_arrays_as_compact_json() {
let v: serde_json::Value = serde_json::json!(["C:", "D:"]);
assert_eq!(json_to_detail(&v).as_deref(), Some(r#"["C:","D:"]"#));
assert!(json_to_detail(&serde_json::Value::Null).is_none());
}
#[test]
fn sink_record_and_read_round_trip() {
let sink = CheckSink::new();
assert!(sink.checks().is_empty());
sink.record(build_check(&hint("bitlocker"), r#"{"status":"ok"}"#));
let checks = sink.checks();
assert_eq!(checks.len(), 1);
assert_eq!(checks[0].name, "bitlocker");
assert_eq!(checks[0].status, CheckStatus::Ok);
}
#[tokio::test]
async fn wait_resolves_when_a_result_is_recorded() {
let sink = CheckSink::new();
sink.record(build_check(&hint("x"), r#"{"status":"ok"}"#));
tokio::time::timeout(std::time::Duration::from_secs(1), sink.wait())
.await
.expect("wait() must observe the stored notify permit");
}
#[test]
fn load_missing_file_is_empty() {
let dir = tempfile::tempdir().unwrap();
let sink = CheckSink::load(dir.path().join("check_results.json"));
assert!(sink.checks().is_empty());
}
#[test]
fn record_persists_and_reloads_across_restart() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("check_results.json");
let sink = CheckSink::load(path.clone());
sink.record(build_check(
&hint("bitlocker"),
r#"{"status":"warn","detail":"D: off"}"#,
));
sink.record(build_check(&hint("av_signature"), r#"{"status":"ok"}"#));
drop(sink);
assert!(path.exists(), "record must persist the cache file");
let reloaded = CheckSink::load(path);
let mut checks = reloaded.checks();
checks.sort_by(|a, b| a.name.cmp(&b.name));
assert_eq!(checks.len(), 2);
assert_eq!(checks[0].name, "av_signature");
assert_eq!(checks[1].name, "bitlocker");
assert_eq!(checks[1].status, CheckStatus::Warn);
assert_eq!(checks[1].detail.as_deref(), Some("D: off"));
}
#[test]
fn load_corrupt_file_is_empty_not_fatal() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("check_results.json");
std::fs::write(&path, b"{ this is not valid json").unwrap();
let sink = CheckSink::load(path);
assert!(sink.checks().is_empty());
}
#[test]
fn new_does_not_persist() {
let sink = CheckSink::new();
sink.record(build_check(&hint("x"), r#"{"status":"ok"}"#));
assert_eq!(sink.checks().len(), 1);
}
}