Skip to main content

confium_test_harness/
vector.rs

1//! TOML test-vector parser.
2//!
3//! A [`TestVector`] is the unit NIST hands to the harness: a scheme
4//! name, the test parameters (parties, threshold, message, RNG seed),
5//! optional expected-output bytes, and a list of per-party Byzantine
6//! behaviors.
7//!
8//! The schema matches `TODO.roadmap/09-nist-evaluation-harness.md`:
9//!
10//! ```toml
11//! [scheme]
12//! name = "FROST-ed25519"
13//! version = "draft-irtf-cfrg-frost-13"
14//!
15//! [test]
16//! parties = 5
17//! threshold = 3
18//! message = "hello world"               # UTF-8 string OR "0x.."
19//! seed = "0xdeadbeef..."                # hex seed for deterministic RNG
20//! expected_signature_hex = "..."        # optional
21//!
22//! conformance_level = "must_pass"       # must_pass | should_pass | informational
23//! reference = "https://..."             # normative spec URL
24//! expected_round_count = 3              # warn if observed differs
25//! share_material = "nist-dkg-set-A"     # named pre-shared share label
26//!
27//! [[peer_behavior]]
28//! party_id = "alice"
29//! type = "honest"
30//! ```
31
32use serde::Deserialize;
33
34use crate::Result;
35use crate::byzantine::{BehaviorSpec, PeerBehavior};
36use crate::error;
37use crate::error::VectorSnafu;
38
39/// Conformance level declared by a vector. Mirrors NIST's
40/// MUST/SHOULD/INFORMATIONAL classification: a `MustPass` failure is a
41/// hard error; a `ShouldPass` failure is a warning (the scheme is
42/// expected to comply but the failure is not disqualifying on its
43/// own); `Informational` failures never count against the candidate.
44///
45/// Wire form is the lowercased tag in the vector's `[test]` block:
46/// `must_pass`, `should_pass`, `informational`. Defaults to
47/// `MustPass` when omitted so existing vectors keep their strict
48/// semantics.
49#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
50pub enum ConformanceLevel {
51    /// The candidate MUST pass this vector. A failure is a hard error
52    /// and disqualifies the submission for this profile.
53    #[default]
54    #[serde(rename = "must_pass")]
55    MustPass,
56    /// The candidate SHOULD pass this vector. A failure is recorded as
57    /// a warning; NIST may tolerate a bounded number of warnings.
58    #[serde(rename = "should_pass")]
59    ShouldPass,
60    /// Informational only. The result is reported but never gates the
61    /// candidate.
62    #[serde(rename = "informational")]
63    Informational,
64}
65
66impl ConformanceLevel {
67    /// Map a tag string to a conformance level. Returns `None` for an
68    /// unknown tag so the parser can surface a clear error.
69    pub fn from_tag(tag: &str) -> Option<Self> {
70        match tag {
71            "must_pass" => Some(ConformanceLevel::MustPass),
72            "should_pass" => Some(ConformanceLevel::ShouldPass),
73            "informational" => Some(ConformanceLevel::Informational),
74            _ => None,
75        }
76    }
77
78    /// Canonical wire tag for this level.
79    pub fn as_tag(self) -> &'static str {
80        match self {
81            ConformanceLevel::MustPass => "must_pass",
82            ConformanceLevel::ShouldPass => "should_pass",
83            ConformanceLevel::Informational => "informational",
84        }
85    }
86}
87
88/// Top-level TOML document.
89#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
90pub struct TestVector {
91    pub scheme: SchemeSpec,
92    pub test: TestVectorTest,
93    #[serde(default)]
94    pub peer_behavior: Vec<PeerBehaviorEntry>,
95    /// NIST-style conformance classification for this vector. Defaults
96    /// to `MustPass` when absent so the schema remains strict-by-default.
97    #[serde(default)]
98    pub conformance_level: ConformanceLevel,
99    /// Optional URL pointing at the normative reference (spec section,
100    /// RFC, NIST publication) this vector exercises. Carried through to
101    /// the report so NIST can attribute every measurement to its source.
102    #[serde(default)]
103    pub reference: Option<String>,
104    /// Optional: the number of rounds a compliant implementation is
105    /// expected to take. When set, the runner warns if the observed
106    /// round count differs (but never fails on it alone).
107    #[serde(default)]
108    pub expected_round_count: Option<u8>,
109    /// Optional: the label of the pre-shared key material to feed into
110    /// each party's `local_share`. NIST publishes named DKG outputs;
111    /// this field lets a vector reference them by name rather than
112    /// inlining bytes. The harness resolves the label to bytes via the
113    /// environment (test fixture or registry lookup).
114    #[serde(default)]
115    pub share_material: Option<String>,
116}
117
118/// `[scheme]` block: identity of the candidate under test.
119#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
120pub struct SchemeSpec {
121    pub name: String,
122    #[serde(default)]
123    pub version: String,
124}
125
126/// `[test]` block: harness inputs.
127#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
128pub struct TestVectorTest {
129    pub parties: u32,
130    pub threshold: u32,
131    /// UTF-8 message, or `"0x..."` for binary. Either form decodes to
132    /// raw bytes via [`TestVectorTest::message_bytes`].
133    #[serde(default)]
134    pub message: String,
135    /// Hex seed (`"0xdeadbeef"`) for the deterministic RNG.
136    #[serde(default)]
137    pub seed: String,
138    /// Optional expected signature / output, hex-encoded.
139    #[serde(default)]
140    pub expected_signature_hex: String,
141}
142
143/// One `[[peer_behavior]]` entry.
144#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
145pub struct PeerBehaviorEntry {
146    pub party_id: String,
147    /// Tag string: `"honest"`, `"byzantine-drop"`, etc. Validated
148    /// against [`PeerBehavior::from_tag`] during parse.
149    #[serde(rename = "type")]
150    pub behavior_tag: String,
151    /// Optional: which round a `byzantine-drop` peer drops in.
152    #[serde(default)]
153    pub drop_round: Option<u8>,
154}
155
156impl TestVector {
157    /// Parse a TOML document. Validates behaviors and decodes seed/message.
158    pub fn parse(toml_str: &str) -> Result<Self> {
159        let raw: TestVector = toml::from_str(toml_str).map_err(|e| {
160            error::VectorSnafu {
161                message: e.to_string(),
162            }
163            .build()
164        })?;
165        raw.validate()?;
166        Ok(raw)
167    }
168
169    /// Read and parse a vector from a file path.
170    pub fn from_path(path: &std::path::Path) -> Result<Self> {
171        let body = std::fs::read_to_string(path).map_err(|e| {
172            VectorSnafu {
173                message: format!("could not read {}: {}", path.display(), e),
174            }
175            .build()
176        })?;
177        Self::parse(&body)
178    }
179
180    fn validate(&self) -> Result<()> {
181        if self.test.parties == 0 {
182            return Err(VectorSnafu {
183                message: "[test] parties must be at least 1".to_string(),
184            }
185            .build());
186        }
187        if self.test.threshold == 0 {
188            return Err(VectorSnafu {
189                message: "[test] threshold must be at least 1".to_string(),
190            }
191            .build());
192        }
193        if self.test.threshold > self.test.parties {
194            return Err(VectorSnafu {
195                message: format!(
196                    "[test] threshold {} exceeds parties {}",
197                    self.test.threshold, self.test.parties
198                ),
199            }
200            .build());
201        }
202        // Every behavior tag must be known.
203        for entry in &self.peer_behavior {
204            if PeerBehavior::from_tag(&entry.behavior_tag).is_none() {
205                return Err(VectorSnafu {
206                    message: format!(
207                        "unknown peer_behavior type '{}' for party '{}'",
208                        entry.behavior_tag, entry.party_id
209                    ),
210                }
211                .build());
212            }
213        }
214        if let Some(rc) = self.expected_round_count {
215            if rc == 0 {
216                return Err(VectorSnafu {
217                    message: "expected_round_count must be at least 1".to_string(),
218                }
219                .build());
220            }
221        }
222        Ok(())
223    }
224
225    /// Decode the seed field into a `u64`. Accepts `"0x..."` hex or bare
226    /// hex; empty defaults to 0.
227    pub fn seed_u64(&self) -> Result<u64> {
228        decode_hex_u64(&self.test.seed).ok_or_else(|| {
229            VectorSnafu {
230                message: format!("could not decode seed '{}' as hex u64", self.test.seed),
231            }
232            .build()
233        })
234    }
235
236    /// Convert the parsed vector into [`BehaviorSpec`]s for the
237    /// [`crate::ByzantineTransport`].
238    pub fn behavior_specs(&self) -> Vec<BehaviorSpec> {
239        self.peer_behavior
240            .iter()
241            .map(|entry| BehaviorSpec {
242                party_id: entry.party_id.clone(),
243                behavior: PeerBehavior::from_tag(&entry.behavior_tag)
244                    .unwrap_or(PeerBehavior::Honest),
245                drop_round: entry.drop_round,
246            })
247            .collect()
248    }
249}
250
251impl TestVectorTest {
252    /// Decode `message` to raw bytes. `"0x..."` is hex; anything else is
253    /// the literal UTF-8 bytes of the string.
254    pub fn message_bytes(&self) -> Vec<u8> {
255        if let Some(rest) = self.message.strip_prefix("0x") {
256            decode_hex_bytes(rest).unwrap_or_else(|| self.message.as_bytes().to_vec())
257        } else {
258            self.message.as_bytes().to_vec()
259        }
260    }
261
262    /// Decode the expected-output hex into bytes, if present.
263    pub fn expected_bytes(&self) -> Option<Vec<u8>> {
264        if self.expected_signature_hex.is_empty() {
265            return None;
266        }
267        let stripped = self
268            .expected_signature_hex
269            .strip_prefix("0x")
270            .unwrap_or(&self.expected_signature_hex);
271        decode_hex_bytes(stripped)
272    }
273}
274
275/// Parse a hex u64, accepting an optional `0x` prefix.
276fn decode_hex_u64(s: &str) -> Option<u64> {
277    let s = s.strip_prefix("0x").unwrap_or(s);
278    let s = s.trim();
279    if s.is_empty() {
280        return Some(0);
281    }
282    u64::from_str_radix(s, 16).ok()
283}
284
285/// Decode a hex string (no `0x` prefix) into bytes. Returns `None` on
286/// odd length or non-hex digits.
287fn decode_hex_bytes(s: &str) -> Option<Vec<u8>> {
288    let s = s.trim();
289    if s.len() % 2 != 0 {
290        return None;
291    }
292    let mut out = Vec::with_capacity(s.len() / 2);
293    let bytes = s.as_bytes();
294    let mut i = 0;
295    while i < bytes.len() {
296        let hi = hex_nibble(bytes[i])?;
297        let lo = hex_nibble(bytes[i + 1])?;
298        out.push((hi << 4) | lo);
299        i += 2;
300    }
301    Some(out)
302}
303
304fn hex_nibble(b: u8) -> Option<u8> {
305    match b {
306        b'0'..=b'9' => Some(b - b'0'),
307        b'a'..=b'f' => Some(b - b'a' + 10),
308        b'A'..=b'F' => Some(b - b'A' + 10),
309        _ => None,
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    const SAMPLE: &str = include_str!("../vectors/sample.toml");
318
319    #[test]
320    fn sample_parses_correctly() {
321        let v = TestVector::parse(SAMPLE).expect("sample.toml must parse");
322        assert_eq!(v.scheme.name, "FROST-ed25519");
323        assert_eq!(v.scheme.version, "draft-irtf-cfrg-frost-13");
324        assert_eq!(v.test.parties, 5);
325        assert_eq!(v.test.threshold, 3);
326        assert_eq!(v.test.message_bytes(), b"hello world");
327        assert_eq!(v.peer_behavior.len(), 3);
328        assert_eq!(v.peer_behavior[0].party_id, "alice");
329        assert_eq!(v.peer_behavior[0].behavior_tag, "honest");
330        assert_eq!(v.peer_behavior[2].behavior_tag, "byzantine-drop");
331    }
332
333    #[test]
334    fn seed_decodes_as_hex_u64() {
335        let v = TestVector::parse(SAMPLE).unwrap();
336        assert_eq!(v.seed_u64().unwrap(), 0xDEAD_BEEF);
337    }
338
339    #[test]
340    fn behavior_specs_round_trip() {
341        let v = TestVector::parse(SAMPLE).unwrap();
342        let specs = v.behavior_specs();
343        assert_eq!(specs.len(), 3);
344        assert_eq!(specs[2].behavior, PeerBehavior::Drop);
345        assert_eq!(specs[2].drop_round, Some(2));
346    }
347
348    #[test]
349    fn rejects_zero_parties() {
350        let bad = r#"
351[scheme]
352name = "x"
353version = "1"
354[test]
355parties = 0
356threshold = 1
357"#;
358        assert!(TestVector::parse(bad).is_err());
359    }
360
361    #[test]
362    fn rejects_threshold_above_parties() {
363        let bad = r#"
364[scheme]
365name = "x"
366[test]
367parties = 2
368threshold = 5
369"#;
370        assert!(TestVector::parse(bad).is_err());
371    }
372
373    #[test]
374    fn rejects_unknown_behavior_tag() {
375        let bad = r#"
376[scheme]
377name = "x"
378[test]
379parties = 2
380threshold = 1
381[[peer_behavior]]
382party_id = "a"
383type = "byzantine-flip-table"
384"#;
385        let err = TestVector::parse(bad).unwrap_err();
386        assert!(format!("{err}").contains("unknown peer_behavior type"));
387    }
388
389    #[test]
390    fn binary_message_via_hex_prefix() {
391        let v = TestVector::parse(
392            r#"
393[scheme]
394name = "x"
395[test]
396parties = 2
397threshold = 1
398message = "0xdeadbeef"
399"#,
400        )
401        .unwrap();
402        assert_eq!(v.test.message_bytes(), vec![0xDE, 0xAD, 0xBE, 0xEF]);
403    }
404
405    #[test]
406    fn expected_signature_decodes_when_present() {
407        let v = TestVector::parse(
408            r#"
409[scheme]
410name = "x"
411[test]
412parties = 2
413threshold = 1
414expected_signature_hex = "0x01020304"
415"#,
416        )
417        .unwrap();
418        assert_eq!(v.test.expected_bytes(), Some(vec![1, 2, 3, 4]));
419    }
420
421    #[test]
422    fn expected_signature_absent_when_empty() {
423        let v = TestVector::parse(
424            r#"
425[scheme]
426name = "x"
427[test]
428parties = 2
429threshold = 1
430"#,
431        )
432        .unwrap();
433        assert!(v.test.expected_bytes().is_none());
434    }
435
436    #[test]
437    fn conformance_level_defaults_to_must_pass() {
438        let v = TestVector::parse(
439            r#"
440[scheme]
441name = "x"
442[test]
443parties = 2
444threshold = 1
445"#,
446        )
447        .unwrap();
448        assert_eq!(v.conformance_level, ConformanceLevel::MustPass);
449    }
450
451    #[test]
452    fn conformance_level_parses_should_pass() {
453        let v = TestVector::parse(
454            r#"
455conformance_level = "should_pass"
456[scheme]
457name = "x"
458[test]
459parties = 2
460threshold = 1
461"#,
462        )
463        .unwrap();
464        assert_eq!(v.conformance_level, ConformanceLevel::ShouldPass);
465    }
466
467    #[test]
468    fn conformance_level_parses_informational() {
469        let v = TestVector::parse(
470            r#"
471conformance_level = "informational"
472[scheme]
473name = "x"
474[test]
475parties = 2
476threshold = 1
477"#,
478        )
479        .unwrap();
480        assert_eq!(v.conformance_level, ConformanceLevel::Informational);
481    }
482
483    #[test]
484    fn conformance_level_rejects_unknown_tag_with_useful_error() {
485        let bad = r#"
486conformance_level = "maybe_pass"
487[scheme]
488name = "x"
489[test]
490parties = 2
491threshold = 1
492"#;
493        let err = TestVector::parse(bad).unwrap_err();
494        let msg = format!("{err}");
495        assert!(
496            msg.contains("conformance_level") || msg.contains("maybe_pass"),
497            "error must point at the bad conformance_level tag: {msg}"
498        );
499    }
500
501    #[test]
502    fn reference_and_share_material_parse() {
503        let v = TestVector::parse(
504            r#"
505reference = "https://example.org/spec"
506share_material = "nist-dkg-A"
507expected_round_count = 4
508[scheme]
509name = "x"
510[test]
511parties = 2
512threshold = 1
513"#,
514        )
515        .unwrap();
516        assert_eq!(v.reference.as_deref(), Some("https://example.org/spec"));
517        assert_eq!(v.share_material.as_deref(), Some("nist-dkg-A"));
518        assert_eq!(v.expected_round_count, Some(4));
519    }
520
521    #[test]
522    fn rejects_zero_expected_round_count() {
523        let bad = r#"
524expected_round_count = 0
525[scheme]
526name = "x"
527[test]
528parties = 2
529threshold = 1
530"#;
531        let err = TestVector::parse(bad).unwrap_err();
532        assert!(
533            format!("{err}").contains("expected_round_count"),
534            "error must name the offending field"
535        );
536    }
537
538    #[test]
539    fn malformed_toml_surfaces_useful_error() {
540        // Missing the [scheme] table entirely — toml::from_str rejects
541        // this, and the parser must relay that as a Vector error
542        // (not a panic, not a raw toml::de::Error).
543        let bad = "this is not toml at all {{{";
544        let err = TestVector::parse(bad).unwrap_err();
545        let msg = format!("{err}");
546        assert!(
547            msg.to_lowercase().contains("malformed") || msg.contains("expected"),
548            "error must describe the malformation: {msg}"
549        );
550    }
551
552    #[test]
553    fn missing_scheme_name_surfaces_useful_error() {
554        // A structurally-valid TOML document that is missing a required
555        // field. The error must name what is missing.
556        let bad = r#"
557[scheme]
558[test]
559parties = 2
560threshold = 1
561"#;
562        let err = TestVector::parse(bad).unwrap_err();
563        let msg = format!("{err}");
564        assert!(
565            msg.contains("name") || msg.contains("scheme"),
566            "error must point at the missing scheme.name: {msg}"
567        );
568    }
569
570    #[test]
571    fn conformance_level_round_trips_through_tags() {
572        for level in [
573            ConformanceLevel::MustPass,
574            ConformanceLevel::ShouldPass,
575            ConformanceLevel::Informational,
576        ] {
577            let tag = level.as_tag();
578            assert_eq!(ConformanceLevel::from_tag(tag), Some(level));
579        }
580    }
581}