weir_core/envelope.rs
1//! The weir wire frame: [`Header`] (the 16-byte fixed header) and [`Envelope`]
2//! (header + payload), with the `encode`/`decode` codecs that own CRC
3//! computation and the DoS-resistant validation order.
4
5use crate::{
6 durability::Durability,
7 error::DecodeError,
8 payload::Payload,
9 version::{MAX_PAYLOAD_HARD_CAP, WIRE_VERSION},
10};
11
12/// Magic bytes at offset 0. Presence at a fixed offset allows stream resync by scanning for b"WEIR".
13const MAGIC: [u8; 4] = *b"WEIR";
14
15/// Wire layout (16 bytes; multi-byte fields are little-endian):
16///
17/// ```text
18/// [0..4] magic b"WEIR"
19/// [4] version u8 — WIRE_VERSION
20/// [5] message_type u8 — MessageType repr
21/// [6] durability u8 — Durability repr
22/// [7] flags u8 — reserved; zero on write
23/// [8..12] payload_len u32 LE
24/// [12..16] header_crc32 u32 LE — CRC32 of bytes [0..12]
25/// ```
26pub const HEADER_LEN: usize = 16;
27
28/// Number of header bytes covered by the CRC. Everything except the CRC field itself.
29const HEADER_CRC_COVERAGE: usize = 12;
30
31/// Minimum valid frame: header + zero-length payload + payload CRC.
32pub const MIN_FRAME_LEN: usize = HEADER_LEN + 4;
33
34/// Wire message types. Values are fixed; changing them requires a WIRE_VERSION bump.
35///
36/// `#[non_exhaustive]`, since 4.0. It was left exhaustive on the reasoning
37/// recorded in `nack.rs` — that changing this enum "requires a `WIRE_VERSION`
38/// bump (a major event)" — and 3.0 disproved that by adding `PushTracked` and
39/// `AckTracked` with `WIRE_VERSION` unchanged, because message-type bytes
40/// `0x08`-`0xFF` are reserved for additive growth. Every such addition was
41/// therefore forcing a major release of the published Rust API for a change the
42/// wire did not consider breaking at all. Closing that costs one break, once.
43///
44/// Downstream matches need a wildcard arm from here on. That is the point: a new
45/// message type is additive on the wire and should be additive in the API too.
46#[repr(u8)]
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum MessageType {
50 /// Producer → daemon: a record to durably buffer.
51 Push = 0x01,
52 /// Daemon → producer: the pushed record was accepted at its durability tier.
53 Ack = 0x02,
54 /// Daemon → producer: the frame or record was rejected (see [`NackReason`](crate::NackReason)).
55 Nack = 0x03,
56 /// Producer → daemon: liveness probe (zero-length payload).
57 HealthCheck = 0x04,
58 /// Daemon → producer: reply to a [`HealthCheck`](MessageType::HealthCheck).
59 HealthCheckResponse = 0x05,
60 /// Producer → daemon: a record to durably buffer, **and** tell me where it
61 /// landed. Identical to [`Push`](MessageType::Push) in every other respect —
62 /// same durability tiers, same caps, same Nack reasons — but answered with
63 /// [`AckTracked`](MessageType::AckTracked) instead of a bare
64 /// [`Ack`](MessageType::Ack).
65 ///
66 /// Additive by construction: a producer built against wire v1 never emits
67 /// this byte, so it never receives the longer reply, and a daemon that
68 /// predates the type rejects it with the already-specified
69 /// [`UnknownMessage`](crate::NackReason::UnknownMessage) — the permanent,
70 /// connection-closing error for exactly this version skew.
71 PushTracked = 0x06,
72 /// Daemon → producer: the pushed record was accepted, and here is its
73 /// [`RecordCoordinate`](crate::RecordCoordinate) — the only weir response
74 /// whose payload exceeds two bytes, and only ever sent in reply to a
75 /// [`PushTracked`](MessageType::PushTracked).
76 AckTracked = 0x07,
77 /// Producer → daemon: N records in one frame, answered once with
78 /// [`AckBatch`](MessageType::AckBatch).
79 ///
80 /// Additive the same way [`PushTracked`](MessageType::PushTracked) was: a
81 /// producer built against an earlier wire never emits this byte, so it never
82 /// receives the reply, and a daemon that predates it answers
83 /// [`UnknownMessage`](crate::NackReason::UnknownMessage) — a permanent,
84 /// connection-closing error that names the version skew rather than
85 /// misparsing.
86 PushBatch = 0x08,
87 /// Daemon → producer: one reply for a whole [`PushBatch`](MessageType::PushBatch),
88 /// carrying a positional bitmap of per-record outcomes.
89 ///
90 /// **Bit `i` is LSB-first** within each byte and means record `i` is durable
91 /// at the requested tier; a clear bit means *not durable as of this reply —
92 /// retry, and expect it may nonetheless have been written*. See
93 /// [`crate::batch`] for why a clear bit needs no reason byte.
94 AckBatch = 0x09,
95}
96
97/// Error returned when a byte does not map to a known MessageType.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub struct UnknownMessageType(pub u8);
100
101impl std::fmt::Display for UnknownMessageType {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 write!(f, "unknown message type byte: {:#04x}", self.0)
104 }
105}
106
107impl std::error::Error for UnknownMessageType {}
108
109impl TryFrom<u8> for MessageType {
110 type Error = UnknownMessageType;
111
112 fn try_from(value: u8) -> Result<Self, Self::Error> {
113 match value {
114 0x01 => Ok(MessageType::Push),
115 0x02 => Ok(MessageType::Ack),
116 0x03 => Ok(MessageType::Nack),
117 0x04 => Ok(MessageType::HealthCheck),
118 0x05 => Ok(MessageType::HealthCheckResponse),
119 0x06 => Ok(MessageType::PushTracked),
120 0x07 => Ok(MessageType::AckTracked),
121 0x08 => Ok(MessageType::PushBatch),
122 0x09 => Ok(MessageType::AckBatch),
123 v => Err(UnknownMessageType(v)),
124 }
125 }
126}
127
128impl From<MessageType> for u8 {
129 /// The wire byte for this message type. Inverse of [`MessageType::try_from`].
130 fn from(m: MessageType) -> u8 {
131 m as u8
132 }
133}
134
135/// Decoded wire header. `magic` and `header_crc32` are not stored: magic is always
136/// `b"WEIR"` after a successful decode (storing it invites confusion about validity),
137/// and CRC is computed on encode rather than held as state.
138///
139/// Fields are private and read-only (accessors below): a header is only ever
140/// produced by [`Header::new`] (which pins `version` to [`WIRE_VERSION`]) or by
141/// [`Header::decode`] (which validates every field). This makes it impossible
142/// for a caller to desync `payload_len` from the real payload or set `version`
143/// off `WIRE_VERSION` after construction.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub struct Header {
146 version: u8,
147 message_type: MessageType,
148 durability: Durability,
149 /// Currently reserved; must be zero on write. Preserved verbatim through encode/decode.
150 flags: u8,
151 payload_len: u32,
152}
153
154impl Header {
155 /// Constructs a header for outbound frames. Version is always `WIRE_VERSION`.
156 ///
157 /// `payload_len` is NOT a parameter: it is set to 0 here and becomes
158 /// authoritative only when the header is wrapped in an [`Envelope`], which
159 /// derives it from the actual payload (see [`Envelope::new`]). This makes a
160 /// header whose declared length disagrees with its payload unrepresentable —
161 /// a bare `Header::encode` always declares `payload_len = 0`, so it is only
162 /// valid for the genuinely-empty-payload frames (Ack / HealthCheck) that use
163 /// it (F50).
164 ///
165 /// In wire v1 `flags` MUST be 0. Any other value encodes a frame that
166 /// [`Header::decode`] rejects with [`DecodeError::ReservedFlagsSet`] (and the
167 /// daemon Nacks then closes the connection); the parameter is kept non-zero-
168 /// capable only so the codec can construct reject-path test frames.
169 #[must_use]
170 pub fn new(message_type: MessageType, durability: Durability, flags: u8) -> Self {
171 Self {
172 version: WIRE_VERSION,
173 message_type,
174 durability,
175 flags,
176 payload_len: 0,
177 }
178 }
179
180 /// Wire protocol version. Always [`WIRE_VERSION`] for a header built by
181 /// [`Header::new`]; a decoded header only exists if its version matched.
182 pub fn version(&self) -> u8 {
183 self.version
184 }
185
186 /// The frame's [`MessageType`].
187 pub fn message_type(&self) -> MessageType {
188 self.message_type
189 }
190
191 /// The frame's [`Durability`] tier.
192 pub fn durability(&self) -> Durability {
193 self.durability
194 }
195
196 /// Reserved flags byte (zero in v1; preserved verbatim through the codec).
197 pub fn flags(&self) -> u8 {
198 self.flags
199 }
200
201 /// Declared payload length in bytes. For an [`Envelope`] this always equals
202 /// the actual payload length (see [`Envelope::new`]); on a bare decoded
203 /// header it is the wire-declared length used to read the payload.
204 pub fn payload_len(&self) -> u32 {
205 self.payload_len
206 }
207
208 /// Serialises the header to exactly `HEADER_LEN` bytes. CRC is computed here;
209 /// the caller does not need to know the CRC coverage range.
210 #[must_use]
211 pub fn encode(&self) -> [u8; HEADER_LEN] {
212 let mut buf = [0u8; HEADER_LEN];
213 buf[0..4].copy_from_slice(&MAGIC);
214 buf[4] = self.version;
215 buf[5] = u8::from(self.message_type);
216 buf[6] = u8::from(self.durability);
217 buf[7] = self.flags;
218 buf[8..12].copy_from_slice(&self.payload_len.to_le_bytes());
219 let crc = crc32fast::hash(&buf[..HEADER_CRC_COVERAGE]);
220 buf[12..16].copy_from_slice(&crc.to_le_bytes());
221 buf
222 }
223
224 /// Decodes and validates a 16-byte header buffer.
225 ///
226 /// Validation order is deliberate:
227 /// 1. Magic — cheapest check; eliminates non-weir traffic before any further work.
228 /// 2. Version — before CRC, so a version-mismatched frame from a v2 client receives
229 /// `VersionMismatch` (with the daemon's version in the Nack payload) rather than
230 /// a confusing `HeaderCrcMismatch` if the frame layout shifted between versions.
231 /// 3. Header CRC — validates the remaining bytes are uncorrupted before parsing them.
232 /// 4. Typed field parsing — only after all integrity checks pass.
233 pub fn decode(buf: &[u8; HEADER_LEN]) -> Result<Self, DecodeError> {
234 if buf[0..4] != MAGIC {
235 return Err(DecodeError::BadMagic);
236 }
237
238 let version = buf[4];
239 if version != WIRE_VERSION {
240 return Err(DecodeError::VersionMismatch {
241 supported: WIRE_VERSION,
242 received: version,
243 });
244 }
245
246 let expected_crc = u32::from_le_bytes(buf[12..16].try_into().unwrap());
247 let computed_crc = crc32fast::hash(&buf[..HEADER_CRC_COVERAGE]);
248 if expected_crc != computed_crc {
249 return Err(DecodeError::HeaderCrcMismatch {
250 expected: expected_crc,
251 computed: computed_crc,
252 });
253 }
254
255 let message_type =
256 MessageType::try_from(buf[5]).map_err(|e| DecodeError::UnknownMessageType(e.0))?;
257 let durability =
258 Durability::try_from(buf[6]).map_err(|e| DecodeError::UnknownDurability(e.0))?;
259 // Reserved flags byte MUST be zero in wire v1. Reject a nonzero value
260 // rather than preserving-and-ignoring it: silently dropping a flag that a
261 // future version gives meaning to would let a producer believe a semantic
262 // flag took effect when this daemon ignored it. Adding flags later is
263 // therefore an explicit, version-gated change (F52).
264 let flags = buf[7];
265 if flags != 0 {
266 return Err(DecodeError::ReservedFlagsSet { flags });
267 }
268 let payload_len = u32::from_le_bytes(buf[8..12].try_into().unwrap());
269
270 Ok(Self {
271 version,
272 message_type,
273 durability,
274 flags,
275 payload_len,
276 })
277 }
278}
279
280/// A complete weir wire frame: validated header + payload bytes.
281///
282/// Fields are private and read-only ([`header`](Envelope::header) /
283/// [`payload`](Envelope::payload)). [`Envelope::new`] makes the payload
284/// authoritative for `header.payload_len`, so the declared length and the
285/// actual payload can never disagree.
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct Envelope {
288 header: Header,
289 payload: Payload,
290}
291
292impl Envelope {
293 /// Builds a frame from a header and payload. The header's `payload_len` is
294 /// overwritten with the payload's actual length, so the two cannot desync
295 /// — the payload is the single source of truth for the length on the wire.
296 #[must_use]
297 pub fn new(header: Header, payload: impl Into<Payload>) -> Self {
298 let payload = payload.into();
299 // Saturate rather than `as u32`-truncate: a >= 4 GiB payload would
300 // otherwise wrap to a small length and desync the wire frame — the exact
301 // failure this "cannot desync" constructor exists to prevent (F49). Such
302 // a payload is far past MAX_PAYLOAD_HARD_CAP (16 MiB) and is rejected on
303 // decode; saturating to u32::MAX keeps it rejected instead of wrapping.
304 let payload_len = u32::try_from(payload.len()).unwrap_or(u32::MAX);
305 let header = Header {
306 payload_len,
307 ..header
308 };
309 Self { header, payload }
310 }
311
312 /// The frame's validated [`Header`].
313 pub fn header(&self) -> Header {
314 self.header
315 }
316
317 /// The frame's payload bytes.
318 pub fn payload(&self) -> &Payload {
319 &self.payload
320 }
321
322 /// Serialises the full frame: header bytes + payload bytes + payload CRC32 (LE).
323 #[must_use]
324 pub fn encode(&self) -> Vec<u8> {
325 let mut out = Vec::with_capacity(HEADER_LEN + self.payload.len() + 4);
326 out.extend_from_slice(&self.header.encode());
327 out.extend_from_slice(&self.payload);
328 let payload_crc = crc32fast::hash(&self.payload);
329 out.extend_from_slice(&payload_crc.to_le_bytes());
330 out
331 }
332
333 /// Decodes and validates a complete frame from a byte slice.
334 ///
335 /// Validation order defends against pre-allocation DoS:
336 /// 1. Buffer length >= HEADER_LEN — check before any parsing.
337 /// 2. Header decode (magic → version → CRC) — before touching payload.
338 /// 3. `payload_len <= MAX_PAYLOAD_HARD_CAP` — rejection before any heap allocation.
339 /// 4. Buffer length == HEADER_LEN + payload_len + 4 — the buffer must be
340 /// *exactly* one frame: shorter is `TruncatedFrame`, longer is `TrailingBytes`
341 /// (the caller owns framing; the codec never silently discards a remainder — G18).
342 /// 5. Payload CRC — validate after allocation, before returning data to caller.
343 ///
344 /// The socket layer applies an additional cap (`min(config.max_payload_bytes, MAX_PAYLOAD_HARD_CAP)`)
345 /// before reaching this function. This function applies only the hard cap as a safety floor.
346 pub fn decode(buf: &[u8]) -> Result<Self, DecodeError> {
347 if buf.len() < HEADER_LEN {
348 return Err(DecodeError::TruncatedFrame);
349 }
350
351 let header_bytes: &[u8; HEADER_LEN] = buf[..HEADER_LEN]
352 .try_into()
353 .expect("slice is exactly HEADER_LEN bytes, checked above");
354 let header = Header::decode(header_bytes)?;
355
356 let payload_len = header.payload_len as usize;
357 if payload_len > MAX_PAYLOAD_HARD_CAP {
358 return Err(DecodeError::PayloadTooLarge {
359 len: payload_len,
360 cap: MAX_PAYLOAD_HARD_CAP,
361 });
362 }
363
364 // payload_len <= MAX_PAYLOAD_HARD_CAP (16 MiB), so HEADER_LEN + payload_len + 4
365 // cannot overflow usize on any supported platform (min usize on 32-bit: 4 GiB).
366 let frame_len = HEADER_LEN + payload_len + 4;
367 if buf.len() < frame_len {
368 return Err(DecodeError::TruncatedFrame);
369 }
370 // The buffer must be EXACTLY one frame. A longer buffer is rejected rather
371 // than decoding the first frame and discarding the rest: this crate is the
372 // executable wire reference, and silently dropping trailing bytes would let
373 // a desynced or concatenated stream lose records without error. The caller
374 // owns framing — weir's own socket/client paths read exactly frame_len and
375 // never reach this arm (G18).
376 if buf.len() > frame_len {
377 return Err(DecodeError::TrailingBytes {
378 extra: buf.len() - frame_len,
379 });
380 }
381
382 let payload = Payload::copy_from_slice(&buf[HEADER_LEN..HEADER_LEN + payload_len]);
383 let expected_crc =
384 u32::from_le_bytes(buf[HEADER_LEN + payload_len..frame_len].try_into().unwrap());
385 let computed_crc = crc32fast::hash(&payload);
386 if expected_crc != computed_crc {
387 return Err(DecodeError::PayloadCrcMismatch {
388 expected: expected_crc,
389 computed: computed_crc,
390 });
391 }
392
393 Ok(Self { header, payload })
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use crate::version::WIRE_VERSION;
401
402 fn push_header() -> Header {
403 Header::new(MessageType::Push, Durability::Durable, 0)
404 }
405
406 // ── Header ──────────────────────────────────────────────────────────────
407
408 #[test]
409 fn header_encode_decode_round_trip() {
410 let h = push_header();
411 let encoded = h.encode();
412 let decoded = Header::decode(&encoded).unwrap();
413 assert_eq!(h, decoded);
414 }
415
416 #[test]
417 fn header_encode_is_16_bytes() {
418 let encoded = push_header().encode();
419 assert_eq!(encoded.len(), HEADER_LEN);
420 }
421
422 #[test]
423 fn header_decode_rejects_bad_magic() {
424 let mut buf = push_header().encode();
425 buf[0] = 0x00;
426 assert_eq!(Header::decode(&buf), Err(DecodeError::BadMagic));
427 }
428
429 #[test]
430 fn header_decode_rejects_future_version() {
431 let mut buf = push_header().encode();
432 buf[4] = WIRE_VERSION + 1;
433 // Must recalculate CRC so we test the version path, not the CRC path.
434 let crc = crc32fast::hash(&buf[..HEADER_CRC_COVERAGE]);
435 buf[12..16].copy_from_slice(&crc.to_le_bytes());
436 assert_eq!(
437 Header::decode(&buf),
438 Err(DecodeError::VersionMismatch {
439 supported: WIRE_VERSION,
440 received: WIRE_VERSION + 1,
441 })
442 );
443 }
444
445 #[test]
446 fn header_decode_rejects_older_version() {
447 let mut buf = push_header().encode();
448 buf[4] = WIRE_VERSION - 1;
449 let crc = crc32fast::hash(&buf[..HEADER_CRC_COVERAGE]);
450 buf[12..16].copy_from_slice(&crc.to_le_bytes());
451 assert_eq!(
452 Header::decode(&buf),
453 Err(DecodeError::VersionMismatch {
454 supported: WIRE_VERSION,
455 received: WIRE_VERSION - 1,
456 })
457 );
458 }
459
460 /// Version is checked before CRC. A frame with a wrong version but valid CRC must
461 /// return VersionMismatch, not HeaderCrcMismatch.
462 #[test]
463 fn header_decode_version_checked_before_crc() {
464 let mut buf = push_header().encode();
465 // Set version to a future value and recompute a valid CRC for that version.
466 buf[4] = WIRE_VERSION + 1;
467 let crc = crc32fast::hash(&buf[..HEADER_CRC_COVERAGE]);
468 buf[12..16].copy_from_slice(&crc.to_le_bytes());
469 // CRC is now valid for this (future-versioned) header.
470 match Header::decode(&buf) {
471 Err(DecodeError::VersionMismatch { .. }) => {}
472 other => panic!("expected VersionMismatch, got {other:?}"),
473 }
474 }
475
476 #[test]
477 fn header_decode_rejects_corrupted_crc() {
478 let mut buf = push_header().encode();
479 buf[12] ^= 0xff;
480 assert!(matches!(
481 Header::decode(&buf),
482 Err(DecodeError::HeaderCrcMismatch { .. })
483 ));
484 }
485
486 #[test]
487 fn header_decode_rejects_unknown_message_type() {
488 let mut buf = push_header().encode();
489 buf[5] = 0xff;
490 let crc = crc32fast::hash(&buf[..HEADER_CRC_COVERAGE]);
491 buf[12..16].copy_from_slice(&crc.to_le_bytes());
492 assert_eq!(
493 Header::decode(&buf),
494 Err(DecodeError::UnknownMessageType(0xff))
495 );
496 }
497
498 #[test]
499 fn header_decode_rejects_unknown_durability() {
500 let mut buf = push_header().encode();
501 buf[6] = 0xff;
502 let crc = crc32fast::hash(&buf[..HEADER_CRC_COVERAGE]);
503 buf[12..16].copy_from_slice(&crc.to_le_bytes());
504 assert_eq!(
505 Header::decode(&buf),
506 Err(DecodeError::UnknownDurability(0xff))
507 );
508 }
509
510 /// In wire v1 the reserved flags byte must be zero. A header that sets any
511 /// flag bit (with an otherwise-valid CRC over that byte) is rejected with
512 /// `ReservedFlagsSet` rather than silently accepted (F52).
513 #[test]
514 fn header_decode_rejects_nonzero_flags() {
515 let buf = Header::new(MessageType::Push, Durability::Durable, 0x01).encode();
516 assert_eq!(
517 Header::decode(&buf),
518 Err(DecodeError::ReservedFlagsSet { flags: 0x01 })
519 );
520 // The full byte is reported verbatim, not just "nonzero".
521 let buf = Header::new(MessageType::Push, Durability::Durable, 0xff).encode();
522 assert_eq!(
523 Header::decode(&buf),
524 Err(DecodeError::ReservedFlagsSet { flags: 0xff })
525 );
526 }
527
528 /// The reserved-flags check fires only after magic/version/CRC/type/durability
529 /// all pass: a frame that is *both* nonzero-flagged and unknown-durability
530 /// surfaces the durability error first (the decode order is load-bearing for
531 /// the daemon's Nack mapping).
532 #[test]
533 fn header_decode_flags_checked_after_durability() {
534 let mut buf = Header::new(MessageType::Push, Durability::Durable, 0x01).encode();
535 buf[6] = 0xff; // unknown durability
536 let crc = crc32fast::hash(&buf[..HEADER_CRC_COVERAGE]);
537 buf[12..16].copy_from_slice(&crc.to_le_bytes());
538 assert_eq!(
539 Header::decode(&buf),
540 Err(DecodeError::UnknownDurability(0xff))
541 );
542 }
543
544 // ── MessageType ──────────────────────────────────────────────────────────
545
546 #[test]
547 fn message_type_try_from_accepts_all_known_values() {
548 assert_eq!(MessageType::try_from(0x01).unwrap(), MessageType::Push);
549 assert_eq!(MessageType::try_from(0x02).unwrap(), MessageType::Ack);
550 assert_eq!(MessageType::try_from(0x03).unwrap(), MessageType::Nack);
551 assert_eq!(
552 MessageType::try_from(0x04).unwrap(),
553 MessageType::HealthCheck
554 );
555 assert_eq!(
556 MessageType::try_from(0x05).unwrap(),
557 MessageType::HealthCheckResponse
558 );
559 assert_eq!(
560 MessageType::try_from(0x06).unwrap(),
561 MessageType::PushTracked
562 );
563 assert_eq!(
564 MessageType::try_from(0x07).unwrap(),
565 MessageType::AckTracked
566 );
567 }
568
569 #[test]
570 fn message_type_try_from_rejects_unknown() {
571 assert!(MessageType::try_from(0x00).is_err());
572 // 0x08/0x09 became PushBatch/AckBatch in 4.0; 0x0A is the first
573 // unassigned byte now. This assertion moves each time the additive
574 // space is used, which is the point — it pins where the boundary is.
575 assert!(MessageType::try_from(0x0A).is_err());
576 assert!(MessageType::try_from(0xff).is_err());
577 }
578
579 /// The byte range `0x08`-`0xFF` is what makes a new message type additive
580 /// within wire v1, so the assigned set must stay a contiguous prefix with no
581 /// accidental gaps or overlaps.
582 #[test]
583 fn the_assigned_message_type_bytes_are_contiguous_from_one() {
584 let assigned: Vec<u8> = (0u8..=u8::MAX)
585 .filter(|b| MessageType::try_from(*b).is_ok())
586 .collect();
587 assert_eq!(
588 assigned,
589 (0x01u8..=0x09).collect::<Vec<u8>>(),
590 "assigned message-type bytes should be 0x01..=0x09 with no holes"
591 );
592 // 0xFF must stay unassigned: the frozen vector
593 // `reject_unknown_message_type` pins it to UnknownMessageType, and
594 // changing a frozen vector is what the freeze forbids.
595 assert!(MessageType::try_from(0xFF).is_err());
596 }
597
598 #[test]
599 fn message_type_from_u8_round_trips() {
600 // From<MessageType> for u8 is the inverse of TryFrom<u8>, matching the
601 // symmetric pair Durability and NackReason already expose (P3-F3).
602 for mt in [
603 MessageType::Push,
604 MessageType::Ack,
605 MessageType::Nack,
606 MessageType::HealthCheck,
607 MessageType::HealthCheckResponse,
608 MessageType::PushTracked,
609 MessageType::AckTracked,
610 MessageType::PushBatch,
611 MessageType::AckBatch,
612 ] {
613 assert_eq!(MessageType::try_from(u8::from(mt)).unwrap(), mt);
614 }
615 }
616
617 #[test]
618 fn message_type_repr_values_match_wire() {
619 assert_eq!(MessageType::Push as u8, 0x01);
620 assert_eq!(MessageType::Ack as u8, 0x02);
621 assert_eq!(MessageType::Nack as u8, 0x03);
622 assert_eq!(MessageType::HealthCheck as u8, 0x04);
623 assert_eq!(MessageType::HealthCheckResponse as u8, 0x05);
624 assert_eq!(MessageType::PushTracked as u8, 0x06);
625 assert_eq!(MessageType::AckTracked as u8, 0x07);
626 }
627
628 // ── Envelope ─────────────────────────────────────────────────────────────
629
630 fn round_trip(msg_type: MessageType) {
631 let payload = b"hello weir".to_vec();
632 let header = Header::new(msg_type, Durability::Durable, 0);
633 let env = Envelope::new(header, payload);
634 let encoded = env.encode();
635 let decoded = Envelope::decode(&encoded).unwrap();
636 assert_eq!(env, decoded);
637 }
638
639 #[test]
640 fn envelope_round_trip_push() {
641 round_trip(MessageType::Push);
642 }
643
644 #[test]
645 fn envelope_round_trip_ack() {
646 round_trip(MessageType::Ack);
647 }
648
649 #[test]
650 fn envelope_round_trip_nack() {
651 round_trip(MessageType::Nack);
652 }
653
654 #[test]
655 fn envelope_round_trip_health_check() {
656 round_trip(MessageType::HealthCheck);
657 }
658
659 #[test]
660 fn envelope_round_trip_health_check_response() {
661 round_trip(MessageType::HealthCheckResponse);
662 }
663
664 #[test]
665 fn envelope_round_trip_push_tracked() {
666 round_trip(MessageType::PushTracked);
667 }
668
669 #[test]
670 fn envelope_round_trip_ack_tracked() {
671 round_trip(MessageType::AckTracked);
672 }
673
674 #[test]
675 fn envelope_round_trip_empty_payload() {
676 let header = push_header();
677 let env = Envelope::new(header, vec![]);
678 let encoded = env.encode();
679 let decoded = Envelope::decode(&encoded).unwrap();
680 assert_eq!(env, decoded);
681 }
682
683 /// F49: Envelope::new makes the payload authoritative for the header's
684 /// payload_len. The >4 GiB saturation arm is intentionally untestable without
685 /// a multi-GiB allocation, but pinning the equality across the realistic
686 /// in-cap range proves no `as`-truncation snuck in (a declared len that
687 /// disagreed with the payload would be a desync the crown invariant relies on
688 /// never happening).
689 #[test]
690 fn envelope_new_payload_len_equals_payload_length() {
691 for len in [0usize, 1, 7, 256, 65_536, 1_000_000] {
692 let env = Envelope::new(push_header(), vec![0xAB; len]);
693 assert_eq!(
694 env.header().payload_len() as usize,
695 len,
696 "payload_len must equal the actual payload length for len={len}"
697 );
698 }
699 }
700
701 #[test]
702 fn envelope_decode_rejects_truncated_header() {
703 let env = Envelope::new(push_header(), b"data".to_vec());
704 let encoded = env.encode();
705 assert_eq!(
706 Envelope::decode(&encoded[..HEADER_LEN - 1]),
707 Err(DecodeError::TruncatedFrame)
708 );
709 }
710
711 #[test]
712 fn envelope_decode_rejects_truncated_payload() {
713 let payload = b"hello".to_vec();
714 let env = Envelope::new(push_header(), payload);
715 let encoded = env.encode();
716 // Strip the last byte of the payload+CRC section.
717 assert_eq!(
718 Envelope::decode(&encoded[..encoded.len() - 1]),
719 Err(DecodeError::TruncatedFrame)
720 );
721 }
722
723 #[test]
724 fn envelope_decode_rejects_header_only() {
725 let env = Envelope::new(push_header(), b"data".to_vec());
726 let encoded = env.encode();
727 assert_eq!(
728 Envelope::decode(&encoded[..HEADER_LEN]),
729 Err(DecodeError::TruncatedFrame)
730 );
731 }
732
733 #[test]
734 fn envelope_decode_rejects_corrupted_payload_crc() {
735 let payload = b"hello".to_vec();
736 let env = Envelope::new(push_header(), payload);
737 let mut encoded = env.encode();
738 // Flip a bit in the trailing CRC.
739 let last = encoded.len() - 1;
740 encoded[last] ^= 0xff;
741 assert!(matches!(
742 Envelope::decode(&encoded),
743 Err(DecodeError::PayloadCrcMismatch { .. })
744 ));
745 }
746
747 #[test]
748 fn envelope_decode_rejects_trailing_bytes() {
749 // A buffer longer than one frame is rejected (G18) rather than decoding
750 // the first frame and silently discarding the rest — the codec never loses
751 // a remainder; the caller owns framing.
752 let env = Envelope::new(push_header(), b"hello".to_vec());
753 let mut encoded = env.encode();
754 encoded.extend_from_slice(b"garbage"); // 7 extra bytes
755 assert_eq!(
756 Envelope::decode(&encoded),
757 Err(DecodeError::TrailingBytes { extra: 7 })
758 );
759 }
760
761 #[test]
762 fn envelope_decode_accepts_exact_frame() {
763 // The boundary case: a buffer that is exactly one frame decodes cleanly.
764 let env = Envelope::new(push_header(), b"hello".to_vec());
765 let encoded = env.encode();
766 assert_eq!(Envelope::decode(&encoded).unwrap(), env);
767 }
768
769 #[test]
770 fn envelope_decode_trailing_bytes_checked_after_payload_too_large() {
771 // A 16-byte buffer declaring an over-cap payload returns PayloadTooLarge,
772 // not TrailingBytes: the cap check (step 3) precedes the frame-length check
773 // (step 4), so an over-cap declared length is rejected before allocation
774 // even though the buffer is "too short" for the declared frame.
775 let oversized_len = (MAX_PAYLOAD_HARD_CAP + 1) as u32;
776 let mut header_bytes = push_header().encode();
777 header_bytes[8..12].copy_from_slice(&oversized_len.to_le_bytes());
778 let crc = crc32fast::hash(&header_bytes[..HEADER_CRC_COVERAGE]);
779 header_bytes[12..16].copy_from_slice(&crc.to_le_bytes());
780 assert_eq!(
781 Envelope::decode(&header_bytes),
782 Err(DecodeError::PayloadTooLarge {
783 len: MAX_PAYLOAD_HARD_CAP + 1,
784 cap: MAX_PAYLOAD_HARD_CAP,
785 })
786 );
787 }
788
789 #[test]
790 fn envelope_decode_rejects_oversized_payload_len() {
791 // The cap check fires immediately after Header::decode, before the frame-length
792 // check, so even a 16-byte buffer returns PayloadTooLarge (not TruncatedFrame).
793 // This ordering is deliberate: it prevents any heap allocation attempt.
794 //
795 // Header::new can no longer declare an oversized length (F50), so patch the
796 // payload_len field + recompute the header CRC directly to put the over-cap
797 // declared length on the wire.
798 let oversized_len = (MAX_PAYLOAD_HARD_CAP + 1) as u32;
799 let mut header_bytes = Header::new(MessageType::Push, Durability::Durable, 0).encode();
800 header_bytes[8..12].copy_from_slice(&oversized_len.to_le_bytes());
801 let crc = crc32fast::hash(&header_bytes[..HEADER_CRC_COVERAGE]);
802 header_bytes[12..16].copy_from_slice(&crc.to_le_bytes());
803 assert_eq!(
804 Envelope::decode(&header_bytes),
805 Err(DecodeError::PayloadTooLarge {
806 len: MAX_PAYLOAD_HARD_CAP + 1,
807 cap: MAX_PAYLOAD_HARD_CAP,
808 })
809 );
810 }
811}