use serde::{Deserialize, Serialize};
fn de_verdict_label<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
struct LabelVisitor;
impl<'de> serde::de::Visitor<'de> for LabelVisitor {
type Value = String;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a verdict label string or an envelope-v2 {type,…} object")
}
fn visit_str<E>(self, v: &str) -> Result<String, E> {
Ok(v.to_string())
}
fn visit_string<E>(self, v: String) -> Result<String, E> {
Ok(v)
}
fn visit_map<A>(self, mut map: A) -> Result<String, A::Error>
where
A: serde::de::MapAccess<'de>,
{
let mut label = String::new();
while let Some(key) = map.next_key::<String>()? {
if key == "type" {
label = map.next_value::<String>()?;
} else {
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
}
Ok(label)
}
}
if deserializer.is_human_readable() {
deserializer.deserialize_any(LabelVisitor)
} else {
deserializer.deserialize_string(LabelVisitor)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct Verdict {
pub verdict_id: String,
#[serde(deserialize_with = "de_verdict_label")]
pub verdict: String,
pub source: String,
pub confidence: f64,
pub composite_score: u8,
pub reasoning: String,
pub similar_to: Vec<String>,
pub evidence_gaps: Vec<String>,
pub suggested_actions: Vec<String>,
pub data_freshness_at: Option<String>,
pub data_oldest_signal_at: Option<String>,
pub stale_since_at: Option<String>,
pub staleness_reason: Option<String>,
pub computed_at: Option<String>,
pub severity: Option<String>,
#[serde(alias = "policy_decision")]
pub decision: Option<String>,
pub previous_verdict: Option<PreviousVerdict>,
pub availability: AvailabilityBlock,
pub envelope_version: Option<u32>,
pub customer_state: Option<String>,
pub state: Option<String>,
pub source_state: Option<String>,
pub policy_version: Option<String>,
pub audit_record_id: Option<String>,
pub audit_record_hash: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
#[serde(default)]
pub struct AvailabilityBlock {
pub degraded_stale: bool,
pub kev: Option<String>,
pub epss: Option<String>,
pub exploitation_fusion: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct PreviousVerdict {
pub verdict_id: String,
pub verdict: String,
pub computed_at: String,
pub diff: String,
}
impl Default for Verdict {
fn default() -> Self {
Self {
verdict_id: String::new(),
verdict: String::new(),
source: String::new(),
confidence: 0.0,
composite_score: 0,
reasoning: String::new(),
similar_to: Vec::new(),
evidence_gaps: Vec::new(),
suggested_actions: Vec::new(),
data_freshness_at: None,
data_oldest_signal_at: None,
stale_since_at: None,
staleness_reason: None,
computed_at: None,
severity: None,
decision: None,
previous_verdict: None,
availability: AvailabilityBlock::default(),
envelope_version: None,
customer_state: None,
state: None,
source_state: None,
policy_version: None,
audit_record_id: None,
audit_record_hash: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PackageRef {
pub ecosystem: String,
#[serde(rename = "package")]
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PolicyPreviewRequest {
pub packages: Vec<PackageRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct PolicyDecision {
pub ecosystem: String,
pub package: String,
pub version: String,
pub decision: String,
pub reason: String,
pub verdict_id: Option<String>,
pub policy_rule_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct PolicyPreviewResponse {
pub decisions: Vec<PolicyDecision>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ScanRequest {
pub packages: Vec<PackageRef>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct ScanResult {
pub ecosystem: String,
pub package: String,
pub version: String,
pub verdict: Option<Verdict>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct ScanResponse {
pub results: Vec<ScanResult>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct AuditEntry {
pub request_id: String,
pub correlation_id: String,
pub ecosystem: String,
pub package_name: String,
pub package_version: String,
pub variant: String,
pub policy_decision: String,
pub verdict_id: String,
pub verdict_source: String,
pub policy_rule_id_matched: String,
pub risk_acceptance_status: String,
pub reasoning: String,
pub gcs_hit: bool,
pub request_at: String,
pub verdict_at: String,
pub response_at: String,
pub app_version: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct AuditWindow {
pub since: Option<String>,
pub until: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct AuditResponse {
pub window: AuditWindow,
pub records: Vec<AuditEntry>,
pub record_count: usize,
pub per_route: std::collections::BTreeMap<String, usize>,
pub backend_status: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_minimal_verdict() {
let json = r#"{
"verdict_id": "01JBYK000",
"verdict": "ALLOWED_NO_FINDINGS",
"source": "ALLOWED_NO_FINDINGS"
}"#;
let v: Verdict = serde_json::from_str(json).unwrap();
assert_eq!(v.verdict_id, "01JBYK000");
assert_eq!(v.verdict, "ALLOWED_NO_FINDINGS");
assert_eq!(v.confidence, 0.0);
assert!(v.similar_to.is_empty());
}
#[test]
fn parses_envelope_v2_path_a_wire_with_verdict_object_and_label() {
let json = r#"{
"verdict_id": "01JBYK042",
"verdict": { "type": "VECTOR_VERDICT", "status": "WARN", "customer_state": "vulnerable" },
"verdict_label": "VECTOR_VERDICT",
"source": "CVE_FINDING",
"envelope_version": 2,
"customer_state": "vulnerable",
"state": "WARN",
"source_state": "CVE_FINDING"
}"#;
let v: Verdict = serde_json::from_str(json)
.expect("Path-A v2 wire must deserialize — CLEANLIB-468");
assert_eq!(v.verdict, "VECTOR_VERDICT");
assert_eq!(v.source, "CVE_FINDING");
assert_eq!(v.customer_state.as_deref(), Some("vulnerable"));
assert_eq!(v.source_state.as_deref(), Some("CVE_FINDING"));
}
#[test]
fn parses_full_verdict() {
let json = r#"{
"verdict_id": "01JBYK001",
"verdict": "VECTOR_VERDICT",
"source": "VECTOR_VERDICT",
"confidence": 0.98,
"composite_score": 92,
"reasoning": "Confirmed malware",
"similar_to": ["01JBYK999"],
"evidence_gaps": [],
"suggested_actions": ["DENY across customers"],
"data_freshness_at": "2026-05-21T10:00:00Z",
"computed_at": "2026-05-21T10:01:00Z"
}"#;
let v: Verdict = serde_json::from_str(json).unwrap();
assert_eq!(v.composite_score, 92);
assert_eq!(v.confidence, 0.98);
assert_eq!(v.similar_to.len(), 1);
assert_eq!(v.suggested_actions[0], "DENY across customers");
}
#[test]
fn parses_policy_preview_response() {
let json = r#"{
"decisions": [
{"ecosystem":"npm","package":"left-pad","version":"1.3.0","decision":"ALLOW","reason":"ok"},
{"ecosystem":"npm","package":"event-stream","version":"3.3.6","decision":"DENY","reason":"malware","verdict_id":"01JBYK999"}
]
}"#;
let resp: PolicyPreviewResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.decisions.len(), 2);
assert_eq!(resp.decisions[0].decision, "ALLOW");
assert_eq!(resp.decisions[1].decision, "DENY");
assert_eq!(resp.decisions[1].verdict_id.as_deref(), Some("01JBYK999"));
}
#[test]
fn parses_audit_response_matches_app_wire_shape() {
let json = r#"{
"window": {"since": "2026-05-22T00:00:00Z", "until": "2026-05-23T00:00:00Z"},
"records": [{
"request_id": "01936b8f-3c4a-7a12-9c00-000000000001",
"correlation_id": "corr-1",
"customer_ip_hashed": "sha256:aaaa",
"ecosystem": "npm",
"package_name": "lodash",
"package_version": "4.17.21",
"variant": "default",
"user_agent": "cleanlib-cli/0.1.4",
"policy_decision": "ALLOW",
"verdict_id": "01936b8f-3c4a-7a12-9c00-0000000000aa",
"verdict_source": "ALLOWED_NO_FINDINGS",
"policy_rule_id_matched": "rule-42",
"risk_acceptance_status": "NONE",
"reasoning": "ok",
"gcs_hit": true,
"gcs_object_path": "gs://bucket/obj",
"bytes_served": 4096,
"request_at": "2026-05-22T10:00:00Z",
"ingest_at": null,
"gcs_at": null,
"verdict_at": "2026-05-22T10:00:01Z",
"policy_eval_at": "2026-05-22T10:00:02Z",
"response_at": "2026-05-22T10:00:03Z",
"app_version": "1.2.3"
}],
"record_count": 1,
"per_route": {"/v1/customer/verdicts/npm": 1},
"backend_status": "wired"
}"#;
let resp: AuditResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.records.len(), 1);
assert_eq!(resp.record_count, 1);
assert_eq!(resp.backend_status, "wired");
assert_eq!(resp.window.since.as_deref(), Some("2026-05-22T00:00:00Z"));
assert_eq!(resp.per_route.get("/v1/customer/verdicts/npm"), Some(&1));
let e = &resp.records[0];
assert_eq!(e.request_id, "01936b8f-3c4a-7a12-9c00-000000000001");
assert_eq!(e.correlation_id, "corr-1");
assert_eq!(e.ecosystem, "npm");
assert_eq!(e.package_name, "lodash");
assert_eq!(e.package_version, "4.17.21");
assert_eq!(e.variant, "default");
assert_eq!(e.policy_decision, "ALLOW");
assert_eq!(e.verdict_id, "01936b8f-3c4a-7a12-9c00-0000000000aa");
assert_eq!(e.verdict_source, "ALLOWED_NO_FINDINGS");
assert_eq!(e.policy_rule_id_matched, "rule-42");
assert_eq!(e.risk_acceptance_status, "NONE");
assert_eq!(e.reasoning, "ok");
assert!(e.gcs_hit);
assert_eq!(e.request_at, "2026-05-22T10:00:00Z");
assert_eq!(e.verdict_at, "2026-05-22T10:00:01Z");
assert_eq!(e.response_at, "2026-05-22T10:00:03Z");
assert_eq!(e.app_version, "1.2.3");
}
#[test]
fn empty_audit_response_carries_backend_status() {
let json = r#"{
"window": {"since": null, "until": null},
"records": [],
"record_count": 0,
"per_route": {},
"backend_status": "not_wired"
}"#;
let resp: AuditResponse = serde_json::from_str(json).unwrap();
assert!(resp.records.is_empty());
assert_eq!(resp.record_count, 0);
assert_eq!(resp.backend_status, "not_wired");
assert!(resp.window.since.is_none());
assert!(resp.window.until.is_none());
}
#[test]
fn policy_preview_request_omits_none_policy() {
let req = PolicyPreviewRequest {
packages: vec![PackageRef {
ecosystem: "npm".to_string(),
name: "lodash".to_string(),
version: "4.17.21".to_string(),
}],
policy: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("policy"));
assert!(json.contains("lodash"));
}
#[test]
fn policy_preview_request_emits_policy_when_some() {
let req = PolicyPreviewRequest {
packages: vec![],
policy: Some(serde_json::json!({"rules": []})),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"policy\""));
assert!(json.contains("\"rules\""));
}
#[test]
fn round_trips_via_json() {
let v = Verdict {
verdict_id: "01JBYK002".to_string(),
verdict: "INSUFFICIENT_DATA".to_string(),
source: "INSUFFICIENT_DATA".to_string(),
stale_since_at: Some("2026-04-21T00:00:00Z".to_string()),
staleness_reason: Some("upstream silent >30d".to_string()),
..Default::default()
};
let s = serde_json::to_string(&v).unwrap();
let parsed: Verdict = serde_json::from_str(&s).unwrap();
assert_eq!(parsed.verdict_id, "01JBYK002");
assert_eq!(parsed.stale_since_at.as_deref(), Some("2026-04-21T00:00:00Z"));
}
#[test]
fn verdict_round_trips_severity_and_decision() {
let v = Verdict {
verdict_id: "01JM1S001".to_string(),
verdict: "VECTOR_VERDICT".to_string(),
source: "VECTOR_VERDICT".to_string(),
severity: Some("HIGH".to_string()),
decision: Some("DENY".to_string()),
..Default::default()
};
let s = serde_json::to_string(&v).unwrap();
let parsed: Verdict = serde_json::from_str(&s).unwrap();
assert_eq!(parsed.severity.as_deref(), Some("HIGH"));
assert_eq!(parsed.decision.as_deref(), Some("DENY"));
}
#[test]
fn verdict_tolerates_missing_severity_and_decision() {
let pre_m1_json = r#"{
"verdict_id": "01JM1S002",
"verdict": "ALLOWED_NO_FINDINGS",
"source": "ALLOWED_NO_FINDINGS",
"confidence": 0.95,
"composite_score": 8,
"reasoning": "",
"similar_to": [],
"evidence_gaps": [],
"suggested_actions": []
}"#;
let v: Verdict = serde_json::from_str(pre_m1_json).expect("pre-M1 shape must still parse");
assert!(v.severity.is_none());
assert!(v.decision.is_none());
}
#[test]
fn verdict_decision_canonical_values_match_js_py_go() {
for d in ["ALLOW", "WARN", "DENY", "RISK_ACCEPTANCE_REQUIRED"] {
let v = Verdict {
decision: Some(d.to_string()),
..Default::default()
};
let s = serde_json::to_string(&v).unwrap();
assert!(s.contains(&format!("\"decision\":\"{}\"", d)));
}
}
#[test]
fn verdict_severity_canonical_values_match_cleanlib_core() {
for sev in ["NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL"] {
let v = Verdict {
severity: Some(sev.to_string()),
..Default::default()
};
let s = serde_json::to_string(&v).unwrap();
assert!(s.contains(&format!("\"severity\":\"{}\"", sev)));
}
}
}