dig_peer_protocol/dig_message.rs
1//! [`DigMessage`] — the native DIG peer envelope, with a raw `u8` opcode.
2//!
3//! ## The problem it exists to solve
4//!
5//! `chia_protocol::Message` stores `msg_type` as `ProtocolMessageTypes`, a closed `#[repr(u8)]`
6//! enum with no `Unknown(u8)` variant. `Message::from_bytes` rejects any opcode outside it, so a
7//! DIG opcode could not be encoded or decoded through that type without forking the upstream
8//! crate — which is exactly what DIG used to do.
9//!
10//! ## The native envelope
11//!
12//! `DigMessage` owns its encoding outright: [`to_bytes`](DigMessage::to_bytes) and
13//! [`from_bytes`](DigMessage::from_bytes) write and read the wire directly, with no upstream
14//! type participating. It is DIG's envelope for every DIG opcode, and it can express a chia-band
15//! opcode identically because the layout is the same one chia peers already speak.
16//!
17//! ```text
18//! [u8 msg_type] [u8 has_id (0/1)] [u16 id (big-endian, if has_id)] [u32 data_len (big-endian)] [data...]
19//! ```
20//!
21//! ## No bridge to `chia_protocol::Message`
22//!
23//! There is deliberately no conversion to or from `chia_protocol::Message`. Earlier revisions
24//! carried four such helpers, and they were the on-ramp back onto the forked route: a consumer
25//! that could cheaply obtain a `Message` kept building one, and the closed enum came back with
26//! it. Chia-band traffic to chia peers goes through
27//! [`DigLink`](crate::DigLink)'s typed `send`/`request`, which derive their opcode from
28//! `ChiaProtocolMessage` — that is the supported chia path, and the only one.
29
30use crate::Bytes;
31
32/// The DIG peer envelope: a raw `u8` opcode, an optional correlation id, and a payload.
33///
34/// Expresses any opcode, DIG-band or chia-band, because the opcode is a plain byte with no enum
35/// standing between it and the wire.
36///
37/// ```text
38/// [u8 msg_type] [u8 has_id] [u16 id (if has_id)] [u32 data_len] [u8... data]
39/// ```
40///
41/// The encoding is pinned as absolute hex in `tests/golden_wire_vectors.rs`. It is a live
42/// network's wire format: a change to these bytes is a coordinated network event, never a
43/// refactor.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct DigMessage {
46 /// Raw wire opcode — no enum restriction.
47 pub msg_type: u8,
48 /// Correlation ID for request/response pairing (same as `Message.id`).
49 pub id: Option<u16>,
50 /// Serialized payload body.
51 pub data: Bytes,
52}
53
54impl DigMessage {
55 /// Protocol-level ceiling on a single message's declared payload length (bytes).
56 ///
57 /// `from_bytes` rejects any `data_len` prefix above this value with `None` before
58 /// slicing/allocating the payload, so a peer cannot force a multi-gigabyte `Vec`
59 /// allocation via a lying (or genuine but oversized) length prefix. Mirrors
60 /// `chia-protocol`'s own message-size ceiling (16 MiB) — comfortably above any
61 /// legitimate DIG opcode payload (attestations, checkpoints, block-transaction
62 /// batches) while bounding worst-case per-message memory.
63 ///
64 /// **Callers MUST still enforce a per-frame size cap at the transport/framing layer
65 /// BEFORE buffering an incoming frame into a contiguous `&[u8]`** — this constant
66 /// only bounds what `from_bytes` will accept once a slice already exists; it cannot
67 /// stop a transport from reading an unbounded number of bytes off the wire first.
68 pub const MAX_MESSAGE_SIZE: usize = 16 * 1024 * 1024; // 16 MiB
69
70 /// Construct a new DIG message.
71 pub fn new(msg_type: u8, id: Option<u16>, data: Bytes) -> Self {
72 Self { msg_type, id, data }
73 }
74
75 /// Serialize to wire bytes (same format as `Message::to_bytes`).
76 ///
77 /// Layout: `[u8 msg_type] [u8 has_id (0/1)] [u16 id if present] [u32 data_len] [data...]`
78 pub fn to_bytes(&self) -> Vec<u8> {
79 let mut buf = Vec::with_capacity(1 + 1 + 2 + 4 + self.data.len());
80 buf.push(self.msg_type);
81 match self.id {
82 Some(id) => {
83 buf.push(1); // has_id = true
84 buf.extend_from_slice(&id.to_be_bytes());
85 }
86 None => {
87 buf.push(0); // has_id = false
88 }
89 }
90 buf.extend_from_slice(&(self.data.len() as u32).to_be_bytes());
91 buf.extend_from_slice(&self.data);
92 buf
93 }
94
95 /// Deserialize from wire bytes. Accepts any `msg_type` value — no enum validation.
96 ///
97 /// Returns `None` if the buffer is too short, the length prefix doesn't match, or
98 /// the declared `data_len` exceeds [`Self::MAX_MESSAGE_SIZE`].
99 ///
100 /// This is a leaf parser over an already-materialized `&[u8]`: the `MAX_MESSAGE_SIZE`
101 /// check bounds the allocation this function performs, but it cannot bound how many
102 /// bytes a transport read off the wire to build `bytes` in the first place. **Callers
103 /// MUST enforce a per-frame size cap at the transport/framing layer before buffering
104 /// an incoming frame**, so that a peer cannot force unbounded buffering ahead of ever
105 /// reaching this function.
106 pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
107 if bytes.len() < 2 {
108 return None;
109 }
110 let msg_type = bytes[0];
111 let has_id = bytes[1];
112 let mut offset: usize = 2;
113
114 let id = if has_id != 0 {
115 let after_id = offset.checked_add(2)?;
116 if bytes.len() < after_id {
117 return None;
118 }
119 let id = u16::from_be_bytes([bytes[offset], bytes[offset + 1]]);
120 offset = after_id;
121 Some(id)
122 } else {
123 None
124 };
125
126 let after_len = offset.checked_add(4)?;
127 if bytes.len() < after_len {
128 return None;
129 }
130 let data_len = u32::from_be_bytes([
131 bytes[offset],
132 bytes[offset + 1],
133 bytes[offset + 2],
134 bytes[offset + 3],
135 ]) as usize;
136 offset = after_len;
137
138 if data_len > Self::MAX_MESSAGE_SIZE {
139 return None;
140 }
141
142 // Checked (not `offset + data_len`) so a peer-controlled `data_len` can never
143 // wrap `usize` on a 32-bit target — overflow here returns None instead of
144 // either panicking (debug) or proceeding with a wrapped, corrupted range
145 // (release). MAX_MESSAGE_SIZE already rejects data_len this large in practice,
146 // but the arithmetic itself stays overflow-safe independent of that cap.
147 let end = offset.checked_add(data_len)?;
148 if bytes.len() < end {
149 return None;
150 }
151 let data = Bytes::new(bytes[offset..end].to_vec());
152
153 Some(Self { msg_type, id, data })
154 }
155
156 /// Deserialize from an OWNED byte buffer, moving the payload instead of copying it.
157 ///
158 /// Identical acceptance/rejection rules to [`Self::from_bytes`] (same length checks,
159 /// same [`Self::MAX_MESSAGE_SIZE`] cap, same `None` on any malformed input) — the
160 /// only difference is that when `buf` already owns its bytes (e.g. a transport that
161 /// read the frame directly into a `Vec<u8>`), the payload is moved into the result
162 /// via `Vec::drain` instead of being copied out of a borrowed `&[u8]` with `to_vec`.
163 /// Prefer this over `from_bytes` whenever the caller already has an owned `Vec<u8>`
164 /// and does not need it afterward.
165 pub fn from_bytes_owned(mut buf: Vec<u8>) -> Option<Self> {
166 if buf.len() < 2 {
167 return None;
168 }
169 let msg_type = buf[0];
170 let has_id = buf[1];
171 let mut offset: usize = 2;
172
173 let id = if has_id != 0 {
174 let after_id = offset.checked_add(2)?;
175 if buf.len() < after_id {
176 return None;
177 }
178 let id = u16::from_be_bytes([buf[offset], buf[offset + 1]]);
179 offset = after_id;
180 Some(id)
181 } else {
182 None
183 };
184
185 let after_len = offset.checked_add(4)?;
186 if buf.len() < after_len {
187 return None;
188 }
189 let data_len = u32::from_be_bytes([
190 buf[offset],
191 buf[offset + 1],
192 buf[offset + 2],
193 buf[offset + 3],
194 ]) as usize;
195 offset = after_len;
196
197 if data_len > Self::MAX_MESSAGE_SIZE {
198 return None;
199 }
200
201 let end = offset.checked_add(data_len)?;
202 if buf.len() < end {
203 return None;
204 }
205
206 // Move just the payload range out of `buf` without copying: drain removes and
207 // yields the [offset..end) elements by value, leaving any trailing bytes (and
208 // the header bytes before offset) dropped rather than retained.
209 let data: Vec<u8> = buf.drain(offset..end).collect();
210
211 Some(Self {
212 msg_type,
213 id,
214 data: Bytes::new(data),
215 })
216 }
217
218 /// Whether this message carries a DIG extension opcode (>= 200).
219 pub fn is_dig_extension(&self) -> bool {
220 self.msg_type >= 200
221 }
222
223 /// Whether this message carries a standard Chia opcode (< 200).
224 pub fn is_chia_standard(&self) -> bool {
225 self.msg_type < 200
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn round_trip_chia_opcode() {
235 // NewPeak = 20
236 let msg = DigMessage::new(20, Some(42), Bytes::new(vec![1, 2, 3]));
237 let wire = msg.to_bytes();
238 let decoded = DigMessage::from_bytes(&wire).expect("decode");
239 assert_eq!(decoded.msg_type, 20);
240 assert_eq!(decoded.id, Some(42));
241 assert_eq!(decoded.data.as_ref(), &[1, 2, 3]);
242 }
243
244 #[test]
245 fn round_trip_dig_opcode() {
246 // RegisterPeer = 218
247 let msg = DigMessage::new(218, None, Bytes::new(vec![0xAB]));
248 let wire = msg.to_bytes();
249 let decoded = DigMessage::from_bytes(&wire).expect("decode");
250 assert_eq!(decoded.msg_type, 218);
251 assert_eq!(decoded.id, None);
252 assert!(decoded.is_dig_extension());
253 assert!(!decoded.is_chia_standard());
254 }
255
256 #[test]
257 fn from_bytes_owned_avoids_copy_when_buffer_is_owned() {
258 // Given an owned Vec<u8> (e.g. a transport that already read the frame into its
259 // own buffer), from_bytes_owned must decode identically to from_bytes but move
260 // the payload bytes into the resulting DigMessage instead of copying them via
261 // to_vec() on a borrowed slice.
262 let msg = DigMessage::new(218, Some(7), Bytes::new(vec![9, 9, 9]));
263 let wire: Vec<u8> = msg.to_bytes();
264 let decoded = DigMessage::from_bytes_owned(wire).expect("decode");
265 assert_eq!(decoded, msg);
266 }
267
268 #[test]
269 fn from_bytes_owned_rejects_same_malformed_inputs_as_from_bytes() {
270 assert!(DigMessage::from_bytes_owned(vec![]).is_none());
271 assert!(DigMessage::from_bytes_owned(vec![20, 1]).is_none());
272
273 let mut wire = vec![1u8, 0];
274 wire.extend_from_slice(&((DigMessage::MAX_MESSAGE_SIZE as u32) + 1).to_be_bytes());
275 assert!(DigMessage::from_bytes_owned(wire).is_none());
276
277 let truncated = vec![20u8, 0, 0x00, 0x00, 0x00, 0x04, 0xAB, 0xCD];
278 assert!(DigMessage::from_bytes_owned(truncated).is_none());
279 }
280
281 #[test]
282 fn from_bytes_owned_ignores_trailing_bytes_like_from_bytes() {
283 // Parity with from_bytes: trailing bytes beyond data_len are not an error and
284 // are simply not included in the decoded payload.
285 let mut wire = vec![20u8, 0, 0x00, 0x00, 0x00, 0x02, 0xAB, 0xCD];
286 wire.extend_from_slice(&[0xEE, 0xFF]); // trailing garbage
287 let decoded = DigMessage::from_bytes_owned(wire).expect("decode");
288 assert_eq!(decoded.data.as_ref(), &[0xAB, 0xCD]);
289 }
290
291 #[test]
292 fn empty_buffer_returns_none() {
293 assert!(DigMessage::from_bytes(&[]).is_none());
294 assert!(DigMessage::from_bytes(&[20]).is_none());
295 }
296
297 #[test]
298 fn truncated_id_prefix_returns_none() {
299 // has_id = 1 but the buffer ends before the 2-byte id can be read.
300 // [msg_type=20][has_id=1] then only ONE of the two id bytes present.
301 assert!(DigMessage::from_bytes(&[20, 1]).is_none());
302 assert!(DigMessage::from_bytes(&[20, 1, 0x00]).is_none());
303 }
304
305 #[test]
306 fn truncated_data_len_prefix_returns_none() {
307 // has_id = 0, so offset = 2, but fewer than 4 bytes remain for the u32 data_len.
308 assert!(DigMessage::from_bytes(&[20, 0]).is_none());
309 assert!(DigMessage::from_bytes(&[20, 0, 0x00, 0x00, 0x00]).is_none());
310
311 // With an id present (offset = 4), still short of the 4-byte data_len.
312 assert!(DigMessage::from_bytes(&[20, 1, 0x00, 0x2A, 0x00, 0x00]).is_none());
313 }
314
315 #[test]
316 fn truncated_data_returns_none() {
317 // Declares data_len = 4 but supplies only 2 payload bytes.
318 // [msg_type=20][has_id=0][data_len=00 00 00 04][data=AB CD] (missing 2 bytes)
319 let wire = [20u8, 0, 0x00, 0x00, 0x00, 0x04, 0xAB, 0xCD];
320 assert!(DigMessage::from_bytes(&wire).is_none());
321
322 // Exact-fit boundary: data_len = 2 with exactly 2 payload bytes decodes.
323 let ok = [20u8, 0, 0x00, 0x00, 0x00, 0x02, 0xAB, 0xCD];
324 let decoded = DigMessage::from_bytes(&ok).expect("exact-length payload decodes");
325 assert_eq!(decoded.data.as_ref(), &[0xAB, 0xCD]);
326 }
327
328 #[test]
329 fn oversized_data_len_is_rejected() {
330 // data_len prefix declares more than MAX_MESSAGE_SIZE — must be rejected with
331 // None BEFORE any attempt to read/allocate the (possibly absent) payload bytes,
332 // regardless of how many bytes actually follow.
333 let over = (DigMessage::MAX_MESSAGE_SIZE as u32) + 1;
334 let mut wire = vec![20u8, 0]; // msg_type=20, has_id=0
335 wire.extend_from_slice(&over.to_be_bytes());
336 // No payload bytes supplied at all — if the cap check didn't fire first, this
337 // would already fail the length check, so also prove the cap fires even when
338 // enough bytes *are* present.
339 assert!(DigMessage::from_bytes(&wire).is_none());
340 }
341
342 #[test]
343 fn max_data_len_prefix_does_not_panic_and_is_rejected() {
344 // data_len = u32::MAX. On a 32-bit target this used to overflow `offset +
345 // data_len` (unchecked usize addition): a debug build would panic (a
346 // network-reachable crash), a release build would wrap and proceed into a
347 // corrupted slice range. The fix uses checked_add and must return None
348 // uniformly, on every target width, without panicking either way — this test
349 // must pass identically under `cargo test` (checked arithmetic, debug or
350 // release) regardless of pointer width. (Also caught by the MAX_MESSAGE_SIZE
351 // cap now, but the bounds-check arithmetic itself must be overflow-safe
352 // independent of that cap — see the direct offset-overflow test below.)
353 let mut wire = vec![20u8, 0]; // msg_type=20, has_id=0
354 wire.extend_from_slice(&u32::MAX.to_be_bytes());
355 assert!(DigMessage::from_bytes(&wire).is_none());
356 }
357
358 #[test]
359 fn offset_plus_data_len_overflow_is_checked_not_wrapping() {
360 // Regression test for the raw `offset + data_len` addition itself (line 98 in
361 // the pre-fix code / the bounds check below the MAX_MESSAGE_SIZE cap): it must
362 // use checked arithmetic and return None on overflow rather than silently
363 // wrapping (which on a 32-bit usize could previously turn a huge data_len into
364 // a small wrapped value that passed the length check and then panicked on the
365 // slice index instead). usize::MAX stands in for "large enough to overflow
366 // offset + data_len on the current target's pointer width" — on 64-bit this
367 // value is not reachable via a real u32 data_len, so this test exercises the
368 // checked_add call path directly rather than only the u32-parsed case above.
369 let bytes = [20u8, 0, 0, 0, 0, 0]; // msg_type, has_id=0, data_len=0
370 // Sanity: normal zero-length message still decodes fine (no regression).
371 assert!(DigMessage::from_bytes(&bytes).is_some());
372
373 // The u32::MAX case above is the reachable-from-the-wire overflow probe; assert
374 // its result is a clean None (not a panic) for every build profile.
375 let mut wire = vec![1u8, 0];
376 wire.extend_from_slice(&u32::MAX.to_be_bytes());
377 let result = std::panic::catch_unwind(|| DigMessage::from_bytes(&wire));
378 assert!(
379 result.is_ok(),
380 "from_bytes must not panic on data_len = u32::MAX"
381 );
382 assert_eq!(result.unwrap(), None);
383 }
384
385 #[test]
386 fn data_len_one_byte_over_cap_is_rejected_at_exact_boundary() {
387 // Prove the cap is enforced as `> MAX_MESSAGE_SIZE`, not some looser bound, by
388 // checking the smallest possible over-cap value (cap + 1) is rejected even
389 // though the length-prefix parsing itself succeeds (only the cap check fails).
390 let over_by_one = (DigMessage::MAX_MESSAGE_SIZE as u32) + 1;
391 let mut wire = vec![1u8, 0];
392 wire.extend_from_slice(&over_by_one.to_be_bytes());
393 assert!(DigMessage::from_bytes(&wire).is_none());
394 }
395
396 #[test]
397 fn zero_length_data_round_trip() {
398 // data_len = 0 is a valid wire message (e.g. RegisterPeersIntroducer body).
399 let msg = DigMessage::new(64, Some(1), Bytes::default());
400 let wire = msg.to_bytes();
401 let decoded = DigMessage::from_bytes(&wire).expect("zero-length decode");
402 assert_eq!(decoded, msg);
403 assert!(decoded.data.as_ref().is_empty());
404 }
405
406 #[test]
407 fn dig_extension_boundary_at_200() {
408 // 199 is the last Chia-standard value; 200 is the first DIG extension value.
409 let below = DigMessage::new(199, None, Bytes::default());
410 assert!(below.is_chia_standard());
411 assert!(!below.is_dig_extension());
412
413 let at = DigMessage::new(200, None, Bytes::default());
414 assert!(at.is_dig_extension());
415 assert!(!at.is_chia_standard());
416 }
417}