From 338ffa8da9d9bab7b5eb3477ddb388b2790f1253 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 17:51:07 +0000 Subject: [PATCH 1/3] venue-test: version the fixture file format Fixture files lead with a format version; a reader fails closed on an unknown tag and refuses an empty vector or golden set, which would conform vacuously. Published reference fixtures regenerated. --- .../goldens/reference-header.json | 1 + crates/nexum-venue-test/src/codec.rs | 43 +++++++++++++-- crates/nexum-venue-test/src/fixture.rs | 53 +++++++++++++++++-- crates/nexum-venue-test/src/header.rs | 42 +++++++++++++-- crates/nexum-venue-test/src/lib.rs | 2 +- .../vectors/reference-body.json | 1 + modules/examples/echo-venue/src/lib.rs | 6 ++- 7 files changed, 131 insertions(+), 17 deletions(-) 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..ecda15f0 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(), } @@ -317,6 +324,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 +351,31 @@ 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_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..b0cb826f 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(), } @@ -288,6 +296,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 +308,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 +369,26 @@ 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_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(), From ec0c95b6cff2f4edf9fb8c3e7ee576c0b2319d1e Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 18:00:05 +0000 Subject: [PATCH 2/3] venue-test: fail conformance checks on an empty fixture set --- crates/nexum-venue-test/src/codec.rs | 23 ++++++++++++++++++++++- crates/nexum-venue-test/src/header.rs | 25 ++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/nexum-venue-test/src/codec.rs b/crates/nexum-venue-test/src/codec.rs index ecda15f0..f31fc5c6 100644 --- a/crates/nexum-venue-test/src/codec.rs +++ b/crates/nexum-venue-test/src/codec.rs @@ -165,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 { @@ -370,6 +377,20 @@ mod tests { )); } + #[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(); diff --git a/crates/nexum-venue-test/src/header.rs b/crates/nexum-venue-test/src/header.rs index b0cb826f..088674f1 100644 --- a/crates/nexum-venue-test/src/header.rs +++ b/crates/nexum-venue-test/src/header.rs @@ -219,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, @@ -229,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) => { @@ -383,6 +390,22 @@ mod tests { 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(); From 72601f23941658c8e91792ecff705c3ba34c424f Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 18:09:04 +0000 Subject: [PATCH 3/3] style: rustfmt the venue-test version-rejection tests --- crates/nexum-venue-test/src/codec.rs | 4 +++- crates/nexum-venue-test/src/header.rs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/nexum-venue-test/src/codec.rs b/crates/nexum-venue-test/src/codec.rs index f31fc5c6..52d46226 100644 --- a/crates/nexum-venue-test/src/codec.rs +++ b/crates/nexum-venue-test/src/codec.rs @@ -361,7 +361,9 @@ mod tests { #[test] fn unknown_format_version_fails_closed() { - let json = published().to_json().replace("\"version\": 1", "\"version\": 2"); + 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"); }; diff --git a/crates/nexum-venue-test/src/header.rs b/crates/nexum-venue-test/src/header.rs index 088674f1..6c32af65 100644 --- a/crates/nexum-venue-test/src/header.rs +++ b/crates/nexum-venue-test/src/header.rs @@ -383,7 +383,9 @@ mod tests { goldens .record("a", vec![1], |_| Ok::<_, VenueError>(wire_header())) .unwrap(); - let json = goldens.to_json().replace("\"version\": 1", "\"version\": 7"); + 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"); };