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}