Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 7 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ build:
sdk-drift:
./scripts/check-sdk-lock.sh

check: sdk-drift
signal-cleanup-check:
./scripts/check-live-cleanup-signals.sh

check: sdk-drift signal-cleanup-check
cargo fmt --all -- --check
cargo test --locked
cargo clippy --all-targets --locked -- -D warnings
Expand All @@ -32,3 +35,6 @@ reproducible:

sigil-check: check
./scripts/check-sigil-compatibility.sh "{{sigil}}" "{{sigil_checkout}}"

live-acceptance: dist
./scripts/check-live-acceptance.sh "{{sigil}}" "{{sigil_checkout}}"
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ revision recorded in `SDK.lock`. `just reproducible` performs two isolated
builds and compares both the component and canonical package bytes.
`just sigil-check` loads the packed component through Sigil's production store
and Lua bridge, then drives it against a deterministic MySQL protocol peer.
`just live-acceptance` runs the same production path against pinned
SingleStoreDB Dev and stock MySQL 8 images, plus hostile protocol peers; see
[`conformance/live/README.md`](conformance/live/README.md) for its prerequisites,
exact identities, matrix, and evidence layout.

Version 0.1.2 is the first keyless-provenance release. The unprivileged
`prepare-release` workflow builds its package and canonical release manifest
Expand Down
94 changes: 94 additions & 0 deletions conformance/live/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Pinned live acceptance

This gate proves the unpublished MySQL SQL 0.2 candidate through Sigil's real
plugin store, lock, component host, and Lua resource bridge. It uses a
rootless Podman or Docker runner on Linux and never publishes an artifact.

## Exact inputs

- Candidate commit:
`e9659bb2c4b04d83c63391422867b1eb0c7f0901`
- `plugin.wasm` SHA-256:
`571501479e22ba02b47adb8e61b51006ca4d70200a6fb518db0a18e80cce80d3`
- `plugin.wasm` BLAKE3:
`d876478d14a9b1c89fff63ea54ed31cc3d225dfe04476c409fd02eed2c1585bf`
- `dist/mysql-0.2.0.sigil-plugin.tar.zst` SHA-256:
`47e039e312b2ada199a6fa47a30a1c9f9bdb99f1891bc5ef7b11cfbc85b37bdd`
- `dist/mysql-0.2.0.sigil-plugin.tar.zst` BLAKE3:
`b95ae75bb3f6384d04a1f3127a568972ac2f6d462a0ae0e3715ff749aed93fd9`
- SingleStoreDB Dev 0.2.35 Linux/amd64 manifest:
`ghcr.io/singlestore-labs/singlestoredb-dev@sha256:603b0ac0c7992becab334534a3ec1b37bac1a630b3e09cb50369fa222c72c269`
- MySQL 8.4.6 Linux/amd64 manifest:
`docker.io/library/mysql@sha256:c296d65ee6ab3ce2f608c1d1b2bdd3c08b087a5834101d76a6db2e00875216cc`

The script rejects changed candidate digests before starting either service.
It runs SingleStore with four CPUs because that image's free license rejects a
larger visible CPU count.

`just signal-cleanup-check` exercises the shared trap dispatcher in isolated
subprocesses. It proves ordinary exit status preservation, distinct 130/143
statuses for INT/TERM, exactly one cleanup call, and managed-resource absence.

## Run

Prerequisites are the normal build toolchain, Python 3.11 or newer, a
compatible Sigil binary and its exact source checkout, and either Podman or
Docker. Then run:

```sh
SIGIL=/absolute/path/to/sigil \
SIGIL_CHECKOUT=/absolute/path/to/sigil-source \
just live-acceptance
```

`OCI_ENGINE=podman` or `OCI_ENGINE=docker` selects a runner explicitly. The
script gives every container a unique name, publishes SQL only on an ephemeral
loopback port, and stops and removes every container plus every volume created
for it in the exit trap. `KEEP_LIVE_SCRATCH=1` retains the temporary Sigil
project for diagnosis; it does not retain service state.

Sigil 0.33.0 already contains the SQL 0.2 host contract but predates this
candidate's declared 0.33.1 floor. For that one version only, the harness
lowers the floor in a scratch manifest so it can exercise the production host.
The candidate component and package are hashed before that scratch operation,
and neither checked-in artifact is changed.

