Fix memory-safety bugs in the C helper layer#9
Conversation
- copy sqlite3_errmsg into owned memory before finalize/reset invalidates it, so error messages are no longer read after the statement is torn down - give SQLiteColumn a delete so text/blob parameter buffers are freed after every query/exec-prepared instead of leaking on each call - give SQLiteRes a delete so the per-row column arrays and the row container array are freed once to-array has consumed the result - free partially-built result rows on the error paths Adds a --log-memory regression test (test/memory.carp), run with `carp -x --log-memory test/memory.carp`, plus two assertions that the error message survives statement teardown.
angler's unsafe-result-unwrap rule flagged the unchecked Result.unsafe-from-success on the in-memory database open. Pattern-match on the open result instead, which keeps the workload identical on the success path and satisfies the linter.
|
The only thing blocking CI was an |
There was a problem hiding this comment.
Build & Tests
Checked out claude/fix-memory-safety, built against system libsqlite3, and ran everything locally:
carp -x test/sqlite3.carp→ 36 passed, 0 failed, including the two new error-survival assertions and the existingblob with embedded nulls round-tripstest.carp -x --log-memory test/memory.carp→ net-zero allocations across 50 workloads (passes).carp -x gendocs.carp→ clean.- CI green on ubuntu-latest and macos-latest.
To confirm the memory test actually has teeth, I temporarily neutered SQLite3_SQLiteColumn_delete to a no-op and re-ran it: it failed loudly with Expected '0', actual '4000' (≈80 leaked param buffers/workload × 50), then I restored the file. So the regression guard genuinely catches fix #2 being reverted.
Findings
I traced the full ownership model end to end and tried to find a leak, double-free, or use-after-free in each of the three fixes:
- Fix #1 (errmsg UAF): in both
SQLite3_exec_c(sqlite3_helper.h:351) andSQLite3_exec_prepared_c(sqlite3_helper.h:411),SQLite3_copy_errmsg(err)runs beforesqlite3_finalize/sqlite3_reset, so the message is copied while still valid. The two new tests exercise both paths and pass. ✓ - Fix #2 (param buffer leak):
to-sqlite3-internalmoves the Carp String/Array buffer into the column; the temp(Array SQLiteColumn)fromcopy-mapis dropped afterexec-, and the newdeletefrees each text/blob buffer exactly once. Binds useSQLITE_STATIC, which is safe because that array outlives the synchronous exec. The tag guard indelete/copycorrectly prevents touching the union for int/float/null columns. ✓ - Fix #3 (container leak): result column buffers are moved into Carp values —
from-texthandscol.sback as aString,from-blobas anArray{data=col.s}— sofree_containerscorrectly frees only the row/data arrays, not the column buffers. The OK result is consumed byto-array, whosedeleterunsfree_containersonce. ✓
Other things I checked and confirmed safe:
res.rowsis initialized (sqlite3_helper.h:328/399) before anygoto, sofree_allon an empty/partial result is well-defined (free(NULL)safe).- The union is written (
res.err) only afterfree_allreadsres.rows— correct ordering, no clobber. - Every
SQLiteResis consumed byto-array(OK) orerror(ERR) inquery/exec-prepared, so an ERR result's err buffer is always transferred to aStringand never hits theis == OKno-op indelete.
Two minor, non-blocking notes for the future (neither affects current behavior):
SQLite3_SQLiteColumn_copysizes TEXT viastrlen+1, so it would truncate a text value containing an embedded NUL.copyisn't exercised on the hot path today (params are moved, results are moved), and text params come from Carp Strings, so this is purely theoretical — but if a column array is ever@-copied with NUL-bearing text it'd be lossy.SQLiteRes.deleteno-ops on ERR, relying on the invariant that ERR results are always consumed byerror. That holds throughout the current code; just worth keeping in mind if a new code path ever drops a result directly.
Prior activity: the only prior commit beyond the fix is the heartbeat's own follow-up swapping Result.unsafe-from-success for a match in the memory test to clear an angler finding — verified that's behavior-preserving (the memory-balance assertion still holds).
Verdict: merge
All three fixes are correct and verified end to end; the memory regression test is sensitive and passes at net-zero; functional suite and docs are green on both platforms. The two notes above are future-proofing, not blockers.
Fixes three memory-safety bugs in
sqlite3_helper.h, all in the result/error handling aroundquery,exec-prepared,prepare, andopen.1. Use-after-free of the error message
sqlite3_errmsgreturns memory that SQLite invalidates on the next library call. BothSQLite3_exec_candSQLite3_exec_prepared_ccaptured that pointer and then calledsqlite3_finalize/sqlite3_resetbefore stashing it in the result, so the error string handed back to Carp was read after it had been invalidated. The message is now copied into owned memory before the statement is torn down (SQLite3_copy_errmsg), matching whatopen/errmsgalready did.SQLiteRes.errornow returns an ownedStringinstead of a borrowed(Ptr CChar).2. Text/blob parameter buffers leaked on every call
SQLiteColumnhad nodelete, so the buffers backing text/blob query parameters (handed to C from Carp Strings/Arrays) were never freed — a leak on everyquery/exec-preparedthat binds a string or blob.SQLiteColumnnow implementsdelete(andcopy); dropping the parameter array frees those buffers. Result columns are unaffected because their buffers are moved into Carp values before the column is dropped.3. Row container arrays leaked after
to-arraySQLiteReshad nodelete, so the per-row column arrays (row->data) and the row array (rows.rows) were never freed onceto-arrayhad consumed the result.SQLiteResnow implementsdelete, freeing those containers (the column buffers themselves belong to Carp by then, so they are left intact).The error paths now also free partially-built result rows, which the union-overwrite with the error string previously dropped.
Verification
query/finalize path, one for theexec-prepared/reset path).test/memory.carp, a--log-memoryregression test that runs 50 mixed query/prepared/error workloads (text + blob params, result sets, syntax and constraint errors) and asserts the allocation balance is unchanged. It fails if any of the three fixes is reverted. Run withcarp -x --log-memory test/memory.carp.Note: I'd have added a CI step for the memory test, but the heartbeat app token lacks
workflowspermission, so.github/workflows/ci.ymlis untouched — adding acarp -x --log-memory test/memory.carpstep is a one-liner for a maintainer.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.