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