## Matrix

The real services cover the advertised `mysql_native_password` and
`caching_sha2_password` handshakes, successful authentication, database
selection, repeated `query` and `exec` calls on one session, temporary-table
continuity and fresh-session isolation, typed signed/unsigned integers,
decimal, finite floating point, SQL NULL, UTF-8 text, bytes, temporal lexemes,
and ten-digit `UNIX_TIMESTAMP` seconds. They also cover affected rows,
last-insert ID, warnings, vendor code and SQLSTATE, a nonterminal server error,
a delayed first response under a longer deadline, caller row/result-byte
ceilings, the fixed packet ceiling, idempotent close, and resource reuse.

SingleStore returns a typed `authentication` error with vendor code 1045 and
SQLSTATE 28000 for a bad native-password response. On the deliberately
plaintext MySQL lane, a bad `caching_sha2_password` token requests full
authentication; the component returns `unsupported` rather than transmitting
the password without TLS. Correct credentials use the cache-primed fast-auth
path.

Separate scenarios prove a one-second operator timeout, socket-loss
`transport`, malformed metadata `protocol`, invalid integer `encoding`, and
signed `TINYINT` overflow `protocol`, and oversized packet `limit`. The
hostile peer records the exact statement trace, requires EOF after every
terminal error, and rejects reconnection or replay.
A one-KiB host wire ceiling is checked both through the lossy JSON
`plugin_infrastructure` projection and the human diagnostic's exact
`PLUGIN_RESOURCE_LIMIT`; the report must contain only the successful connect
assertion, never partial row output.

Each run writes ignored evidence under `target/live-acceptance/run.*`, including
image pull identities, raw handshake probes, auth-plugin evidence, JSON
reports, hostile-peer traces, service logs, candidate identities, elapsed
times, the one-line `cleanup-count.txt`, container and volume inventories
before and after teardown, and `teardown.txt`. A successful teardown file ends
with:

```text
all live acceptance containers and volumes removed
```
22 changes: 22 additions & 0 deletions conformance/live/mysql-host-limit.sigil.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
return {
title = "Operator wire ceiling cannot return a partial SQL result",
priority = "P0",
policy = { capabilities = { "wasm.mysql" } },

run = function()
local mysql = require("wasm.mysql")
local connection, connect_err = mysql.connect({
endpoint = "database",
["username-secret"] = "MYSQL_USER",
["password-secret"] = "MYSQL_PASSWORD",
database = "app",
})
expect(connection ~= nil, connect_err and connect_err.message)
local rows, limit_err = connection:query("SELECT LPAD('', 4096, 'x')")
-- A host-owned max_bytes breach is deliberately latched by Sigil and
-- cannot be caught or relabelled by guest code. The harness expects this
-- scenario to abort with PLUGIN_RESOURCE_LIMIT before either assertion.
expect(rows == nil)
expect(limit_err.class == "limit")
end,
}
35 changes: 35 additions & 0 deletions conformance/live/mysql-protocol-faults.sigil.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
local function connect(mysql)
local connection, err = mysql.connect({
endpoint = "database",
["username-secret"] = "MYSQL_USER",
["password-secret"] = "MYSQL_PASSWORD",
database = "app",
})
expect(connection ~= nil, err and err.message)
return connection
end

local function expect_terminal(connection, statement, expected_class)
local rows, err = connection:query(statement)
expect(rows == nil)
expect(err.class == expected_class)
local after, closed = connection:query("SELECT must_not_replay")
expect(after == nil)
expect(closed.class == "closed")
connection:close()
connection:close()
end

return {
title = "Malformed MySQL results fail closed without partial values",
priority = "P0",
policy = { capabilities = { "wasm.mysql" } },

run = function()
local mysql = require("wasm.mysql")
expect_terminal(connect(mysql), "SELECT malformed_metadata", "protocol")
expect_terminal(connect(mysql), "SELECT invalid_integer", "encoding")
expect_terminal(connect(mysql), "SELECT integer_overflow", "protocol")
expect_terminal(connect(mysql), "SELECT oversized_packet", "limit")
end,
}
200 changes: 200 additions & 0 deletions conformance/live/mysql-session.sigil.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
local function connect(mysql, password_secret, limits)
limits = limits or {}
local connection, err = mysql.connect({
endpoint = "database",
["username-secret"] = "MYSQL_USER",
["password-secret"] = password_secret,
database = "app",
["max-rows"] = limits.max_rows,
["max-result-bytes"] = limits.max_result_bytes,
})
return connection, err
end

