//! RFC 010 c2 — owned envelope tests (roadmap: per-frame roundtrip, //! truncation mid-field, unknown tag, length prefix lying long and short, //! zero-length payload, adversarial lengths). #![cfg(feature = "cluster")] use serde::{Deserialize, Serialize}; use smarm::cluster::envelope::{ decode_payload, encode_payload, DecodeError, Frame, NodeMeta, RejectReason, MAX_FRAME_LEN, PROTO_VERSION, }; use smarm::cluster::RemoteDownReason; use smarm::monitor::DownReason; use smarm::pg::Incarnation; fn meta() -> NodeMeta { NodeMeta { role: "worker".into(), region: "eu-west".into(), } } fn all_frames() -> Vec { vec![ Frame::Hello { proto_version: PROTO_VERSION, build_hash: 0xDEAD_BEEF_CAFE_F00D, node_name: "alpha".into(), incarnation: Incarnation::new(7), meta: meta(), }, Frame::HelloAck { node_name: "beta".into(), incarnation: Incarnation::new(9), meta: meta(), }, Frame::HelloReject { reason: RejectReason::NameTaken, }, Frame::Heartbeat, Frame::Send { index: 42, generation: 3, type_hash: 0x1234_5678_9ABC_DEF0, payload: vec![1, 2, 3, 4, 5], }, Frame::SendNamed { name: "the_counter".into(), type_hash: 0xFFFF_0000_FFFF_0000, payload: vec![], }, Frame::Monitor { monitor_id: 77, index: 42, generation: 3, }, Frame::Demonitor { monitor_id: 77 }, Frame::Down { monitor_id: 77, reason: RemoteDownReason::Local(DownReason::Panic), }, Frame::Down { monitor_id: 78, reason: RemoteDownReason::Disconnected, }, ] } fn encode_one(f: &Frame) -> Vec { let mut buf = Vec::new(); f.encode(&mut buf).unwrap(); buf } #[test] fn per_frame_roundtrip() { for f in all_frames() { let buf = encode_one(&f); let (decoded, consumed) = Frame::decode(&buf).unwrap().unwrap(); assert_eq!(decoded, f, "roundtrip mismatch"); assert_eq!(consumed, buf.len(), "consumed != buffer length for {f:?}"); } } #[test] fn back_to_back_frames_decode_sequentially() { let mut buf = Vec::new(); for f in all_frames() { f.encode(&mut buf).unwrap(); } let mut off = 0; let mut decoded = Vec::new(); while off < buf.len() { let (f, n) = Frame::decode(&buf[off..]).unwrap().unwrap(); decoded.push(f); off += n; } assert_eq!(decoded, all_frames()); assert_eq!(off, buf.len()); } #[test] fn heartbeat_golden_bytes() { // Locks the layout: u32 LE length prefix, then the tag byte. let buf = encode_one(&Frame::Heartbeat); assert_eq!(buf, vec![1, 0, 0, 0, 4]); } #[test] fn zero_length_payload_roundtrips() { let f = Frame::Send { index: 0, generation: 0, type_hash: 0, payload: vec![], }; let buf = encode_one(&f); let (decoded, consumed) = Frame::decode(&buf).unwrap().unwrap(); assert_eq!(decoded, f); assert_eq!(consumed, buf.len()); } #[test] fn incomplete_is_none_not_error() { let buf = encode_one(&all_frames()[0]); // Every strict prefix short of the full frame must report "need more". for cut in 0..buf.len() { assert_eq!( Frame::decode(&buf[..cut]).unwrap(), None, "cut at {cut} should be incomplete" ); } } #[test] fn unknown_frame_tag() { let buf = vec![1, 0, 0, 0, 250]; assert_eq!(Frame::decode(&buf), Err(DecodeError::UnknownTag(250))); } #[test] fn unknown_enum_tags() { // HelloReject with a bogus reason tag. let buf = vec![2, 0, 0, 0, 3, 99]; assert_eq!( Frame::decode(&buf), Err(DecodeError::UnknownEnumTag { what: "RejectReason", tag: 99 }) ); // Down with a bogus reason tag (id = 0u64). let mut buf = vec![10, 0, 0, 0, 9]; buf.extend_from_slice(&0u64.to_le_bytes()); buf.push(200); assert_eq!( Frame::decode(&buf), Err(DecodeError::UnknownEnumTag { what: "RemoteDownReason", tag: 200 }) ); } #[test] fn length_prefix_lying_long_with_bytes_present_is_trailing() { let mut buf = encode_one(&Frame::Heartbeat); // Declare 3 extra body bytes and actually supply them. let declared = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]) + 3; buf[0..4].copy_from_slice(&declared.to_le_bytes()); buf.extend_from_slice(&[0xAA, 0xBB, 0xCC]); assert_eq!(Frame::decode(&buf), Err(DecodeError::Trailing { extra: 3 })); } #[test] fn length_prefix_lying_long_without_bytes_is_incomplete() { // Indistinguishable from a partial read — must be None, not an error. let mut buf = encode_one(&Frame::Heartbeat); let declared = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]) + 3; buf[0..4].copy_from_slice(&declared.to_le_bytes()); assert_eq!(Frame::decode(&buf).unwrap(), None); } #[test] fn length_prefix_lying_short_truncates_a_field() { let f = &all_frames()[0]; // Hello: plenty of fields to cut into let mut buf = encode_one(f); let declared = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); let lie = declared - 4; // cut mid-field buf[0..4].copy_from_slice(&lie.to_le_bytes()); assert_eq!(Frame::decode(&buf), Err(DecodeError::Truncated)); } #[test] fn truncation_mid_string_field() { // A frame whose declared length is intact but whose inner string length // runs past the body: SendNamed claiming a 1000-byte name in a tiny body. let mut body = vec![6u8]; // TAG_SEND_NAMED body.extend_from_slice(&1000u16.to_le_bytes()); body.extend_from_slice(b"short"); let mut buf = (body.len() as u32).to_le_bytes().to_vec(); buf.extend_from_slice(&body); assert_eq!(Frame::decode(&buf), Err(DecodeError::Truncated)); } #[test] fn adversarial_lengths() { // Length prefix of u32::MAX: reject as oversized, do not wait for 4 GiB. let buf = [0xFF, 0xFF, 0xFF, 0xFF, 0]; assert_eq!( Frame::decode(&buf), Err(DecodeError::FrameTooLarge { declared: u32::MAX as usize }) ); // Just over the cap: also rejected. let over = (MAX_FRAME_LEN as u32 + 1).to_le_bytes(); assert!(matches!( Frame::decode(&over), Err(DecodeError::FrameTooLarge { .. }) )); // Zero-length frame: there is no tag byte; corrupt, not incomplete. let buf = [0, 0, 0, 0]; assert_eq!(Frame::decode(&buf), Err(DecodeError::EmptyFrame)); } #[test] fn invalid_utf8_in_string_field() { let mut buf = encode_one(&Frame::SendNamed { name: "abcd".into(), type_hash: 0, payload: vec![], }); // name bytes start after: 4 (len) + 1 (tag) + 2 (str len) = offset 7 buf[7] = 0xFF; assert_eq!(Frame::decode(&buf), Err(DecodeError::Utf8)); } #[derive(Debug, PartialEq, Serialize, Deserialize)] struct Ping { seq: u64, label: String, } #[test] fn payload_seam_roundtrip() { let ping = Ping { seq: 31337, label: "hello".into(), }; let blob = encode_payload(&ping).unwrap(); // Carry it through a real frame, as it will travel in c9. let f = Frame::Send { index: 1, generation: 1, type_hash: 0xABCD, payload: blob, }; let buf = encode_one(&f); let (decoded, _) = Frame::decode(&buf).unwrap().unwrap(); let Frame::Send { payload, .. } = decoded else { panic!("wrong frame"); }; let back: Ping = decode_payload(&payload).unwrap(); assert_eq!(back, ping); } #[test] fn payload_seam_rejects_truncated_blob() { let blob = encode_payload(&Ping { seq: 1, label: "x".into(), }) .unwrap(); assert!(decode_payload::(&blob[..blob.len() - 1]).is_err()); }