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