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}