Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .add/state.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"project": "moon",
"stage": "production",
"active_task": "client-identity-introspection",
"active_task": "protocol-error-lifetime",
"active_milestone": "v0-9-client-compat",
"tasks": {
"hotpath-lock-quickwins": {
Expand Down Expand Up @@ -332,14 +332,14 @@
},
"protocol-error-lifetime": {
"title": "Protocol errors reply and close cleanly, never stall or eat the valid prefix",
"phase": "ground",
"phase": "tests",
"gate": "none",
"milestone": "v0-9-client-compat",
"depends_on": [
"client-compat-harness"
],
"created": "2026-08-09T16:42:35+00:00",
"updated": "2026-08-09T16:42:35+00:00"
"updated": "2026-08-12T04:55:05+00:00"
},
"multi-exec-queue-semantics": {
"title": "MULTI queues every command, and EXEC answers correctly on every shard count",
Expand Down Expand Up @@ -476,7 +476,7 @@
}
},
"created": "2026-06-11T03:18:21+00:00",
"updated": "2026-08-11T18:20:42+00:00",
"updated": "2026-08-12T04:55:06+00:00",
"setup": {
"locked": true,
"locked_at": "2026-06-11T03:28:00+00:00",
Expand Down
251 changes: 223 additions & 28 deletions .add/tasks/multi-exec-queue-semantics/TASK.md

Large diffs are not rendered by default.

234 changes: 205 additions & 29 deletions .add/tasks/protocol-error-lifetime/TASK.md

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- **A malformed frame no longer closes the connection silently, and no longer eats the valid
commands that arrived with it.** `Err(_) => break` in the read loops discarded two things: the
parse error's reason, so a client got a bare FIN and could not tell a bad encoder from a dropped
network, and the already-parsed batch — so `PING\r\n*-9\r\n` in a single write answered
*nothing at all*. All three handlers now execute and flush the valid prefix, then send
`-ERR Protocol error: <reason>` using redis-server 8.6.1's verbatim wording, then close. Also
measured and fixed alongside it: `*-9` (any negative multibulk count) killed the connection where
Redis ignores it; an oversized inline request closed mute even though Moon already *built* the
correct "too big inline request" message; and inline quoting did not exist at all, so
`SET k "a b"` became three arguments containing literal quote bytes and `GET "unclosed` was
silently accepted as a key — the inline parser is now a port of Redis's `sdssplitargs`.
- **`MULTI` is atomic with respect to queue-time faults.** Moon had no queue-time validation, so
`EXEC` ran whichever half of a transaction happened to parse: `MULTI / NOSUCHCMD / SET k v / EXEC`
left `k` **set** where Redis discards everything — data corruption, not a compatibility nit. A
command that could never run (unknown name, impossible arity, `SUBSCRIBE`, `WATCH`) is now refused
as it is queued and poisons the transaction, and `EXEC` answers
`-EXECABORT Transaction discarded because of previous errors.` Fixing this exposed a wider defect:
Moon decided "am I in a transaction?" hundreds of lines *below* the `INFO` / `CLIENT` / `WS` /
`MQ` / `PUBLISH` / `SUBSCRIBE` intercepts, so every one of those executed for real inside a
transaction — `SUBSCRIBE ch` put the connection into subscriber mode mid-`MULTI`, and
`INFO server` returned a 3 KB dump where Redis returns `+QUEUED`. The queue decision now sits
directly below each handler's ACL gate, which fixes the whole class at once.

### Added
- **`ROLE`, `RESET`, and a real `COMMAND` introspection surface.** `COMMAND` and `COMMAND COUNT`
each returned the OTHER'S RESP TYPE — bare `COMMAND` replied `:0` (an Integer where an Array
Expand Down
80 changes: 79 additions & 1 deletion src/protocol/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,77 @@ impl PartialEq for Frame {
}
}

/// Which protocol fault occurred, in the vocabulary Redis puts on the wire.
///
/// `ParseError::Invalid` already carries a detailed `message` — it is what a
/// fuzz triage or a log reader wants ("invalid bulk string length: -5"
/// localises a bug; "invalid bulk length" does not). But that detail is
/// Moon's own wording, and a driver author reading it cannot match it against
/// the Redis error they wrote their reconnect logic around.
///
/// So the fault carries BOTH: this enum for the wire, the message for us.
/// Redis's set is small and fixed (`networking.c`), which is why this is a
/// closed enum of `&'static str` rather than a formatted string — nothing
/// here allocates, and it is only ever reached on a connection that is
/// already terminating.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProtoFault {
/// A bulk header's length was absent, non-numeric, negative (other than
/// the `-1` null), or past the configured maximum.
BulkLen,
/// An array/set/map/push header's element count was malformed or too deep.
MultibulkLen,
/// An array element did not begin with `$`. Carries the offending byte.
ExpectedDollar(u8),
/// An inline request exceeded the inline cap.
InlineTooBig,
/// An inline request ended with a quote still open.
UnbalancedQuotes,
/// The multibulk count string itself was implausibly long.
MbulkCountTooBig,
/// A type byte Moon parses but a Redis client should never send inbound.
/// Redis conflates this with `ExpectedDollar`, and so do we.
UnknownType(u8),
}

impl ProtoFault {
/// The reason text, verbatim from redis-server 8.6.1. The caller prefixes
/// `-ERR `. Returns `None` for the two byte-carrying variants, whose text
/// must be formatted — see [`ProtoFault::wire_text_owned`].
pub fn wire_text(&self) -> &'static str {
match self {
ProtoFault::BulkLen => "Protocol error: invalid bulk length",
ProtoFault::MultibulkLen => "Protocol error: invalid multibulk length",
ProtoFault::InlineTooBig => "Protocol error: too big inline request",
ProtoFault::UnbalancedQuotes => "Protocol error: unbalanced quotes in request",
ProtoFault::MbulkCountTooBig => "Protocol error: too big mbulk count string",
// Both byte-carrying variants render through `wire_text_owned`;
// this arm exists so a caller that only wants a static string
// still gets the right *shape* rather than a panic.
ProtoFault::ExpectedDollar(_) | ProtoFault::UnknownType(_) => {
"Protocol error: expected '$', got '?'"
}
}
}
Comment on lines +253 to +271

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the wire_text doc comment.

The doc says the method "Returns None for the two byte-carrying variants". The signature is -> &'static str, so it never returns None; the byte-carrying variants return the placeholder "Protocol error: expected '$', got '?'". Reword the doc to describe the placeholder.

📝 Proposed doc fix
     /// The reason text, verbatim from redis-server 8.6.1. The caller prefixes
-    /// `-ERR `. Returns `None` for the two byte-carrying variants, whose text
-    /// must be formatted — see [`ProtoFault::wire_text_owned`].
+    /// `-ERR `. The two byte-carrying variants return a `'?'` placeholder,
+    /// because their offending byte must be formatted in — see
+    /// [`ProtoFault::wire_text_owned`].
     pub fn wire_text(&self) -> &'static str {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
