nixfleet_proto/evidence.rs
1//! Consumer-side typed view of the `evidence.json` wire format
2//! produced by `compliance-evidence-collector.service`.
3//!
4//! LOADBEARING: this struct is **one implementation** of the canonical
5//! schema; the contract is the JSON-on-disk format, not the Rust type.
6//! The authoritative spec lives in the producer repo at
7//! `nixfleet-compliance/docs/evidence-format.md` with a reference
8//! fixture at `nixfleet-compliance/docs/evidence-format-fixture.json`.
9//! Multiple language implementations (this Rust struct, the bash+jq
10//! emitter in `probe-runner.sh`, the bash reader in
11//! `compliance-check`, the untyped `serde_json::Value` consumer in
12//! `nixfleet-compliance-verify`) all conform to the same wire spec.
13//!
14//! Cardinality: one entry per control with a `framework_articles`
15//! map. The same control (e.g. `access-control`) typically satisfies
16//! multiple articles across multiple frameworks; nesting the map
17//! avoids duplicating probe details on the wire. The agent expands
18//! each entry into one `ProbeSubResult` per `(framework, article)`
19//! tuple for per-row accounting in `probe_failures` (RFC-0007 §7.2).
20//!
21//! Versioning: `schema_version: 1` is the first formally-defined
22//! evidence schema. Additive fields keep this constant (consumers
23//! ignore unknown fields via `serde(default)`); field removal or
24//! semantic change bumps the version in lockstep with the upstream
25//! spec doc. The unreleased pre-retag v0.2.0 emit shape (no
26//! schemaVersion field, `control` key spelling, `status` enum) is
27//! superseded by the joint v0.2 retag — no production hosts ran the
28//! broken integration end-to-end.
29//!
30//! Drift detection: `tests::fixture_round_trip` deserialises the
31//! upstream reference fixture (copied verbatim from the producer
32//! repo with a SYNC header). Schema changes in the producer that
33//! aren't mirrored here surface as a test failure on the next build.
34
35use std::collections::HashMap;
36
37use chrono::{DateTime, Utc};
38use serde::{Deserialize, Serialize};
39
40/// Schema version emitted by current producers. Bumped on any
41/// field-removal or semantic change; additive fields keep this
42/// constant.
43pub const SCHEMA_VERSION: u32 = 1;
44
45/// Top-level `evidence.json` shape. The signed bytes consumed by
46/// `nixfleet-compliance-verify` (and the agent's signature verify
47/// step) are the JCS canonical bytes of this struct.
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49#[serde(rename_all = "camelCase")]
50pub struct EvidenceFile {
51 pub schema_version: u32,
52 pub hostname: String,
53 pub collected_at: DateTime<Utc>,
54 pub controls: Vec<EvidenceControlEntry>,
55}
56
57/// One entry per control evaluated by the compliance collector.
58/// `passed` is the control-level aggregate (every check in `details`
59/// passed). `framework_articles` lists which framework articles the
60/// control satisfies — used by the agent to emit one
61/// `ProbeSubResult` per (framework, article) tuple downstream.
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
63#[serde(rename_all = "camelCase")]
64pub struct EvidenceControlEntry {
65 /// Capability-named control identifier (e.g. `"access-control"`,
66 /// `"secure-boot"`). Matches the `controlId` parameter of
67 /// `nixfleet-compliance/lib/mkTypedControl.nix`.
68 pub control_id: String,
69
70 /// Control-level aggregate: `true` iff every probe check passed.
71 /// Failure on any check sets this to `false`; the agent
72 /// propagates this to every framework/article tuple the control
73 /// covers when emitting sub-results.
74 pub passed: bool,
75
76 /// Framework → article-IDs the control satisfies. Empty map is
77 /// valid (control covers no framework articles — synthetic
78 /// always-fail control, smoke probe, etc.). The agent skips
79 /// entries with an empty map for per-article accounting.
80 #[serde(default)]
81 pub framework_articles: HashMap<String, Vec<String>>,
82
83 /// Free-form probe output for human display (compliance-check
84 /// CLI, auditor reports). NOT consumed by the gate; preserved on
85 /// the wire so a single signed file reproduces the operator-
86 /// facing detail without re-running probes.
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub details: Option<serde_json::Value>,
89
90 /// Typed-control schema hint (e.g. `"anssi-bp028/v1"`). Optional;
91 /// set only when the producer used the typed-control pipeline
92 /// (nixfleet-compliance/lib/mkTypedControl.nix). Auditor tools
93 /// use this to apply schema-specific decoders to `details`.
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub schema: Option<String>,
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101 use chrono::TimeZone;
102
103 fn fixture() -> EvidenceFile {
104 let mut framework_articles = HashMap::new();
105 framework_articles.insert(
106 "nis2-essential".to_string(),
107 vec!["art21.i".to_string()],
108 );
109 framework_articles.insert(
110 "iso27001".to_string(),
111 vec!["A.5.1".to_string()],
112 );
113 EvidenceFile {
114 schema_version: SCHEMA_VERSION,
115 hostname: "agent-01".to_string(),
116 collected_at: Utc.with_ymd_and_hms(2026, 5, 18, 12, 0, 0).unwrap(),
117 controls: vec![EvidenceControlEntry {
118 control_id: "access-control".to_string(),
119 passed: true,
120 framework_articles,
121 details: Some(serde_json::json!({
122 "password_auth_disabled": true,
123 "root_login_restricted": true,
124 })),
125 schema: Some("anssi-bp028/v1".to_string()),
126 }],
127 }
128 }
129
130 #[test]
131 fn evidence_file_round_trip() {
132 let f = fixture();
133 let json = serde_json::to_string(&f).unwrap();
134 let parsed: EvidenceFile = serde_json::from_str(&json).unwrap();
135 assert_eq!(parsed, f);
136 }
137
138 #[test]
139 fn evidence_file_serialises_camel_case() {
140 let f = fixture();
141 let json = serde_json::to_string(&f).unwrap();
142 assert!(json.contains("\"schemaVersion\":1"));
143 assert!(json.contains("\"collectedAt\""));
144 assert!(json.contains("\"controlId\":\"access-control\""));
145 assert!(json.contains("\"frameworkArticles\""));
146 }
147
148 #[test]
149 fn evidence_file_empty_framework_articles_legal() {
150 let f = EvidenceFile {
151 schema_version: SCHEMA_VERSION,
152 hostname: "synthetic-host".to_string(),
153 collected_at: Utc.with_ymd_and_hms(2026, 5, 18, 12, 0, 0).unwrap(),
154 controls: vec![EvidenceControlEntry {
155 control_id: "synthetic".to_string(),
156 passed: false,
157 framework_articles: HashMap::new(),
158 details: None,
159 schema: None,
160 }],
161 };
162 let json = serde_json::to_string(&f).unwrap();
163 let parsed: EvidenceFile = serde_json::from_str(&json).unwrap();
164 assert_eq!(parsed, f);
165 }
166
167 #[test]
168 fn evidence_file_missing_schema_version_fails() {
169 // The legacy v0.2.0-unreleased compliance emit (no schemaVersion
170 // at the file level) does not deserialise. The retagged
171 // nixfleet-compliance emits schemaVersion=1 in lockstep.
172 let no_version = serde_json::json!({
173 "hostname": "agent-01",
174 "collectedAt": "2026-05-18T12:00:00Z",
175 "controls": [],
176 });
177 let err = serde_json::from_value::<EvidenceFile>(no_version).unwrap_err();
178 assert!(
179 err.to_string().contains("schemaVersion"),
180 "missing schemaVersion must produce a clear error; got: {err}",
181 );
182 }
183
184 /// Upstream-fixture roundtrip: drift detection between
185 /// `nixfleet-compliance/docs/evidence-format-fixture.json` (the
186 /// authoritative reference instance) and this struct. When the
187 /// producer adds or renames a field, copy the upstream fixture
188 /// into `src/fixtures/evidence-format.json`; this test confirms
189 /// the consumer struct still parses it cleanly. Failing here
190 /// means the consumer hasn't tracked an upstream schema change.
191 #[test]
192 fn fixture_round_trip() {
193 const FIXTURE: &str = include_str!("fixtures/evidence-format.json");
194 let parsed: EvidenceFile =
195 serde_json::from_str(FIXTURE).expect("upstream fixture must parse");
196 assert_eq!(parsed.schema_version, SCHEMA_VERSION);
197 assert_eq!(parsed.hostname, "water-plant-01");
198 assert_eq!(parsed.controls.len(), 3);
199
200 // Spot-check fields the consumer struct doesn't currently
201 // gate on but the wire still carries — surfaces silent drops.
202 let access = parsed
203 .controls
204 .iter()
205 .find(|c| c.control_id == "access-control")
206 .expect("access-control entry");
207 assert!(access.passed);
208 assert!(access.framework_articles.contains_key("nis2-essential"));
209 assert!(access.framework_articles.contains_key("iso27001"));
210 assert!(access.framework_articles.contains_key("anssi-bp028"));
211 assert!(access.details.is_some());
212 assert_eq!(access.schema.as_deref(), Some("anssi-bp028/v1"));
213
214 let synthetic = parsed
215 .controls
216 .iter()
217 .find(|c| c.control_id == "synthetic")
218 .expect("synthetic entry");
219 assert!(!synthetic.passed);
220 assert!(synthetic.framework_articles.is_empty());
221 assert!(synthetic.schema.is_none());
222
223 // Re-serialise + re-parse: idempotent at the struct level.
224 let reserialised = serde_json::to_string(&parsed).unwrap();
225 let re_parsed: EvidenceFile = serde_json::from_str(&reserialised).unwrap();
226 assert_eq!(re_parsed, parsed);
227 }
228}