Skip to content

Phase 3: run statements with COM_QUERY - #8

Merged
m13m merged 1 commit into
masterfrom
phase-3-com-query
Oct 29, 2026
Merged

m13m merged 1 commit into
masterfrom
phase-3-com-query

Conversation

@m13m

@m13m m13m commented Oct 29, 2026

Copy link
Copy Markdown
Member

Every statement that returns no rows now works end to end against a real server — INSERT, UPDATE, DELETE, CREATE, DROP, SET, USE.

{:ok, insert, client} = Client.query(client, "INSERT INTO widgets (name) VALUES ('first')")
insert.affected_rows   # => 1
insert.last_insert_id  # => 1

58 tests with --include integration; 50 and 4 excluded without.

The command is trivial, the response is the work

COM_QUERY is one byte and the SQL, since CLIENT_QUERY_ATTRIBUTES is not claimed. Its response is decided entirely by the first byte:

0x00  OK      the statement returned no rows
0xFF  ERR     it failed
0xFB  LOCAL INFILE — the server wants a file from the client
else  a length-encoded column count, introducing a resultset

What to look at

0xFB is refused outright. CLIENT_LOCAL_FILES is not claimed, so a server asking the client to upload a file is either broken or hostile. The answer is an error, not a filesystem read. This is the attack the capability refusal in #5 was guarding against, and here is where that guard actually pays.

A resultset returns :resultset_not_implemented rather than a guess, and the integration test asserts that against a real SELECT. The gap is visible in the suite rather than implied by its absence.

Sequence ids reset per command. The statement goes out as 0 and the reply arrives as 1. This is the tracking phase 1 deliberately deferred until there was a command to track it across — see the "deliberately not here" list in ROADMAP.md.

query/3 returns three-tuples — {:ok, result, client} / {:error, reason, client} — so the read buffer threads through. Leftover bytes from one message belong to the next, and the client is the only thing that can carry them.

Integration coverage

Against MySQL 8.0: creates a table, inserts two rows and checks both AUTO_INCREMENT ids, verifies Rows matched: 1 in an update's info string, and gets 1146 / 42S02 / "doesn't exist" back for a missing table.

Next

The resultset: column count, column definitions, then rows as length-encoded strings with 0xFB for NULL. Every codec it needs already exists, so it is three packet parsers and a loop.

The command itself is one byte and the SQL, since CLIENT_QUERY_ATTRIBUTES
is not claimed. The work is in the response, whose first byte decides
everything: 0x00 an OK for a statement that returned no rows, 0xFF an
error, 0xFB the server asking the client to upload a file, anything else
a length-encoded column count introducing a resultset.

Commands reset the sequence id, so the statement goes out as 0 and the
reply arrives as 1 — the tracking phase 1 deliberately left until there
was a command to track it across.

A resultset returns :resultset_not_implemented rather than a guess. The
integration test asserts that too, so the gap is visible rather than
implied.

0xFB is refused outright. CLIENT_LOCAL_FILES is not claimed, so a server
asking for a file is either broken or hostile, and the answer is not to
read the filesystem.

Against a real MySQL 8.0 this creates a table, inserts rows and gets
their AUTO_INCREMENT ids back, reports rows matched for an update, and
returns the server's own error for a missing table.
@m13m
m13m merged commit dccb885 into master Oct 29, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant