Skip to content

A truncated FT.CREATE (... VECTOR HNSW with nothing after) panics the shard thread and aborts the whole process #681

Description

@TinDang97

A truncated FT.CREATE kills the whole server process

One malformed command from any client aborts moon. No auth, no privileged
context, no large payload — a single short line.

$ redis-cli -p 6487 FT.CREATE tidx ON HASH PREFIX 1 d: SCHEMA v VECTOR HNSW
Error: Server closed the connection
$ redis-cli -p 6487 PING
Could not connect to Redis at 127.0.0.1:6487: Connection refused

Server log:

thread 'shard-0' panicked at src/command/vector_search/ft_create.rs:550:39:
index out of bounds: the len is 10 but the index is 10
FATAL: thread 'shard-0' panicked; aborting the whole process rather than
serving with a dead shard or a dead cluster control plane

The panic is a shard-thread panic, and moon's policy deliberately escalates
that to a process abort — so the blast radius is the entire server, every
database, every other client, not just the offending connection.

Cause

parse_vector_field_params (src/command/vector_search/ft_create.rs:542)
bounds-checks the algorithm keyword, then advances past it and reads the
parameter count with no second check:

if *pos >= args.len() || !matches_keyword(&args[*pos], b"HNSW") {
    return Err(Frame::Error(Bytes::from_static(b"ERR expected HNSW algorithm")));
}
*pos += 1;

let num_params = match parse_u32(&args[*pos]) {   // <-- line 550, no bounds check

When HNSW is the last argument, *pos becomes args.len() and the index
panics.

Scope: exactly one reachable site, not a family

My first assumption was that every *pos += 1; args[*pos] pair in this
function was a separate crash — a dozen of them. Measured, and that is
wrong.
The parameter loop guards both ends:

while *pos + 1 < param_end && *pos + 1 < args.len() {

so after the key is consumed, the value read is in bounds. Probed one server
per case, each freshly spawned and liveness-checked by listener PID:

Command (truncated at the end) Result
... VECTOR HNSW DEAD — ft_create.rs:550:39
... VECTOR HNSW 6 TYPE alive
... VECTOR HNSW 6 TYPE FLOAT32 DIM alive
... VECTOR HNSW 6 TYPE FLOAT32 DIM 4 DISTANCE_METRIC alive
... VECTOR HNSW 6 TYPE FLOAT32 DIM 4 M alive
well-formed control alive

Line 550 is the one unguarded read.

Present on main

src/command/vector_search/ is byte-identical between main and the branch
this was found on (git diff main --stat -- src/command/vector_search/ is
empty), so this is not branch-local. The ERR expected HNSW algorithm string
above it has been in the file since #27.

No fuzz coverage for this parser

There are 19 targets in fuzz/fuzz_targets/ and none of them reach
FT.CREATE argument parsing. CLAUDE.md requires a fuzz target for any new
parser or decoder; the vector schema parser has never had one, which is why a
one-line truncation survived this long.

Suggested fix

The bounds check, returning the error the very next arm already uses for an
unparseable count so the two truncation shapes agree:

*pos += 1;
if *pos >= args.len() {
    return Err(Frame::Error(Bytes::from_static(b"ERR invalid param count")));
}

Plus a fuzz target over FT.CREATE argument vectors, listed in both
matrices in .github/workflows/fuzz.yml (an unlisted target never runs —
moon#576).

Note there is no redis oracle to match here: the redis-server 8.6.1 on this
host has no query engine, so FT.CREATE is unknown command there. The error
string is moon's own to choose.

How this surfaced

Reading ft_create.rs while scoping #679(c), which reports that the harness's
FT.CREATE ... VECTOR FLAT row has never passed.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions