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
12use kevy_resp::{Argv, ArgvView, ProtocolError, parse_command_into};
13
14// Snapshot ship helpers live in [`crate::wire_snapshot`] (split out
15// to keep this file under the 500-LOC project ceiling); re-export
16// here so the canonical import path stays `kevy_replicate::wire::*`.
17pub use crate::wire_snapshot::{
18    SNAPSHOT_CHUNK_MAX, SNAPSHOT_LINE_MAX, SnapshotMarker, decode_replconf_ack,
19    decode_snapshot_chunk, decode_snapshot_marker, encode_ping, encode_replconf_ack,
20    encode_snapshot_begin, encode_snapshot_chunk, encode_snapshot_end,
21};
22
23/// Wire-layer error. Only [`WireError::Truncated`] is recoverable by
24/// the caller (read more bytes and retry); the other variants signal
25/// a corrupt or protocol-violating peer and call for dropping the
26/// connection.
27#[derive(Debug)]
28pub enum WireError {
29    /// Buffer ended before a complete frame; accumulate more bytes
30    /// and call [`decode_frame`] again.
31    Truncated,
32    /// Outer envelope did not start with `*2\r\n` (the only legal
33    /// envelope length).
34    BadEnvelope,
35    /// Offset element did not parse as a RESP integer (`:N\r\n`).
36    BadOffset,
37    /// RESP integer parsed but is negative. Offsets are `u64`.
38    NegativeOffset(i64),
39    /// Inner multi-bulk argv was malformed at the RESP layer.
40    BadPayload(ProtocolError),
41}
42
43impl std::fmt::Display for WireError {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            Self::Truncated => write!(f, "wire frame truncated"),
47            Self::BadEnvelope => write!(f, "wire envelope not *2"),
48            Self::BadOffset => write!(f, "wire offset element not RESP integer"),
49            Self::NegativeOffset(n) => write!(f, "wire offset is negative: {n}"),
50            Self::BadPayload(e) => write!(f, "wire inner payload malformed: {e:?}"),
51        }
52    }
53}
54
55impl std::error::Error for WireError {}
56
57impl PartialEq for WireError {
58    fn eq(&self, other: &Self) -> bool {
59        // ProtocolError carries `&'static str` reasons; comparing
60        // discriminants is enough for test assertions. Avoids
61        // forcing PartialEq onto ProtocolError just for the test
62        // surface here.
63        core::mem::discriminant(self) == core::mem::discriminant(other)
64    }
65}
66
67/// Encode one replication frame: outer `*2`, offset integer, then the
68/// argv as a RESP2 multi-bulk request. Allocates a fresh `Vec<u8>`.
69/// Generic over [`ArgvView`] so the hot path can pass a borrowed argv
70/// straight from the dispatcher (no Argv materialisation per write).
71///
72/// See `docs/wire.md` for the byte layout.
73///
74/// `offset` must fit in [`i64::MAX`] — the wire envelope uses a RESP
75/// integer for the offset, which is signed by spec. `i64::MAX` is 9.2
76/// exabytes of frames; at 10M writes/s that is ~30,000 years, so no
77/// real deployment is at risk. In debug builds we assert; release
78/// builds emit a frame the peer will reject with `BadOffset`.
79pub fn encode_frame<A: ArgvView + ?Sized>(offset: u64, argv: &A) -> Vec<u8> {
80    debug_assert!(
81        i64::try_from(offset).is_ok(),
82        "replication offset {offset} exceeds i64::MAX — wire envelope cannot encode",
83    );
84    // Pre-size: outer header (~5) + offset line (≤22) + inner array
85    // header (~6) + argv buf bytes + per-arg `$N\r\n` headers (~8 each)
86    // + trailing CRLF per arg (2 each). Slight overshoot is fine.
87    let est = 32 + argv_byte_estimate_view(argv);
88    let mut out = Vec::with_capacity(est);
89    // Envelope: 2 elements.
90    out.extend_from_slice(b"*2\r\n");
91    // Element 1: offset as RESP integer.
92    out.push(b':');
93    push_u64(&mut out, offset);
94    out.extend_from_slice(b"\r\n");
95    // Element 2: RESP2 multi-bulk argv (byte-identical to a client
96    // request, so the receiver feeds it through parse_command_into).
97    let n = argv.len();
98    out.push(b'*');
99    push_u64(&mut out, n as u64);
100    out.extend_from_slice(b"\r\n");
101    for i in 0..n {
102        let arg = &argv[i];
103        out.push(b'$');
104        push_u64(&mut out, arg.len() as u64);
105        out.extend_from_slice(b"\r\n");
106        out.extend_from_slice(arg);
107        out.extend_from_slice(b"\r\n");
108    }
109    out
110}
111
112/// Decode the first complete frame at the front of `buf`.
113///
114/// Returns `(offset, argv, used)` on success; `used` is the number of
115/// bytes the frame consumed (advance the caller's read cursor by that
116/// much). On [`WireError::Truncated`], the caller should read more
117/// bytes and retry; any other error signals an unrecoverable peer
118/// violation.
119pub fn decode_frame(buf: &[u8]) -> Result<(u64, Argv, usize), WireError> {
120    // Outer envelope: must be exactly `*2\r\n`.
121    let after_env = parse_envelope_header(buf)?;
122    // Offset line: `:<u64>\r\n`.
123    let (offset, after_offset) = parse_offset_line(buf, after_env)?;
124    // Inner argv: defer to kevy-resp's RESP2 multi-bulk parser.
125    let inner = &buf[after_offset..];
126    let mut argv = Argv::default();
127    let consumed_inner = match parse_command_into(inner, &mut argv) {
128        Ok(Some(n)) => n,
129        Ok(None) => return Err(WireError::Truncated),
130        Err(e) => return Err(WireError::BadPayload(e)),
131    };
132    Ok((offset, argv, after_offset + consumed_inner))
133}
134
135/// Verify the outer `*2\r\n` header and return the cursor position just
136/// after the trailing CRLF.
137fn parse_envelope_header(buf: &[u8]) -> Result<usize, WireError> {
138    // Need at least `*N\r\n` — minimum 4 bytes for `*2\r\n`.
139    if buf.len() < 4 {
140        return Err(WireError::Truncated);
141    }
142    if buf[0] != b'*' {
143        return Err(WireError::BadEnvelope);
144    }
145    let eol = find_crlf(buf, 1).ok_or(WireError::Truncated)?;
146    let count = parse_decimal(&buf[1..eol]).ok_or(WireError::BadEnvelope)?;
147    if count != 2 {
148        return Err(WireError::BadEnvelope);
149    }
150    Ok(eol + 2)
151}
152
153/// Parse `:<int>\r\n` starting at `start`; return `(offset, new_cursor)`.
154fn parse_offset_line(buf: &[u8], start: usize) -> Result<(u64, usize), WireError> {
155    if start >= buf.len() {
156        return Err(WireError::Truncated);
157    }
158    if buf[start] != b':' {
159        return Err(WireError::BadOffset);
160    }
161    let eol = find_crlf(buf, start + 1).ok_or(WireError::Truncated)?;
162    let raw = &buf[start + 1..eol];
163    // Allow a leading `-` so we can return NegativeOffset with the
164    // value instead of a generic BadOffset for that specific case.
165    let signed = parse_signed_decimal(raw).ok_or(WireError::BadOffset)?;
166    if signed < 0 {
167        return Err(WireError::NegativeOffset(signed));
168    }
169    Ok((signed as u64, eol + 2))
170}
171
172/// Find the next `\r\n` at or after `from`. Returns the index of the
173/// `\r` byte. `None` = no CRLF in remaining buffer.
174pub(crate) fn find_crlf(buf: &[u8], from: usize) -> Option<usize> {
175    let mut i = from;
176    while i + 1 < buf.len() {
177        if buf[i] == b'\r' && buf[i + 1] == b'\n' {
178            return Some(i);
179        }
180        i += 1;
181    }
182    None
183}
184
185/// Parse an unsigned decimal byte slice. Empty / non-digit = `None`.
186pub(crate) fn parse_decimal(bytes: &[u8]) -> Option<u64> {
187    if bytes.is_empty() {
188        return None;
189    }
190    let mut n: u64 = 0;
191    for &b in bytes {
192        if !b.is_ascii_digit() {
193            return None;
194        }
195        n = n.checked_mul(10)?.checked_add(u64::from(b - b'0'))?;
196    }
197    Some(n)
198}
199
200/// Parse a signed decimal byte slice (`-` or `+` optional, then digits).
201/// Empty / overflow / non-digit body = `None`.
202fn parse_signed_decimal(bytes: &[u8]) -> Option<i64> {
203    if bytes.is_empty() {
204        return None;
205    }
206    let (neg, digits) = match bytes[0] {
207        b'-' => (true, &bytes[1..]),
208        b'+' => (false, &bytes[1..]),
209        _ => (false, bytes),
210    };
211    let n = parse_decimal(digits)?;
212    if neg {
213        // i64::MIN handling: parse_decimal returns u64 so n can be up
214        // to i64::MAX as u64 + 1, exactly i64::MIN when negated.
215        if n > (i64::MAX as u64) + 1 {
216            return None;
217        }
218        if n == (i64::MAX as u64) + 1 {
219            return Some(i64::MIN);
220        }
221        Some(-(n as i64))
222    } else {
223        if n > i64::MAX as u64 {
224            return None;
225        }
226        Some(n as i64)
227    }
228}
229
230/// Append the base-10 representation of `n` to `out` without allocating
231/// an intermediate string.
232pub(crate) fn push_u64(out: &mut Vec<u8>, n: u64) {
233    if n == 0 {
234        out.push(b'0');
235        return;
236    }
237    let mut tmp = [0u8; 20]; // u64::MAX is 20 digits
238    let mut i = tmp.len();
239    let mut v = n;
240    while v != 0 {
241        i -= 1;
242        tmp[i] = b'0' + (v % 10) as u8;
243        v /= 10;
244    }
245    out.extend_from_slice(&tmp[i..]);
246}
247
248/// Rough size estimate for pre-allocating the encoded frame buffer.
249/// Generic over [`ArgvView`] so the borrowed and owned hot paths share
250/// the same pre-allocation logic.
251fn argv_byte_estimate_view<A: ArgvView + ?Sized>(argv: &A) -> usize {
252    // 10 bytes of overhead per argument (`$N\r\n` header + trailing CRLF
253    // worst-case) plus the raw argv bytes.
254    let mut bytes = 0usize;
255    for i in 0..argv.len() {
256        bytes += argv[i].len();
257    }
258    bytes + argv.len() * 10
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    fn argv_from(args: &[&[u8]]) -> Argv {
266        let mut a = Argv::default();
267        for arg in args {
268            a.push(arg);
269        }
270        a
271    }
272
273    #[test]
274    fn roundtrip_simple_set() {
275        let argv = argv_from(&[b"SET", b"foo", b"bar"]);
276        let bytes = encode_frame(42, &argv);
277        let (offset, decoded, used) = decode_frame(&bytes).expect("decode");
278        assert_eq!(offset, 42);
279        assert_eq!(decoded, argv);
280        assert_eq!(used, bytes.len());
281    }
282
283    #[test]
284    fn roundtrip_offset_zero_and_max() {
285        // Offset is u64 in the API but wire envelope (RESP integer) caps
286        // at i64::MAX. See docs/wire.md + encode_frame's doc comment.
287        for offset in [0u64, 1, i64::MAX as u64] {
288            let argv = argv_from(&[b"PING"]);
289            let bytes = encode_frame(offset, &argv);
290            let (back, _, _) = decode_frame(&bytes).expect("decode");
291            assert_eq!(back, offset);
292        }
293    }
294
295    #[test]
296    #[cfg(debug_assertions)] // the trip wire is a debug_assert! — release builds skip the panic by design
297    #[should_panic(expected = "exceeds i64::MAX")]
298    fn encoding_offset_above_i64_max_panics_in_debug() {
299        // Catches accidental over-encoding before the frame goes on the
300        // wire. In release builds the assert is gone and the peer would
301        // see a BadOffset; we test the debug-build trip wire here.
302        let argv = argv_from(&[b"PING"]);
303        let _ = encode_frame(u64::MAX, &argv);
304    }
305
306    #[test]
307    fn roundtrip_argv_with_binary_and_empty_args() {
308        let bin: Vec<u8> = (0u8..=255).collect();
309        let argv = argv_from(&[b"HSET", b"key", b"field", &bin, b""]);
310        let bytes = encode_frame(7, &argv);
311        let (_, decoded, _) = decode_frame(&bytes).expect("decode");
312        assert_eq!(decoded.len(), 5);
313        assert_eq!(decoded.get(3), Some(bin.as_slice()));
314        assert_eq!(decoded.get(4), Some(&b""[..]));
315    }
316
317    #[test]
318    fn two_concatenated_frames_decode_in_order() {
319        let a = encode_frame(1, &argv_from(&[b"SET", b"k", b"a"]));
320        let b = encode_frame(2, &argv_from(&[b"DEL", b"k"]));
321        let mut buf = a.clone();
322        buf.extend_from_slice(&b);
323
324        let (off1, argv1, used1) = decode_frame(&buf).expect("frame 1");
325        assert_eq!(off1, 1);
326        assert_eq!(argv1, argv_from(&[b"SET", b"k", b"a"]));
327        assert_eq!(used1, a.len());
328
329        let (off2, argv2, used2) = decode_frame(&buf[used1..]).expect("frame 2");
330        assert_eq!(off2, 2);
331        assert_eq!(argv2, argv_from(&[b"DEL", b"k"]));
332        assert_eq!(used1 + used2, buf.len());
333    }
334
335    #[test]
336    fn offsets_are_strictly_increasing_when_emitted_in_order() {
337        let mut bytes = Vec::new();
338        for o in 0u64..16 {
339            bytes.extend(encode_frame(o, &argv_from(&[b"PING"])));
340        }
341        let mut pos = 0;
342        let mut last: Option<u64> = None;
343        while pos < bytes.len() {
344            let (offset, _, used) = decode_frame(&bytes[pos..]).expect("decode");
345            if let Some(prev) = last {
346                assert!(offset > prev, "offset {offset} not > prev {prev}");
347            }
348            last = Some(offset);
349            pos += used;
350        }
351        assert_eq!(last, Some(15));
352        assert_eq!(pos, bytes.len());
353    }
354
355    #[test]
356    fn truncated_envelope_is_truncated_not_bad() {
357        // Empty.
358        assert_eq!(decode_frame(&[]), Err(WireError::Truncated));
359        // Just `*` no header end.
360        assert_eq!(decode_frame(b"*"), Err(WireError::Truncated));
361        // `*2\r\n` then nothing.
362        assert_eq!(decode_frame(b"*2\r\n"), Err(WireError::Truncated));
363        // Offset start with no CRLF.
364        assert_eq!(decode_frame(b"*2\r\n:42"), Err(WireError::Truncated));
365        // Header + offset but inner argv missing.
366        assert_eq!(decode_frame(b"*2\r\n:42\r\n"), Err(WireError::Truncated));
367        // Header + offset + partial inner array.
368        assert_eq!(decode_frame(b"*2\r\n:42\r\n*1\r\n$3\r\nfo"), Err(WireError::Truncated));
369    }
370
371    #[test]
372    fn wrong_envelope_count_rejected() {
373        // *1 instead of *2.
374        let bad = b"*1\r\n:42\r\n";
375        assert!(matches!(decode_frame(bad), Err(WireError::BadEnvelope)));
376        // *3 (future-extension shape) is rejected too.
377        let bad3 = b"*3\r\n:42\r\n*0\r\n:0\r\n";
378        assert!(matches!(decode_frame(bad3), Err(WireError::BadEnvelope)));
379    }
380
381    #[test]
382    fn non_array_envelope_rejected() {
383        // Starts with `:` instead of `*`.
384        let bad = b":42\r\n*1\r\n$4\r\nPING\r\n";
385        assert!(matches!(decode_frame(bad), Err(WireError::BadEnvelope)));
386    }
387
388    #[test]
389    fn offset_not_integer_rejected() {
390        // Second element is a bulk string, not an integer.
391        let bad = b"*2\r\n$2\r\n42\r\n*1\r\n$4\r\nPING\r\n";
392        assert!(matches!(decode_frame(bad), Err(WireError::BadOffset)));
393    }
394
395    #[test]
396    fn negative_offset_rejected_with_value() {
397        let bad = b"*2\r\n:-7\r\n*1\r\n$4\r\nPING\r\n";
398        match decode_frame(bad) {
399            Err(WireError::NegativeOffset(n)) => assert_eq!(n, -7),
400            other => panic!("expected NegativeOffset, got {other:?}"),
401        }
402    }
403
404    #[test]
405    fn malformed_inner_payload_surfaces_bad_payload() {
406        // Outer envelope + offset OK, inner claims `*1` but follows with
407        // an unknown type byte (`!`) — the inner parser rejects.
408        let bad = b"*2\r\n:1\r\n*1\r\n!nope\r\n";
409        assert!(matches!(decode_frame(bad), Err(WireError::BadPayload(_))));
410    }
411
412    #[test]
413    fn offset_with_extra_digits_overflow_rejected() {
414        // 21 nines — bigger than u64::MAX (20 digits). parse_decimal
415        // returns None on the checked-multiply overflow, and parse_signed
416        // returns None on top of that, so we see BadOffset.
417        let mut bad = b"*2\r\n:".to_vec();
418        bad.extend(std::iter::repeat_n(b'9', 21));
419        bad.extend_from_slice(b"\r\n*1\r\n$4\r\nPING\r\n");
420        assert!(matches!(decode_frame(&bad), Err(WireError::BadOffset)));
421    }
422
423    // Snapshot-wire tests live in `tests/wire_snapshot.rs`
424    // as an integration test so this file stays under the 500-LOC
425    // project ceiling. Only public API there.
426
427    #[test]
428    fn encoded_bytes_are_exactly_what_spec_says() {
429        // Hand-spell the spec's example so any future refactor that
430        // changes byte order trips this test.
431        let argv = argv_from(&[b"SET", b"foo", b"bar"]);
432        let bytes = encode_frame(99, &argv);
433        let expected = b"*2\r\n:99\r\n*3\r\n$3\r\nSET\r\n$3\r\nfoo\r\n$3\r\nbar\r\n";
434        assert_eq!(bytes, expected);
435    }
436}