dig_peer_protocol/dig_message.rs
1//! [`DigMessage`] — wire-compatible `Message` with raw `u8` opcode support.
2//!
3//! ## Problem
4//!
5//! `chia_protocol::Message` stores `msg_type` as `ProtocolMessageTypes` (a closed `#[repr(u8)]`
6//! enum). `Message::from_bytes` rejects any opcode not in that enum, which means DIG extension
7//! opcodes (200–219) cannot be decoded without patching the upstream crate.
8//!
9//! ## Solution
10//!
11//! `DigMessage` uses the same wire layout (`u8 + Option<u16> + Bytes`) but stores `msg_type`
12//! as a plain `u8`. This allows encoding and decoding any opcode — Chia or DIG — without
13//! modifying upstream types. Conversion to/from `chia_protocol::Message` is provided for
14//! opcodes that the Chia enum recognizes.
15
16use chia_protocol::{Bytes, Message, ProtocolMessageTypes};
17use chia_traits::Streamable;
18
19/// Wire message with raw `u8` opcode — handles both Chia (0–107) and DIG (200–219) opcodes.
20///
21/// Same binary layout as `chia_protocol::Message`:
22/// ```text
23/// [u8 msg_type] [bool has_id] [u16 id (if has_id)] [u32 data_len] [u8... data]
24/// ```
25///
26/// Use [`DigMessage::to_bytes`] / [`DigMessage::from_bytes`] for wire serialization.
27/// Use [`DigMessage::try_into_chia_message`] to convert to stock `Message` when the opcode
28/// is a known Chia type.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct DigMessage {
31 /// Raw wire opcode — no enum restriction.
32 pub msg_type: u8,
33 /// Correlation ID for request/response pairing (same as `Message.id`).
34 pub id: Option<u16>,
35 /// Serialized payload body.
36 pub data: Bytes,
37}
38
39impl DigMessage {
40 /// Protocol-level ceiling on a single message's declared payload length (bytes).
41 ///
42 /// `from_bytes` rejects any `data_len` prefix above this value with `None` before
43 /// slicing/allocating the payload, so a peer cannot force a multi-gigabyte `Vec`
44 /// allocation via a lying (or genuine but oversized) length prefix. Mirrors
45 /// `chia-protocol`'s own message-size ceiling (16 MiB) — comfortably above any
46 /// legitimate DIG opcode payload (attestations, checkpoints, block-transaction
47 /// batches) while bounding worst-case per-message memory.
48 ///
49 /// **Callers MUST still enforce a per-frame size cap at the transport/framing layer
50 /// BEFORE buffering an incoming frame into a contiguous `&[u8]`** — this constant
51 /// only bounds what `from_bytes` will accept once a slice already exists; it cannot
52 /// stop a transport from reading an unbounded number of bytes off the wire first.
53 pub const MAX_MESSAGE_SIZE: usize = 16 * 1024 * 1024; // 16 MiB
54
55 /// Construct a new DIG message.
56 pub fn new(msg_type: u8, id: Option<u16>, data: Bytes) -> Self {
57 Self { msg_type, id, data }
58 }
59
60 /// Serialize to wire bytes (same format as `Message::to_bytes`).
61 ///
62 /// Layout: `[u8 msg_type] [u8 has_id (0/1)] [u16 id if present] [u32 data_len] [data...]`
63 pub fn to_bytes(&self) -> Vec<u8> {
64 let mut buf = Vec::with_capacity(1 + 1 + 2 + 4 + self.data.len());
65 buf.push(self.msg_type);
66 match self.id {
67 Some(id) => {
68 buf.push(1); // has_id = true
69 buf.extend_from_slice(&id.to_be_bytes());
70 }
71 None => {
72 buf.push(0); // has_id = false
73 }
74 }
75 buf.extend_from_slice(&(self.data.len() as u32).to_be_bytes());
76 buf.extend_from_slice(&self.data);
77 buf
78 }
79
80 /// Deserialize from wire bytes. Accepts any `msg_type` value — no enum validation.
81 ///
82 /// Returns `None` if the buffer is too short, the length prefix doesn't match, or
83 /// the declared `data_len` exceeds [`Self::MAX_MESSAGE_SIZE`].
84 ///
85 /// This is a leaf parser over an already-materialized `&[u8]`: the `MAX_MESSAGE_SIZE`
86 /// check bounds the allocation this function performs, but it cannot bound how many
87 /// bytes a transport read off the wire to build `bytes` in the first place. **Callers
88 /// MUST enforce a per-frame size cap at the transport/framing layer before buffering
89 /// an incoming frame**, so that a peer cannot force unbounded buffering ahead of ever
90 /// reaching this function.
91 pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
92 if bytes.len() < 2 {
93 return None;
94 }
95 let msg_type = bytes[0];
96 let has_id = bytes[1];
97 let mut offset: usize = 2;
98
99 let id = if has_id != 0 {
100 let after_id = offset.checked_add(2)?;
101 if bytes.len() < after_id {
102 return None;
103 }
104 let id = u16::from_be_bytes([bytes[offset], bytes[offset + 1]]);
105 offset = after_id;
106 Some(id)
107 } else {
108 None
109 };
110
111 let after_len = offset.checked_add(4)?;
112 if bytes.len() < after_len {
113 return None;
114 }
115 let data_len = u32::from_be_bytes([
116 bytes[offset],
117 bytes[offset + 1],
118 bytes[offset + 2],
119 bytes[offset + 3],
120 ]) as usize;
121 offset = after_len;
122
123 if data_len > Self::MAX_MESSAGE_SIZE {
124 return None;
125 }
126
127 // Checked (not `offset + data_len`) so a peer-controlled `data_len` can never
128 // wrap `usize` on a 32-bit target — overflow here returns None instead of
129 // either panicking (debug) or proceeding with a wrapped, corrupted range
130 // (release). MAX_MESSAGE_SIZE already rejects data_len this large in practice,
131 // but the arithmetic itself stays overflow-safe independent of that cap.
132 let end = offset.checked_add(data_len)?;
133 if bytes.len() < end {
134 return None;
135 }
136 let data = Bytes::new(bytes[offset..end].to_vec());
137
138 Some(Self { msg_type, id, data })
139 }
140
141 /// Deserialize from an OWNED byte buffer, moving the payload instead of copying it.
142 ///
143 /// Identical acceptance/rejection rules to [`Self::from_bytes`] (same length checks,
144 /// same [`Self::MAX_MESSAGE_SIZE`] cap, same `None` on any malformed input) — the
145 /// only difference is that when `buf` already owns its bytes (e.g. a transport that
146 /// read the frame directly into a `Vec<u8>`), the payload is moved into the result
147 /// via `Vec::drain` instead of being copied out of a borrowed `&[u8]` with `to_vec`.
148 /// Prefer this over `from_bytes` whenever the caller already has an owned `Vec<u8>`
149 /// and does not need it afterward.
150 pub fn from_bytes_owned(mut buf: Vec<u8>) -> Option<Self> {
151 if buf.len() < 2 {
152 return None;
153 }
154 let msg_type = buf[0];
155 let has_id = buf[1];
156 let mut offset: usize = 2;
157
158 let id = if has_id != 0 {
159 let after_id = offset.checked_add(2)?;
160 if buf.len() < after_id {
161 return None;
162 }
163 let id = u16::from_be_bytes([buf[offset], buf[offset + 1]]);
164 offset = after_id;
165 Some(id)
166 } else {
167 None
168 };
169
170 let after_len = offset.checked_add(4)?;
171 if buf.len() < after_len {
172 return None;
173 }
174 let data_len = u32::from_be_bytes([
175 buf[offset],
176 buf[offset + 1],
177 buf[offset + 2],
178 buf[offset + 3],
179 ]) as usize;
180 offset = after_len;
181
182 if data_len > Self::MAX_MESSAGE_SIZE {
183 return None;
184 }
185
186 let end = offset.checked_add(data_len)?;
187 if buf.len() < end {
188 return None;
189 }
190
191 // Move just the payload range out of `buf` without copying: drain removes and
192 // yields the [offset..end) elements by value, leaving any trailing bytes (and
193 // the header bytes before offset) dropped rather than retained.
194 let data: Vec<u8> = buf.drain(offset..end).collect();
195
196 Some(Self {
197 msg_type,
198 id,
199 data: Bytes::new(data),
200 })
201 }
202
203 /// Convert from a stock `chia_protocol::Message` (lossless — opcode stored as u8).
204 ///
205 /// Clones `msg.data`. Prefer [`Self::from_chia_message_owned`] when an owned
206 /// `Message` is available and does not need to be kept afterward.
207 pub fn from_chia_message(msg: &Message) -> Self {
208 Self {
209 msg_type: msg.msg_type as u8,
210 id: msg.id,
211 data: msg.data.clone(),
212 }
213 }
214
215 /// Convert from an OWNED stock `chia_protocol::Message`, moving `data` instead of
216 /// cloning it (lossless — opcode stored as u8).
217 pub fn from_chia_message_owned(msg: Message) -> Self {
218 Self {
219 msg_type: msg.msg_type as u8,
220 id: msg.id,
221 data: msg.data,
222 }
223 }
224
225 /// Try to convert to a stock `chia_protocol::Message`, cloning `self.data`.
226 ///
227 /// Fails if `msg_type` is not a valid `ProtocolMessageTypes` discriminant
228 /// (i.e., DIG extension opcodes 200+ will fail here — use `DigMessage` directly
229 /// for those). Prefer [`Self::into_chia_message`] when `self` does not need to be
230 /// kept afterward.
231 pub fn try_into_chia_message(&self) -> Option<Message> {
232 // ProtocolMessageTypes implements Streamable; from_bytes on a single u8
233 let pmt = ProtocolMessageTypes::from_bytes(&[self.msg_type]).ok()?;
234 Some(Message {
235 msg_type: pmt,
236 id: self.id,
237 data: self.data.clone(),
238 })
239 }
240
241 /// Try to convert into a stock `chia_protocol::Message` BY VALUE, moving `self.data`
242 /// instead of cloning it.
243 ///
244 /// Same failure condition as [`Self::try_into_chia_message`]: `None` when `msg_type`
245 /// is not a valid `ProtocolMessageTypes` discriminant. On failure `self` is dropped
246 /// (its opcode was not a known Chia type, so there is nothing useful to hand back).
247 pub fn into_chia_message(self) -> Option<Message> {
248 let pmt = ProtocolMessageTypes::from_bytes(&[self.msg_type]).ok()?;
249 Some(Message {
250 msg_type: pmt,
251 id: self.id,
252 data: self.data,
253 })
254 }
255
256 /// Whether this message carries a DIG extension opcode (>= 200).
257 pub fn is_dig_extension(&self) -> bool {
258 self.msg_type >= 200
259 }
260
261 /// Whether this message carries a standard Chia opcode (< 200).
262 pub fn is_chia_standard(&self) -> bool {
263 self.msg_type < 200
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 #[test]
272 fn round_trip_chia_opcode() {
273 // NewPeak = 20
274 let msg = DigMessage::new(20, Some(42), Bytes::new(vec![1, 2, 3]));
275 let wire = msg.to_bytes();
276 let decoded = DigMessage::from_bytes(&wire).expect("decode");
277 assert_eq!(decoded.msg_type, 20);
278 assert_eq!(decoded.id, Some(42));
279 assert_eq!(decoded.data.as_ref(), &[1, 2, 3]);
280 }
281
282 #[test]
283 fn round_trip_dig_opcode() {
284 // RegisterPeer = 218
285 let msg = DigMessage::new(218, None, Bytes::new(vec![0xAB]));
286 let wire = msg.to_bytes();
287 let decoded = DigMessage::from_bytes(&wire).expect("decode");
288 assert_eq!(decoded.msg_type, 218);
289 assert_eq!(decoded.id, None);
290 assert!(decoded.is_dig_extension());
291 assert!(!decoded.is_chia_standard());
292 }
293
294 #[test]
295 fn from_bytes_owned_avoids_copy_when_buffer_is_owned() {
296 // Given an owned Vec<u8> (e.g. a transport that already read the frame into its
297 // own buffer), from_bytes_owned must decode identically to from_bytes but move
298 // the payload bytes into the resulting DigMessage instead of copying them via
299 // to_vec() on a borrowed slice.
300 let msg = DigMessage::new(218, Some(7), Bytes::new(vec![9, 9, 9]));
301 let wire: Vec<u8> = msg.to_bytes();
302 let decoded = DigMessage::from_bytes_owned(wire).expect("decode");
303 assert_eq!(decoded, msg);
304 }
305
306 #[test]
307 fn from_bytes_owned_rejects_same_malformed_inputs_as_from_bytes() {
308 assert!(DigMessage::from_bytes_owned(vec![]).is_none());
309 assert!(DigMessage::from_bytes_owned(vec![20, 1]).is_none());
310
311 let mut wire = vec![1u8, 0];
312 wire.extend_from_slice(&((DigMessage::MAX_MESSAGE_SIZE as u32) + 1).to_be_bytes());
313 assert!(DigMessage::from_bytes_owned(wire).is_none());
314
315 let truncated = vec![20u8, 0, 0x00, 0x00, 0x00, 0x04, 0xAB, 0xCD];
316 assert!(DigMessage::from_bytes_owned(truncated).is_none());
317 }
318
319 #[test]
320 fn from_bytes_owned_ignores_trailing_bytes_like_from_bytes() {
321 // Parity with from_bytes: trailing bytes beyond data_len are not an error and
322 // are simply not included in the decoded payload.
323 let mut wire = vec![20u8, 0, 0x00, 0x00, 0x00, 0x02, 0xAB, 0xCD];
324 wire.extend_from_slice(&[0xEE, 0xFF]); // trailing garbage
325 let decoded = DigMessage::from_bytes_owned(wire).expect("decode");
326 assert_eq!(decoded.data.as_ref(), &[0xAB, 0xCD]);
327 }
328
329 #[test]
330 fn into_chia_message_moves_data_without_cloning() {
331 // into_chia_message consumes self by value; the resulting Message's data must
332 // equal what try_into_chia_message (the cloning, borrowing variant) would have
333 // produced, proving the by-value path is behaviourally identical.
334 let dig = DigMessage::new(20, Some(3), Bytes::new(vec![1, 2, 3, 4]));
335 let expected_data = dig.data.clone();
336 let msg = dig.into_chia_message().expect("known opcode");
337 assert_eq!(msg.msg_type, ProtocolMessageTypes::NewPeak);
338 assert_eq!(msg.id, Some(3));
339 assert_eq!(msg.data, expected_data);
340 }
341
342 #[test]
343 fn into_chia_message_rejects_unknown_opcode() {
344 let dig = DigMessage::new(250, None, Bytes::default());
345 assert!(dig.into_chia_message().is_none());
346 }
347
348 #[test]
349 fn from_chia_message_owned_moves_data_without_cloning() {
350 let chia_msg = Message {
351 msg_type: ProtocolMessageTypes::NewPeak,
352 id: Some(11),
353 data: Bytes::new(vec![7, 7, 7]),
354 };
355 let expected_data = chia_msg.data.clone();
356 let dig = DigMessage::from_chia_message_owned(chia_msg);
357 assert_eq!(dig.msg_type, 20);
358 assert_eq!(dig.id, Some(11));
359 assert_eq!(dig.data, expected_data);
360 }
361
362 #[test]
363 fn from_chia_message() {
364 let chia_msg = Message {
365 msg_type: ProtocolMessageTypes::NewPeak,
366 id: Some(7),
367 data: Bytes::new(vec![0xFF]),
368 };
369 let dig = DigMessage::from_chia_message(&chia_msg);
370 assert_eq!(dig.msg_type, 20); // NewPeak = 20
371 assert_eq!(dig.id, Some(7));
372
373 // Round-trip back to Chia
374 let back = dig.try_into_chia_message().expect("known opcode");
375 assert_eq!(back.msg_type, ProtocolMessageTypes::NewPeak);
376 }
377
378 #[test]
379 fn unknown_opcode_cannot_convert_to_chia_message() {
380 // Use an opcode that is NOT in ProtocolMessageTypes (neither Chia nor vendored DIG).
381 let dig = DigMessage::new(250, None, Bytes::default());
382 assert!(dig.try_into_chia_message().is_none());
383 }
384
385 #[test]
386 fn empty_buffer_returns_none() {
387 assert!(DigMessage::from_bytes(&[]).is_none());
388 assert!(DigMessage::from_bytes(&[20]).is_none());
389 }
390
391 #[test]
392 fn truncated_id_prefix_returns_none() {
393 // has_id = 1 but the buffer ends before the 2-byte id can be read.
394 // [msg_type=20][has_id=1] then only ONE of the two id bytes present.
395 assert!(DigMessage::from_bytes(&[20, 1]).is_none());
396 assert!(DigMessage::from_bytes(&[20, 1, 0x00]).is_none());
397 }
398
399 #[test]
400 fn truncated_data_len_prefix_returns_none() {
401 // has_id = 0, so offset = 2, but fewer than 4 bytes remain for the u32 data_len.
402 assert!(DigMessage::from_bytes(&[20, 0]).is_none());
403 assert!(DigMessage::from_bytes(&[20, 0, 0x00, 0x00, 0x00]).is_none());
404
405 // With an id present (offset = 4), still short of the 4-byte data_len.
406 assert!(DigMessage::from_bytes(&[20, 1, 0x00, 0x2A, 0x00, 0x00]).is_none());
407 }
408
409 #[test]
410 fn truncated_data_returns_none() {
411 // Declares data_len = 4 but supplies only 2 payload bytes.
412 // [msg_type=20][has_id=0][data_len=00 00 00 04][data=AB CD] (missing 2 bytes)
413 let wire = [20u8, 0, 0x00, 0x00, 0x00, 0x04, 0xAB, 0xCD];
414 assert!(DigMessage::from_bytes(&wire).is_none());
415
416 // Exact-fit boundary: data_len = 2 with exactly 2 payload bytes decodes.
417 let ok = [20u8, 0, 0x00, 0x00, 0x00, 0x02, 0xAB, 0xCD];
418 let decoded = DigMessage::from_bytes(&ok).expect("exact-length payload decodes");
419 assert_eq!(decoded.data.as_ref(), &[0xAB, 0xCD]);
420 }
421
422 #[test]
423 fn oversized_data_len_is_rejected() {
424 // data_len prefix declares more than MAX_MESSAGE_SIZE — must be rejected with
425 // None BEFORE any attempt to read/allocate the (possibly absent) payload bytes,
426 // regardless of how many bytes actually follow.
427 let over = (DigMessage::MAX_MESSAGE_SIZE as u32) + 1;
428 let mut wire = vec![20u8, 0]; // msg_type=20, has_id=0
429 wire.extend_from_slice(&over.to_be_bytes());
430 // No payload bytes supplied at all — if the cap check didn't fire first, this
431 // would already fail the length check, so also prove the cap fires even when
432 // enough bytes *are* present.
433 assert!(DigMessage::from_bytes(&wire).is_none());
434 }
435
436 #[test]
437 fn max_data_len_prefix_does_not_panic_and_is_rejected() {
438 // data_len = u32::MAX. On a 32-bit target this used to overflow `offset +
439 // data_len` (unchecked usize addition): a debug build would panic (a
440 // network-reachable crash), a release build would wrap and proceed into a
441 // corrupted slice range. The fix uses checked_add and must return None
442 // uniformly, on every target width, without panicking either way — this test
443 // must pass identically under `cargo test` (checked arithmetic, debug or
444 // release) regardless of pointer width. (Also caught by the MAX_MESSAGE_SIZE
445 // cap now, but the bounds-check arithmetic itself must be overflow-safe
446 // independent of that cap — see the direct offset-overflow test below.)
447 let mut wire = vec![20u8, 0]; // msg_type=20, has_id=0
448 wire.extend_from_slice(&u32::MAX.to_be_bytes());
449 assert!(DigMessage::from_bytes(&wire).is_none());
450 }
451
452 #[test]
453 fn offset_plus_data_len_overflow_is_checked_not_wrapping() {
454 // Regression test for the raw `offset + data_len` addition itself (line 98 in
455 // the pre-fix code / the bounds check below the MAX_MESSAGE_SIZE cap): it must
456 // use checked arithmetic and return None on overflow rather than silently
457 // wrapping (which on a 32-bit usize could previously turn a huge data_len into
458 // a small wrapped value that passed the length check and then panicked on the
459 // slice index instead). usize::MAX stands in for "large enough to overflow
460 // offset + data_len on the current target's pointer width" — on 64-bit this
461 // value is not reachable via a real u32 data_len, so this test exercises the
462 // checked_add call path directly rather than only the u32-parsed case above.
463 let bytes = [20u8, 0, 0, 0, 0, 0]; // msg_type, has_id=0, data_len=0
464 // Sanity: normal zero-length message still decodes fine (no regression).
465 assert!(DigMessage::from_bytes(&bytes).is_some());
466
467 // The u32::MAX case above is the reachable-from-the-wire overflow probe; assert
468 // its result is a clean None (not a panic) for every build profile.
469 let mut wire = vec![1u8, 0];
470 wire.extend_from_slice(&u32::MAX.to_be_bytes());
471 let result = std::panic::catch_unwind(|| DigMessage::from_bytes(&wire));
472 assert!(
473 result.is_ok(),
474 "from_bytes must not panic on data_len = u32::MAX"
475 );
476 assert_eq!(result.unwrap(), None);
477 }
478
479 #[test]
480 fn data_len_one_byte_over_cap_is_rejected_at_exact_boundary() {
481 // Prove the cap is enforced as `> MAX_MESSAGE_SIZE`, not some looser bound, by
482 // checking the smallest possible over-cap value (cap + 1) is rejected even
483 // though the length-prefix parsing itself succeeds (only the cap check fails).
484 let over_by_one = (DigMessage::MAX_MESSAGE_SIZE as u32) + 1;
485 let mut wire = vec![1u8, 0];
486 wire.extend_from_slice(&over_by_one.to_be_bytes());
487 assert!(DigMessage::from_bytes(&wire).is_none());
488 }
489
490 #[test]
491 fn zero_length_data_round_trip() {
492 // data_len = 0 is a valid wire message (e.g. RegisterPeersIntroducer body).
493 let msg = DigMessage::new(64, Some(1), Bytes::default());
494 let wire = msg.to_bytes();
495 let decoded = DigMessage::from_bytes(&wire).expect("zero-length decode");
496 assert_eq!(decoded, msg);
497 assert!(decoded.data.as_ref().is_empty());
498 }
499
500 #[test]
501 fn from_chia_message_preserves_no_id() {
502 // Exercise the id == None branch of from_chia_message + the boundary opcode 199/200.
503 let chia_msg = Message {
504 msg_type: ProtocolMessageTypes::Handshake,
505 id: None,
506 data: Bytes::default(),
507 };
508 let dig = DigMessage::from_chia_message(&chia_msg);
509 assert_eq!(dig.id, None);
510 assert!(dig.is_chia_standard());
511 assert!(!dig.is_dig_extension());
512 }
513
514 #[test]
515 fn dig_extension_boundary_at_200() {
516 // 199 is the last Chia-standard value; 200 is the first DIG extension value.
517 let below = DigMessage::new(199, None, Bytes::default());
518 assert!(below.is_chia_standard());
519 assert!(!below.is_dig_extension());
520
521 let at = DigMessage::new(200, None, Bytes::default());
522 assert!(at.is_dig_extension());
523 assert!(!at.is_chia_standard());
524 }
525}