diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs index 6ea82ba8..f711a863 100644 --- a/fuzz/src/lib.rs +++ b/fuzz/src/lib.rs @@ -17,10 +17,11 @@ use std::cell::RefCell; use std::collections::VecDeque; use std::rc::Rc; +use tines_core::conformance; use tines_core::transport::FrameBuf; use tines_core::{ consts::{ACK_IDS_CAP, MAX_FRAME, MAX_PAYLOAD, MAX_TOPIC, SUMMARY_BYTES}, - Event, Frame, MemStore, Node, NodeId, ObservationId, SendError, Tick, Transport, + Event, MemStore, Node, NodeId, ObservationId, SendError, Tick, Transport, }; /// Wire address of a peer on the scripted transport. The exchange pins each @@ -191,15 +192,13 @@ pub struct Harness { dropped_total: u64, } -/// Serialize a frame into bytes via its public [`Frame::encode`] (version byte + -/// postcard), returning `None` if it cannot fit in a `MAX_FRAME` buffer — the -/// harness never panics on an over-large structured frame; it just skips it. -fn encode_frame(frame: Frame) -> Option> { +/// Serialize a frame into bytes via one of core's hidden `conformance` +/// encoders (version byte + postcard), returning `None` if it cannot fit in a +/// `MAX_FRAME` buffer — the harness never panics on an over-large structured +/// frame; it just skips it. +fn encode_frame(enc: impl FnOnce(&mut [u8]) -> Option) -> Option> { let mut buf = [0u8; MAX_FRAME]; - match frame.encode(&mut buf) { - Ok(n) => Some(buf[..n].to_vec()), - Err(_) => None, - } + enc(&mut buf).map(|n| buf[..n].to_vec()) } /// Build a well-formed `Obs` frame from an [`ObsSpec`], clamping the topic and @@ -221,32 +220,20 @@ fn build_obs(spec: &ObsSpec) -> Option> { lifetime_ms, ), }; - encode_frame(Frame::Obs { - from: spec.from, - obs, - }) + encode_frame(|out| conformance::encode_obs(spec.from, &obs, out)) } /// Build a well-formed `Summary` frame, clamping the bloom bits to /// `SUMMARY_BYTES`. fn build_summary(spec: &SpecSummary) -> Option> { - let bits: heapless::Vec = - spec.bits.iter().cloned().take(SUMMARY_BYTES).collect(); - encode_frame(Frame::Summary { - from: spec.from, - salt: spec.salt, - bits, - }) + let bits = &spec.bits[..spec.bits.len().min(SUMMARY_BYTES)]; + encode_frame(|out| conformance::encode_summary(spec.from, spec.salt, bits, out)) } /// Build a well-formed `Ack` frame, capping the id list at `ACK_IDS_CAP`. fn build_ack(spec: &AckSpec) -> Option> { - let ids: heapless::Vec = - spec.ids.iter().copied().take(ACK_IDS_CAP).collect(); - encode_frame(Frame::Ack { - from: spec.from, - ids, - }) + let ids: Vec = spec.ids.iter().copied().take(ACK_IDS_CAP).collect(); + encode_frame(|out| conformance::encode_ack(spec.from, &ids, out)) } /// Push a built frame (or skip it) into the inbound queue for `addr`. diff --git a/tines-core/Cargo.toml b/tines-core/Cargo.toml index 9b6a0066..d9993a9b 100644 --- a/tines-core/Cargo.toml +++ b/tines-core/Cargo.toml @@ -11,6 +11,10 @@ readme.workspace = true documentation = "https://docs.rs/tines-core" keywords = ["robotics", "gossip", "delay-tolerant", "no-std", "sync"] categories = ["no-std", "embedded", "network-programming", "science::robotics"] +# Test targets are listed by hand below: `tests/vectors.rs` is not one. It pins +# the wire format against the crate-private `Frame`, so `src/lib.rs` compiles +# it into the unit tests instead (`#[path = "../tests/vectors.rs"]`). +autotests = false [features] default = ["alloc"] @@ -19,6 +23,8 @@ alloc = [] # backends run against themselves to prove they honor the `Store` contract. # Std-free so it builds with `--no-default-features`; enabled only by the # backend crates that exercise the suite (MemStore, LogStore, CappedStore). +# It also turns on the `#[doc(hidden)]` `conformance` module, which gives the +# fuzz harness byte-level frame helpers and core's local limits. conformance = [] [dependencies] @@ -33,3 +39,16 @@ proptest = "1" # the test suite via a `#[path]` module include to keep it the SAME harness. # That source names `heapless` directly, so restate it here for the twin. heapless = { version = "0.8", features = ["serde"] } + +[[test]] +name = "pair" + +[[test]] +name = "public_api" + +# The proptest twin of the `fuzz/` harness builds frames through the hidden +# `conformance` helpers, so it runs only with that feature (as `scripts/ci.sh` +# does) and needs `MemStore` (`alloc`). +[[test]] +name = "fuzz_twin" +required-features = ["alloc", "conformance"] diff --git a/tines-core/src/conformance.rs b/tines-core/src/conformance.rs new file mode 100644 index 00000000..5a9a1ecf --- /dev/null +++ b/tines-core/src/conformance.rs @@ -0,0 +1,144 @@ +//! Test-only hooks into crate-private wire types and local constants. +//! +//! **Not public API.** Hidden from the docs, behind the `conformance` +//! feature, and outside the 1.x stability promise: anything here may change +//! or vanish in any release. +//! +//! `Frame` and `WireError` are crate-private (spec §3), and the local +//! capacities and timings are `pub(crate)` (decision K). The fuzz harness in +//! `fuzz/` and its proptest twin (`tines-core/tests/fuzz_twin.rs`) still need +//! to build well-formed frames, read the frames a `Node` sends, and size +//! their scenarios by core's local limits. These functions give them exactly +//! that, as byte-level helpers, without exposing the `Frame` enum. +//! +//! Every function is `no_std`, allocation-free and panic-free on any input. + +use crate::consts; +use crate::wire::Frame; +use crate::{NodeId, Observation, ObservationId, SigAlgo, Signature}; + +/// Core's local peer-table capacity (`consts::MAX_PEERS`). +pub const MAX_PEERS: usize = consts::MAX_PEERS; +/// Core's idle-eviction window (`consts::PEER_IDLE_MS`). +pub const PEER_IDLE_MS: u64 = consts::PEER_IDLE_MS; +/// Core's summary cadence (`consts::SUMMARY_INTERVAL_MS`). +pub const SUMMARY_INTERVAL_MS: u64 = consts::SUMMARY_INTERVAL_MS; +/// Core's per-peer send-queue and bounce-table capacity (`consts::SEND_QUEUE_CAP`). +pub const SEND_QUEUE_CAP: usize = consts::SEND_QUEUE_CAP; +/// Core's `Delivered` event-queue capacity (`consts::EVENT_QUEUE_CAP`). +pub const EVENT_QUEUE_CAP: usize = consts::EVENT_QUEUE_CAP; +/// Core's MTU-sweep lower bound (`consts::MIN_MTU`). +pub const MIN_MTU: usize = consts::MIN_MTU; +/// Bytes a `Frag` frame may add around its data (`frag::FRAG_OVERHEAD`). +pub const FRAG_OVERHEAD: usize = crate::frag::FRAG_OVERHEAD; +/// Most fragments one observation may be split into (`frag::MAX_FRAGMENTS`). +pub const MAX_FRAGMENTS: usize = crate::frag::MAX_FRAGMENTS; + +fn encode(frame: &Frame, out: &mut [u8]) -> Option { + frame.encode(out).ok() +} + +/// Encodes a `Summary` frame into `out`. `None` if `bits` is longer than +/// `SUMMARY_BYTES` or `out` is too small. +pub fn encode_summary(from: NodeId, salt: u32, bits: &[u8], out: &mut [u8]) -> Option { + let bits = heapless::Vec::from_slice(bits).ok()?; + encode(&Frame::Summary { from, salt, bits }, out) +} + +/// Encodes an `Obs` frame carrying `obs` into `out`. `None` if `out` is too +/// small. +pub fn encode_obs(from: NodeId, obs: &Observation, out: &mut [u8]) -> Option { + let obs = obs.clone(); + encode(&Frame::Obs { from, obs }, out) +} + +/// Encodes a `Frag` frame into `out`. `None` if `data` is longer than +/// `MAX_FRAME` or `out` is too small. `index`/`count` are not validated: an +/// adversarial harness sends inconsistent ones on purpose. +pub fn encode_frag( + from: NodeId, + id: ObservationId, + index: u8, + count: u8, + data: &[u8], + out: &mut [u8], +) -> Option { + let data = heapless::Vec::from_slice(data).ok()?; + encode( + &Frame::Frag { + from, + id, + index, + count, + data, + }, + out, + ) +} + +/// Encodes an `Ack` frame into `out`. `None` if `ids` is longer than +/// `ACK_IDS_CAP` or `out` is too small. +pub fn encode_ack(from: NodeId, ids: &[ObservationId], out: &mut [u8]) -> Option { + let ids = heapless::Vec::from_slice(ids).ok()?; + encode(&Frame::Ack { from, ids }, out) +} + +/// Encodes `obs` alone (no frame envelope) into `out`: the byte stream a +/// sender cuts into `Frag` data. `None` if `out` is too small. +pub fn encode_observation(obs: &Observation, out: &mut [u8]) -> Option { + postcard::to_slice(obs, out).ok().map(|s| s.len()) +} + +/// The sender of `bytes` if they decode as a complete, canonical frame — the +/// same decode the exchange runs — else `None` (the exchange counts such a +/// frame as `VersionMismatch` or `Malformed`). +pub fn decoded_sender(bytes: &[u8]) -> Option { + Frame::decode(bytes).ok().map(|f| f.sender()) +} + +/// The observation in `bytes` if they decode as an `Obs` frame. +pub fn obs_of(bytes: &[u8]) -> Option { + match Frame::decode(bytes).ok()? { + Frame::Obs { obs, .. } => Some(obs), + _ => None, + } +} + +/// `(id, index, count)` of `bytes` if they decode as a `Frag` frame. +pub fn frag_of(bytes: &[u8]) -> Option<(ObservationId, u8, u8)> { + match Frame::decode(bytes).ok()? { + Frame::Frag { + id, index, count, .. + } => Some((id, index, count)), + _ => None, + } +} + +/// Calls `f` on each id of `bytes` if they decode as an `Ack` frame, and +/// returns whether they did. +pub fn ack_of(bytes: &[u8], f: &mut dyn FnMut(&ObservationId)) -> bool { + match Frame::decode(bytes) { + Ok(Frame::Ack { ids, .. }) => { + ids.iter().for_each(f); + true + } + _ => false, + } +} + +/// Whether `bytes` decode as a `Summary` frame. +pub fn is_summary(bytes: &[u8]) -> bool { + matches!(Frame::decode(bytes), Ok(Frame::Summary { .. })) +} + +/// Builds a [`Signature`] (a `#[non_exhaustive]` struct only core can +/// construct). `None` if `bytes` is longer than `MAX_SIG`. The shape rules +/// (version `0`, a non-64-byte Ed25519) are not checked: a harness builds +/// invalid signatures on purpose to see them rejected on decode. +pub fn signature(version: u8, algo: SigAlgo, bytes: &[u8]) -> Option { + Some(Signature { + version, + algo, + bytes: heapless::Vec::from_slice(bytes).ok()?, + }) +} diff --git a/tines-core/src/lib.rs b/tines-core/src/lib.rs index a825e3ca..9b5aee6b 100644 --- a/tines-core/src/lib.rs +++ b/tines-core/src/lib.rs @@ -7,6 +7,9 @@ extern crate alloc; pub mod belief; mod bloom; +#[cfg(feature = "conformance")] +#[doc(hidden)] +pub mod conformance; pub mod consts; mod exchange; mod frag; @@ -28,10 +31,18 @@ pub use observation::{ }; pub use store::{Store, StoreError, TrimPolicy, WallClockStore}; pub use transport::{Event, FrameBuf, SendError, Transport}; -pub use wire::{frame_sender, Frame, WireError}; +pub use wire::frame_sender; #[cfg(feature = "alloc")] pub use mem_store::MemStore; #[cfg(test)] extern crate std; + +// The pinned wire vectors (spec §7) need the crate-private `Frame` and +// `WireError`, so they compile as a unit-test module rather than as an +// integration test (`autotests = false` in Cargo.toml keeps Cargo from also +// building them as one). +#[cfg(test)] +#[path = "../tests/vectors.rs"] +mod vectors; diff --git a/tines-core/src/wire.rs b/tines-core/src/wire.rs index 4322c4f1..23f5188d 100644 --- a/tines-core/src/wire.rs +++ b/tines-core/src/wire.rs @@ -14,13 +14,21 @@ use serde::{Deserialize, Serialize}; /// avoid per-frame heap allocation in the `no_std` core; this keeps the enum /// large, so the informative `large_enum_variant` lint is suppressed here. /// -/// Exposed publicly (see [`crate::Frame`]) so that `tines-core/tests/vectors.rs` -/// can decode the pinned golden wire vectors and assert on the decoded shape. -/// The wire layout (version byte, positional postcard tags, reserved Signature -/// slot) is the frozen contract this crate guarantees across 0.x → 1.0. +/// Crate-private (spec §3): the type is not API, but its encoding is. The +/// wire layout (version byte, positional postcard tags, reserved Signature +/// slot) is frozen for wire v1 and pinned byte-for-byte by +/// `tines-core/tests/vectors.rs`, which runs as an in-crate test module. +/// +/// ```compile_fail +/// use tines_core::Frame; // crate-private: not reachable from outside core +/// ``` +/// +/// ```compile_fail +/// use tines_core::WireError; // crate-private too +/// ``` #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[allow(clippy::large_enum_variant)] -pub enum Frame { +pub(crate) enum Frame { /// A broadcast bloom summary of a node's observation-id set. Summary { /// The sending node. @@ -143,13 +151,10 @@ pub fn frame_sender(bytes: &[u8]) -> Option { Some(from) } -/// Errors produced while (de)serializing frames on the wire. -/// -/// Exposed publicly (see [`crate::WireError`]) so that `tines-core/tests/vectors.rs` -/// can assert on the exact decode error (e.g. `VersionMismatch`) for the pinned -/// malformed vectors. +/// Errors produced while (de)serializing frames on the wire. Crate-private +/// (spec §3); the exchange reports decode failures as [`crate::DropReason`]. #[derive(Debug, PartialEq, Eq)] -pub enum WireError { +pub(crate) enum WireError { /// The output buffer was too small to hold the serialized frame. TooLarge, /// The input bytes were malformed, truncated, or not a valid `Frame`. @@ -315,7 +320,8 @@ mod tests { #[test] fn frame_sender_peeks_from_field() { - // Version byte 0, variant tag 1, then `from: [3;16]` at bytes 2..18. + // Version byte (`WIRE_VERSION` = 4) at byte 0, variant tag 0 + // (Summary) at byte 1, then `from: [3;16]` at bytes 2..18. const GOLDEN: [u8; 201] = [ 4u8, 0u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 239u8, 253u8, 182u8, 245u8, 13u8, 128u8, 4u8, 170u8, 170u8, 170u8, 170u8, 170u8, @@ -340,9 +346,9 @@ mod tests { #[test] fn frame_sender_reads_from_fixed_offset_not_just_the_start() { - // The leading byte (the externally-tagged variant tag) is skipped, so a - // payload whose first byte is not the sender must still yield the `from` - // recorded at byte 1. + // The two leading bytes (the version byte and the externally-tagged + // variant tag) are skipped, so a payload whose first bytes are not the + // sender must still yield the `from` recorded at bytes 2..18. let bytes = { let mut b = [0u8; 32]; b[0] = consts::WIRE_VERSION; // version slot (skipped by frame_sender) @@ -514,53 +520,4 @@ mod tests { other => panic!("expected Obs, got {other:?}"), } } - - #[test] - fn gen_missing_golden_vectors() { - // THROWAWAY: encode canonical frames and print hex for vectors.rs. - let enc = |f: Frame| { - let mut buf = [0u8; consts::MAX_FRAME]; - let n = f.encode(&mut buf).unwrap(); - let hex: std::string::String = - buf[..n].iter().map(|b| std::format!("{b:02x}")).collect(); - std::println!("{n} {hex}"); - }; - - // unknown-algo Obs - let f = Frame::Obs { - from: [1; 16], - obs: Observation { - observer: [1; 16], - seq: 1, - age_ms: 0, - observed_at: None, - topic: Topic::from_slice(b"door/3").unwrap(), - payload: Payload::from_slice(&[0u8; consts::MAX_PAYLOAD]).unwrap(), - dest: None, - sig: Some(Signature { - version: 1, - algo: SigAlgo::Unknown(7), - bytes: Vec::from_slice(&[0u8; 64]).unwrap(), - }), - }, - }; - enc(f); - - // Frag - let f = Frame::Frag { - from: [1; 16], - id: [2; 16], - index: 0, - count: 1, - data: Vec::from_slice(&[0x5Cu8; 64]).unwrap(), - }; - enc(f); - - // Ack - let ids = - Vec::::from_slice(&[[2u8; 16], [3u8; 16]]) - .unwrap(); - let f = Frame::Ack { from: [1; 16], ids }; - enc(f); - } } diff --git a/tines-core/tests/vectors.rs b/tines-core/tests/vectors.rs index d6d8cfac..8bfb368c 100644 --- a/tines-core/tests/vectors.rs +++ b/tines-core/tests/vectors.rs @@ -1,7 +1,53 @@ //! Pinned identity + wire vectors (spec §7 "Evidence"). A change to any byte //! or expected shape below is an identity- or wire-format change by definition. +//! +//! `Frame` and `WireError` are crate-private (spec §3), so this file is not a +//! Cargo integration-test target: `tines-core/src/lib.rs` compiles it into the +//! crate's own unit tests with `#[cfg(test)] #[path = "../tests/vectors.rs"]`. +//! Each golden vector is checked both ways: the hand-built frame must +//! `encode()` to exactly the pinned bytes, and the pinned bytes must +//! `decode()` back to that frame. -use tines_core::{consts, Frame, Observation, SigAlgo, WireError}; +use crate::consts; +use crate::wire::{Frame, WireError}; +use crate::{Dest, Observation, Payload, SigAlgo, Signature, Topic}; +use heapless::Vec; + +/// Encodes `frame` and asserts the bytes equal `golden`, then decodes +/// `golden` and asserts it equals `frame`. +fn assert_golden(frame: &Frame, golden: &[u8]) { + let mut buf = [0u8; consts::MAX_FRAME]; + let n = frame.encode(&mut buf).expect("golden frame encodes"); + assert_eq!( + &buf[..n], + golden, + "encode() must reproduce the pinned bytes" + ); + assert_eq!( + Frame::decode(golden).as_ref(), + Ok(frame), + "decode() of the pinned bytes must give the frame" + ); +} + +/// The `[1;16]` / seq 1 / `"door/3"` / `MAX_PAYLOAD` zero-byte broadcast both +/// signed vectors carry, signed with 64 zero bytes under `algo`. +fn signed_door_obs(algo: SigAlgo) -> Observation { + Observation { + observer: [1; 16], + seq: 1, + age_ms: 0, + observed_at: None, + topic: Topic::from_slice(b"door/3").unwrap(), + payload: Payload::from_slice(&[0u8; consts::MAX_PAYLOAD]).unwrap(), + dest: None, + sig: Some(Signature { + version: 1, + algo, + bytes: Vec::from_slice(&[0u8; 64]).unwrap(), + }), + } +} #[test] fn id_known_vector() { @@ -62,12 +108,12 @@ fn summary_golden_vector() { 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, 170u8, ]; - let Frame::Summary { from, salt, bits } = Frame::decode(&GOLDEN).unwrap() else { - panic!("expected a Summary frame") + let frame = Frame::Summary { + from: [3; 16], + salt: 0xdeadbeef, + bits: Vec::from_slice(&[0xAAu8; consts::SUMMARY_BYTES]).unwrap(), }; - assert_eq!(from, [3; 16]); - assert_eq!(salt, 0xdeadbeef); - assert_eq!(&bits[..], &[0xAAu8; consts::SUMMARY_BYTES][..]); + assert_golden(&frame, &GOLDEN); } #[test] @@ -150,22 +196,11 @@ fn obs_golden_vector_ed25519() { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]; - let Frame::Obs { from, obs } = Frame::decode(&GOLDEN).unwrap() else { - panic!("expected an Obs frame") + let frame = Frame::Obs { + from: [1; 16], + obs: signed_door_obs(SigAlgo::Ed25519), }; - assert_eq!(from, [1; 16]); - assert_eq!(obs.observer, [1; 16]); - assert_eq!(obs.seq, 1); - assert_eq!(obs.age_ms, 0); - assert_eq!(obs.observed_at, None); - assert_eq!(&obs.topic[..], b"door/3"); - assert_eq!(obs.payload.len(), consts::MAX_PAYLOAD); - assert_eq!(obs.dest, None); - let sig = obs.sig.expect("a signed obs carries a signature"); - assert_eq!(sig.version, 1); - assert_eq!(sig.algo, SigAlgo::Ed25519); - assert_eq!(sig.bytes.len(), 64); - assert!(sig.bytes.iter().all(|b| *b == 0)); + assert_golden(&frame, &GOLDEN); } #[test] @@ -248,17 +283,11 @@ fn obs_golden_vector_unknown_algo() { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]; - let Frame::Obs { obs, .. } = Frame::decode(&GOLDEN).unwrap() else { - panic!("expected an Obs frame") + let frame = Frame::Obs { + from: [1; 16], + obs: signed_door_obs(SigAlgo::Unknown(7)), }; - assert_eq!(obs.observer, [1; 16]); - assert_eq!(obs.seq, 1); - assert_eq!(obs.observed_at, None); - let sig = obs.sig.expect("a signed obs carries a signature"); - assert_eq!(sig.version, 1); - assert_eq!(sig.algo, SigAlgo::Unknown(7)); - assert_eq!(sig.bytes.len(), 64); - assert!(sig.bytes.iter().all(|b| *b == 0)); + assert_golden(&frame, &GOLDEN); } #[test] @@ -272,21 +301,14 @@ fn frag_golden_vector() { 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, 0x5c, ]; - let Frame::Frag { - from, - id, - index, - count, - data, - } = Frame::decode(&GOLDEN).unwrap() - else { - panic!("expected a Frag frame") + let frame = Frame::Frag { + from: [1; 16], + id: [2; 16], + index: 0, + count: 1, + data: Vec::from_slice(&[0x5Cu8; 64]).unwrap(), }; - assert_eq!(from, [1; 16]); - assert_eq!(id, [2; 16]); - assert_eq!(index, 0); - assert_eq!(count, 1); - assert_eq!(&data[..], &[0x5Cu8; 64][..]); + assert_golden(&frame, &GOLDEN); } #[test] @@ -297,11 +319,55 @@ fn ack_golden_vector() { 0x02, 0x02, 0x02, 0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, ]; - let Frame::Ack { from, ids } = Frame::decode(&GOLDEN).unwrap() else { - panic!("expected an Ack frame") + let frame = Frame::Ack { + from: [1; 16], + ids: Vec::from_slice(&[[2u8; 16], [3u8; 16]]).unwrap(), }; - assert_eq!(from, [1; 16]); - assert_eq!(&ids[..], &[[2u8; 16], [3u8; 16]][..]); + assert_golden(&frame, &GOLDEN); +} + +#[test] +fn ack_with_trailing_byte_is_malformed() { + // F13 / §10: frames are canonical. This is the Ack golden vector above + // plus one trailing 0x00 byte; the version byte matches and postcard can + // decode the Ack, but the leftover byte makes the whole frame Malformed. + const TRAILING: [u8; 52] = [ + 0x04, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, + 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x00, + ]; + assert_eq!(Frame::decode(&TRAILING), Err(WireError::Malformed)); +} + +#[test] +fn addressed_obs_golden_vector() { + // An addressed observation with a nonzero age and an `observed_at`, so + // the vectors pin the `Dest` layout and both optional/varint fields. + const GOLDEN: [u8; 73] = [ + 0x04, 0x01, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, + 0x05, 0x05, 0x05, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x06, 0x06, 0x06, 0x06, 0xac, 0x02, 0xd2, 0x09, 0x01, 0x80, 0xd0, 0x95, 0xff, 0xbc, 0x31, + 0x03, 0x63, 0x6d, 0x64, 0x02, 0x67, 0x6f, 0x01, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, + 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0xe0, 0xd4, 0x03, 0x00, + ]; + let frame = Frame::Obs { + from: [5; 16], + obs: Observation { + observer: [6; 16], + seq: 300, + age_ms: 1234, + observed_at: Some(1_700_000_000_000), + topic: Topic::from_slice(b"cmd").unwrap(), + payload: Payload::from_slice(b"go").unwrap(), + dest: Some(Dest { + to: [7; 16], + lifetime_ms: 60_000, + }), + sig: None, + }, + }; + assert_golden(&frame, &GOLDEN); } #[test]