diff --git a/crates/nexum-venue-test/goldens/reference-header.json b/crates/nexum-venue-test/goldens/reference-header.json index ded49d81..a90bc3c4 100644 --- a/crates/nexum-venue-test/goldens/reference-header.json +++ b/crates/nexum-venue-test/goldens/reference-header.json @@ -1,4 +1,5 @@ { + "version": 1, "venue": "nexum-venue-test/reference", "goldens": [ { diff --git a/crates/nexum-venue-test/src/codec.rs b/crates/nexum-venue-test/src/codec.rs index 3041326e..52d46226 100644 --- a/crates/nexum-venue-test/src/codec.rs +++ b/crates/nexum-venue-test/src/codec.rs @@ -2,7 +2,8 @@ //! `IntentBody` wire bytes, and the check that holds a codec to them. //! //! A vector file is the venue's codec contract in portable form: JSON, -//! bytes as lowercase hex, one entry per published body. A non-Rust +//! a leading format version (unknown versions fail closed), bytes as +//! lowercase hex, one entry per published body, never zero. A non-Rust //! adapter author proves byte-exactness by decoding and re-encoding //! each `round-trip` vector in their own language and comparing bytes; //! a Rust author runs [`CodecVectors::assert_conforms`] against the @@ -15,17 +16,21 @@ use std::path::Path; use nexum_venue_sdk::{BodyError, IntentBody}; use serde::{Deserialize, Serialize}; -use crate::fixture::{self, FixtureError, hex_bytes}; +use crate::fixture::{self, FixtureError, FormatVersion, hex_bytes}; use crate::report::{ConformanceReport, Violation, settle}; /// A published set of codec vectors for one venue body schema. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CodecVectors { + /// File-format discriminator; an unknown version fails to parse. + pub version: FormatVersion, /// Name of the body schema the vectors bind, e.g. /// `acme-dex/order-body`. Informational: the check never reads it. pub schema: String, - /// The vectors, in publication order. + /// The vectors, in publication order. Never empty in a parsed + /// file: an empty set would conform vacuously. + #[serde(deserialize_with = "fixture::non_empty")] pub vectors: Vec, } @@ -80,9 +85,11 @@ impl std::fmt::Display for Expectation { } impl CodecVectors { - /// An empty vector set for `schema`. + /// An empty vector set for `schema`. Push at least one vector + /// before publishing: a parsed file is never empty. pub fn new(schema: impl Into) -> Self { Self { + version: FormatVersion, schema: schema.into(), vectors: Vec::new(), } @@ -158,9 +165,16 @@ impl CodecVectors { /// /// A `round-trip` vector must decode and re-encode to the exact /// published bytes; a failure vector must produce the matching - /// [`BodyError`] case (the free-text detail is not compared). + /// [`BodyError`] case (the free-text detail is not compared). An + /// empty set is itself a violation: it would conform vacuously. pub fn check(&self) -> Result<(), ConformanceReport> { let mut violations = Vec::new(); + if self.vectors.is_empty() { + violations.push(Violation { + vector: "".to_owned(), + detail: "published vector set is empty".to_owned(), + }); + } for vector in &self.vectors { if let Err(detail) = vector.check::() { violations.push(Violation { @@ -317,6 +331,7 @@ mod tests { let json = vectors.to_json(); assert_eq!(CodecVectors::from_json(&json).unwrap(), vectors); // The wire spellings are the contract for non-Rust readers. + assert!(json.contains("\"version\": 1")); assert!(json.contains("\"round-trip\"")); assert!(json.contains("\"unknown-version\"")); assert!(json.contains("\"notes\": \"first published body\"")); @@ -343,4 +358,47 @@ mod tests { Err(FixtureError::Read { .. }), )); } + + #[test] + fn unknown_format_version_fails_closed() { + let json = published() + .to_json() + .replace("\"version\": 1", "\"version\": 2"); + let Err(FixtureError::Format(detail)) = CodecVectors::from_json(&json) else { + panic!("version 2 must not parse"); + }; + assert!(detail.contains("unknown fixture format version 2")); + } + + #[test] + fn missing_format_version_fails() { + let json = published().to_json().replace(" \"version\": 1,\n", ""); + assert!(matches!( + CodecVectors::from_json(&json), + Err(FixtureError::Format(_)), + )); + } + + #[test] + fn empty_vector_set_fails_the_check() { + let report = CodecVectors::new("test/body").check::().unwrap_err(); + assert_eq!(report.violations.len(), 1, "violations: {report}"); + assert_eq!(report.violations[0].vector, ""); + assert!(report.violations[0].detail.contains("empty")); + } + + #[test] + #[should_panic(expected = "codec does not conform")] + fn assert_conforms_rejects_an_empty_set() { + CodecVectors::new("test/body").assert_conforms::(); + } + + #[test] + fn empty_vector_set_fails_to_parse() { + let json = CodecVectors::new("test/body").to_json(); + let Err(FixtureError::Format(detail)) = CodecVectors::from_json(&json) else { + panic!("an empty set must not parse"); + }; + assert!(detail.contains("never empty")); + } } diff --git a/crates/nexum-venue-test/src/fixture.rs b/crates/nexum-venue-test/src/fixture.rs index 7174573c..624ec1c9 100644 --- a/crates/nexum-venue-test/src/fixture.rs +++ b/crates/nexum-venue-test/src/fixture.rs @@ -1,11 +1,54 @@ -//! The shared fixture-file plumbing: JSON on disk, byte fields as -//! lowercase hex, and the typed [`FixtureError`] both file formats -//! load and save through. +//! The shared fixture-file plumbing: JSON on disk, a leading +//! [`FormatVersion`] (unknown versions fail closed), byte fields as +//! lowercase hex, non-empty entry lists, and the typed +//! [`FixtureError`] both file formats load and save through. use std::path::Path; -use serde::Serialize; -use serde::de::DeserializeOwned; +use serde::de::{DeserializeOwned, Error as _}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// The one published fixture file-format version. +const FORMAT_VERSION: u32 = 1; + +/// Fixture file-format discriminator: serializes as the current +/// version, refuses any other on parse (fail-closed), so a reader +/// never guesses at a future layout. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct FormatVersion; + +impl Serialize for FormatVersion { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_u32(FORMAT_VERSION) + } +} + +impl<'de> Deserialize<'de> for FormatVersion { + fn deserialize>(deserializer: D) -> Result { + let version = u32::deserialize(deserializer)?; + if version == FORMAT_VERSION { + Ok(Self) + } else { + Err(D::Error::custom(format!( + "unknown fixture format version {version}; this reader speaks {FORMAT_VERSION}", + ))) + } + } +} + +/// Deserialize a fixture's entry list, refusing an empty one: an empty +/// set would conform vacuously. +pub(crate) fn non_empty<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + let entries = Vec::::deserialize(deserializer)?; + if entries.is_empty() { + return Err(D::Error::custom("a published fixture set is never empty")); + } + Ok(entries) +} /// Why a fixture file failed to load or save. The JSON case carries /// serde's rendered detail rather than the error value so the type diff --git a/crates/nexum-venue-test/src/header.rs b/crates/nexum-venue-test/src/header.rs index f2908622..6c32af65 100644 --- a/crates/nexum-venue-test/src/header.rs +++ b/crates/nexum-venue-test/src/header.rs @@ -4,8 +4,10 @@ //! //! A golden file pairs wire bodies with the intent header a conforming //! adapter derives from them, spelled in the golden mirror types below -//! (JSON, kebab-case case names matching the WIT, bytes as lowercase -//! hex). The mirrors exist because wit-bindgen types carry no serde; +//! (JSON, a leading format version that fails closed on an unknown tag, +//! kebab-case case names matching the WIT, bytes as lowercase hex, +//! never zero goldens). The mirrors exist because wit-bindgen types +//! carry no serde; //! [`GoldenHeader`] converts from the venue SDK's `IntentHeader`, and a //! macro-built adapter whose bindgen mints its own header type bridges //! with a field-for-field `From` impl on its crate boundary, the same @@ -18,17 +20,21 @@ use nexum_venue_sdk::value_flow::{Asset, AssetAmount}; use nexum_venue_sdk::{AuthScheme, IntentHeader, Settlement}; use serde::{Deserialize, Serialize}; -use crate::fixture::{self, FixtureError, hex_bytes}; +use crate::fixture::{self, FixtureError, FormatVersion, hex_bytes}; use crate::report::{ConformanceReport, Violation, settle}; /// A published set of header goldens for one venue. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct HeaderGoldens { + /// File-format discriminator; an unknown version fails to parse. + pub version: FormatVersion, /// The venue the goldens bind. Informational: the check never /// reads it. pub venue: String, - /// The goldens, in publication order. + /// The goldens, in publication order. Never empty in a parsed + /// file: an empty set would conform vacuously. + #[serde(deserialize_with = "fixture::non_empty")] pub goldens: Vec, } @@ -157,9 +163,11 @@ impl From for GoldenAuthScheme { } impl HeaderGoldens { - /// An empty golden set for `venue`. + /// An empty golden set for `venue`. Record at least one golden + /// before publishing: a parsed file is never empty. pub fn new(venue: impl Into) -> Self { Self { + version: FormatVersion, venue: venue.into(), goldens: Vec::new(), } @@ -211,7 +219,8 @@ impl HeaderGoldens { /// collecting all violations rather than stopping at the first. /// /// `derive` is the adapter's derivation; a trait-based adapter - /// passes `MyAdapter::derive_header` directly. + /// passes `MyAdapter::derive_header` directly. An empty set is + /// itself a violation: it would conform vacuously. pub fn check( &self, mut derive: impl FnMut(Vec) -> Result, @@ -221,6 +230,12 @@ impl HeaderGoldens { E: fmt::Debug, { let mut violations = Vec::new(); + if self.goldens.is_empty() { + violations.push(Violation { + vector: "".to_owned(), + detail: "published golden set is empty".to_owned(), + }); + } for golden in &self.goldens { match derive(golden.body.clone()) { Ok(header) => { @@ -288,6 +303,7 @@ mod tests { fn golden_mirror_covers_every_wire_case_and_round_trips_as_json() { let golden: GoldenHeader = wire_header().into(); let goldens = HeaderGoldens { + version: FormatVersion, venue: "acme".to_owned(), goldens: vec![HeaderGolden { name: "kitchen-sink".to_owned(), @@ -299,6 +315,7 @@ mod tests { let json = goldens.to_json(); assert_eq!(HeaderGoldens::from_json(&json).unwrap(), goldens); // The wire spellings are the contract for non-Rust readers. + assert!(json.contains("\"version\": 1")); assert!(json.contains("\"native\"")); assert!(json.contains("\"erc20\"")); assert!(json.contains("\"token\"")); @@ -359,4 +376,44 @@ mod tests { .unwrap(); goldens.assert_conforms(|_| Err::(VenueError::Timeout)); } + + #[test] + fn unknown_format_version_fails_closed() { + let mut goldens = HeaderGoldens::new("acme"); + goldens + .record("a", vec![1], |_| Ok::<_, VenueError>(wire_header())) + .unwrap(); + let json = goldens + .to_json() + .replace("\"version\": 1", "\"version\": 7"); + let Err(FixtureError::Format(detail)) = HeaderGoldens::from_json(&json) else { + panic!("version 7 must not parse"); + }; + assert!(detail.contains("unknown fixture format version 7")); + } + + #[test] + fn empty_golden_set_fails_the_check() { + let report = HeaderGoldens::new("acme") + .check(|_| Ok::<_, VenueError>(wire_header())) + .unwrap_err(); + assert_eq!(report.violations.len(), 1, "violations: {report}"); + assert_eq!(report.violations[0].vector, ""); + assert!(report.violations[0].detail.contains("empty")); + } + + #[test] + #[should_panic(expected = "derive-header does not conform")] + fn assert_conforms_rejects_an_empty_set() { + HeaderGoldens::new("acme").assert_conforms(|_| Ok::<_, VenueError>(wire_header())); + } + + #[test] + fn empty_golden_set_fails_to_parse() { + let json = HeaderGoldens::new("acme").to_json(); + let Err(FixtureError::Format(detail)) = HeaderGoldens::from_json(&json) else { + panic!("an empty set must not parse"); + }; + assert!(detail.contains("never empty")); + } } diff --git a/crates/nexum-venue-test/src/lib.rs b/crates/nexum-venue-test/src/lib.rs index e8c372e3..a4c4fd83 100644 --- a/crates/nexum-venue-test/src/lib.rs +++ b/crates/nexum-venue-test/src/lib.rs @@ -69,7 +69,7 @@ pub mod report; pub mod transport; pub use codec::{CodecVector, CodecVectors, Expectation}; -pub use fixture::FixtureError; +pub use fixture::{FixtureError, FormatVersion}; pub use header::{ GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, GoldenSettlement, HeaderGolden, HeaderGoldens, diff --git a/crates/nexum-venue-test/vectors/reference-body.json b/crates/nexum-venue-test/vectors/reference-body.json index 297d32b3..a2ffd1db 100644 --- a/crates/nexum-venue-test/vectors/reference-body.json +++ b/crates/nexum-venue-test/vectors/reference-body.json @@ -1,4 +1,5 @@ { + "version": 1, "schema": "nexum-venue-test/reference-body", "vectors": [ { diff --git a/modules/examples/echo-venue/src/lib.rs b/modules/examples/echo-venue/src/lib.rs index c3e675c7..92b0e62c 100644 --- a/modules/examples/echo-venue/src/lib.rs +++ b/modules/examples/echo-venue/src/lib.rs @@ -108,8 +108,8 @@ fn minimal_be(value: u64) -> Vec { mod conformance { use super::*; use nexum_venue_test::{ - GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, GoldenSettlement, - HeaderGolden, HeaderGoldens, + FormatVersion, GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, + GoldenSettlement, HeaderGolden, HeaderGoldens, }; fn asset_to_golden(asset: Asset) -> GoldenAsset { @@ -177,6 +177,7 @@ mod conformance { notes: Some("amount is the minimal big-endian body length".to_owned()), }; let goldens = HeaderGoldens { + version: FormatVersion, venue: "echo-venue".to_owned(), goldens: vec![golden], }; @@ -188,6 +189,7 @@ mod conformance { // A non-minimal amount is the classic uint bug; the golden must // reject it, proving the check has teeth on echo-venue. let goldens = HeaderGoldens { + version: FormatVersion, venue: "echo-venue".to_owned(), goldens: vec![HeaderGolden { name: "four-byte-body".to_owned(),