impl ProtoFault {
/// The reason text, verbatim from redis-server 8.6.1. The caller prefixes
/// `-ERR `. Returns `None` for the two byte-carrying variants, whose text
/// must be formatted — see [`ProtoFault::wire_text_owned`].
pub fn wire_text(&self) -> &'static str {
match self {
ProtoFault::BulkLen => "Protocol error: invalid bulk length",
ProtoFault::MultibulkLen => "Protocol error: invalid multibulk length",
ProtoFault::InlineTooBig => "Protocol error: too big inline request",
ProtoFault::UnbalancedQuotes => "Protocol error: unbalanced quotes in request",
ProtoFault::MbulkCountTooBig => "Protocol error: too big mbulk count string",
// Both byte-carrying variants render through `wire_text_owned`;
// this arm exists so a caller that only wants a static string
// still gets the right *shape* rather than a panic.
ProtoFault::ExpectedDollar(_) | ProtoFault::UnknownType(_) => {
"Protocol error: expected '$', got '?'"
}
}
}
impl ProtoFault {
/// The reason text, verbatim from redis-server 8.6.1. The caller prefixes
/// `-ERR `. The two byte-carrying variants return a `'?'` placeholder,
/// because their offending byte must be formatted in — see
/// [`ProtoFault::wire_text_owned`].
pub fn wire_text(&self) -> &'static str {
match self {
ProtoFault::BulkLen => "Protocol error: invalid bulk length",
ProtoFault::MultibulkLen => "Protocol error: invalid bulk length",
ProtoFault::InlineTooBig => "Protocol error: too big inline request",
ProtoFault::UnbalancedQuotes => "Protocol error: unbalanced quotes in request",
ProtoFault::MbulkCountTooBig => "Protocol error: too big mbulk count string",
// Both byte-carrying variants render through `wire_text_owned`;
// this arm exists so a caller that only wants a static string
// still gets the right *shape* rather than a panic.
ProtoFault::ExpectedDollar(_) | ProtoFault::UnknownType(_) => {
"Protocol error: expected '$', got '?'"
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/protocol/frame.rs` around lines 253 - 271, Correct the doc comment for
ProtoFault::wire_text to state that the two byte-carrying variants return the
placeholder "Protocol error: expected '$', got '?'", rather than claiming the
method returns None. Keep the existing signature and implementation unchanged.


/// The reason text with any offending byte substituted in.
///
/// Allocates, deliberately: this runs once per doomed connection, never on
/// a serving path, and the alternative (a stack buffer threaded through
/// three handlers) buys nothing measurable.
pub fn wire_text_owned(&self) -> String {
match self {
ProtoFault::ExpectedDollar(b) | ProtoFault::UnknownType(b) => {
// Redis prints the raw byte. A non-printable one renders as
// whatever the terminal makes of it, which is Redis's
// behavior too — matching it matters more than prettiness.
format!("Protocol error: expected '$', got '{}'", *b as char)
}
other => other.wire_text().to_string(),
}
}
}

/// Errors that can occur when parsing RESP2 frames.
#[derive(Debug, Error)]
pub enum ParseError {
Expand All @@ -226,8 +297,15 @@ pub enum ParseError {
Incomplete,

/// The data violates the RESP2 protocol specification.
///
/// `kind` is what goes on the wire; `message` is the detailed internal
/// reason kept for logs and fuzz triage. They are deliberately different.
#[error("invalid frame at byte {offset}: {message}")]
Invalid { message: String, offset: usize },
Invalid {
kind: ProtoFault,
message: String,
offset: usize,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// An I/O error occurred while reading from the buffer.
#[error("io error: {0}")]
Expand Down
161 changes: 160 additions & 1 deletion src/protocol/inline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use memchr::{memchr, memchr2};

use bytes::{Buf, Bytes, BytesMut};

use super::frame::{Frame, FrameVec, ParseError};
use super::frame::{Frame, FrameVec, ParseError, ProtoFault};

/// Parse an inline command from the buffer.
///
Expand Down Expand Up @@ -32,6 +32,7 @@ pub fn parse_inline(
// forever. Redis caps this at PROTO_INLINE_MAX_SIZE.
if buf.len() > max_inline_size {
return Err(ParseError::Invalid {
kind: ProtoFault::InlineTooBig,
message: "Protocol error: too big inline request".into(),
offset: 0,
});
Expand All @@ -44,6 +45,7 @@ pub fn parse_inline(
// any inline request past the cap regardless of termination).
if crlf_pos > max_inline_size {
return Err(ParseError::Invalid {
kind: ProtoFault::InlineTooBig,
message: "Protocol error: too big inline request".into(),
offset: 0,
});
Expand All @@ -52,6 +54,24 @@ pub fn parse_inline(
// Extract line content before CRLF
let line = &buf[..crlf_pos];

// Quoted inline arguments take the careful path. Redis parses inline
// commands with `sdssplitargs`, which understands quoting and escapes;
// Moon used to split on whitespace unconditionally, so `SET k "a b"`
// became three arguments with literal quote bytes in them, and an
// unterminated quote was silently accepted as part of a key.
//
// memchr2 over the line is one SIMD pass and is only paid once per inline
// command — the unquoted case (every benchmark, every redis-cli one-liner
// without spaces in values) keeps the original loop untouched below.
if memchr2(b'"', b'\'', line).is_some() {
let args = split_args_quoted(line)?;
buf.advance(crlf_pos + 2);
if args.is_empty() {
return Ok(None);
}
return Ok(Some(Frame::Array(args)));
}

// Split by whitespace (spaces and tabs) using SIMD, filtering empty slices
let mut args = FrameVec::new();
let mut start = 0;
Expand Down Expand Up @@ -89,6 +109,145 @@ pub fn parse_inline(
Ok(Some(Frame::Array(args)))
}

/// Split an inline command line that contains at least one quote character.
///
/// A port of Redis's `sdssplitargs` (`sds.c`), which is what defines inline
/// argument syntax for every client that speaks it — telnet users, `redis-cli`
/// pasting a quoted value, and the health-check scripts that send `PING\r\n`
/// down a bare socket.
///
/// Rules, all of them Redis's:
/// * double quotes honour `\xHH` hex and the `\n \r \t \b \a` escapes;
/// any other `\<c>` is that literal character.
/// * single quotes honour only `\'`; everything else is literal.
/// * a closing quote must be followed by whitespace or end-of-line —
/// `"foo"bar` is an error, not two tokens.
/// * an unterminated quote is an error for the whole request.
///
/// Returns `ParseError::Invalid` with [`ProtoFault::UnbalancedQuotes`] on any
/// of the error cases, which the caller turns into Redis's
/// `-ERR Protocol error: unbalanced quotes in request` and then closes.
fn split_args_quoted(line: &[u8]) -> Result<FrameVec, ParseError> {
#[inline]
fn hex_val(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
}
let unbalanced = || ParseError::Invalid {
kind: ProtoFault::UnbalancedQuotes,
message: "Protocol error: unbalanced quotes in request".into(),
offset: 0,
};

let mut args = FrameVec::new();
let mut i = 0;
loop {
while i < line.len() && (line[i] == b' ' || line[i] == b'\t') {
i += 1;
}
if i >= line.len() {
return Ok(args);
}

// One token. `current` is only allocated for tokens that actually
// need unescaping; a plain token is copied once, same as the fast path.
let mut current: Vec<u8> = Vec::new();
let quote = match line[i] {
q @ (b'"' | b'\'') => {
i += 1;
Some(q)
}
_ => None,
};

match quote {
Some(b'"') => loop {
if i >= line.len() {
return Err(unbalanced());
}
match line[i] {
b'\\' if i + 3 < line.len() && line[i + 1] == b'x' => {
match (hex_val(line[i + 2]), hex_val(line[i + 3])) {
(Some(hi), Some(lo)) => {
current.push(hi * 16 + lo);
i += 4;
}
// Not a valid hex escape: `\x` is literal, exactly
// as Redis falls through here.
_ => {
current.push(b'x');
i += 2;
}
}
}
b'\\' if i + 1 < line.len() => {
current.push(match line[i + 1] {
b'n' => b'\n',
b'r' => b'\r',
b't' => b'\t',
b'b' => 0x08,
b'a' => 0x07,
other => other,
});
i += 2;
}
b'"' => {
// A closing quote must end the token.
if i + 1 < line.len() && line[i + 1] != b' ' && line[i + 1] != b'\t' {
return Err(unbalanced());
}
i += 1;
break;
}
c => {
current.push(c);
i += 1;
}
}
},
Some(_) => loop {
// Single quotes: only \' is an escape.
if i >= line.len() {
return Err(unbalanced());
}
match line[i] {
b'\\' if i + 1 < line.len() && line[i + 1] == b'\'' => {
current.push(b'\'');
i += 2;
}
b'\'' => {
if i + 1 < line.len() && line[i + 1] != b' ' && line[i + 1] != b'\t' {
return Err(unbalanced());
}
i += 1;
break;
}
c => {
current.push(c);
i += 1;
}
}
},
None => {
while i < line.len() {
match line[i] {
b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c => break,
c => {
current.push(c);
i += 1;
}
}
}
}
Comment on lines +235 to +245

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Redis sdssplitargs inline command parsing behavior for an unquoted token containing a double quote

💡 Result:

In Redis, the sdssplitargs function is used to parse inline command arguments. Its behavior regarding tokens containing double quotes is strict to prevent malformed input [1][2][3]. If a token is unquoted (i.e., not wrapped in double or single quotes), it must not contain double quotes [4][2]. Specifically, sdssplitargs enforces a rule where a closing quote (either double or single) must be immediately followed by a whitespace character or be at the very end of the input string [1][2][3]. If a double quote appears within what is otherwise an unquoted token—such as "foo"bar—the parser will interpret the first double quote as a closing quote and then encounter the subsequent characters (like 'b' or 'a') as a violation of this rule [1][2][3]. This violation triggers an error, and the function returns NULL [1][2][3]. The documentation explicitly cites "foo"bar as an example of an invalid format that causes the parser to fail [1][2][3]. This design ensures that quoted and unquoted segments are not ambiguously mixed, maintaining consistent parsing logic [4].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- inline.rs relevant sections ---'
sed -n '50,90p;110,245p' src/protocol/inline.rs

printf '%s\n' '--- parser symbols and tests ---'
rg -n -C 3 'split_args_quoted|parse_inline|unbalanced|0x0b|0x0c|sdssplitargs' src/protocol tests 2>/dev/null || true

printf '%s\n' '--- tracked files containing inline parser tests ---'
git ls-files | rg '(^|/)(inline|protocol).*(_test|test|tests)?|inline\.rs$' || true

Repository: pilotspace/moon

Length of output: 27423


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
p = Path("src/protocol/inline.rs").read_text()
for needle in ("fn split_args_quoted", "fn split_args", "parse_frame_zerocopy"):
    pos = p.find(needle)
    print(f"{needle}: {pos}")
PY

printf '%s\n' '--- Redis reference implementation excerpt ---'
if command -v curl >/dev/null 2>&1; then
  curl -L --fail --silent --show-error \
    https://raw.githubusercontent.com/redis/redis/unstable/deps/hiredis/sds.c |
    rg -n -C 18 'sdssplitargs|inq|insq' | head -n 180
else
  printf '%s\n' 'curl unavailable'
fi

Repository: pilotspace/moon

Length of output: 6688


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- parse_inline and token finalization ---'
sed -n '1,115p;230,280p' src/protocol/inline.rs

printf '%s\n' '--- inline parser tests after line 440 ---'
sed -n '280,520p' src/protocol/inline.rs

Repository: pilotspace/moon

Length of output: 12010


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from enum import Enum

def redis_split(line: bytes):
    out, i = [], 0
    whitespace = b" \n\r\t\v\f"
    while True:
        while i < len(line) and line[i] in whitespace:
            i += 1
        if i >= len(line):
            return out
        cur = bytearray()
        inq = insq = False
        while True:
            c = line[i] if i < len(line) else 0
            if inq:
                if c == 92 and i + 1 < len(line) and line[i+1] == ord("x") and i + 3 < len(line):
                    # Representative inputs below do not use hex escapes.
                    cur.extend(line[i+1:i+2]); i += 2
                elif c == 92 and i + 1 < len(line):
                    cur.append(line[i+1]); i += 2
                elif c == ord('"'):
                    if i + 1 < len(line) and line[i+1] not in whitespace:
                        return None
                    inq = False; i += 1; break
                elif i >= len(line):
                    return None
                else:
                    cur.append(c); i += 1
            elif insq:
                if c == 92 and i + 1 < len(line) and line[i+1] == ord("'"):
                    cur.append(ord("'")); i += 2
                elif c == ord("'"):
                    if i + 1 < len(line) and line[i+1] not in whitespace:
                        return None
                    insq = False; i += 1; break
                elif i >= len(line):
                    return None
                else:
                    cur.append(c); i += 1
            else:
                if i >= len(line) or line[i] in whitespace:
                    break
                if c == ord('"'):
                    inq = True; i += 1
                elif c == ord("'"):
                    insq = True; i += 1
                else:
                    cur.append(c); i += 1
        out.append(bytes(cur))

def moon_split(line: bytes):
    # This models split_args_quoted's relevant branches, including its
    # space/tab-only leading and closing checks.
    out, i = [], 0
    while True:
        while i < len(line) and line[i] in b" \t":
            i += 1
        if i >= len(line):
            return out
        cur = bytearray()
        quote = line[i] if line[i] in (ord('"'), ord("'")) else None
        if quote is not None:
            i += 1
        if quote == ord('"') or quote == ord("'"):
            while True:
                if i >= len(line): return None
                c = line[i]
                if c == quote:
                    if i + 1 < len(line) and line[i+1] not in b" \t":
                        return None
                    i += 1; break
                cur.append(c); i += 1
        else:
            while i < len(line) and line[i] not in b" \t\n\r\v\f":
                cur.append(line[i]); i += 1
        out.append(bytes(cur))

cases = [b'SET k v"x', b'SET k v"x foo"', b'SET k "v"x', b'SET k "v" x',
         b'SET\vkey value', b'SET key\v"value"', b'SET key "value"\vnext']
for line in cases:
    print(line, "redis=", redis_split(line), "moon=", moon_split(line))
PY

Repository: pilotspace/moon

Length of output: 145


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
WS_REDIS = set(b" \n\r\t\v\f")
WS_MOON_LEADING = set(b" \t")
WS_MOON_UNQUOTED = set(b" \t\n\r\v\f")

def redis_split(line, limit=1000):
    out, i, steps = [], 0, 0
    while True:
        while i < len(line) and line[i] in WS_REDIS:
            i += 1
        if i >= len(line):
            return ("ok", out)
        cur = bytearray()
        inq = insq = False
        while True:
            steps += 1
            if steps > limit:
                return ("stuck", i)
            c = line[i] if i < len(line) else 0
            if inq:
                if c == ord('"'):
                    if i + 1 < len(line) and line[i + 1] not in WS_REDIS:
                        return ("error", "closing quote")
                    inq = False; i += 1; break
                cur.append(c); i += 1
            elif insq:
                if c == ord("'"):
                    if i + 1 < len(line) and line[i + 1] not in WS_REDIS:
                        return ("error", "closing quote")
                    insq = False; i += 1; break
                cur.append(c); i += 1
            else:
                if i >= len(line) or c in WS_REDIS:
                    break
                if c == ord('"'):
                    inq = True; i += 1
                elif c == ord("'"):
                    insq = True; i += 1
                else:
                    cur.append(c); i += 1
        if inq or insq:
            return ("error", "unbalanced quote")
        out.append(bytes(cur))

def moon_split(line, limit=1000):
    out, i, steps = [], 0, 0
    while True:
        while i < len(line) and line[i] in WS_MOON_LEADING:
            i += 1
        if i >= len(line):
            return ("ok", out)
        cur = bytearray()
        quote = line[i] if line[i] in (ord('"'), ord("'")) else None
        if quote is not None:
            i += 1
        while True:
            steps += 1
            if steps > limit:
                return ("stuck", i)
            if quote is not None:
                if i >= len(line):
                    return ("error", "unbalanced quote")
                c = line[i]
                if c == quote:
                    if i + 1 < len(line) and line[i + 1] not in WS_MOON_LEADING:
                        return ("error", "closing quote")
                    i += 1; break
                cur.append(c); i += 1
            else:
                if i >= len(line) or line[i] in WS_MOON_UNQUOTED:
                    break
                cur.append(line[i]); i += 1
        out.append(bytes(cur))

cases = [
    b'SET k v"x',
    b'SET k v"x foo"',
    b'SET k "v"x',
    b'SET k "v" x',
    b'SET key\v"value"',
    b'SET key\n"value"',
    b'SET key "value"\vnext',
]
for case in cases:
    print(repr(case))
    print("  redis:", redis_split(case))
    print("  moon :", moon_split(case))
PY

Repository: pilotspace/moon

Length of output: 820


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
WS_REDIS = set(b" \n\r\t\v\f")
WS_MOON_LEADING = set(b" \t")
WS_MOON_UNQUOTED = set(b" \t\n\r\v\f")

def redis_split(line, limit=1000):
    out, i, steps = [], 0, 0
    while True:
        while i < len(line) and line[i] in WS_REDIS:
            i += 1
        if i >= len(line):
            return ("ok", out)
        cur = bytearray()
        inq = insq = False
        while True:
            steps += 1
            if steps > limit:
                return ("stuck", i)
            if i >= len(line):
                if inq or insq:
                    return ("error", "unbalanced quote")
                break
            c = line[i]
            if inq:
                if c == ord('"'):
                    if i + 1 < len(line) and line[i + 1] not in WS_REDIS:
                        return ("error", "closing quote")
                    inq = False; i += 1; break
                cur.append(c); i += 1
            elif insq:
                if c == ord("'"):
                    if i + 1 < len(line) and line[i + 1] not in WS_REDIS:
                        return ("error", "closing quote")
                    insq = False; i += 1; break
                cur.append(c); i += 1
            else:
                if c in WS_REDIS:
                    break
                if c == ord('"'):
                    inq = True; i += 1
                elif c == ord("'"):
                    insq = True; i += 1
                else:
                    cur.append(c); i += 1
        out.append(bytes(cur))

def moon_split(line, limit=1000):
    out, i, steps = [], 0, 0
    while True:
        while i < len(line) and line[i] in WS_MOON_LEADING:
            i += 1
        if i >= len(line):
            return ("ok", out)
        cur = bytearray()
        quote = line[i] if line[i] in (ord('"'), ord("'")) else None
        if quote is not None:
            i += 1
        while True:
            steps += 1
            if steps > limit:
                return ("stuck", i)
            if quote is not None:
                if i >= len(line):
                    return ("error", "unbalanced quote")
                c = line[i]
                if c == quote:
                    if i + 1 < len(line) and line[i + 1] not in WS_MOON_LEADING:
                        return ("error", "closing quote")
                    i += 1; break
                cur.append(c); i += 1
            else:
                if i >= len(line) or line[i] in WS_MOON_UNQUOTED:
                    break
                cur.append(line[i]); i += 1
        out.append(bytes(cur))

for case in (
    b'SET k v"x',
    b'SET k v"x foo"',
    b'SET k "v"x',
    b'SET k "v" x',
    b'SET key\v"value"',
    b'SET key\n"value"',
    b'SET key "value"\vnext',
):
    print(repr(case))
    print("  redis:", redis_split(case))
    print("  moon :", moon_split(case))
PY

Repository: pilotspace/moon

Length of output: 834


Match Redis quote and whitespace handling in split_args_quoted

Treat " and ' as quote-state transitions inside unquoted tokens. Do not append them literally. SET k v"x must return an unbalanced-quotes error, while SET k v"x foo" must produce vx foo as one argument. Use the same Redis whitespace predicate for token boundaries and post-quote checks. The current mismatch can leave inputs such as SET key\v"value" stuck at \v because the parser does not advance past it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/protocol/inline.rs` around lines 235 - 245, Update split_args_quoted’s
unquoted-token parsing to treat single and double quotes as quote-state
transitions rather than appending them, returning an unbalanced-quotes error
when the quote is not closed and merging quoted content into the current
argument when it is. Reuse the Redis whitespace predicate for token boundaries
and post-quote checks, ensuring whitespace such as vertical tab advances the
parser instead of leaving it stuck.

}
args.push(Frame::BulkString(Bytes::from(current)));
}
}

/// SIMD-accelerated CRLF position finder. Returns position of \r.
#[inline]
fn find_crlf_position(buf: &[u8]) -> Option<usize> {
Expand Down
2 changes: 1 addition & 1 deletion src/protocol/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ pub mod parse;
pub mod resp3;
pub mod serialize;

pub use frame::{Frame, FrameVec, ParseConfig, ParseError};
pub use frame::{Frame, FrameVec, ParseConfig, ParseError, ProtoFault};
pub use inline::parse_inline;
pub use parse::parse;
pub use serialize::{serialize, serialize_resp3};
Loading
Loading