local function expect_cell(cell, tag, value)
expect(cell.tag == tag)
if tag == "unsigned" and type(value) == "string" then
expect(tostring(cell.value) == value)
else
expect(cell.value == value)
end
end

return {
title = "Live MySQL dialect is typed, stateful, bounded, and fail-closed",
priority = "P0",
budget = { max_seconds = 30 },
policy = { capabilities = { "wasm.mysql" } },

run = function()
local mysql = require("wasm.mysql")
local dialect = sigil.env("MYSQL_DIALECT")
expect(dialect == "singlestore" or dialect == "mysql84")

local rejected, auth_err = connect(mysql, "MYSQL_BAD_PASSWORD")
expect(rejected == nil)
if dialect == "mysql84" then
-- The lane-less route is intentionally plaintext. A wrong
-- caching_sha2_password token requests full authentication, which this
-- driver must refuse rather than transmit a password without TLS.
expect(auth_err.class == "unsupported")
expect(auth_err["vendor-code"] == nil)
expect(auth_err.sqlstate == nil)
else
expect(auth_err.class == "authentication")
expect(auth_err["vendor-code"] == 1045)
expect(auth_err.sqlstate == "28000")
end

local session, connect_err = connect(mysql, "MYSQL_PASSWORD")
expect(session ~= nil, connect_err and connect_err.message)

local selected_database, database_err = session:query("SELECT DATABASE()")
expect(selected_database ~= nil, database_err and database_err.message)
expect_cell(selected_database.rows[1].cells[1], "text", "app")

local created, create_err = session:exec([[
CREATE TEMPORARY TABLE sigil_acceptance (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
signed_value BIGINT,
unsigned_value BIGINT UNSIGNED,
decimal_value DECIMAL(30,6),
float_value DOUBLE,
null_value BIGINT NULL,
text_value VARCHAR(64),
bytes_value VARBINARY(3),
ts_value DATETIME(6)
)
]])
expect(created ~= nil, create_err and create_err.message)
expect(created["affected-rows"] == 0)
expect(created["last-insert-id"] == 0)
expect(created.warnings == 0)

local inserted, insert_err = session:exec([[
INSERT INTO sigil_acceptance
(signed_value, unsigned_value, decimal_value, float_value,
null_value, text_value, bytes_value, ts_value)
VALUES
(-9223372036854775808, 18446744073709551615,
12345678901234567890.123400, 1.25,
NULL, 'snowman ☃', _binary 0x00ff80,
'2026-08-30 12:34:56.000001')
]])
expect(inserted ~= nil, insert_err and insert_err.message)
expect(inserted["affected-rows"] == 1)
expect(inserted["last-insert-id"] == 1)
expect(inserted.warnings == 0)

local rows, rows_err = session:query([[
SELECT signed_value, unsigned_value, decimal_value, float_value,
null_value, text_value, bytes_value, ts_value,
UNIX_TIMESTAMP('2026-08-30 12:34:56') AS epoch_seconds
FROM sigil_acceptance
]])
expect(rows ~= nil, rows_err and rows_err.message)
expect(#rows.columns == 9)
expect(#rows.rows == 1)
expect(#rows.rows[1].cells == 9)
expect_cell(rows.rows[1].cells[1], "signed", -9223372036854775807 - 1)
expect_cell(rows.rows[1].cells[2], "unsigned", "18446744073709551615")
expect_cell(rows.rows[1].cells[3], "decimal", "12345678901234567890.123400")
expect_cell(rows.rows[1].cells[4], "floating", 1.25)
expect_cell(rows.rows[1].cells[5], "null", nil)
expect_cell(rows.rows[1].cells[6], "text", "snowman ☃")
expect(rows.rows[1].cells[7].tag == "bytes")
local b1, b2, b3 = string.byte(rows.rows[1].cells[7].value, 1, 3)
expect(b1 == 0 and b2 == 255 and b3 == 128)
expect_cell(rows.rows[1].cells[8], "temporal", "2026-08-30 12:34:56.000001")
expect(rows.columns[8].type == "temporal")
expect(rows.columns[8]["temporal-type"] == "datetime")
expect(rows.rows[1].cells[9].value == 1788093296)
expect(tostring(rows.rows[1].cells[9].value):match("^%d%d%d%d%d%d%d%d%d%d$") ~= nil)

local warning_table, warning_table_err = session:exec(
"CREATE TEMPORARY TABLE sigil_warning(value VARCHAR(3))"
)
expect(warning_table ~= nil, warning_table_err and warning_table_err.message)
local warned, warned_err = session:exec(
"INSERT IGNORE INTO sigil_warning(value) VALUES ('toolong')"
)
expect(warned ~= nil, warned_err and warned_err.message)
expect(warned["affected-rows"] == 1)
if dialect == "mysql84" then
expect(warned.warnings == 1)
else
expect(warned.warnings == 0)
end

local duplicate, duplicate_err = session:exec([[
INSERT INTO sigil_acceptance
(id, signed_value, unsigned_value, decimal_value, float_value,
null_value, text_value, bytes_value, ts_value)
VALUES
(1, 0, 0, 0, 0, NULL, 'duplicate', _binary 0x000000,
'2026-08-30 12:34:56')
]])
expect(duplicate == nil)
expect(duplicate_err.class == "server")
expect(duplicate_err["vendor-code"] == 1062)
expect(duplicate_err.sqlstate == "23000")

local after_server, after_server_err = session:query(
"SELECT signed_value FROM sigil_acceptance"
)
expect(after_server ~= nil, after_server_err and after_server_err.message)
expect(after_server.rows[1].cells[1].value == -9223372036854775807 - 1)

local delayed, delayed_err = session:query("SELECT SLEEP(2), 7")
expect(delayed ~= nil, delayed_err and delayed_err.message)
expect(delayed.rows[1].cells[2].value == 7)

session:close()
session:close()

local fresh, fresh_err = connect(mysql, "MYSQL_PASSWORD")
expect(fresh ~= nil, fresh_err and fresh_err.message)
local leaked, leaked_err = fresh:query("SELECT signed_value FROM sigil_acceptance")
expect(leaked == nil)
expect(leaked_err.class == "server")
fresh:close()

local row_limited, row_connect_err = connect(mysql, "MYSQL_PASSWORD", { max_rows = 1 })
expect(row_limited ~= nil, row_connect_err and row_connect_err.message)
local partial_rows, row_limit_err = row_limited:query("SELECT 1 UNION ALL SELECT 2")
expect(partial_rows == nil)
expect(row_limit_err.class == "limit")
local row_after, row_closed = row_limited:query("SELECT 3")
expect(row_after == nil)
expect(row_closed.class == "closed")
row_limited:close()

local byte_limited, byte_connect_err = connect(
mysql,
"MYSQL_PASSWORD",
{ max_result_bytes = 7 }
)
expect(byte_limited ~= nil, byte_connect_err and byte_connect_err.message)
local partial_bytes, byte_limit_err = byte_limited:query("SELECT 1")
expect(partial_bytes == nil)
expect(byte_limit_err.class == "limit")
local byte_after, byte_closed = byte_limited:exec("DO 1")
expect(byte_after == nil)
expect(byte_closed.class == "closed")
byte_limited:close()

local fixed_limited, fixed_connect_err = connect(
mysql,
"MYSQL_PASSWORD",
{ max_rows = 4294967295, max_result_bytes = 33554432 }
)
expect(fixed_limited ~= nil, fixed_connect_err and fixed_connect_err.message)
local oversized, fixed_limit_err = fixed_limited:query("SELECT LPAD('', 1048576, 'x')")
expect(oversized == nil)
expect(fixed_limit_err.class == "limit")
local fixed_after, fixed_closed = fixed_limited:query("SELECT 4")
expect(fixed_after == nil)
expect(fixed_closed.class == "closed")
fixed_limited:close()
end,
}
Loading