Skip to main content

confium_test_harness/
report.rs

1//! JSON report writer for NIST submission.
2//!
3//! NIST MPTS consumes machine-readable JSON: one entry per vector run,
4//! each carrying the scheme identity, pass/fail outcome, message and
5//! byte tallies, round count, and elapsed time. The harness produces
6//! raw measurements only — Confium does not score or rank; NIST decides
7//! what the numbers mean (see `09-nist-evaluation-harness.md`:
8//! "Anti-goals").
9//!
10//! [`Report`] is a thin typed wrapper over a `Vec<ReportEntry>`; call
11//! [`Report::to_json`] for the string NIST expects, or
12//! [`Report::to_json_pretty`] for a human-friendly variant.
13
14use serde::{Deserialize, Serialize};
15
16use crate::Result;
17use crate::TestResult;
18
19/// One row in the output report.
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct ReportEntry {
22    pub scheme_name: String,
23    pub scheme_version: String,
24    pub parties: u32,
25    pub threshold: u32,
26    pub outcome: String,
27    pub output_hex: String,
28    pub messages_exchanged: u64,
29    pub bytes_exchanged: u64,
30    pub rounds: u8,
31    pub elapsed_nanos: u128,
32    pub note: Option<String>,
33}
34
35impl From<&TestResult> for ReportEntry {
36    fn from(r: &TestResult) -> Self {
37        ReportEntry {
38            scheme_name: r.scheme_name.clone(),
39            scheme_version: r.scheme_version.clone(),
40            parties: r.parties,
41            threshold: r.threshold,
42            outcome: r.outcome.as_str().to_string(),
43            output_hex: to_hex(&r.output),
44            messages_exchanged: r.messages_exchanged,
45            bytes_exchanged: r.bytes_exchanged,
46            rounds: r.rounds,
47            elapsed_nanos: r.elapsed.as_nanos(),
48            note: r.note.clone(),
49        }
50    }
51}
52
53/// A batch of vector results, serializable as the JSON NIST ingests.
54#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
55pub struct Report {
56    pub entries: Vec<ReportEntry>,
57}
58
59impl Report {
60    pub fn new() -> Self {
61        Report::default()
62    }
63
64    pub fn from_results(results: &[TestResult]) -> Self {
65        Report {
66            entries: results.iter().map(ReportEntry::from).collect(),
67        }
68    }
69
70    pub fn push(&mut self, entry: ReportEntry) {
71        self.entries.push(entry);
72    }
73
74    /// Compact JSON — one line of header + entries. NIST's ingestion
75    /// pipeline handles either form; this is the default.
76    pub fn to_json(&self) -> Result<String> {
77        Ok(serde_json::to_string(self)?)
78    }
79
80    /// Pretty-printed JSON for human review.
81    pub fn to_json_pretty(&self) -> Result<String> {
82        Ok(serde_json::to_string_pretty(self)?)
83    }
84}
85
86/// Lowercase hex encoding without pulling in a dedicated crate.
87fn to_hex(bytes: &[u8]) -> String {
88    let mut out = String::with_capacity(bytes.len() * 2);
89    for b in bytes {
90        out.push_str(&format!("{b:02x}"));
91    }
92    out
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::Outcome;
99    use std::time::Duration;
100
101    fn fake_result(outcome: Outcome) -> TestResult {
102        TestResult {
103            scheme_name: "FROST-ed25519".into(),
104            scheme_version: "draft-irtf-cfrg-frost-13".into(),
105            parties: 5,
106            threshold: 3,
107            outcome,
108            output: vec![0xDE, 0xAD],
109            messages_exchanged: 12,
110            bytes_exchanged: 1024,
111            rounds: 3,
112            elapsed: Duration::from_micros(12_345),
113            note: None,
114        }
115    }
116
117    #[test]
118    fn entry_serializes_to_expected_fields() {
119        let r = fake_result(Outcome::Pass);
120        let entry = ReportEntry::from(&r);
121        let json = serde_json::to_string(&entry).unwrap();
122        assert!(json.contains("\"scheme_name\":\"FROST-ed25519\""));
123        assert!(json.contains("\"outcome\":\"pass\""));
124        assert!(json.contains("\"output_hex\":\"dead\""));
125        assert!(json.contains("\"messages_exchanged\":12"));
126        assert!(json.contains("\"rounds\":3"));
127    }
128
129    #[test]
130    fn report_round_trips_through_json() {
131        let report =
132            Report::from_results(&[fake_result(Outcome::Pass), fake_result(Outcome::Fail)]);
133        let json = report.to_json().unwrap();
134        let reparsed: Report = serde_json::from_str(&json).unwrap();
135        assert_eq!(reparsed.entries.len(), 2);
136        assert_eq!(reparsed.entries[0].outcome, "pass");
137        assert_eq!(reparsed.entries[1].outcome, "fail");
138    }
139
140    #[test]
141    fn pretty_json_is_multiline() {
142        let report = Report::from_results(&[fake_result(Outcome::Pass)]);
143        let pretty = report.to_json_pretty().unwrap();
144        assert!(pretty.contains('\n'));
145    }
146
147    #[test]
148    fn to_hex_lowercase_no_prefix() {
149        assert_eq!(to_hex(&[0xDE, 0xAD, 0xBE, 0xEF]), "deadbeef");
150        assert_eq!(to_hex(&[]), "");
151    }
152}