feat(cluster): RFC 010 c2 — owned wire envelope
Frame enum per the RFC inventory; hand-rolled encode/decode with u32 LE length prefix + u8 tag; strings u16-prefixed, payload blobs u32-prefixed; MAX_FRAME_LEN cap (control plane never carries bulk, §5). Streaming decode: Ok(None) = need more bytes, every Err = corruption. postcard confined to encode_payload/decode_payload — the single codec seam (§2); postcard gains the alloc feature for to_allocvec (still no_std-aligned, no default features). DownReason/RejectReason travel as single tag bytes; Down tag 5 is reserved for c11's Disconnected. Tests: per-frame roundtrip, back-to-back frames, golden heartbeat bytes, zero-length payload, every-prefix incomplete, unknown frame/enum tags, length prefix lying long (with and without bytes present) and short, truncation mid-string, adversarial lengths (u32::MAX, cap+1, zero), UTF-8 corruption, payload seam roundtrip through a real Send frame.
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
//! 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::monitor::DownReason;
|
||||
use smarm::pg::Incarnation;
|
||||
|
||||
fn meta() -> NodeMeta {
|
||||
NodeMeta {
|
||||
role: "worker".into(),
|
||||
region: "eu-west".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn all_frames() -> Vec<Frame> {
|
||||
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: DownReason::Panic,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn encode_one(f: &Frame) -> Vec<u8> {
|
||||
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: "DownReason",
|
||||
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::<Ping>(&blob[..blob.len() - 1]).is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user