Skip to main content

kevy_replicate/
wire.rs

1//! Wire format for replicated mutations — see `docs/wire.md` for the
2//! full spec.
3//!
4//! Each frame is `*2\r\n:<offset>\r\n<RESP2 multi-bulk argv>`. The
5//! envelope is itself a valid RESP2 array of 2 elements, so any
6//! RESP-aware debug tool can peek a captured stream. The inner argv
7//! payload is byte-identical to what a client would have sent when
8//! issuing the same command, so feeding it through the existing
9//! [`parse_command_into`] reconstructs the same [`Argv`] the primary
10//! applied.
11//!
12//! ```
13//! use kevy_replicate::wire::{decode_frame, encode_frame};
14//!
15//! let set = kevy_resp::Argv::from(vec![b"SET".to_vec(), b"k".to_vec(), b"v".to_vec()]);
16//! let bytes = encode_frame(7, &set);
17//! assert!(bytes.starts_with(b"*2\r\n:7\r\n")); // envelope, then the offset
18//! // the payload is the request a client would have sent
19//! let mut argv = kevy_resp::Argv::default();
20//! kevy_resp::parse_command_into(&bytes[8..], &mut argv)?;
21//! assert_eq!(argv, set);
22//! assert_eq!(decode_frame(&bytes)?.0.argv, set);
23//! # Ok::<(), Box<dyn std::error::Error>>(())
24//! ```
25
26use crate::replica::DecodedFrame;
27use kevy_resp::{Argv, ArgvView, ProtocolError, parse_command_into};
28
29// Snapshot ship helpers live in [`crate::wire_snapshot`] (split out
30// to keep this file under the 500-LOC project ceiling); re-export
31// here so the canonical import path stays `kevy_replicate::wire::*`.
32pub use crate::wire_snapshot::{
33    SNAPSHOT_CHUNK_MAX, SNAPSHOT_LINE_MAX, SnapshotMarker, decode_replconf_ack,
34    decode_snapshot_chunk, decode_snapshot_marker, encode_ping, encode_replconf_ack,
35    encode_snapshot_begin, encode_snapshot_chunk, encode_snapshot_end,
36};
37
38/// Wire-layer error. Only [`WireError::Truncated`] is recoverable by
39/// the caller (read more bytes and retry); the other variants signal
40/// a corrupt or protocol-violating peer and call for dropping the
41/// connection.
42///
43/// ```
44/// use kevy_replicate::wire::{WireError, decode_frame, encode_frame};
45///
46/// let set = kevy_resp::Argv::from(vec![b"SET".to_vec(), b"k".to_vec(), b"v".to_vec()]);
47/// let bytes = encode_frame(0, &set);
48/// // a partial read: keep the bytes and read more
49/// assert_eq!(decode_frame(&bytes[..10]).unwrap_err(), WireError::Truncated);
50/// // garbage: drop the peer
51/// let err = decode_frame(b"+OK\r\n").unwrap_err();
52/// assert_eq!(err, WireError::BadEnvelope);
53/// assert_eq!(err.to_string(), "wire envelope not *2");
54/// ```
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56#[non_exhaustive]
57pub enum WireError {
58    /// Buffer ended before a complete frame; accumulate more bytes
59    /// and call [`decode_frame`] again.
60    ///
61    /// ```
62    /// use kevy_replicate::wire::{WireError, decode_frame, encode_frame};
63    ///
64    /// let set = kevy_resp::Argv::from(vec![b"SET".to_vec(), b"k".to_vec(), b"v".to_vec()]);
65    /// let bytes = encode_frame(3, &set);
66    /// let (mut buf, mut decoded) = (Vec::new(), None);
67    /// for chunk in bytes.chunks(8) {
68    ///     buf.extend_from_slice(chunk); // bytes arrive a few at a time
69    ///     match decode_frame(&buf) {
70    ///         Err(WireError::Truncated) => continue,
71    ///         Ok((frame, used)) => decoded = Some((frame.offset, used)),
72    ///         Err(e) => panic!("{e}"),
73    ///     }
74    /// }
75    /// assert_eq!(decoded, Some((3, bytes.len())));
76    /// ```
77    Truncated,
78    /// Outer envelope did not start with `*2\r\n` (the only legal
79    /// envelope length).
80    ///
81    /// ```
82    /// use kevy_replicate::wire::{WireError, decode_frame};
83    ///
84    /// // a three-element envelope is not a frame
85    /// let err = decode_frame(b"*3\r\n:1\r\n*0\r\n:0\r\n").unwrap_err();
86    /// assert_eq!(err, WireError::BadEnvelope);
87    /// ```
88    BadEnvelope,
89    /// Offset element did not parse as a RESP integer (`:N\r\n`).
90    ///
91    /// ```
92    /// use kevy_replicate::wire::{WireError, decode_frame};
93    ///
94    /// // the offset arrives as a bulk string instead of an integer
95    /// let err = decode_frame(b"*2\r\n$2\r\n42\r\n*1\r\n$4\r\nPING\r\n").unwrap_err();
96    /// assert_eq!(err, WireError::BadOffset);
97    /// ```
98    BadOffset,
99    /// RESP integer parsed but is negative. Offsets are `u64`.
100    ///
101    /// ```
102    /// use kevy_replicate::wire::{WireError, decode_frame};
103    ///
104    /// let err = decode_frame(b"*2\r\n:-7\r\n*1\r\n$4\r\nPING\r\n").unwrap_err();
105    /// assert_eq!(err, WireError::NegativeOffset(-7));
106    /// ```
107    NegativeOffset(i64),
108    /// Inner multi-bulk argv was malformed at the RESP layer.
109    ///
110    /// ```
111    /// use std::error::Error;
112    /// use kevy_replicate::wire::{WireError, decode_frame};
113    ///
114    /// let err = decode_frame(b"*2\r\n:1\r\n*1\r\n!nope\r\n").unwrap_err();
115    /// assert!(matches!(err, WireError::BadPayload(_)));
116    /// assert!(err.source().is_some()); // the RESP parser's own error
117    /// ```
118    BadPayload(ProtocolError),
119}
120
121impl std::fmt::Display for WireError {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        match self {
124            Self::Truncated => write!(f, "wire frame truncated"),
125            Self::BadEnvelope => write!(f, "wire envelope not *2"),
126            Self::BadOffset => write!(f, "wire offset element not RESP integer"),
127            Self::NegativeOffset(n) => write!(f, "wire offset is negative: {n}"),
128            Self::BadPayload(e) => write!(f, "wire inner payload malformed: {e}"),
129        }
130    }
131}
132
133impl std::error::Error for WireError {
134    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
135        match self {
136            Self::BadPayload(e) => Some(e),
137            _ => None,
138        }
139    }
140}
141
142/// Encode one replication frame: outer `*2`, offset integer, then the
143/// argv as a RESP2 multi-bulk request. Allocates a fresh `Vec<u8>`.
144/// Generic over [`ArgvView`] so the hot path can pass a borrowed argv
145/// straight from the dispatcher (no Argv materialisation per write).
146///
147/// See `docs/wire.md` for the byte layout.
148///
149/// `offset` must fit in [`i64::MAX`] — the wire envelope uses a RESP
150/// integer for the offset, which is signed by spec. `i64::MAX` is 9.2
151/// exabytes of frames; at 10M writes/s that is ~30,000 years, so no
152/// real deployment is at risk. In debug builds we assert; release
153/// builds emit a frame the peer will reject with `BadOffset`.
154///
155/// ```
156/// use kevy_replicate::wire::encode_frame;
157///
158/// let set = kevy_resp::Argv::from(vec![b"SET".to_vec(), b"k".to_vec(), b"v".to_vec()]);
159/// assert_eq!(encode_frame(99, &set), b"*2\r\n:99\r\n*3\r\n$3\r\nSET\r\n$1\r\nk\r\n$1\r\nv\r\n");
160/// ```
161pub fn encode_frame<A: ArgvView + ?Sized>(offset: u64, argv: &A) -> Vec<u8> {
162    debug_assert!(
163        i64::try_from(offset).is_ok(),
164        "replication offset {offset} exceeds i64::MAX — wire envelope cannot encode",
165    );
166    // Pre-size: outer header (~5) + offset line (≤22) + inner array
167    // header (~6) + argv buf bytes + per-arg `$N\r\n` headers (~8 each)
168    // + trailing CRLF per arg (2 each). Slight overshoot is fine.
169    let est = 32 + argv_byte_estimate_view(argv);
170    let mut out = Vec::with_capacity(est);
171    // Envelope: 2 elements.
172    out.extend_from_slice(b"*2\r\n");
173    // Element 1: offset as RESP integer.
174    out.push(b':');
175    push_u64(&mut out, offset);
176    out.extend_from_slice(b"\r\n");
177    // Element 2: RESP2 multi-bulk argv (byte-identical to a client
178    // request, so the receiver feeds it through parse_command_into).
179    let n = argv.len();
180    out.push(b'*');
181    push_u64(&mut out, n as u64);
182    out.extend_from_slice(b"\r\n");
183    for i in 0..n {
184        let arg = &argv[i];
185        out.push(b'$');
186        push_u64(&mut out, arg.len() as u64);
187        out.extend_from_slice(b"\r\n");
188        out.extend_from_slice(arg);
189        out.extend_from_slice(b"\r\n");
190    }
191    out
192}
193
194/// Decode the first complete frame at the front of `buf`.
195///
196/// Returns the frame and the number of bytes it consumed (advance the
197/// caller's read cursor by that much). On [`WireError::Truncated`], the caller should read more
198/// bytes and retry; any other error signals an unrecoverable peer
199/// violation.
200///
201/// ```
202/// use kevy_replicate::wire::{decode_frame, encode_frame};
203///
204/// let set = kevy_resp::Argv::from(vec![b"SET".to_vec(), b"k".to_vec(), b"v".to_vec()]);
205/// let mut stream = encode_frame(0, &set);
206/// stream.extend(encode_frame(1, &set));
207/// let (first, used) = decode_frame(&stream)?;
208/// let (second, _) = decode_frame(&stream[used..])?; // advance past the first
209/// assert_eq!((first.offset, second.offset), (0, 1));
210/// # Ok::<(), kevy_replicate::wire::WireError>(())
211/// ```
212pub fn decode_frame(buf: &[u8]) -> Result<(DecodedFrame, usize), WireError> {
213    // Outer envelope: must be exactly `*2\r\n`.
214    let after_env = parse_envelope_header(buf)?;
215    // Offset line: `:<u64>\r\n`.
216    let (offset, after_offset) = parse_offset_line(buf, after_env)?;
217    // Inner argv: defer to kevy-resp's RESP2 multi-bulk parser.
218    let inner = &buf[after_offset..];
219    let mut argv = Argv::default();
220    let consumed_inner = match parse_command_into(inner, &mut argv) {
221        Ok(Some(n)) => n,
222        Ok(None) => return Err(WireError::Truncated),
223        Err(e) => return Err(WireError::BadPayload(e)),
224    };
225    Ok((DecodedFrame { offset, argv }, after_offset + consumed_inner))
226}
227
228/// Verify the outer `*2\r\n` header and return the cursor position just
229/// after the trailing CRLF.
230fn parse_envelope_header(buf: &[u8]) -> Result<usize, WireError> {
231    // Need at least `*N\r\n` — minimum 4 bytes for `*2\r\n`.
232    if buf.len() < 4 {
233        return Err(WireError::Truncated);
234    }
235    if buf[0] != b'*' {
236        return Err(WireError::BadEnvelope);
237    }
238    let eol = find_crlf(buf, 1).ok_or(WireError::Truncated)?;
239    let count = parse_decimal(&buf[1..eol]).ok_or(WireError::BadEnvelope)?;
240    if count != 2 {
241        return Err(WireError::BadEnvelope);
242    }
243    Ok(eol + 2)
244}
245
246/// Parse `:<int>\r\n` starting at `start`; return `(offset, new_cursor)`.
247fn parse_offset_line(buf: &[u8], start: usize) -> Result<(u64, usize), WireError> {
248    if start >= buf.len() {
249        return Err(WireError::Truncated);
250    }
251    if buf[start] != b':' {
252        return Err(WireError::BadOffset);
253    }
254    let eol = find_crlf(buf, start + 1).ok_or(WireError::Truncated)?;
255    let raw = &buf[start + 1..eol];
256    // Allow a leading `-` so we can return NegativeOffset with the
257    // value instead of a generic BadOffset for that specific case.
258    let signed = parse_signed_decimal(raw).ok_or(WireError::BadOffset)?;
259    if signed < 0 {
260        return Err(WireError::NegativeOffset(signed));
261    }
262    Ok((signed as u64, eol + 2))
263}
264
265/// Find the next `\r\n` at or after `from`. Returns the index of the
266/// `\r` byte. `None` = no CRLF in remaining buffer.
267pub(crate) fn find_crlf(buf: &[u8], from: usize) -> Option<usize> {
268    let mut i = from;
269    while i + 1 < buf.len() {
270        if buf[i] == b'\r' && buf[i + 1] == b'\n' {
271            return Some(i);
272        }
273        i += 1;
274    }
275    None
276}
277
278/// Parse an unsigned decimal byte slice. Empty / non-digit = `None`.
279pub(crate) fn parse_decimal(bytes: &[u8]) -> Option<u64> {
280    if bytes.is_empty() {
281        return None;
282    }
283    let mut n: u64 = 0;
284    for &b in bytes {
285        if !b.is_ascii_digit() {
286            return None;
287        }
288        n = n.checked_mul(10)?.checked_add(u64::from(b - b'0'))?;
289    }
290    Some(n)
291}
292
293/// Parse a signed decimal byte slice (`-` or `+` optional, then digits).
294/// Empty / overflow / non-digit body = `None`.
295fn parse_signed_decimal(bytes: &[u8]) -> Option<i64> {
296    if bytes.is_empty() {
297        return None;
298    }
299    let (neg, digits) = match bytes[0] {
300        b'-' => (true, &bytes[1..]),
301        b'+' => (false, &bytes[1..]),
302        _ => (false, bytes),
303    };
304    let n = parse_decimal(digits)?;
305    if neg {
306        // i64::MIN handling: parse_decimal returns u64 so n can be up
307        // to i64::MAX as u64 + 1, exactly i64::MIN when negated.
308        if n > (i64::MAX as u64) + 1 {
309            return None;
310        }
311        if n == (i64::MAX as u64) + 1 {
312            return Some(i64::MIN);
313        }
314        Some(-(n as i64))
315    } else {
316        if n > i64::MAX as u64 {
317            return None;
318        }
319        Some(n as i64)
320    }
321}
322
323/// Append the base-10 representation of `n` to `out` without allocating
324/// an intermediate string.
325pub(crate) fn push_u64(out: &mut Vec<u8>, n: u64) {
326    if n == 0 {
327        out.push(b'0');
328        return;
329    }
330    let mut tmp = [0u8; 20]; // u64::MAX is 20 digits
331    let mut i = tmp.len();
332    let mut v = n;
333    while v != 0 {
334        i -= 1;
335        tmp[i] = b'0' + (v % 10) as u8;
336        v /= 10;
337    }
338    out.extend_from_slice(&tmp[i..]);
339}
340
341/// Rough size estimate for pre-allocating the encoded frame buffer.
342/// Generic over [`ArgvView`] so the borrowed and owned hot paths share
343/// the same pre-allocation logic.
344fn argv_byte_estimate_view<A: ArgvView + ?Sized>(argv: &A) -> usize {
345    // 10 bytes of overhead per argument (`$N\r\n` header + trailing CRLF
346    // worst-case) plus the raw argv bytes.
347    let mut bytes = 0usize;
348    for i in 0..argv.len() {
349        bytes += argv[i].len();
350    }
351    bytes + argv.len() * 10
352}
353
354#[cfg(test)]
355#[path = "wire_tests.rs"]
356mod tests;