Skip to main content

dynomite/proto/
dnode.rs

1//! DNODE wire codec.
2//!
3//! The DNODE protocol frames every Dynomite peer-to-peer message
4//! with a small ASCII header followed by an opaque payload. The
5//! header carries the message id, type tag, encryption/compression
6//! flags, protocol version, same-datacenter bit, an inline data
7//! field (either a one-byte placeholder or an RSA-wrapped AES key),
8//! and the byte length of the payload that follows after `\r\n`.
9//!
10//! The parser is a single state machine driven byte-by-byte. This
11//! module exposes:
12//!
13//! * [`DynParseState`] - the parser's state alphabet.
14//! * [`DmsgType`] - the full set of message-type discriminators.
15//! * [`Dmsg`] - the in-memory header.
16//! * [`DnodeParser`] - the state machine, advanced by feeding bytes
17//!   through [`DnodeParser::step`].
18//! * [`dmsg_write`] / [`dmsg_write_mbuf`] - the canonical encoders.
19//! * [`parse_req`] / [`parse_rsp`] - thin sync wrappers around the
20//!   parser that operate on a [`crate::msg::Msg`]'s mbuf chain.
21//! * [`dmsg_process`] - dispatcher that classifies a parsed
22//!   [`Dmsg`] by type for the cluster layer to act on.
23//!
24//! The encoder accepts an optional `aes_key_payload`: when present,
25//! the caller provides the bytes the inline data field should hold
26//! (the RSA-wrapped AES key produced by [`crate::crypto::Crypto`]).
27//! When absent, the encoder writes the single-byte `'d'` placeholder
28//! used after the first handshake message.
29
30// The parser truncates accumulated decimals into the same fixed
31// bit widths the wire format uses (`u8` for the type and flags,
32// `u32` for the data and payload lengths). The allowance covers
33// these intentional `as u8` / `as u32` casts; out-of-range numerals
34// are surfaced as
35// parse errors elsewhere in the state machine.
36#![allow(clippy::cast_possible_truncation)]
37#![allow(clippy::needless_continue)]
38
39use std::net::SocketAddr;
40
41use crate::core::types::MsgId;
42use crate::io::mbuf::{Mbuf, MbufQueue};
43use crate::msg::message::Msg;
44use crate::msg::message::MsgParseResult;
45
46/// Magic literal that opens every DNODE header.
47pub const MAGIC: &[u8] = b"$2014$";
48
49/// Default protocol version emitted by [`dmsg_write`] (version 10).
50pub const VERSION_10: u8 = 1;
51
52/// CRLF delimiter that separates the DNODE header from its payload.
53pub const CRLF: &[u8] = b"\r\n";
54
55/// Single-byte placeholder used by [`dmsg_write`] when no AES key
56/// payload accompanies the header.
57pub const HANDSHAKE_PLACEHOLDER_DATA: u8 = b'd';
58
59/// Single-byte placeholder used by [`dmsg_write_mbuf`] when no AES
60/// key payload accompanies the header. The gossip path emits `'a'`
61/// instead of `'d'` to disambiguate the two encoder flavours.
62pub const GOSSIP_PLACEHOLDER_DATA: u8 = b'a';
63
64/// Per-frame upper bound on a parser-accepted length field.
65///
66/// The on-the-wire DNODE header carries `mlen` and `plen` as ASCII
67/// decimal numerals that the streaming parser accumulates into a
68/// `u64` before casting to the wire's `u32`. Without an explicit
69/// cap on the accumulator, a single byte run of `1`s inflates
70/// `self.num` past `u32::MAX`; the silent truncation then drives
71/// [`Vec::reserve`] into a multi-gigabyte malloc (libfuzzer 1h soak
72/// finding 2026-06-02, captured at
73/// `crates/fuzz/seeds/dnode_parse/regression-oom-2026-06-02`).
74///
75/// 256 MiB is well above any legitimate DNODE frame on the wire
76/// today (the largest production payloads we have observed are a
77/// few hundred KiB) while staying well below an allocation that
78/// would produce a real OOM under typical RSS budgets. The parser
79/// surfaces [`ParseStep::Error`] the moment any DataLen or
80/// PayloadLen accumulator exceeds this bound.
81pub const MAX_DATA_LEN: u64 = 256 * 1024 * 1024;
82
83/// Parser state transitions.
84///
85/// Each variant is one state of the DNODE frame parser. The numeric
86/// values are stable so external parity tooling can compare them.
87#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
88pub enum DynParseState {
89    /// Initial state; consumes leading whitespace until the magic
90    /// literal is observed.
91    #[default]
92    Start,
93    /// `$2014$` was matched; awaiting the trailing space.
94    MagicString,
95    /// Reading the decimal message id.
96    MsgId,
97    /// Reading the decimal message type.
98    TypeId,
99    /// Reading the decimal flags bit field.
100    BitField,
101    /// Reading the decimal protocol version.
102    Version,
103    /// Reading the same-datacenter digit.
104    SameDc,
105    /// Awaiting the leading `*` before the data length.
106    Star,
107    /// Reading the decimal data length.
108    DataLen,
109    /// Consuming the inline data of `mlen` bytes.
110    Data,
111    /// Skipping spaces before the payload-length marker.
112    SpacesBeforePayloadLen,
113    /// Reading the decimal payload length.
114    PayloadLen,
115    /// Awaiting the LF that terminates the header.
116    CrlfBeforeDone,
117    /// Header complete; payload position recorded.
118    Done,
119    /// Header complete and post-handshake decryption applied.
120    PostDone,
121    /// Recovery state after the parser hit a malformed byte.
122    Unknown,
123}
124
125/// DNODE message type identifier.
126///
127/// The numeric values are stable wire discriminators
128/// because the type travels on the wire as a decimal.
129#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash)]
130#[repr(u8)]
131pub enum DmsgType {
132    /// Unset / unknown type.
133    #[default]
134    Unknown = 0,
135    /// Diagnostic frame (unused on the live wire; kept for parity).
136    Debug = 1,
137    /// Parse-error frame (unused on the live wire; kept for parity).
138    ParseError = 2,
139    /// Datastore request bound for the local DC.
140    Req = 3,
141    /// Datastore request to be forwarded across DCs.
142    ReqForward = 4,
143    /// Datastore response.
144    Res = 5,
145    /// AES key handshake.
146    CryptoHandshake = 6,
147    /// Gossip SYN.
148    GossipSyn = 7,
149    /// Gossip SYN reply.
150    GossipSynReply = 8,
151    /// Gossip ACK.
152    GossipAck = 9,
153    /// Gossip digest SYN.
154    GossipDigestSyn = 10,
155    /// Gossip digest ACK.
156    GossipDigestAck = 11,
157    /// Gossip digest ACK round 2.
158    GossipDigestAck2 = 12,
159    /// Gossip shutdown notice.
160    GossipShutdown = 13,
161    /// Explicit handoff chunk frame.
162    ///
163    /// Carries one chunk of a token-range handoff stream from the
164    /// previous owner of the range to the new owner. Distinct from
165    /// the AAE exchange variants so the receiver can route handoff
166    /// frames to the dedicated handoff coordinator without parsing
167    /// the payload first.
168    HandoffChunk = 14,
169    /// Cluster-wide RediSearch FT.SEARCH request frame.
170    ///
171    /// Sent by the FT.SEARCH coordinator on the node that
172    /// received the client request to every primary peer
173    /// covering the index's key range. The payload encodes a
174    /// broadcast request (table name, serialised query body,
175    /// top-K) - see the `dynomite-search` crate's
176    /// `query_fsm::BroadcastRequest`. Routed by the dispatcher
177    /// to the dedicated FT.SEARCH coordinator FSM instead of
178    /// the data-plane stack so the per-peer query runs against
179    /// the local registry rather than being re-forwarded.
180    FtSearchReq = 15,
181    /// Cluster-wide RediSearch FT.SEARCH reply frame.
182    ///
183    /// Returned by every peer that received a [`Self::FtSearchReq`]
184    /// once its local search completed (or the per-peer
185    /// deadline elapsed). The payload encodes the per-peer
186    /// top-K hit list plus a `timed_out` flag the coordinator
187    /// uses to mark partial results.
188    FtSearchRep = 16,
189    /// Cross-node XA prepare request.
190    ///
191    /// Carries one transaction branch's writes to the peer that
192    /// owns it. The receiver runs start + apply + end + prepare
193    /// against its local resource manager and replies with a
194    /// [`Self::XaVote`]. The payload layout is owned by the
195    /// `dyniak` transaction layer (`dyniak::datastore::xa`).
196    XaPrepare = 17,
197    /// Cross-node XA prepare reply carrying a branch's vote
198    /// (commit / read-only / abort) for a [`Self::XaPrepare`].
199    XaVote = 18,
200    /// Cross-node XA commit request for a durably prepared branch.
201    /// The receiver commits idempotently and replies
202    /// [`Self::XaAck`].
203    XaCommit = 19,
204    /// Cross-node XA rollback request for a branch. The receiver
205    /// rolls back idempotently and replies [`Self::XaAck`].
206    XaRollback = 20,
207    /// Cross-node XA acknowledgement for a [`Self::XaCommit`] or
208    /// [`Self::XaRollback`].
209    XaAck = 21,
210    /// Dyniak cross-node object-replica op.
211    ///
212    /// Carries one fire-and-forget replica write (`Put` / `Del`)
213    /// or read-repair read (`Get`) forwarded from the node that
214    /// received the client request to a peer on the object's
215    /// replica list. The payload is the compact `PeerOp` encoding
216    /// owned by the `dyniak` routing layer
217    /// (`dyniak::proto::replica_wire`). The receiver applies the
218    /// op to its LOCAL object store and does NOT re-forward it, so
219    /// a replica write fans out exactly once. Bypassed by
220    /// [`dmsg_process`] alongside the XA variants so the receive
221    /// path routes it to the dyniak replica sink rather than the
222    /// data-plane stack.
223    RiakReplica = 22,
224    /// Cross-node RAMP-Fast prepare / commit / read leg.
225    ///
226    /// Carries one RAMP transaction's per-peer work (a versioned
227    /// invisible write in PREPARE, a visible-pointer advance in
228    /// COMMIT, or a versioned read round) to the peer that owns the
229    /// key. The payload layout is owned by the `dyniak` RAMP layer
230    /// (`dyniak::ramp_store`). This variant is the wire hook for the
231    /// cross-node fan-out; the single-node coordinator does not yet
232    /// emit it.
233    RampPrepare = 23,
234}
235
236impl DmsgType {
237    /// Build a type from its on-the-wire integer value.
238    ///
239    /// # Examples
240    ///
241    /// ```
242    /// use dynomite::proto::dnode::DmsgType;
243    /// assert_eq!(DmsgType::from_u8(3), Some(DmsgType::Req));
244    /// assert_eq!(DmsgType::from_u8(99), None);
245    /// ```
246    #[must_use]
247    pub fn from_u8(v: u8) -> Option<Self> {
248        Some(match v {
249            0 => DmsgType::Unknown,
250            1 => DmsgType::Debug,
251            2 => DmsgType::ParseError,
252            3 => DmsgType::Req,
253            4 => DmsgType::ReqForward,
254            5 => DmsgType::Res,
255            6 => DmsgType::CryptoHandshake,
256            7 => DmsgType::GossipSyn,
257            8 => DmsgType::GossipSynReply,
258            9 => DmsgType::GossipAck,
259            10 => DmsgType::GossipDigestSyn,
260            11 => DmsgType::GossipDigestAck,
261            12 => DmsgType::GossipDigestAck2,
262            13 => DmsgType::GossipShutdown,
263            14 => DmsgType::HandoffChunk,
264            15 => DmsgType::FtSearchReq,
265            16 => DmsgType::FtSearchRep,
266            17 => DmsgType::XaPrepare,
267            18 => DmsgType::XaVote,
268            19 => DmsgType::XaCommit,
269            20 => DmsgType::XaRollback,
270            21 => DmsgType::XaAck,
271            23 => DmsgType::RampPrepare,
272            22 => DmsgType::RiakReplica,
273            _ => return None,
274        })
275    }
276
277    /// Numeric on-the-wire value.
278    ///
279    /// # Examples
280    ///
281    /// ```
282    /// use dynomite::proto::dnode::DmsgType;
283    /// assert_eq!(DmsgType::CryptoHandshake.as_u8(), 6);
284    /// ```
285    #[must_use]
286    pub const fn as_u8(self) -> u8 {
287        self as u8
288    }
289}
290
291/// Encryption bit in [`Dmsg::flags`].
292pub const DMSG_FLAG_ENCRYPTED: u8 = 0x1;
293
294/// Compression bit in [`Dmsg::flags`].
295pub const DMSG_FLAG_COMPRESSED: u8 = 0x2;
296
297/// Parsed DNODE header.
298///
299/// `data` and `payload` hold copies of the on-the-wire bytes. The
300/// encoder side fills both before emitting; the parser fills them as
301/// it advances through the state machine.
302#[derive(Clone, Debug, Default, Eq, PartialEq)]
303pub struct Dmsg {
304    /// Message id.
305    pub id: MsgId,
306    /// Message type.
307    pub ty: DmsgType,
308    /// Flag bit field; encryption is bit 0, compression is bit 1.
309    pub flags: u8,
310    /// Protocol version.
311    pub version: u8,
312    /// True when sender and receiver share a datacenter.
313    pub same_dc: bool,
314    /// Source address recorded by the recv path. The parser leaves
315    /// it `None`; a caller with the connection state may stamp it
316    /// after parsing.
317    pub source_address: Option<SocketAddr>,
318    /// Length (in bytes) of the inline data field.
319    pub mlen: u32,
320    /// Inline data: either the single-byte placeholder or the
321    /// RSA-wrapped AES key during the crypto handshake.
322    pub data: Vec<u8>,
323    /// Length (in bytes) of the trailing payload framed by the
324    /// header.
325    pub plen: u32,
326    /// Payload bytes, if collected by the parser.
327    pub payload: Vec<u8>,
328}
329
330impl Dmsg {
331    /// Construct an empty `Dmsg` with all fields at their defaults.
332    ///
333    /// # Examples
334    ///
335    /// ```
336    /// use dynomite::proto::dnode::{Dmsg, DmsgType, VERSION_10};
337    /// let d = Dmsg::new();
338    /// assert_eq!(d.ty, DmsgType::Unknown);
339    /// assert_eq!(d.version, VERSION_10);
340    /// assert!(d.same_dc);
341    /// ```
342    #[must_use]
343    pub fn new() -> Self {
344        Self {
345            id: 0,
346            ty: DmsgType::Unknown,
347            flags: 0,
348            version: VERSION_10,
349            same_dc: true,
350            source_address: None,
351            mlen: 0,
352            data: Vec::new(),
353            plen: 0,
354            payload: Vec::new(),
355        }
356    }
357
358    /// True when the encryption flag is set.
359    ///
360    /// # Examples
361    ///
362    /// ```
363    /// use dynomite::proto::dnode::{Dmsg, DMSG_FLAG_ENCRYPTED};
364    /// let mut d = Dmsg::new();
365    /// d.flags = DMSG_FLAG_ENCRYPTED;
366    /// assert!(d.is_encrypted());
367    /// ```
368    #[must_use]
369    pub fn is_encrypted(&self) -> bool {
370        self.flags & DMSG_FLAG_ENCRYPTED != 0
371    }
372
373    /// True when the compression flag is set.
374    ///
375    /// # Examples
376    ///
377    /// ```
378    /// use dynomite::proto::dnode::{Dmsg, DMSG_FLAG_COMPRESSED};
379    /// let mut d = Dmsg::new();
380    /// d.flags = DMSG_FLAG_COMPRESSED;
381    /// assert!(d.is_compressed());
382    /// ```
383    #[must_use]
384    pub fn is_compressed(&self) -> bool {
385        self.flags & DMSG_FLAG_COMPRESSED != 0
386    }
387}
388
389/// Result of a single [`DnodeParser::step`] invocation.
390#[derive(Copy, Clone, Debug, Eq, PartialEq)]
391pub enum ParseStep {
392    /// More bytes are required to advance the state machine. The
393    /// `consumed` field records how many of the input bytes the
394    /// parser already absorbed.
395    NeedMore {
396        /// Number of input bytes the parser absorbed before it
397        /// stopped waiting for more.
398        consumed: usize,
399    },
400    /// The header (up to and including the trailing LF) has been
401    /// parsed. The `consumed` field records the offset just past
402    /// the LF, so the caller can read the payload starting at that
403    /// index.
404    HeaderDone {
405        /// Offset just past the trailing LF.
406        consumed: usize,
407    },
408    /// The parser hit an unrecoverable bad byte. The caller should
409    /// drop the buffer (or split it at `consumed`) and reset.
410    Error {
411        /// Offset of the byte that triggered the error.
412        consumed: usize,
413    },
414}
415
416/// Errors that can be raised when encoding or parsing a DNODE
417/// header without going through the streaming state machine.
418#[derive(Copy, Clone, Debug, Eq, PartialEq)]
419#[non_exhaustive]
420pub enum DnodeError {
421    /// Buffer too small to encode the header.
422    OutOfSpace,
423    /// Header does not begin with the magic literal.
424    BadMagic,
425    /// Numeric field could not be parsed.
426    BadNumber,
427    /// Trailing CRLF missing.
428    MissingCrlf,
429    /// Type discriminator out of range.
430    BadType,
431    /// Inline data shorter than the declared `mlen`.
432    TruncatedData,
433}
434
435/// Streaming DNODE header parser.
436#[derive(Debug)]
437pub struct DnodeParser {
438    state: DynParseState,
439    num: u64,
440    dmsg: Dmsg,
441    data_remaining: u32,
442    magic_progress: u8,
443    /// Whether the previous byte was an ASCII digit. The header
444    /// state machine only transitions out of the numeric header
445    /// fields (MSG_ID, TYPE_ID, BIT_FIELD, VERSION, SAME_DC) when
446    /// the byte immediately preceding the field-terminating space
447    /// was a digit; the parser reproduces this guard so extra
448    /// whitespace (or any other non-digit byte) is rejected with
449    /// the wire protocol's strictness.
450    prev_was_digit: bool,
451}
452
453impl DnodeParser {
454    /// Build a fresh parser positioned at [`DynParseState::Start`].
455    ///
456    /// # Examples
457    ///
458    /// ```
459    /// use dynomite::proto::dnode::{DnodeParser, DynParseState};
460    /// let p = DnodeParser::new();
461    /// assert_eq!(p.state(), DynParseState::Start);
462    /// ```
463    #[must_use]
464    pub fn new() -> Self {
465        Self {
466            state: DynParseState::Start,
467            num: 0,
468            dmsg: Dmsg::new(),
469            data_remaining: 0,
470            magic_progress: 0,
471            prev_was_digit: false,
472        }
473    }
474
475    /// Reset the parser to [`DynParseState::Start`] with a fresh
476    /// accumulator [`Dmsg`].
477    pub fn reset(&mut self) {
478        *self = Self::new();
479    }
480
481    /// Current state.
482    #[must_use]
483    pub fn state(&self) -> DynParseState {
484        self.state
485    }
486
487    /// Borrow the partial [`Dmsg`].
488    #[must_use]
489    pub fn dmsg(&self) -> &Dmsg {
490        &self.dmsg
491    }
492
493    /// Move the parsed [`Dmsg`] out of the parser. Only meaningful
494    /// after a [`ParseStep::HeaderDone`] step.
495    pub fn take_dmsg(&mut self) -> Dmsg {
496        let mut out = Dmsg::new();
497        std::mem::swap(&mut out, &mut self.dmsg);
498        self.state = DynParseState::Start;
499        self.num = 0;
500        self.data_remaining = 0;
501        self.magic_progress = 0;
502        self.prev_was_digit = false;
503        out
504    }
505
506    /// Feed `input` to the parser. The parser advances as far as it
507    /// can and returns one of the three [`ParseStep`] variants.
508    ///
509    /// The state machine is byte-driven and can be reentered with a
510    /// fresh slice when [`ParseStep::NeedMore`] indicates the input
511    /// was truncated mid-header.
512    ///
513    /// # Examples
514    ///
515    /// ```
516    /// use dynomite::proto::dnode::{DnodeParser, ParseStep};
517    /// let mut p = DnodeParser::new();
518    /// let bytes = b"$2014$ 1 3 0 1 1 *1 d *0\r\n";
519    /// match p.step(bytes) {
520    ///     ParseStep::HeaderDone { consumed } => assert_eq!(consumed, bytes.len()),
521    ///     other => panic!("unexpected: {other:?}"),
522    /// }
523    /// ```
524    /// The state machine intentionally stays in one function:
525    /// splitting the per-state arms across helpers would obscure
526    /// the byte-by-byte control flow.
527    #[allow(clippy::too_many_lines)]
528    pub fn step(&mut self, input: &[u8]) -> ParseStep {
529        let mut idx = 0usize;
530        while idx < input.len() {
531            let ch = input[idx];
532            match self.state {
533                DynParseState::Start => {
534                    // Phase 1: skip leading whitespace.
535                    if self.magic_progress == 0 {
536                        if ch == b' ' {
537                            idx += 1;
538                            continue;
539                        }
540                        if ch != b'$' {
541                            return ParseStep::Error { consumed: idx };
542                        }
543                    }
544                    // Phase 2: byte-incrementally match the magic
545                    // literal so split inputs are tolerated.
546                    let want = MAGIC[usize::from(self.magic_progress)];
547                    if ch != want {
548                        return ParseStep::Error { consumed: idx };
549                    }
550                    self.magic_progress += 1;
551                    idx += 1;
552                    if usize::from(self.magic_progress) == MAGIC.len() {
553                        self.state = DynParseState::MagicString;
554                        self.magic_progress = 0;
555                    }
556                    continue;
557                }
558                DynParseState::MagicString => {
559                    if ch == b' ' {
560                        self.state = DynParseState::MsgId;
561                        self.num = 0;
562                        idx += 1;
563                        continue;
564                    }
565                    return ParseStep::Error { consumed: idx };
566                }
567                DynParseState::MsgId => {
568                    // DYN_MSG_ID state: digits accumulate, a single
569                    // space terminates the field but only when the
570                    // byte immediately
571                    // before it was a digit. Anything else is
572                    // rejected: the streaming parser surfaces an
573                    // error so the caller can drop the buffer.
574                    if ch.is_ascii_digit() {
575                        self.num = self.num.wrapping_mul(10) + u64::from(ch - b'0');
576                        self.prev_was_digit = true;
577                        idx += 1;
578                        continue;
579                    }
580                    if ch == b' ' && self.prev_was_digit {
581                        self.dmsg.id = self.num;
582                        self.state = DynParseState::TypeId;
583                        self.num = 0;
584                        self.prev_was_digit = false;
585                        idx += 1;
586                        continue;
587                    }
588                    return ParseStep::Error { consumed: idx };
589                }
590                DynParseState::TypeId => {
591                    if ch.is_ascii_digit() {
592                        self.num = self.num.wrapping_mul(10) + u64::from(ch - b'0');
593                        self.prev_was_digit = true;
594                        idx += 1;
595                        continue;
596                    }
597                    if ch == b' ' && self.prev_was_digit {
598                        self.dmsg.ty = match DmsgType::from_u8(self.num as u8) {
599                            Some(t) => t,
600                            None => return ParseStep::Error { consumed: idx },
601                        };
602                        self.state = DynParseState::BitField;
603                        self.num = 0;
604                        self.prev_was_digit = false;
605                        idx += 1;
606                        continue;
607                    }
608                    return ParseStep::Error { consumed: idx };
609                }
610                DynParseState::BitField => {
611                    if ch.is_ascii_digit() {
612                        self.num = self.num.wrapping_mul(10) + u64::from(ch - b'0');
613                        self.prev_was_digit = true;
614                        idx += 1;
615                        continue;
616                    }
617                    if ch == b' ' && self.prev_was_digit {
618                        self.dmsg.flags = (self.num as u8) & 0xF;
619                        self.state = DynParseState::Version;
620                        self.num = 0;
621                        self.prev_was_digit = false;
622                        idx += 1;
623                        continue;
624                    }
625                    return ParseStep::Error { consumed: idx };
626                }
627                DynParseState::Version => {
628                    if ch.is_ascii_digit() {
629                        self.num = self.num.wrapping_mul(10) + u64::from(ch - b'0');
630                        self.prev_was_digit = true;
631                        idx += 1;
632                        continue;
633                    }
634                    if ch == b' ' && self.prev_was_digit {
635                        self.dmsg.version = self.num as u8;
636                        self.state = DynParseState::SameDc;
637                        self.num = 0;
638                        self.prev_was_digit = false;
639                        idx += 1;
640                        continue;
641                    }
642                    return ParseStep::Error { consumed: idx };
643                }
644                DynParseState::SameDc => {
645                    if ch.is_ascii_digit() {
646                        self.dmsg.same_dc = ch != b'0';
647                        self.prev_was_digit = true;
648                        idx += 1;
649                        continue;
650                    }
651                    if ch == b' ' && self.prev_was_digit {
652                        self.state = DynParseState::DataLen;
653                        self.num = 0;
654                        self.prev_was_digit = false;
655                        idx += 1;
656                        continue;
657                    }
658                    return ParseStep::Error { consumed: idx };
659                }
660                DynParseState::Star | DynParseState::DataLen => {
661                    if ch == b'*' {
662                        idx += 1;
663                        continue;
664                    }
665                    if ch.is_ascii_digit() {
666                        self.num = self.num.wrapping_mul(10) + u64::from(ch - b'0');
667                        // Reject pathological-size length fields
668                        // before the cast to u32 wraps and a
669                        // downstream Vec::reserve allocates the
670                        // wrapped value as bytes. See MAX_DATA_LEN.
671                        if self.num > MAX_DATA_LEN {
672                            return ParseStep::Error { consumed: idx };
673                        }
674                        idx += 1;
675                        continue;
676                    }
677                    if ch == b' ' && self.state == DynParseState::DataLen {
678                        self.dmsg.mlen = self.num as u32;
679                        self.data_remaining = self.dmsg.mlen;
680                        self.dmsg.data.clear();
681                        self.dmsg.data.reserve(self.data_remaining as usize);
682                        self.state = DynParseState::Data;
683                        self.num = 0;
684                        idx += 1;
685                        continue;
686                    }
687                    return ParseStep::Error { consumed: idx };
688                }
689                DynParseState::Data => {
690                    if self.data_remaining == 0 {
691                        self.state = DynParseState::SpacesBeforePayloadLen;
692                        continue;
693                    }
694                    let take = std::cmp::min(self.data_remaining as usize, input.len() - idx);
695                    self.dmsg.data.extend_from_slice(&input[idx..idx + take]);
696                    self.data_remaining -= take as u32;
697                    idx += take;
698                    if self.data_remaining == 0 {
699                        self.state = DynParseState::SpacesBeforePayloadLen;
700                    }
701                    continue;
702                }
703                DynParseState::SpacesBeforePayloadLen => {
704                    if ch == b' ' {
705                        idx += 1;
706                        continue;
707                    }
708                    if ch == b'*' {
709                        self.state = DynParseState::PayloadLen;
710                        self.num = 0;
711                        idx += 1;
712                        continue;
713                    }
714                    return ParseStep::Error { consumed: idx };
715                }
716                DynParseState::PayloadLen => {
717                    if ch.is_ascii_digit() {
718                        self.num = self.num.wrapping_mul(10) + u64::from(ch - b'0');
719                        if self.num > MAX_DATA_LEN {
720                            return ParseStep::Error { consumed: idx };
721                        }
722                        idx += 1;
723                        continue;
724                    }
725                    if ch == b'\r' {
726                        self.dmsg.plen = self.num as u32;
727                        self.state = DynParseState::CrlfBeforeDone;
728                        self.num = 0;
729                        idx += 1;
730                        continue;
731                    }
732                    return ParseStep::Error { consumed: idx };
733                }
734                DynParseState::CrlfBeforeDone => {
735                    if ch == b'\n' {
736                        self.state = DynParseState::Done;
737                        idx += 1;
738                        return ParseStep::HeaderDone { consumed: idx };
739                    }
740                    return ParseStep::Error { consumed: idx };
741                }
742                DynParseState::Done | DynParseState::PostDone | DynParseState::Unknown => {
743                    return ParseStep::HeaderDone { consumed: idx };
744                }
745            }
746        }
747        ParseStep::NeedMore { consumed: idx }
748    }
749}
750
751impl Default for DnodeParser {
752    fn default() -> Self {
753        Self::new()
754    }
755}
756
757/// Encode a DNODE header into the writable region of `mbuf`.
758///
759/// `aes_key_payload`, when `Some`, is written as the inline data
760/// field; this is how the crypto handshake transports the
761/// RSA-wrapped AES key. When `None`, a single-byte `'d'` placeholder
762/// is emitted.
763///
764/// `flags` is taken verbatim (the encryption bit must be set by the
765/// caller, alongside any compression bit).
766///
767/// The encoder writes the entire header as a single contiguous
768/// region; if `mbuf` lacks the necessary capacity,
769/// [`DnodeError::OutOfSpace`] is returned.
770///
771/// # Examples
772///
773/// ```
774/// use dynomite::io::mbuf::MbufPool;
775/// use dynomite::proto::dnode::{dmsg_write, DmsgType};
776///
777/// let pool = MbufPool::default();
778/// let mut buf = pool.get();
779/// dmsg_write(
780///     &mut buf,
781///     /* msg_id */ 1,
782///     DmsgType::Req,
783///     /* flags */ 0,
784///     /* same_dc */ true,
785///     /* aes_key_payload */ None,
786///     /* plen */ 0,
787/// )
788/// .unwrap();
789/// assert!(buf.readable().starts_with(b"   $2014$ 1 3 0"));
790/// ```
791pub fn dmsg_write(
792    mbuf: &mut Mbuf,
793    msg_id: MsgId,
794    ty: DmsgType,
795    flags: u8,
796    same_dc: bool,
797    aes_key_payload: Option<&[u8]>,
798    plen: u32,
799) -> Result<(), DnodeError> {
800    let header = build_header(msg_id, ty, flags, same_dc, aes_key_payload, plen, false);
801    write_chain(mbuf, &header)
802}
803
804/// Encode a gossip-flavored DNODE header.
805///
806/// Differs from [`dmsg_write`] only in the placeholder byte emitted
807/// when no AES key payload accompanies the header (`'a'` instead of
808/// `'d'`).
809///
810/// # Examples
811///
812/// ```
813/// use dynomite::io::mbuf::MbufPool;
814/// use dynomite::proto::dnode::{dmsg_write_mbuf, DmsgType};
815///
816/// let pool = MbufPool::default();
817/// let mut buf = pool.get();
818/// dmsg_write_mbuf(
819///     &mut buf,
820///     /* msg_id */ 5,
821///     DmsgType::GossipSyn,
822///     /* flags */ 0,
823///     /* same_dc */ true,
824///     /* aes_key_payload */ None,
825///     /* plen */ 64,
826/// )
827/// .unwrap();
828/// assert!(buf.readable().contains(&b'a'));
829/// ```
830pub fn dmsg_write_mbuf(
831    mbuf: &mut Mbuf,
832    msg_id: MsgId,
833    ty: DmsgType,
834    flags: u8,
835    same_dc: bool,
836    aes_key_payload: Option<&[u8]>,
837    plen: u32,
838) -> Result<(), DnodeError> {
839    let header = build_header(msg_id, ty, flags, same_dc, aes_key_payload, plen, true);
840    write_chain(mbuf, &header)
841}
842
843fn build_header(
844    msg_id: MsgId,
845    ty: DmsgType,
846    flags: u8,
847    same_dc: bool,
848    aes_key_payload: Option<&[u8]>,
849    plen: u32,
850    gossip_placeholder: bool,
851) -> Vec<u8> {
852    use std::io::Write as _;
853    let mut buf: Vec<u8> = Vec::with_capacity(64);
854    // Three leading spaces are part of the magic literal as written
855    // on the wire; the parser tolerates and skips them in DYN_START.
856    buf.extend_from_slice(b"   $2014$ ");
857    let _ = write!(buf, "{msg_id}");
858    buf.push(b' ');
859    let _ = write!(buf, "{}", ty.as_u8());
860    buf.push(b' ');
861    let _ = write!(buf, "{}", flags & 0xF);
862    buf.push(b' ');
863    let _ = write!(buf, "{VERSION_10}");
864    buf.push(b' ');
865    buf.push(if same_dc { b'1' } else { b'0' });
866    buf.push(b' ');
867    buf.push(b'*');
868    if let Some(payload) = aes_key_payload {
869        let _ = write!(buf, "{}", payload.len());
870        buf.push(b' ');
871        buf.extend_from_slice(payload);
872    } else {
873        buf.extend_from_slice(b"1 ");
874        buf.push(if gossip_placeholder {
875            GOSSIP_PLACEHOLDER_DATA
876        } else {
877            HANDSHAKE_PLACEHOLDER_DATA
878        });
879    }
880    buf.push(b' ');
881    buf.push(b'*');
882    let _ = write!(buf, "{plen}");
883    buf.extend_from_slice(CRLF);
884    buf
885}
886
887fn write_chain(mbuf: &mut Mbuf, payload: &[u8]) -> Result<(), DnodeError> {
888    if mbuf.remaining() < payload.len() {
889        return Err(DnodeError::OutOfSpace);
890    }
891    let n = mbuf.recv(payload);
892    debug_assert_eq!(n, payload.len());
893    Ok(())
894}
895
896/// Sync byte parser that drives a request message's DNODE header
897/// state machine.
898///
899/// The parser walks the contiguous bytes spanning the message's
900/// mbuf chain and updates the [`Msg`] in place. On a fully parsed
901/// header, the function attaches the [`Dmsg`] to the message and
902/// returns `MsgParseResult::Ok`. On truncated input the parser
903/// returns `MsgParseResult::Again`. On invalid bytes the parser
904/// records `MsgParseResult::Error` and surfaces the same value.
905///
906/// This is the synchronous header parser. The async wrapping
907/// (per-connection task scheduling, decryption hand-off when the
908/// encryption bit is set) is driven by [`crate::net`].
909///
910/// # Examples
911///
912/// ```
913/// use dynomite::io::mbuf::MbufPool;
914/// use dynomite::msg::{Msg, MsgType};
915/// use dynomite::proto::dnode::{parse_req, DmsgType, DynParseState};
916///
917/// let pool = MbufPool::default();
918/// let mut msg = Msg::new(0, MsgType::Unknown, true);
919/// let mut mb = pool.get();
920/// mb.recv(b"$2014$ 1 3 0 1 1 *1 d *0\r\n");
921/// msg.mbufs_mut().push_back(mb);
922/// msg.recompute_mlen();
923/// let result = parse_req(&mut msg);
924/// assert_eq!(msg.dyn_parse_state(), DynParseState::Done);
925/// assert_eq!(msg.dmsg().unwrap().ty, DmsgType::Req);
926/// drop(result);
927/// ```
928pub fn parse_req(msg: &mut Msg) -> MsgParseResult {
929    parse_msg(msg, false)
930}
931
932/// Sync byte parser counterpart to [`parse_req`] for response
933/// messages.
934///
935/// # Examples
936///
937/// ```
938/// use dynomite::io::mbuf::MbufPool;
939/// use dynomite::msg::{Msg, MsgType};
940/// use dynomite::proto::dnode::{parse_rsp, DmsgType};
941///
942/// let pool = MbufPool::default();
943/// let mut msg = Msg::new(0, MsgType::Unknown, false);
944/// let mut mb = pool.get();
945/// mb.recv(b"$2014$ 9 5 0 1 1 *1 d *0\r\n");
946/// msg.mbufs_mut().push_back(mb);
947/// msg.recompute_mlen();
948/// let _ = parse_rsp(&mut msg);
949/// assert_eq!(msg.dmsg().unwrap().ty, DmsgType::Res);
950/// ```
951pub fn parse_rsp(msg: &mut Msg) -> MsgParseResult {
952    parse_msg(msg, true)
953}
954
955fn parse_msg(msg: &mut Msg, _is_response: bool) -> MsgParseResult {
956    // Flatten the chain into a single buffer for parsing. The
957    // parser tolerates splits at arbitrary boundaries, but this
958    // entry point drives the state machine over one contiguous
959    // slice rather than streaming chunk by chunk.
960    let mut bytes: Vec<u8> = Vec::with_capacity(msg.mbufs().total_len());
961    for mbuf in msg.mbufs() {
962        bytes.extend_from_slice(mbuf.readable());
963    }
964
965    let mut parser = DnodeParser::new();
966    parser.state = msg.dyn_parse_state();
967    match parser.step(&bytes) {
968        ParseStep::HeaderDone { .. } => {
969            let dmsg = parser.take_dmsg();
970            msg.set_dyn_parse_state(DynParseState::Done);
971            msg.set_dmsg(dmsg);
972            msg.set_parse_result(MsgParseResult::Ok);
973            MsgParseResult::Ok
974        }
975        ParseStep::NeedMore { .. } => {
976            msg.set_dyn_parse_state(parser.state);
977            msg.set_parse_result(MsgParseResult::Again);
978            MsgParseResult::Again
979        }
980        ParseStep::Error { .. } => {
981            msg.set_dyn_parse_state(DynParseState::Unknown);
982            msg.set_parse_result(MsgParseResult::Error);
983            MsgParseResult::Error
984        }
985    }
986}
987
988/// Outcome of [`dmsg_process`].
989///
990/// `Bypass` means the header has been recognised as control traffic
991/// and the cluster layer should not pass the message further down
992/// the protocol stack.
993#[derive(Copy, Clone, Debug, Eq, PartialEq)]
994pub enum DmsgDispatch {
995    /// Frame consumed by a control-plane handler.
996    Bypass,
997    /// Frame should continue through the data-plane stack.
998    Forward,
999}
1000
1001/// Classify a parsed [`Dmsg`] as control-plane traffic the cluster
1002/// layer should consume directly (`Bypass`), or data-plane traffic
1003/// that should continue through the protocol stack (`Forward`).
1004///
1005/// This decides the message-shape routing only; decoding the
1006/// forwarded gossip variants into cluster events is done by the
1007/// cluster layer, not here.
1008///
1009/// # Examples
1010///
1011/// ```
1012/// use dynomite::proto::dnode::{dmsg_process, Dmsg, DmsgDispatch, DmsgType};
1013///
1014/// let mut d = Dmsg::new();
1015/// d.ty = DmsgType::CryptoHandshake;
1016/// assert_eq!(dmsg_process(&d), DmsgDispatch::Bypass);
1017///
1018/// // Gossip variants other than SYN / SYN_REPLY fall through.
1019/// d.ty = DmsgType::GossipShutdown;
1020/// assert_eq!(dmsg_process(&d), DmsgDispatch::Forward);
1021///
1022/// d.ty = DmsgType::Req;
1023/// assert_eq!(dmsg_process(&d), DmsgDispatch::Forward);
1024/// ```
1025#[must_use]
1026pub fn dmsg_process(dmsg: &Dmsg) -> DmsgDispatch {
1027    // Dmsg dispatch table: only CRYPTO_HANDSHAKE,
1028    // GOSSIP_SYN, and GOSSIP_SYN_REPLY short-circuit; the other
1029    // gossip variants (ACK, DIGEST_SYN, DIGEST_ACK, DIGEST_ACK2,
1030    // SHUTDOWN) fall through to the default branch and are
1031    // forwarded to the cluster handlers. HANDOFF_CHUNK frames are
1032    // control-plane traffic for the explicit handoff coordinator
1033    // and bypass the data-plane stack alongside the crypto / gossip
1034    // handshake variants.
1035    match dmsg.ty {
1036        DmsgType::CryptoHandshake
1037        | DmsgType::GossipSyn
1038        | DmsgType::GossipSynReply
1039        | DmsgType::HandoffChunk
1040        | DmsgType::FtSearchReq
1041        | DmsgType::FtSearchRep
1042        | DmsgType::XaPrepare
1043        | DmsgType::XaVote
1044        | DmsgType::XaCommit
1045        | DmsgType::XaRollback
1046        | DmsgType::XaAck
1047        | DmsgType::RampPrepare
1048        | DmsgType::RiakReplica => DmsgDispatch::Bypass,
1049        _ => DmsgDispatch::Forward,
1050    }
1051}
1052
1053/// Drain `chain` into a contiguous `Vec<u8>` recycling each chunk
1054/// back to `pool`. Useful for tests and for callers that need a
1055/// flat buffer of decrypted payload bytes.
1056pub fn flatten_chain(chain: &mut MbufQueue) -> Vec<u8> {
1057    let mut out = Vec::with_capacity(chain.total_len());
1058    while let Some(buf) = chain.pop_front() {
1059        out.extend_from_slice(buf.readable());
1060    }
1061    out
1062}
1063
1064/// Peer-handshake control payload exchanged on top of a
1065/// [`DmsgType::GossipSyn`] frame.
1066///
1067/// Today the handshake carries the cluster-wide capability
1068/// advertisement (see [`crate::cluster::capability`]). Future
1069/// fields will be appended as new typed records; older peers
1070/// ignore unknown trailing bytes.
1071///
1072/// # Wire format
1073///
1074/// ```text
1075/// magic(4) = "DHS1"
1076/// flags(2) = 0
1077/// CapabilityAd (length-prefixed, see
1078///                `CapabilityAd::encode` for the exact layout)
1079/// ```
1080///
1081/// All multi-byte integers are little-endian. The encoding uses
1082/// only the standard library; no external codec is pulled in.
1083///
1084/// # Examples
1085///
1086/// ```
1087/// use dynomite::cluster::capability::{CapabilityAd, CapabilityAdEntry};
1088/// use dynomite::proto::dnode::Handshake;
1089/// let ad = CapabilityAd::from_entries(vec![
1090///     CapabilityAdEntry::new("framing".into(), vec![vec![1, 0, 0, 0]]),
1091/// ]);
1092/// let hs = Handshake::new(ad.clone());
1093/// let bytes = hs.encode();
1094/// let back = Handshake::decode(&bytes).unwrap();
1095/// assert_eq!(back.capabilities(), &ad);
1096/// ```
1097#[derive(Clone, Debug, Default, Eq, PartialEq)]
1098pub struct Handshake {
1099    capabilities: crate::cluster::capability::CapabilityAd,
1100}
1101
1102impl Handshake {
1103    /// Magic literal that opens every handshake payload.
1104    pub const MAGIC: [u8; 4] = *b"DHS1";
1105
1106    /// Build a handshake carrying `capabilities`.
1107    #[must_use]
1108    pub fn new(capabilities: crate::cluster::capability::CapabilityAd) -> Self {
1109        Self { capabilities }
1110    }
1111
1112    /// Borrow the embedded capability advertisement.
1113    #[must_use]
1114    pub fn capabilities(&self) -> &crate::cluster::capability::CapabilityAd {
1115        &self.capabilities
1116    }
1117
1118    /// Consume the handshake and return the embedded
1119    /// advertisement.
1120    #[must_use]
1121    pub fn into_capabilities(self) -> crate::cluster::capability::CapabilityAd {
1122        self.capabilities
1123    }
1124
1125    /// Serialise the handshake to a length-prefixed byte
1126    /// stream.
1127    #[must_use]
1128    pub fn encode(&self) -> Vec<u8> {
1129        let cap_bytes = self.capabilities.encode();
1130        let mut out = Vec::with_capacity(Self::MAGIC.len() + 2 + cap_bytes.len());
1131        out.extend_from_slice(&Self::MAGIC);
1132        out.extend_from_slice(&0u16.to_le_bytes()); // flags
1133        out.extend_from_slice(&cap_bytes);
1134        out
1135    }
1136
1137    /// Inverse of [`Handshake::encode`]. Surfaces a typed error
1138    /// when the magic / version is wrong or the embedded
1139    /// advertisement is malformed.
1140    pub fn decode(bytes: &[u8]) -> Result<Self, crate::cluster::capability::CapabilityCodecError> {
1141        use crate::cluster::capability::CapabilityCodecError;
1142        if bytes.len() < Self::MAGIC.len() + 2 {
1143            return Err(CapabilityCodecError::Truncated);
1144        }
1145        if bytes[..Self::MAGIC.len()] != Self::MAGIC {
1146            return Err(CapabilityCodecError::BadMagic);
1147        }
1148        // Flags are reserved; the only currently legal value is
1149        // zero. Any non-zero value is reserved for future use
1150        // and rejected here so older builds fail closed.
1151        let flags_off = Self::MAGIC.len();
1152        let flags = u16::from_le_bytes([bytes[flags_off], bytes[flags_off + 1]]);
1153        if flags != 0 {
1154            return Err(CapabilityCodecError::BadMagic);
1155        }
1156        let cap_bytes = &bytes[flags_off + 2..];
1157        let capabilities = crate::cluster::capability::CapabilityAd::decode(cap_bytes)?;
1158        Ok(Self { capabilities })
1159    }
1160
1161    /// Number of bytes the handshake's fixed-size prefix
1162    /// occupies before the embedded advertisement. Useful in
1163    /// tests that assert the on-the-wire delta.
1164    #[must_use]
1165    pub const fn header_len() -> usize {
1166        Self::MAGIC.len() + 2
1167    }
1168}
1169
1170#[cfg(test)]
1171mod tests {
1172    use super::*;
1173    use crate::io::mbuf::MbufPool;
1174
1175    #[test]
1176    fn parse_simple_req() {
1177        let mut p = DnodeParser::new();
1178        let bytes = b"$2014$ 1 3 0 1 1 *1 d *0\r\n";
1179        match p.step(bytes) {
1180            ParseStep::HeaderDone { consumed } => assert_eq!(consumed, bytes.len()),
1181            other => panic!("unexpected: {other:?}"),
1182        }
1183        let d = p.take_dmsg();
1184        assert_eq!(d.id, 1);
1185        assert_eq!(d.ty, DmsgType::Req);
1186        assert_eq!(d.flags, 0);
1187        assert_eq!(d.version, 1);
1188        assert!(d.same_dc);
1189        assert_eq!(d.mlen, 1);
1190        assert_eq!(d.data, b"d");
1191        assert_eq!(d.plen, 0);
1192    }
1193
1194    #[test]
1195    fn parse_payload_len() {
1196        let mut p = DnodeParser::new();
1197        let bytes = b"$2014$ 2 3 0 1 1 *1 d *413\r\n";
1198        match p.step(bytes) {
1199            ParseStep::HeaderDone { consumed } => assert_eq!(consumed, bytes.len()),
1200            other => panic!("unexpected: {other:?}"),
1201        }
1202        assert_eq!(p.dmsg().plen, 413);
1203    }
1204
1205    #[test]
1206    fn parse_three_back_to_back() {
1207        let mut input: Vec<u8> = Vec::new();
1208        input.extend_from_slice(b"$2014$ 1 3 0 1 1 *1 d *0\r\n");
1209        input.extend_from_slice(b"some redis bytes here ignored");
1210        input.extend_from_slice(b"$2014$ 2 3 0 1 1 *1 d *3\r\nABC");
1211        input.extend_from_slice(b"$2014$ 3 3 0 1 1 *1 d *0\r\n");
1212        let mut p = DnodeParser::new();
1213        let mut idx = 0;
1214        let mut count = 0;
1215        while idx < input.len() {
1216            match p.step(&input[idx..]) {
1217                ParseStep::HeaderDone { consumed } => {
1218                    let d = p.take_dmsg();
1219                    count += 1;
1220                    let after_header = idx + consumed;
1221                    if count == 1 {
1222                        assert_eq!(d.id, 1);
1223                        // skip past the redis bytes by scanning for the next '$'
1224                        idx = input[after_header..]
1225                            .iter()
1226                            .position(|&b| b == b'$')
1227                            .map_or(input.len(), |n| after_header + n);
1228                    } else if count == 2 {
1229                        assert_eq!(d.id, 2);
1230                        assert_eq!(d.plen, 3);
1231                        idx = after_header + d.plen as usize;
1232                    } else {
1233                        assert_eq!(d.id, 3);
1234                        idx = after_header;
1235                    }
1236                    p.reset();
1237                }
1238                ParseStep::NeedMore { .. } => {
1239                    break;
1240                }
1241                ParseStep::Error { consumed } => {
1242                    idx += consumed.max(1);
1243                    p.reset();
1244                }
1245            }
1246        }
1247        assert_eq!(count, 3);
1248    }
1249
1250    #[test]
1251    fn need_more_when_truncated() {
1252        let mut p = DnodeParser::new();
1253        let prefix = b"$2014$ 1 3 0 1 1 *1 d *";
1254        match p.step(prefix) {
1255            ParseStep::NeedMore { consumed } => assert_eq!(consumed, prefix.len()),
1256            other => panic!("unexpected: {other:?}"),
1257        }
1258        let suffix = b"42\r\n";
1259        match p.step(suffix) {
1260            ParseStep::HeaderDone { consumed } => assert_eq!(consumed, suffix.len()),
1261            other => panic!("unexpected: {other:?}"),
1262        }
1263        assert_eq!(p.take_dmsg().plen, 42);
1264    }
1265
1266    #[test]
1267    fn parse_error_on_garbage_prefix() {
1268        let mut p = DnodeParser::new();
1269        match p.step(b"!nope") {
1270            ParseStep::Error { consumed } => assert_eq!(consumed, 0),
1271            other => panic!("unexpected: {other:?}"),
1272        }
1273    }
1274
1275    /// Regression for the libfuzzer 1h soak finding 2026-06-02:
1276    /// a numeric DataLen field that exceeds [`MAX_DATA_LEN`]
1277    /// must be rejected with `ParseStep::Error` BEFORE the
1278    /// downstream `Vec::reserve` would convert the wrapped u32
1279    /// into a multi-gigabyte malloc.
1280    #[test]
1281    fn parse_rejects_oversized_data_len() {
1282        let mut p = DnodeParser::new();
1283        // 11 ones drives self.num to 11_111_111_111, which casts
1284        // to u32 as 2_521_176_519 (~2.4 GiB). Pre-fix the parser
1285        // accepted this and called Vec::reserve(2_521_176_519).
1286        let bytes = b"$2014$ 1 3 0 1 1 *11111111111 ";
1287        match p.step(bytes) {
1288            ParseStep::Error { consumed: _ } => (),
1289            other => panic!("expected Error, got {other:?}"),
1290        }
1291    }
1292
1293    /// Regression for the libfuzzer 1h soak finding 2026-06-02:
1294    /// the captured 112-byte OOM artifact must drive `step()`
1295    /// to a clean Error rather than allocating gigabytes.
1296    #[test]
1297    fn parse_oom_artifact_2026_06_02() {
1298        let bytes = include_bytes!("../../../fuzz/seeds/dnode_parse/regression-oom-2026-06-02");
1299        let mut p = DnodeParser::new();
1300        match p.step(bytes) {
1301            ParseStep::Error { .. } | ParseStep::HeaderDone { .. } => (),
1302            ParseStep::NeedMore { .. } => panic!("unexpected NeedMore"),
1303        }
1304    }
1305
1306    #[test]
1307    fn writer_round_trip_unencrypted() {
1308        let pool = MbufPool::default();
1309        let mut buf = pool.get();
1310        dmsg_write(&mut buf, 42, DmsgType::Req, 0, true, None, 0).unwrap();
1311        let bytes = buf.readable().to_vec();
1312        let mut p = DnodeParser::new();
1313        let step = p.step(&bytes);
1314        assert!(matches!(step, ParseStep::HeaderDone { .. }));
1315        let d = p.take_dmsg();
1316        assert_eq!(d.id, 42);
1317        assert_eq!(d.ty, DmsgType::Req);
1318        assert_eq!(d.flags, 0);
1319        assert!(d.same_dc);
1320        assert_eq!(d.mlen, 1);
1321        assert_eq!(d.data, b"d");
1322        assert_eq!(d.plen, 0);
1323    }
1324
1325    #[test]
1326    fn writer_round_trip_with_aes_payload() {
1327        let pool = MbufPool::default();
1328        let mut buf = pool.get();
1329        let payload = vec![0xAB; 128];
1330        dmsg_write(
1331            &mut buf,
1332            7,
1333            DmsgType::CryptoHandshake,
1334            DMSG_FLAG_ENCRYPTED,
1335            false,
1336            Some(&payload),
1337            512,
1338        )
1339        .unwrap();
1340        let bytes = buf.readable().to_vec();
1341        let mut p = DnodeParser::new();
1342        match p.step(&bytes) {
1343            ParseStep::HeaderDone { consumed } => assert_eq!(consumed, bytes.len()),
1344            other => panic!("unexpected: {other:?}"),
1345        }
1346        let d = p.take_dmsg();
1347        assert_eq!(d.id, 7);
1348        assert_eq!(d.ty, DmsgType::CryptoHandshake);
1349        assert!(d.is_encrypted());
1350        assert!(!d.same_dc);
1351        assert_eq!(d.data, payload);
1352        assert_eq!(d.plen, 512);
1353    }
1354
1355    #[test]
1356    fn dispatcher_classifies_control_plane() {
1357        let mut d = Dmsg::new();
1358        // Pin the exact three variants the C `dmsg_process`
1359        // bypasses.
1360        for ty in [
1361            DmsgType::CryptoHandshake,
1362            DmsgType::GossipSyn,
1363            DmsgType::GossipSynReply,
1364        ] {
1365            d.ty = ty;
1366            assert_eq!(dmsg_process(&d), DmsgDispatch::Bypass);
1367        }
1368        // Every other gossip variant falls through to the default
1369        // branch (forward), matching the C switch.
1370        for ty in [
1371            DmsgType::GossipAck,
1372            DmsgType::GossipDigestSyn,
1373            DmsgType::GossipDigestAck,
1374            DmsgType::GossipDigestAck2,
1375            DmsgType::GossipShutdown,
1376            DmsgType::Req,
1377            DmsgType::ReqForward,
1378            DmsgType::Res,
1379        ] {
1380            d.ty = ty;
1381            assert_eq!(dmsg_process(&d), DmsgDispatch::Forward);
1382        }
1383        // HandoffChunk routes to the explicit handoff coordinator
1384        // and is therefore bypassed alongside the handshake
1385        // variants.
1386        d.ty = DmsgType::HandoffChunk;
1387        assert_eq!(dmsg_process(&d), DmsgDispatch::Bypass);
1388        // FT.SEARCH coordinator messages are routed to the
1389        // dedicated query-fsm coordinator via the same
1390        // bypass path used by the handoff coordinator.
1391        for ty in [DmsgType::FtSearchReq, DmsgType::FtSearchRep] {
1392            d.ty = ty;
1393            assert_eq!(dmsg_process(&d), DmsgDispatch::Bypass);
1394        }
1395        // Cross-node XA frames are routed to the dyniak XA
1396        // handler and bypass the data plane the same way.
1397        for ty in [
1398            DmsgType::XaPrepare,
1399            DmsgType::XaVote,
1400            DmsgType::XaCommit,
1401            DmsgType::XaRollback,
1402            DmsgType::XaAck,
1403        ] {
1404            d.ty = ty;
1405            assert_eq!(dmsg_process(&d), DmsgDispatch::Bypass);
1406        }
1407        // Cross-node RAMP frames route to the dyniak RAMP handler
1408        // and bypass the data plane the same way.
1409        d.ty = DmsgType::RampPrepare;
1410        assert_eq!(dmsg_process(&d), DmsgDispatch::Bypass);
1411        // Dyniak cross-node object-replica ops are routed to the
1412        // dyniak replica sink and bypass the data plane the same
1413        // way, so a replica apply fans out exactly once.
1414        d.ty = DmsgType::RiakReplica;
1415        assert_eq!(dmsg_process(&d), DmsgDispatch::Bypass);
1416    }
1417}