commit 59ff9c1e3ec71d3fa4e9918c138683aaa2dd9088
parent 924a2d919032142e97c129583274c7073ea8c3da
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Thu, 24 Sep 2026 17:44:01 -0300
sqlite3: fuzz the bindings against the properties they promise
jm:sqlite3/fuzz generates values and damaged SQL and checks six properties:
a bound value reads back as itself, a value is never parsed as SQL, broken
SQL faults and leaves the connection usable, a mismatched argument list is
refused, a failed transaction leaves nothing behind, and a reused statement
stays honest. tools/sqlite3-fuzz runs it for a time or a count; `just test`
runs 600 cases on each of four fixed seeds.
A run is a function of its seed, and every report names the seed it used, so
a random run that fails replays exactly. Report.digest fingerprints the
randomness each case consumed, which is what makes that checkable on the runs
where nothing failed.
Two things the harness needed to be worth having. Its properties read every
row before comparing any of them, because comparing inside the loop passes
even when a column read hands back SQLite's memory instead of a copy:
deleting the clone from text leaves the example-based tests green and fails
one case in three here. And each case has a deadline, since a damaged
recursive CTE can return rows without end; sqlite3.interrupt is new, and is
what stops one.
Diffstat:
7 files changed, 1037 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
@@ -42,6 +42,7 @@ binary.
| `flow` | `width`, `each`, `manage`: lock-free worker pools where each worker owns one state slot and the caller merges afterwards |
| `tar` | `read`, `extract`: `git archive` output without a tar program |
| `sqlite3` | `open`, `exec`, `exec_args`, `query`/`next`, `prepare`, `transact` over a statically linked SQLite |
+| `sqlite3/fuzz` | property fuzzer for `jm:sqlite3`: generated values and damaged SQL, replayable by seed |
`tools/odin-run` is the runner. Every package reads on its own; the doc
comment at the top of each file is the reference.
@@ -85,7 +86,7 @@ just build debug odin-run just release optimised odin-run
just test all package tests just check 3-target type-check
just install odin-run -> ~/.local/bin (BINDIR overrides)
just sqlite compile the vendored SQLite just clean
-just example run examples/hello.odin
+just example run examples/hello.odin just fuzz 30s of jm:sqlite3 fuzzing
```
`just install` bakes this checkout's path into the runner as the `jm`
@@ -119,3 +120,35 @@ a text or blob column are freed on the next `step`, and SQLite reuses its own
pool rather than returning them to libc, so reading a stale pointer yields the
*next* row's data instead of crashing. AddressSanitizer cannot see it. That is
why `text` and `blob` clone into the allocator the query was given.
+
+## Fuzzing jm:sqlite3
+
+`jm:sqlite3/fuzz` generates values and damaged SQL and checks the properties
+the package promises: a bound value reads back as itself, a value is never
+parsed as SQL, broken SQL faults and leaves the connection usable, an
+argument list that does not match the statement is refused, a failed
+transaction leaves nothing behind, and a reused statement stays honest.
+
+```
+just fuzz 30 seconds, roughly a million cases
+just fuzz "-for=5m" longer
+just fuzz "-seed=12345" replay a reported seed exactly
+just fuzz-asan the same under AddressSanitizer
+```
+
+A run is a pure function of its seed, and a report always names the seed it
+used, so a failure found by a random run replays deterministically. `just
+test` runs 600 cases on each of four fixed seeds.
+
+Two things about it are worth knowing, because both were found by building
+it:
+
+- **The properties read every row before comparing any of them.** Comparing a
+ column while the cursor is still on its row passes even when the read handed
+ back SQLite's own memory instead of a copy, because the bytes have not been
+ reused yet. Deleting the clone from `text` leaves the whole example-based
+ test suite green and fails 1 case in 3 here.
+- **Each case has a watchdog.** Nothing in SQLite bounds how long a statement
+ runs, and a recursive CTE whose recursion stops advancing returns rows
+ forever, so a case that overruns is interrupted and reported as a hang
+ rather than stopping the run. `sqlite3.interrupt` is what does it.
diff --git a/justfile b/justfile
@@ -5,6 +5,7 @@
# just test run every package's tests
# just check type-check every package for linux, darwin and windows
# just sqlite compile the vendored SQLite amalgamation into sqlite3/lib
+# just fuzz run the jm:sqlite3 property fuzzer for thirty seconds
# just install release odin-run into ~/.local/bin with this checkout baked in
# just example compile and run examples/hello.odin through the collection
# just clean remove build/
@@ -14,7 +15,7 @@ root := justfile_directory()
flags := "-vet -strict-style -collection:jm=" + root
exe := if os() == "windows" { ".exe" } else { "" }
bindir := env("BINDIR", home_directory() / ".local" / "bin")
-packages := "prelude sh http path timefmt debug flow tar sqlite3"
+packages := "prelude sh http path timefmt debug flow tar sqlite3 sqlite3/fuzz"
cc := env("CC", "cc")
sqlite_lib := if os() == "windows" { "sqlite3/lib/sqlite3.lib" } else { "sqlite3/lib/sqlite3.a" }
@@ -69,13 +70,16 @@ sqlite:
# Run every package's tests
test: sqlite
mkdir -p build/test
- for p in {{packages}}; do {{odin}} test $p {{flags}} -out:build/test/$p{{exe}} || exit 1; done
+ for p in {{packages}}; do \
+ {{odin}} test $p {{flags}} -out:build/test/$(echo $p | tr / -){{exe}} || exit 1; \
+ done
# Type-check every package and the runner for each target
check:
for t in {{targets}}; do \
for p in {{packages}}; do {{odin}} check $p {{flags}} -no-entry-point -target:$t || exit 1; done; \
{{odin}} check tools/odin-run {{flags}} -target:$t || exit 1; \
+ {{odin}} check tools/sqlite3-fuzz {{flags}} -target:$t || exit 1; \
{{odin}} check examples/hello.odin -file {{flags}} -target:$t || exit 1; \
done
@@ -84,6 +88,22 @@ install: release
mkdir -p {{bindir}}
cp build/release/odin-run{{exe}} {{bindir}}/odin-run{{exe}}
+# `just fuzz` runs the default bound; `just fuzz "-for=5m"` or
+# `just fuzz "-seed=12345"` passes arguments straight through.
+
+# Throw generated values and damaged SQL at jm:sqlite3 until something gives
+fuzz args="-for=30s": sqlite
+ mkdir -p build/debug
+ {{odin}} build tools/sqlite3-fuzz -debug {{flags}} -out:build/debug/sqlite3-fuzz{{exe}}
+ build/debug/sqlite3-fuzz{{exe}} {{args}}
+
+# The same, with the FFI boundary under AddressSanitizer
+[unix]
+fuzz-asan args="-for=30s": sqlite
+ mkdir -p build/debug
+ {{odin}} build tools/sqlite3-fuzz -debug -sanitize:address {{flags}} -out:build/debug/sqlite3-fuzz-asan
+ build/debug/sqlite3-fuzz-asan {{args}}
+
# Compile and run the example script
example: build sqlite
ODIN_RUN_VERBOSE=1 build/debug/odin-run{{exe}} examples/hello.odin
diff --git a/sqlite3/ffi.odin b/sqlite3/ffi.odin
@@ -54,6 +54,7 @@ foreign lib {
sqlite3_errmsg :: proc(db: ^Connection) -> cstring ---
sqlite3_extended_errcode :: proc(db: ^Connection) -> c.int ---
sqlite3_busy_timeout :: proc(db: ^Connection, ms: c.int) -> c.int ---
+ sqlite3_interrupt :: proc(db: ^Connection) ---
sqlite3_changes64 :: proc(db: ^Connection) -> i64 ---
sqlite3_last_insert_rowid :: proc(db: ^Connection) -> i64 ---
diff --git a/sqlite3/fuzz/fuzz.odin b/sqlite3/fuzz/fuzz.odin
@@ -0,0 +1,785 @@
+/*
+Package fuzz throws randomly generated values and randomly damaged SQL at
+jm:sqlite3 and checks the properties that must hold whatever comes back.
+
+ report := fuzz.run({seed = 1, iterations = 10_000})
+ for f in report.failures {
+ fmt.eprintf("%s failed at iteration %d: %s\n", f.property, f.iteration, f.detail)
+ }
+
+Every run is a pure function of its seed, so a failure replays exactly:
+`just fuzz seed=<seed>` runs the same cases in the same order. A report names
+the seed it used even when it was asked for a random one.
+
+The properties are the promises the package makes that a test with fixed
+inputs can only sample:
+
+ round_trip a bound value reads back as itself, whatever bytes it holds
+ injection a value is never parsed as SQL, however much it looks like it
+ damaged_sql broken SQL faults and leaves the connection usable
+ arity an argument list that does not match the statement is refused
+ atomicity a transaction that fails leaves nothing behind
+ reuse bind, step and reset in any order keep a statement honest
+
+What this cannot see: SQLite reuses memory from its own pool, so reading a
+column after the next step returns stale bytes rather than tripping
+AddressSanitizer. The clone in text and blob is what prevents it, and only
+round_trip's comparison catches a regression there.
+*/
+package fuzz
+
+import "base:runtime"
+import "core:fmt"
+import "core:math"
+import "core:math/rand"
+import "core:mem"
+import "core:slice"
+import "core:strings"
+import "core:sync"
+import "core:thread"
+import "core:time"
+
+import "jm:sqlite3"
+
+// Opts bounds a run. The zero value is one thousand iterations from a seed
+// taken off the clock.
+Opts :: struct {
+ // The seed to replay. 0 takes one from the clock and reports it.
+ seed: u64,
+ // How many cases to run. 0 means one thousand, or until duration runs out.
+ iterations: int,
+ // Stop after this long, however many iterations are left. 0 is no limit.
+ duration: time.Duration,
+ // Stop at the first failure rather than collecting them all.
+ stop_on_first: bool,
+ // How long one case may run before it is interrupted and reported as a
+ // hang. 0 means five seconds. Nothing in SQLite bounds a statement, and
+ // a recursive query can return rows without end, so a harness with no
+ // watchdog stops being a harness the first time it generates one.
+ case_timeout: time.Duration,
+ // Called once per property failure, and once per 1000 iterations when
+ // progress is worth showing. nil is silent.
+ log: proc(format: string, args: ..any),
+}
+
+// Failure is one property that did not hold, with enough to reproduce it.
+Failure :: struct {
+ property: string,
+ iteration: int,
+ // The seed the whole run used; replaying it reaches this case again.
+ seed: u64,
+ // What differed, in full: the value bound and the value read back.
+ detail: string,
+}
+
+// Report is what a run found. Its failures are allocated in the allocator
+// run was given, and belong to the caller.
+Report :: struct {
+ seed: u64,
+ iterations: int,
+ elapsed: time.Duration,
+ failures: []Failure,
+ // A fingerprint of the randomness each case consumed. Two runs of the
+ // same seed agree on it; a run that generated anything differently does
+ // not. It is what makes determinism checkable when nothing failed.
+ digest: u64,
+}
+
+// watchdog interrupts a case that overruns. The mutex covers both fields, so
+// the connection cannot be closed between the deadline check and the
+// interrupt that follows it.
+@(private)
+Watchdog :: struct {
+ mutex: sync.Mutex,
+ db: sqlite3.Db,
+ deadline: time.Time,
+ fired: bool,
+ stop: bool,
+}
+
+// Property is one promise, checked against a scratch database that is thrown
+// away afterwards. It returns the detail of what went wrong, and whether it
+// held.
+Property :: struct {
+ name: string,
+ check: proc(db: sqlite3.Db) -> (detail: string, ok: bool),
+}
+
+// properties is every promise a run cycles through, one per iteration.
+properties := []Property {
+ {"round_trip", round_trip},
+ {"injection", injection},
+ {"damaged_sql", damaged_sql},
+ {"arity", arity},
+ {"atomicity", atomicity},
+ {"reuse", reuse},
+}
+
+// run cycles the properties over generated cases and reports what failed.
+run :: proc(opts := Opts{}, allocator := context.allocator) -> Report {
+ opts := opts
+ if opts.seed == 0 {
+ opts.seed = u64(time.now()._nsec) | 1
+ }
+ if opts.iterations == 0 {
+ opts.iterations = 1000
+ }
+
+ state := rand.create(opts.seed)
+ context.random_generator = runtime.default_random_generator(&state)
+
+ dog := new(Watchdog, context.temp_allocator)
+ timeout := opts.case_timeout if opts.case_timeout > 0 else 5 * time.Second
+ guard := thread.create_and_start_with_poly_data2(dog, timeout, watch)
+ defer {
+ sync.lock(&dog.mutex)
+ dog.stop = true
+ sync.unlock(&dog.mutex)
+ thread.join(guard)
+ thread.destroy(guard)
+ }
+
+ failures := make([dynamic]Failure, allocator)
+ started := time.now()
+ done := 0
+ digest := u64(1469598103934665603)
+ for i in 0 ..< opts.iterations {
+ if opts.duration > 0 && time.since(started) >= opts.duration {
+ break
+ }
+ done = i + 1
+ p := properties[i % len(properties)]
+
+ // Each case gets its own database and its own arena, so a case can
+ // neither inherit state from the last nor keep memory after it.
+ arena: mem.Dynamic_Arena
+ mem.dynamic_arena_init(&arena)
+ defer mem.dynamic_arena_destroy(&arena)
+ case_context := context
+ case_context.allocator = mem.dynamic_arena_allocator(&arena)
+
+ detail, ok := run_case(p, case_context, dog, timeout)
+ // Drawn after the case, so it reflects how much randomness the case
+ // used, not just which property ran.
+ digest = (digest ~ rand.uint64()) * 1099511628211
+ if !ok {
+ // The detail was built in the case arena, which is about to go.
+ f := Failure {
+ property = p.name,
+ iteration = i,
+ seed = opts.seed,
+ detail = strings.clone(detail, allocator),
+ }
+ append(&failures, f)
+ if opts.log != nil {
+ opts.log("%s failed at iteration %d: %s", p.name, i, f.detail)
+ }
+ if opts.stop_on_first {
+ break
+ }
+ }
+ if opts.log != nil && done % 1000 == 0 {
+ opts.log("%d iterations, %d failures", done, len(failures))
+ }
+ }
+ return Report {
+ seed = opts.seed,
+ iterations = done,
+ elapsed = time.since(started),
+ failures = failures[:],
+ digest = digest,
+ }
+}
+
+// run_case opens the scratch database, checks one property against it and
+// closes it again, under the case's own allocator.
+@(private)
+run_case :: proc(
+ p: Property,
+ case_context: runtime.Context,
+ dog: ^Watchdog,
+ timeout: time.Duration,
+) -> (
+ detail: string,
+ ok: bool,
+) {
+ context = case_context
+ db, err := sqlite3.open(sqlite3.MEMORY)
+ if err != nil {
+ return fmt.tprintf("open failed: %v", err), false
+ }
+
+ sync.lock(&dog.mutex)
+ dog.db = db
+ dog.deadline = time.time_add(time.now(), timeout)
+ dog.fired = false
+ sync.unlock(&dog.mutex)
+
+ detail, ok = p.check(db)
+
+ // Retire the connection from the watchdog before closing it, so an
+ // interrupt can never land on a closed handle.
+ sync.lock(&dog.mutex)
+ hung := dog.fired
+ dog.db = {}
+ sync.unlock(&dog.mutex)
+ sqlite3.close(&db)
+
+ if hung {
+ // Whatever the property made of the interrupt, the case did not
+ // finish on its own, and that is the thing worth reporting.
+ if detail == "" {
+ return fmt.tprintf("did not finish within %v", timeout), false
+ }
+ return fmt.tprintf("did not finish within %v, then: %s", timeout, detail), false
+ }
+ return detail, ok
+}
+
+// watch interrupts a case that has run past its deadline, and keeps
+// interrupting until the case retires its connection. Interrupting once is
+// not enough: a property runs several statements, and the next one would
+// simply hang in place of the one that was stopped.
+@(private)
+watch :: proc(dog: ^Watchdog, timeout: time.Duration) {
+ for {
+ time.sleep(10 * time.Millisecond)
+ sync.lock(&dog.mutex)
+ if dog.stop {
+ sync.unlock(&dog.mutex)
+ return
+ }
+ if dog.db.handle != nil && time.since(dog.deadline) > 0 {
+ sqlite3.interrupt(dog.db)
+ dog.fired = true
+ }
+ sync.unlock(&dog.mutex)
+ }
+}
+
+// round_trip binds one generated value and reads it back. Whatever bytes go
+// in come out, and the storage class is the one the value asked for.
+round_trip :: proc(db: sqlite3.Db) -> (detail: string, ok: bool) {
+ // An untyped column keeps whatever class it is given, with no affinity
+ // to convert it on the way in.
+ if err := sqlite3.exec(db, `CREATE TABLE t(v)`); err != nil {
+ return fmt.tprintf("create: %v", err), false
+ }
+ v := value()
+ if err := sqlite3.exec_args(db, `INSERT INTO t VALUES (?)`, v); err != nil {
+ return fmt.tprintf("insert %s: %v", show(v), err), false
+ }
+ rows, qerr := sqlite3.query(db, `SELECT v FROM t`)
+ if qerr != nil {
+ return fmt.tprintf("select: %v", qerr), false
+ }
+ defer sqlite3.finish(&rows)
+ if !sqlite3.next(&rows) {
+ return fmt.tprintf("%s vanished", show(v)), false
+ }
+
+ want := expected_type(v)
+ if got := sqlite3.type_of(rows, 0); got != want {
+ return fmt.tprintf("%s stored as %v, wanted %v", show(v), got, want), false
+ }
+ switch bound in v {
+ case i64:
+ if got := sqlite3.integer(rows, 0); got != bound {
+ return fmt.tprintf("%d read back as %d", bound, got), false
+ }
+ case f64:
+ // SQLite has no NaN: binding one stores NULL, which expected_type
+ // already accounts for.
+ if !math.is_nan(bound) {
+ if got := sqlite3.real(rows, 0); got != bound {
+ return fmt.tprintf("%v read back as %v", bound, got), false
+ }
+ }
+ case bool:
+ if got := sqlite3.boolean(rows, 0); got != bound {
+ return fmt.tprintf("%v read back as %v", bound, got), false
+ }
+ case string:
+ if got := sqlite3.text(rows, 0); got != bound {
+ return fmt.tprintf("%s read back as %s", show(v), show(got)), false
+ }
+ case []byte:
+ got := sqlite3.blob(rows, 0)
+ if len(got) != len(bound) || !slice.equal(got, bound) {
+ return fmt.tprintf("%s read back as %s", show(v), show(got)), false
+ }
+ }
+ return "", true
+}
+
+// injection writes generated text that is trying to look like SQL, and checks
+// that none of it was parsed as any. The canary table is what a successful
+// injection would drop.
+injection :: proc(db: sqlite3.Db) -> (detail: string, ok: bool) {
+ if err := sqlite3.exec(db, `CREATE TABLE t(v TEXT); CREATE TABLE canary(x)`); err != nil {
+ return fmt.tprintf("create: %v", err), false
+ }
+ count := rand.int_range(1, 16)
+ written := make([]string, count, context.temp_allocator)
+ for i in 0 ..< count {
+ written[i] = sql_shaped_text()
+ if err := sqlite3.exec_args(db, `INSERT INTO t VALUES (?)`, written[i]); err != nil {
+ return fmt.tprintf("insert %s: %v", show(written[i]), err), false
+ }
+ }
+
+ got, gerr := collect(db, `SELECT v FROM t ORDER BY rowid`)
+ if gerr != "" {
+ return gerr, false
+ }
+ if len(got) != count {
+ return fmt.tprintf("wrote %d rows, read %d", count, len(got)), false
+ }
+ for want, i in written {
+ if got[i] != want {
+ return fmt.tprintf("row %d: %s read back as %s", i, show(want), show(got[i])), false
+ }
+ }
+
+ // Nothing bound may have reached the parser, so the canary is untouched.
+ check, cerr := sqlite3.query(db, `SELECT count(*) FROM canary`)
+ if cerr != nil {
+ return fmt.tprintf("canary gone: %v", cerr), false
+ }
+ defer sqlite3.finish(&check)
+ if !sqlite3.next(&check) {
+ return "canary unreadable", false
+ }
+ return "", true
+}
+
+// damaged_sql feeds the parser text it should refuse. A refusal is a Fault,
+// never a crash, and the connection still works afterwards.
+damaged_sql :: proc(db: sqlite3.Db) -> (detail: string, ok: bool) {
+ bad := damaged_statement()
+ // Either outcome is allowed: damage can land on something valid. What is
+ // not allowed is a crash, or a connection that stops answering.
+ _ = sqlite3.exec(db, bad)
+ if rows, err := sqlite3.query(db, bad); err == nil {
+ for sqlite3.next(&rows) {
+ // Reading every column of every row is where a wrong column
+ // count or a stale pointer would show.
+ for col in 0 ..< sqlite3.column_count(rows) {
+ _ = sqlite3.type_of(rows, col)
+ _ = sqlite3.text(rows, col)
+ _ = sqlite3.blob(rows, col)
+ }
+ }
+ _ = sqlite3.finish(&rows)
+ }
+
+ live, lerr := sqlite3.query(db, `SELECT 1`)
+ if lerr != nil {
+ return fmt.tprintf("connection lost after %s: %v", show(bad), lerr), false
+ }
+ defer sqlite3.finish(&live)
+ if !sqlite3.next(&live) || sqlite3.integer(live, 0) != 1 {
+ return fmt.tprintf("connection unusable after %s", show(bad)), false
+ }
+ return "", true
+}
+
+// arity checks the guard in bind: a list that does not match the statement's
+// parameter count is refused, and one that matches is accepted.
+arity :: proc(db: sqlite3.Db) -> (detail: string, ok: bool) {
+ want := rand.int_range(1, 8)
+ marks := make([dynamic]string, context.temp_allocator)
+ for _ in 0 ..< want {
+ append(&marks, "?")
+ }
+ sql := fmt.tprintf("SELECT %s", strings.join(marks[:], ", ", context.temp_allocator))
+ stmt, perr := sqlite3.prepare(db, sql)
+ if perr != nil {
+ return fmt.tprintf("prepare %s: %v", sql, perr), false
+ }
+ defer sqlite3.finish(&stmt)
+
+ give := rand.int_range(0, 9)
+ args := make([]sqlite3.Value, give, context.temp_allocator)
+ for i in 0 ..< give {
+ args[i] = value()
+ }
+ err := sqlite3.bind(&stmt, ..args)
+ if give == want && err != nil {
+ return fmt.tprintf("%d parameters, %d args, refused: %v", want, give, err), false
+ }
+ if give != want {
+ fault, is_fault := err.(sqlite3.Fault)
+ if !is_fault {
+ return fmt.tprintf("%d parameters, %d args, accepted", want, give), false
+ }
+ if fault.code != .Range {
+ return fmt.tprintf("%d parameters, %d args, gave %v", want, give, fault.code), false
+ }
+ }
+ return "", true
+}
+
+// atomicity rolls a transaction back from a random point and checks that the
+// table holds exactly what it held before.
+atomicity :: proc(db: sqlite3.Db) -> (detail: string, ok: bool) {
+ if err := sqlite3.exec(db, `CREATE TABLE t(v UNIQUE)`); err != nil {
+ return fmt.tprintf("create: %v", err), false
+ }
+ before := rand.int_range(0, 8)
+ for i in 0 ..< before {
+ if err := sqlite3.exec_args(db, `INSERT INTO t VALUES (?)`, i64(i)); err != nil {
+ return fmt.tprintf("seed row %d: %v", i, err), false
+ }
+ }
+
+ // The body writes a random number of fresh rows, then collides with one
+ // that is already there, which fails the transaction wherever it is.
+ doomed := Batch {
+ fresh = rand.int_range(0, 8),
+ collide_with = before > 0 ? i64(rand.int_range(0, before)) : 0,
+ has_collision = before > 0,
+ }
+ err := sqlite3.transact(db, batch_body, &doomed)
+ if doomed.has_collision && err == nil {
+ return "the colliding transaction was not refused", false
+ }
+
+ rows, qerr := sqlite3.query(db, `SELECT count(*) FROM t`)
+ if qerr != nil {
+ return fmt.tprintf("count: %v", qerr), false
+ }
+ defer sqlite3.finish(&rows)
+ if !sqlite3.next(&rows) {
+ return "count returned no row", false
+ }
+ got := sqlite3.integer(rows, 0)
+ want := i64(before)
+ if !doomed.has_collision {
+ // With nothing to collide with the body commits, so its rows stay.
+ want += i64(doomed.fresh)
+ }
+ if got != want {
+ return fmt.tprintf("%d rows after rollback, wanted %d", got, want), false
+ }
+ return "", true
+}
+
+// Batch is what batch_body should write inside a transaction. It travels
+// through transact's user pointer rather than a package global, so two runs
+// in one process cannot rewrite each other's expectations: a package global
+// here made the tests fail whenever two of them ran at once.
+@(private)
+Batch :: struct {
+ fresh: int,
+ collide_with: i64,
+ has_collision: bool,
+}
+
+@(private)
+batch_body :: proc(db: sqlite3.Db, user: rawptr) -> sqlite3.Error {
+ doomed := (^Batch)(user)
+ for i in 0 ..< doomed.fresh {
+ sqlite3.exec_args(db, `INSERT INTO t VALUES (?)`, i64(1000 + i)) or_return
+ }
+ if doomed.has_collision {
+ sqlite3.exec_args(db, `INSERT INTO t VALUES (?)`, doomed.collide_with) or_return
+ }
+ return nil
+}
+
+// reuse drives one prepared statement through a random sequence of binds,
+// steps and resets, and checks the rows that came out are the rows put in.
+reuse :: proc(db: sqlite3.Db) -> (detail: string, ok: bool) {
+ if err := sqlite3.exec(db, `CREATE TABLE t(v)`); err != nil {
+ return fmt.tprintf("create: %v", err), false
+ }
+ stmt, perr := sqlite3.prepare(db, `INSERT INTO t VALUES (?)`)
+ if perr != nil {
+ return fmt.tprintf("prepare: %v", perr), false
+ }
+
+ rounds := rand.int_range(1, 32)
+ sent := make([dynamic]string, context.temp_allocator)
+ for _ in 0 ..< rounds {
+ v := text()
+ if err := sqlite3.bind(&stmt, v); err != nil {
+ sqlite3.finish(&stmt)
+ return fmt.tprintf("bind %s: %v", show(v), err), false
+ }
+ if sqlite3.next(&stmt) {
+ sqlite3.finish(&stmt)
+ return "an insert returned a row", false
+ }
+ if err := sqlite3.reset(&stmt); err != nil {
+ sqlite3.finish(&stmt)
+ return fmt.tprintf("reset: %v", err), false
+ }
+ append(&sent, v)
+ }
+ if err := sqlite3.finish(&stmt); err != nil {
+ return fmt.tprintf("finish: %v", err), false
+ }
+
+ got, gerr := collect(db, `SELECT v FROM t ORDER BY rowid`)
+ if gerr != "" {
+ return gerr, false
+ }
+ if len(got) != len(sent) {
+ return fmt.tprintf("sent %d rows, read %d", len(sent), len(got)), false
+ }
+ for want, i in sent {
+ if got[i] != want {
+ return fmt.tprintf("row %d: %s read back as %s", i, show(want), show(got[i])), false
+ }
+ }
+ return "", true
+}
+
+// collect reads a one-column query into a slice and only then compares
+// anything, which is the whole point: a column read that handed back SQLite's
+// own memory still looks right while the cursor is on the row, and turns into
+// the next row's bytes once it has moved. Comparing inside the loop cannot
+// see that, so nothing here does.
+@(private)
+collect :: proc(db: sqlite3.Db, sql: string) -> (out: []string, detail: string) {
+ rows, err := sqlite3.query(db, sql)
+ if err != nil {
+ return nil, fmt.tprintf("select: %v", err)
+ }
+ read := make([dynamic]string, context.temp_allocator)
+ for sqlite3.next(&rows) {
+ append(&read, sqlite3.text(rows, 0))
+ }
+ if ferr := sqlite3.finish(&rows); ferr != nil {
+ return nil, fmt.tprintf("finish: %v", ferr)
+ }
+ // Every value was read before the statement was finalized, so anything
+ // still pointing into SQLite's memory is now pointing at whatever took
+ // its place.
+ return read[:], ""
+}
+
+// expected_type is the storage class SQLite should give a bound value.
+@(private)
+expected_type :: proc(v: sqlite3.Value) -> sqlite3.Type {
+ switch bound in v {
+ case i64:
+ return .Integer
+ case bool:
+ return .Integer
+ case f64:
+ // A NaN has no SQLite representation, so it lands as NULL.
+ return math.is_nan(bound) ? .Null : .Real
+ case string:
+ return .Text
+ case []byte:
+ return .Blob
+ }
+ return .Null
+}
+
+// value generates one bound parameter, weighted towards the edges of each
+// type rather than the middle.
+value :: proc() -> sqlite3.Value {
+ switch rand.int_range(0, 6) {
+ case 0:
+ return integer()
+ case 1:
+ return real()
+ case 2:
+ return rand.int_range(0, 2) == 1
+ case 3:
+ return text()
+ case 4:
+ return bytes()
+ }
+ return nil
+}
+
+// integer generates an i64, often one of the values that overflow or sign
+// flip if a conversion is wrong somewhere.
+integer :: proc() -> i64 {
+ edges := []i64 {
+ 0,
+ 1,
+ -1,
+ 127,
+ 128,
+ 255,
+ 256,
+ -128,
+ -129,
+ 65535,
+ 65536,
+ 2147483647,
+ 2147483648,
+ -2147483648,
+ -2147483649,
+ max(i64),
+ min(i64),
+ max(i64) - 1,
+ min(i64) + 1,
+ }
+ if rand.int_range(0, 2) == 0 {
+ return rand.choice(edges)
+ }
+ return i64(rand.uint64())
+}
+
+// real generates an f64, including the values SQLite has no room for.
+real :: proc() -> f64 {
+ edges := []f64 {
+ 0,
+ -0,
+ 1,
+ -1,
+ 0.1,
+ math.INF_F64,
+ math.NEG_INF_F64,
+ math.nan_f64(),
+ max(f64),
+ min(f64),
+ 1e308,
+ 1e-308,
+ }
+ if rand.int_range(0, 2) == 0 {
+ return rand.choice(edges)
+ }
+ return transmute(f64)rand.uint64()
+}
+
+// text generates a string from the bytes that break SQL built by hand, plus
+// multi-byte runes and, deliberately, byte sequences that are not UTF-8.
+text :: proc() -> string {
+ pieces := []string {
+ "'",
+ `"`,
+ "`",
+ ";",
+ "--",
+ "/*",
+ "*/",
+ "\\",
+ "\n",
+ "\r",
+ "\t",
+ "\x00",
+ "%",
+ "_",
+ "?",
+ "$1",
+ ":name",
+ "DROP TABLE t",
+ "' OR '1'='1",
+ "é",
+ "\U0001F600",
+ "\xff\xfe",
+ "a",
+ " ",
+ "",
+ }
+ n := rand.int_range(0, 24)
+ b := strings.builder_make(context.temp_allocator)
+ for _ in 0 ..< n {
+ strings.write_string(&b, rand.choice(pieces))
+ }
+ return strings.to_string(b)
+}
+
+// sql_shaped_text generates text that is trying hard to be mistaken for SQL.
+sql_shaped_text :: proc() -> string {
+ attacks := []string {
+ "'; DROP TABLE canary; --",
+ "' OR 1=1; --",
+ "'||(SELECT name FROM sqlite_schema)||'",
+ "\"; DROP TABLE canary; \"",
+ "'); DELETE FROM t; --",
+ "x'41'",
+ "' UNION SELECT * FROM canary --",
+ "?; DROP TABLE canary",
+ "$1; DROP TABLE canary",
+ }
+ if rand.int_range(0, 2) == 0 {
+ return rand.choice(attacks)
+ }
+ return strings.concatenate({rand.choice(attacks), text()}, context.temp_allocator)
+}
+
+// bytes generates a blob, which unlike text has no encoding to respect.
+bytes :: proc() -> []byte {
+ n := rand.int_range(0, 64)
+ out := make([]byte, n, context.temp_allocator)
+ _ = rand.read(out)
+ return out
+}
+
+// damaged_statement generates SQL the parser should refuse: a valid statement
+// with a piece cut out or a byte flipped, or simply a run of random bytes.
+damaged_statement :: proc() -> string {
+ valid := []string {
+ `SELECT 1`,
+ `CREATE TABLE z(a, b)`,
+ `INSERT INTO z VALUES (1, 2)`,
+ `SELECT a FROM z WHERE b = ?`,
+ `BEGIN`,
+ `PRAGMA journal_mode`,
+ // The LIMIT is load-bearing. A recursive CTE is worth feeding to
+ // the parser, but a damaged one can recurse without end, and a
+ // bound keeps this corpus entry from relying on the watchdog.
+ `WITH r(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM r WHERE n < 3) SELECT n FROM r LIMIT 4`,
+ `SELECT count(*) FROM sqlite_schema`,
+ }
+ switch rand.int_range(0, 4) {
+ case 0:
+ return text()
+ case 1:
+ // A byte flipped somewhere in the middle.
+ src := rand.choice(valid)
+ if len(src) == 0 {
+ return src
+ }
+ out := make([]byte, len(src), context.temp_allocator)
+ copy(out, src)
+ out[rand.int_range(0, len(out))] = byte(rand.int_range(0, 256))
+ return string(out)
+ case 2:
+ // Truncated at a random point, which is where a parser that reads
+ // past its input would fall off.
+ src := rand.choice(valid)
+ return src[:rand.int_range(0, len(src) + 1)]
+ }
+ return strings.concatenate({rand.choice(valid), text()}, context.temp_allocator)
+}
+
+// show renders a value so a failure can be read and retyped, with the bytes
+// spelled out rather than printed raw.
+show :: proc {
+ show_value,
+ show_string,
+ show_bytes,
+}
+
+show_value :: proc(v: sqlite3.Value) -> string {
+ switch bound in v {
+ case i64:
+ return fmt.tprintf("i64(%d)", bound)
+ case f64:
+ return fmt.tprintf("f64(%v / %08x)", bound, transmute(u64)bound)
+ case bool:
+ return fmt.tprintf("bool(%v)", bound)
+ case string:
+ return show_string(bound)
+ case []byte:
+ return show_bytes(bound)
+ }
+ return "nil"
+}
+
+show_string :: proc(s: string) -> string {
+ return fmt.tprintf("string(%d bytes, %02x)", len(s), transmute([]byte)s)
+}
+
+show_bytes :: proc(b: []byte) -> string {
+ return fmt.tprintf("blob(%d bytes, %02x)", len(b), b)
+}
diff --git a/sqlite3/fuzz/fuzz_test.odin b/sqlite3/fuzz/fuzz_test.odin
@@ -0,0 +1,68 @@
+package fuzz
+
+import "core:testing"
+import "core:time"
+
+// A short run on fixed seeds, so `just test` catches a regression the
+// example-based tests would not: they compare each column while the cursor is
+// still on its row, which is exactly when a column read that forgot to clone
+// still looks correct. Long runs are `just fuzz`.
+@(test)
+properties_hold :: proc(t: ^testing.T) {
+ for seed in ([]u64{1, 3, 7, 99}) {
+ report := run({seed = seed, iterations = 600, case_timeout = 30 * time.Second})
+ testing.expect_value(t, report.iterations, 600)
+ for f in report.failures {
+ testing.expectf(
+ t,
+ false,
+ "%s failed at iteration %d (replay: sqlite3-fuzz -seed=%d): %s",
+ f.property,
+ f.iteration,
+ f.seed,
+ f.detail,
+ )
+ }
+ }
+}
+
+// A run must be a pure function of its seed, or a reported failure cannot be
+// replayed and the harness is not worth having. The digest is what makes that
+// checkable on a run where nothing failed, which is most of them.
+@(test)
+same_seed_same_run :: proc(t: ^testing.T) {
+ first := run({seed = 42, iterations = 120})
+ second := run({seed = 42, iterations = 120})
+ // Absolute, so two equally empty reports cannot satisfy the pairwise
+ // comparisons below.
+ testing.expect_value(t, first.iterations, 120)
+ testing.expect_value(t, second.iterations, 120)
+ testing.expect(t, first.digest != 0, "a run that generated cases has a digest")
+ testing.expect_value(t, first.seed, 42)
+ testing.expect_value(t, first.digest, second.digest)
+ testing.expect_value(t, len(first.failures), len(second.failures))
+ for f, i in first.failures {
+ testing.expect_value(t, f.property, second.failures[i].property)
+ testing.expect_value(t, f.iteration, second.failures[i].iteration)
+ testing.expect_value(t, f.detail, second.failures[i].detail)
+ }
+}
+
+// A different seed must reach different cases, or the digest above would hold
+// for any two runs and prove nothing.
+@(test)
+different_seed_different_run :: proc(t: ^testing.T) {
+ a := run({seed = 42, iterations = 120})
+ b := run({seed = 43, iterations = 120})
+ testing.expect(t, a.digest != b.digest, "two seeds must not generate the same cases")
+}
+
+// A run asked for no particular seed must still report the one it used, or a
+// random run that fails cannot be turned back into a deterministic one.
+@(test)
+unset_seed_is_filled_in :: proc(t: ^testing.T) {
+ report := run({iterations = 6})
+ testing.expect(t, report.seed != 0, "the seed used must come back in the report")
+ replay := run({seed = report.seed, iterations = 6})
+ testing.expect_value(t, replay.digest, report.digest)
+}
diff --git a/sqlite3/sqlite3.odin b/sqlite3/sqlite3.odin
@@ -498,6 +498,19 @@ is_null :: proc(stmt: Stmt, col: int) -> bool {
return type_of(stmt, col) == .Null
}
+// interrupt is sqlite3_interrupt: it asks the connection to abandon what it
+// is running, and is the one call here meant to be made from another thread.
+// The interrupted call comes back as a Fault with code Interrupt.
+//
+// It exists because nothing else here bounds how long a statement runs. A
+// query that keeps returning rows keeps exec and next busy until it is
+// interrupted; jm:sqlite3/fuzz uses this to put a deadline on a case.
+interrupt :: proc(db: Db) {
+ if db.handle != nil {
+ sqlite3_interrupt(db.handle)
+ }
+}
+
// changes is how many rows the last INSERT, UPDATE or DELETE touched.
changes :: proc(db: Db) -> i64 {
return sqlite3_changes64(db.handle)
diff --git a/tools/sqlite3-fuzz/main.odin b/tools/sqlite3-fuzz/main.odin
@@ -0,0 +1,114 @@
+/*
+sqlite3-fuzz runs jm:sqlite3/fuzz for as long as it is asked to and reports
+what did not hold.
+
+ sqlite3-fuzz 1000 cases from a seed off the clock
+ sqlite3-fuzz -iters=1000000 a million cases
+ sqlite3-fuzz -for=30s as many as fit in thirty seconds
+ sqlite3-fuzz -seed=12345 replay a reported seed exactly
+ sqlite3-fuzz -quiet only the summary
+
+The exit status is 0 when every property held and 1 when one did not, so it
+drops into a pipeline. A failure prints the seed to replay it with.
+
+Build it with -sanitize:address to put the FFI boundary under a sanitizer as
+well; `just fuzz asan=1` does that.
+*/
+package main
+
+import "core:fmt"
+import "core:os"
+import "core:strconv"
+import "core:strings"
+import "core:time"
+
+import "jm:sqlite3/fuzz"
+
+main :: proc() {
+ opts := fuzz.Opts {
+ log = report,
+ }
+ for arg in os.args[1:] {
+ switch {
+ case arg == "-h", arg == "--help":
+ fmt.eprintln(USAGE)
+ os.exit(2)
+ case arg == "-quiet":
+ opts.log = nil
+ case arg == "-stop":
+ opts.stop_on_first = true
+ case strings.has_prefix(arg, "-seed="):
+ opts.seed = u64(number(arg, "-seed="))
+ case strings.has_prefix(arg, "-iters="):
+ opts.iterations = number(arg, "-iters=")
+ case strings.has_prefix(arg, "-for="):
+ opts.duration = span(arg[len("-for="):])
+ case:
+ fmt.eprintfln("sqlite3-fuzz: unknown argument %s", arg)
+ fmt.eprintln(USAGE)
+ os.exit(2)
+ }
+ }
+ // A run bounded only by time needs no iteration ceiling to stop at.
+ if opts.duration > 0 && opts.iterations == 0 {
+ opts.iterations = max(int)
+ }
+
+ report := fuzz.run(opts)
+ fmt.printfln(
+ "sqlite3-fuzz: %d iterations in %v, seed %d, %d failures",
+ report.iterations,
+ time.duration_round(report.elapsed, time.Millisecond),
+ report.seed,
+ len(report.failures),
+ )
+ if len(report.failures) == 0 {
+ return
+ }
+ for f in report.failures {
+ fmt.eprintfln(" %s at iteration %d: %s", f.property, f.iteration, f.detail)
+ }
+ fmt.eprintfln("replay with: sqlite3-fuzz -seed=%d", report.failures[0].seed)
+ os.exit(1)
+}
+
+USAGE :: `usage: sqlite3-fuzz [-seed=N] [-iters=N] [-for=30s] [-stop] [-quiet]`
+
+// number reads the digits after a flag's prefix, or gives up loudly: a
+// mistyped bound that silently became zero would report a clean run.
+number :: proc(arg, prefix: string) -> int {
+ v, ok := strconv.parse_int(arg[len(prefix):])
+ if !ok || v < 0 {
+ fmt.eprintfln("sqlite3-fuzz: %s needs a whole number, got %s", prefix, arg)
+ os.exit(2)
+ }
+ return v
+}
+
+// span reads a duration written as 30s, 5m or 250ms.
+span :: proc(s: string) -> time.Duration {
+ unit := time.Second
+ digits := s
+ switch {
+ case strings.has_suffix(s, "ms"):
+ unit, digits = time.Millisecond, s[:len(s) - 2]
+ case strings.has_suffix(s, "s"):
+ unit, digits = time.Second, s[:len(s) - 1]
+ case strings.has_suffix(s, "m"):
+ unit, digits = time.Minute, s[:len(s) - 1]
+ case strings.has_suffix(s, "h"):
+ unit, digits = time.Hour, s[:len(s) - 1]
+ }
+ v, ok := strconv.parse_int(digits)
+ if !ok || v < 0 {
+ fmt.eprintfln("sqlite3-fuzz: -for needs a duration like 30s, got %s", s)
+ os.exit(2)
+ }
+ return time.Duration(v) * unit
+}
+
+// report is what the run calls as it goes, so a long run says something
+// before it finishes.
+report :: proc(format: string, args: ..any) {
+ fmt.eprintfln(format, ..args)
+}