From 156b04d28dcedac2e597ddaf136f6af915a8f21d Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Thu, 10 Sep 2026 14:58:35 +0200 Subject: [PATCH] fix(db2-keys): wo_wal_fold_row_at tolerates msg == NULL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Second half of the fresh-log seed SEGV: every `*msg = ...` in the fold was unguarded, and `wo_idx_probe` (table.c:373) borrows with `msg == NULL` because a candidate that does not fold is simply not a hit; a malformed record under an index probe was therefore a zero-page write. - Guard: `const char *sink; if (!msg) msg = &sink;` at the top of the fold; wal.h documents [msg] as optional. A future malformed record refuses the candidate by name instead of segfaulting. - Failing test first: `test_fold_row_at_tolerates_null_msg` (test_wal.c) — head-only log, fold at offset 0 (schema record) and past the tail with `msg == NULL` -> -1 both; with a real `msg` the names "record header is malformed" / "no intact record at that offset" still arrive. Pre-guard: ASan SEGV `wo_wal_fold_row_at wal.c:1886` from the test. - Gates: test_wal 6660/0 (was 6650); `make -C runtime test` 21 suites 8462/0 (was 8452); wovm-asan clean; residency `seed` fresh dir + fresh app.db rc 0 under wovm_asan. - CODE-LOGIC §Schema migrations bullet extended with the guard + test. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 1b6750d78db991af464994c2188d219519dfe16f) --- database/src/CODE-LOGIC.md | 5 ++++- database/src/wal.c | 2 ++ database/src/wal.h | 4 +++- runtime/test/test_wal.c | 31 +++++++++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/database/src/CODE-LOGIC.md b/database/src/CODE-LOGIC.md index 5798329..03b528b 100644 --- a/database/src/CODE-LOGIC.md +++ b/database/src/CODE-LOGIC.md @@ -373,7 +373,10 @@ A `@table` class is the schema; the log is the database; boot compares them. `wo_wal_stage_fatal`. Compaction and migration stage the head explicitly on a schema-less replacement log and were never exposed. Pinned by `test_keys_resident_fresh_log_first_row` (test_wal.c): the db.c:78 - sequence call for call, then read-by-id, `wo_idx_probe`, and replay. + sequence call for call, then read-by-id, `wo_idx_probe`, and replay. The + fold's `msg` is optional since the same fix (`test_fold_row_at_tolerates_null_msg`): + a malformed record under an index probe refuses the candidate by name + instead of writing the zero page. - **The diff is name-keyed** (`wo_schema_diff`). Classes match by name, fields by name + kind, owned references (`fclass`) by the NAME the number resolves to — so pure declaration reordering costs only a cid remap, which diff --git a/database/src/wal.c b/database/src/wal.c index 4b71093..0593ae3 100644 --- a/database/src/wal.c +++ b/database/src/wal.c @@ -1851,6 +1851,8 @@ int wo_wal_read_row_at(wo_wal *w, wo_db *db, wo_rt *rt, uint64_t off, int wo_wal_fold_row_at(wo_wal *w, wo_db *db, uint64_t off, uint32_t *class_out, uint64_t *id_out, uint64_t *out_vals, uint32_t *hops_out, const char **msg) { + const char *sink; /* [msg] is optional: wo_idx_probe borrows without one */ + if (!msg) msg = &sink; uint32_t hops = 0; /* databasev2 11: DELTA records crossed */ if (hops_out) *hops_out = 0; uint32_t cid = 0; diff --git a/database/src/wal.h b/database/src/wal.h index 33dfe6a..7e4fb78 100644 --- a/database/src/wal.h +++ b/database/src/wal.h @@ -496,7 +496,9 @@ int wo_wal_read_row_at(wo_wal *w, wo_db *db, wo_rt *rt, uint64_t off, * cannot revisit one. * * 0 ok, -1 no intact/malformed/corrupt record anywhere in the chain (or a - * REMOVE tombstone reached mid-chain), -2 out of memory (*msg set). */ + * REMOVE tombstone reached mid-chain), -2 out of memory. [msg] may be NULL + * (wo_idx_probe borrows without one: a candidate that does not fold is not a + * hit); when given it names every refusal. */ /* databasev2 11: `hops_out` (may be NULL) reports how many DELTA records the * walk crossed before reaching the full-row record that terminates the chain — * 0 for a row that has never been updated. The walk already visits each hop, so diff --git a/runtime/test/test_wal.c b/runtime/test/test_wal.c index 1db69f4..edd915b 100644 --- a/runtime/test/test_wal.c +++ b/runtime/test/test_wal.c @@ -1957,6 +1957,36 @@ static void test_schema_fresh_log(void) { wo_db_destroy(&db2); } +/* 2026-09-10 defect, second half: every `*msg = …` in the fold was + * unguarded, and wo_idx_probe borrows with msg == NULL (a candidate that does + * not fold is simply not a hit), so a malformed record under an index probe + * was a zero-page write instead of a refused row. [msg] is optional; a + * caller that asks still gets the name. */ +static void test_fold_row_at_tolerates_null_msg(void) { + char path[128]; + snprintf(path, sizeof path, "%s/foldnull.wal", g_dir); + wo_db db; + T_EQ(wo_db_init(&db, CLASSES, 1, 0, 1), 0); + wo_wal w; + T_EQ(wo_wal_open(&w, path, 1 << 16), 0); + wo_schema sc = mig_schema_sample(); + T_EQ(wo_wal_set_schema(&w, &sc), 0); + T_EQ(wo_wal_ensure_schema(&w), 0); /* offset 0 holds the head, not a row */ + uint32_t cid = 99, hops = 0; + uint64_t id = 0, vals[2] = {0, 0}; + /* the two refusals a probe can meet: a non-row record, and no record */ + T_EQ(wo_wal_fold_row_at(&w, &db, 0, &cid, &id, vals, &hops, NULL), -1); + T_EQ(wo_wal_fold_row_at(&w, &db, 1u << 20, &cid, &id, vals, &hops, NULL), -1); + const char *msg = NULL; + T_EQ(wo_wal_fold_row_at(&w, &db, 0, &cid, &id, vals, &hops, &msg), -1); + T_STREQ(msg, "record header is malformed"); + msg = NULL; + T_EQ(wo_wal_fold_row_at(&w, &db, 1u << 20, &cid, &id, vals, &hops, &msg), -1); + T_STREQ(msg, "no intact record at that offset"); + wo_wal_close(&w); + wo_db_destroy(&db); +} + /* a LEGACY log (rows, no schema record) reports 1 from read_schema, and its * first compaction with a schema set writes the record at the head */ static void test_schema_compaction_adopts_legacy(void) { @@ -3680,6 +3710,7 @@ int main(void) { test_schema_diff_verdicts(); test_schema_roundtrip(); test_schema_fresh_log(); + test_fold_row_at_tolerates_null_msg(); test_schema_compaction_adopts_legacy(); test_schema_read_absent(); test_should_compact_absolute_and_ceiling();