diff --git a/crates/videre-host/src/lib.rs b/crates/videre-host/src/lib.rs index 34187ca9..82784126 100644 --- a/crates/videre-host/src/lib.rs +++ b/crates/videre-host/src/lib.rs @@ -156,8 +156,9 @@ async fn status_poll_task( for update in registry.poll_status_transitions().await { let attrs = vec![("venue", update.venue.clone())]; // The transition rides the generic `custom` channel: the - // envelope is borsh, the status body its inner encoding. A - // keeper recovers it through `videre_sdk::event`. + // envelope is a version tag plus borsh, the status body its + // inner encoding. A keeper recovers it through + // `videre_sdk::event`. let payload = match update.encode() { Ok(payload) => payload, Err(err) => { diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index c068fdca..ccfce70b 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -100,8 +100,8 @@ fn block(chain_id: u64) -> nexum::host::types::Block { } /// Wrap a polled transition as the extension event the platform emits: -/// the transition rides the generic `custom` channel, its borsh envelope -/// the opaque payload. +/// the transition rides the generic `custom` channel, its tagged borsh +/// envelope the opaque payload. fn status_event(update: videre_host::IntentStatusUpdate) -> ExtensionEvent { let attrs = vec![("venue", update.venue.clone())]; let payload = update.encode().expect("encode intent-status envelope"); diff --git a/crates/videre-sdk/src/event.rs b/crates/videre-sdk/src/event.rs index fe450ba6..31a188a0 100644 --- a/crates/videre-sdk/src/event.rs +++ b/crates/videre-sdk/src/event.rs @@ -14,7 +14,8 @@ pub use crate::status_body::EnvelopeError; /// Recover an [`IntentStatusUpdate`] from a `custom` event, keyed by its /// `kind` and `payload`. `None` when the kind is another extension's; -/// `Some(Err)` when the payload is malformed. +/// `Some(Err)` when the payload is empty, tagged to an envelope version +/// this build does not publish, or malformed. pub fn intent_status_update( kind: &str, payload: &[u8], @@ -61,12 +62,30 @@ mod tests { assert!(intent_status_update("other-kind", &envelope()).is_none()); } + /// A host framing the envelope to a version this guest does not + /// publish is refused at the seam, not misread into a plausible + /// update. + #[test] + fn reports_a_skewed_envelope_version() { + let mut skewed = envelope(); + skewed[0] = crate::status_body::ENVELOPE_VERSION_V1 + 1; + assert!(matches!( + intent_status_update(INTENT_STATUS_KIND, &skewed).expect("kind matches"), + Err(EnvelopeError::UnknownVersion { .. }), + )); + } + + /// A payload tagged to a version this build does publish, whose body + /// is garbage, is the caller's `invalid-input`, not a skew report. #[test] fn reports_a_malformed_payload() { - assert!( - intent_status_update(INTENT_STATUS_KIND, b"\xff") - .expect("kind matches") - .is_err() - ); + assert!(matches!( + intent_status_update( + INTENT_STATUS_KIND, + &[crate::status_body::ENVELOPE_VERSION_V1, 0xff], + ) + .expect("kind matches"), + Err(EnvelopeError::Malformed { .. }), + )); } } diff --git a/crates/videre-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs index c4be49d6..9145681f 100644 --- a/crates/videre-sdk/src/lib.rs +++ b/crates/videre-sdk/src/lib.rs @@ -119,9 +119,10 @@ pub use bindings::videre::value_flow::types as value_flow; pub use videre_status_body as status_body; /// The intent-status transition a keeper recovers from a `custom` event -/// through [`event::intent_status_update`]. Its wire form is a borsh -/// envelope defined by this struct, not a WIT record: it crosses the -/// `custom` event as opaque bytes. The status body rides its inner codec. +/// through [`event::intent_status_update`]. Its wire form is a version +/// tag plus the borsh envelope defined by this struct, not a WIT record: +/// it crosses the `custom` event as opaque bytes, and an unknown tag +/// fails closed. The status body rides its own inner codec. pub use videre_status_body::IntentStatusUpdate; /// The wire config table (`nexum:host/types.config`) `init` receives. diff --git a/crates/videre-status-body/src/lib.rs b/crates/videre-status-body/src/lib.rs index 271b6f22..327c285c 100644 --- a/crates/videre-status-body/src/lib.rs +++ b/crates/videre-status-body/src/lib.rs @@ -7,6 +7,13 @@ //! //! v1 wire form: `0x01`, the [`IntentStatus`] discriminant, then the //! borsh `option` encodings of `proof` and `reason`. +//! +//! The [`IntentStatusUpdate`] envelope that carries such a body is tagged +//! the same way and fails closed the same way, on its own version line: +//! the envelope tag describes the `venue`/`receipt`/`status` framing, the +//! body tag describes the status payload, and the two move independently. +//! v1 envelope wire form: `0x01`, then the borsh `{venue, receipt, +//! status}` payload. #![warn(missing_docs)] @@ -15,6 +22,10 @@ use borsh::{BorshDeserialize, BorshSerialize}; /// Wire tag of the v1 payload. pub const VERSION_V1: u8 = 1; +/// Wire tag of the v1 [`IntentStatusUpdate`] envelope. Independent of +/// [`VERSION_V1`]: the framing versions apart from the body it carries. +pub const ENVELOPE_VERSION_V1: u8 = 1; + /// The extension event kind an intent-status transition rides on: the /// `custom-event.kind` the venue platform stamps and a subscribing /// module matches. Shared by the emit side and the decode side so the @@ -22,8 +33,9 @@ pub const VERSION_V1: u8 = 1; pub const INTENT_STATUS_KIND: &str = "intent-status"; /// The intent-status transition an intent-status `custom` event carries -/// in its opaque payload: borsh `{venue, receipt, status}`, where -/// `status` is a [`StatusBody`]-encoded body (the inner codec above). +/// in its opaque payload: [`ENVELOPE_VERSION_V1`], then borsh +/// `{venue, receipt, status}`, where `status` is a [`StatusBody`]-encoded +/// body (the inner codec above). #[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] pub struct IntentStatusUpdate { /// Venue id the receipt was issued by. @@ -35,29 +47,55 @@ pub struct IntentStatusUpdate { } impl IntentStatusUpdate { - /// Borsh-encode the envelope. + /// Encode as the envelope version tag plus the borsh payload. pub fn encode(&self) -> Result, EncodeError> { - let mut out = Vec::new(); + let mut out = vec![ENVELOPE_VERSION_V1]; borsh::to_writer(&mut out, self).map_err(|err| EncodeError { detail: err.to_string(), })?; Ok(out) } - /// Decode a borsh envelope, failing typedly on malformed bytes. + /// Decode, failing typedly on an empty envelope, an unknown version + /// tag (fail-closed), or a payload that does not parse as the tagged + /// version (including trailing bytes). pub fn decode(bytes: &[u8]) -> Result { - borsh::from_slice(bytes).map_err(|err| EnvelopeError { - detail: err.to_string(), - }) + match bytes { + [] => Err(EnvelopeError::Empty), + [ENVELOPE_VERSION_V1, payload @ ..] => { + borsh::from_slice(payload).map_err(|err| EnvelopeError::Malformed { + version: ENVELOPE_VERSION_V1, + detail: err.to_string(), + }) + } + [version, ..] => Err(EnvelopeError::UnknownVersion { version: *version }), + } } } /// Why bytes failed to decode as an [`IntentStatusUpdate`] envelope. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] -#[error("malformed intent-status envelope: {detail}")] -pub struct EnvelopeError { - /// Borsh's decode failure detail. - pub detail: String, +#[non_exhaustive] +pub enum EnvelopeError { + /// No bytes at all: not even a version tag. + #[error("empty intent-status envelope: missing the version tag")] + Empty, + /// The version tag names no published envelope version. Fail-closed: + /// a skewed peer's framing is refused, never guessed at. + #[error("unknown intent-status envelope version {version}")] + UnknownVersion { + /// The unrecognised wire tag. + version: u8, + }, + /// The tag named a known version but its payload did not decode + /// (malformed borsh or trailing bytes). + #[error("malformed version {version} intent-status envelope: {detail}")] + Malformed { + /// The wire tag whose payload failed. + version: u8, + /// Borsh's decode failure detail. + detail: String, + }, } /// Where an intent is in its life at the venue. The borsh discriminant @@ -177,6 +215,119 @@ mod tests { } } + fn envelope(venue: &str) -> IntentStatusUpdate { + IntentStatusUpdate { + venue: venue.to_owned(), + receipt: b"receipt".to_vec(), + status: body(IntentStatus::Open).encode().expect("encode body"), + } + } + + #[test] + fn envelope_leads_with_its_version_tag() { + let encoded = envelope("cow").encode().expect("encode"); + assert_eq!(encoded[0], ENVELOPE_VERSION_V1); + } + + /// Pins the whole v1 envelope framing, not just the tag's position: + /// a borsh layout drift is a wire break, so it must fail here. + #[test] + fn golden_envelope() { + let encoded = envelope("cow").encode().expect("encode"); + let expected = [ + &[ENVELOPE_VERSION_V1, 3, 0, 0, 0][..], + b"cow", + &[7, 0, 0, 0], + b"receipt", + &[4, 0, 0, 0, VERSION_V1, 1, 0, 0], + ] + .concat(); + assert_eq!(encoded, expected); + } + + #[test] + fn envelope_round_trips() { + let original = envelope("cow"); + let decoded = + IntentStatusUpdate::decode(&original.encode().expect("encode")).expect("decode"); + assert_eq!(decoded, original); + } + + #[test] + fn empty_envelope_fails_typedly() { + assert_eq!(IntentStatusUpdate::decode(&[]), Err(EnvelopeError::Empty)); + } + + /// A peer that framed the envelope to a version this build does not + /// publish is refused, not misparsed into plausible-looking fields. + #[test] + fn future_envelope_version_fails_closed() { + let mut skewed = envelope("cow").encode().expect("encode"); + skewed[0] = ENVELOPE_VERSION_V1 + 1; + assert_eq!( + IntentStatusUpdate::decode(&skewed), + Err(EnvelopeError::UnknownVersion { + version: ENVELOPE_VERSION_V1 + 1, + }), + ); + } + + /// The pre-tag framing (bare borsh, no leading tag) is skew too: it + /// must never decode, whatever the leading length byte happens to be. + #[test] + fn untagged_envelope_never_decodes() { + for venue in ["c", "cow", "a longer venue id"] { + let mut untagged = Vec::new(); + borsh::to_writer(&mut untagged, &envelope(venue)).expect("encode"); + assert!( + IntentStatusUpdate::decode(&untagged).is_err(), + "untagged {venue} envelope decoded", + ); + } + } + + #[test] + fn envelope_trailing_bytes_are_malformed() { + let mut encoded = envelope("cow").encode().expect("encode"); + encoded.push(0); + assert!(matches!( + IntentStatusUpdate::decode(&encoded), + Err(EnvelopeError::Malformed { + version: ENVELOPE_VERSION_V1, + .. + }), + )); + } + + #[test] + fn truncated_envelope_is_malformed() { + let encoded = envelope("cow").encode().expect("encode"); + assert!(matches!( + IntentStatusUpdate::decode(&encoded[..encoded.len() - 1]), + Err(EnvelopeError::Malformed { + version: ENVELOPE_VERSION_V1, + .. + }), + )); + } + + /// The envelope tag and the body tag are separate wire lines: the + /// envelope decodes even when the body it carries is a version this + /// build refuses. + #[test] + fn envelope_and_body_versions_are_independent() { + let mut update = envelope("cow"); + update.status[0] = VERSION_V1 + 1; + let decoded = + IntentStatusUpdate::decode(&update.encode().expect("encode")).expect("decode"); + assert_eq!( + StatusBody::decode(&decoded.status), + Err(DecodeError::UnknownVersion { + version: VERSION_V1 + 1, + }), + ); + } + #[test] fn golden_minimal_open() { let encoded = body(IntentStatus::Open).encode().expect("encode");