Skip to main content

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}