fix(db2-keys): wo_wal_fold_row_at tolerates msg == NULL

- 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 <noreply@anthropic.com>
(cherry picked from commit 1b6750d78db991af464994c2188d219519dfe16f)
This commit is contained in:
shoney.arickathil 2026-09-10 14:58:35 +02:00
parent 0b9f09fc12
commit 156b04d28d
4 changed files with 40 additions and 2 deletions

View file

@ -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 `wo_wal_stage_fatal`. Compaction and migration stage the head explicitly
on a schema-less replacement log and were never exposed. Pinned by 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 `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, - **The diff is name-keyed** (`wo_schema_diff`). Classes match by name,
fields by name + kind, owned references (`fclass`) by the NAME the number fields by name + kind, owned references (`fclass`) by the NAME the number
resolves to — so pure declaration reordering costs only a cid remap, which resolves to — so pure declaration reordering costs only a cid remap, which

View file

@ -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, 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, uint64_t *id_out, uint64_t *out_vals, uint32_t *hops_out,
const char **msg) { 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 */ uint32_t hops = 0; /* databasev2 11: DELTA records crossed */
if (hops_out) *hops_out = 0; if (hops_out) *hops_out = 0;
uint32_t cid = 0; uint32_t cid = 0;

View file

@ -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. * cannot revisit one.
* *
* 0 ok, -1 no intact/malformed/corrupt record anywhere in the chain (or a * 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 /* 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 — * 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 * 0 for a row that has never been updated. The walk already visits each hop, so

View file

@ -1957,6 +1957,36 @@ static void test_schema_fresh_log(void) {
wo_db_destroy(&db2); 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 /* 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 */ * first compaction with a schema set writes the record at the head */
static void test_schema_compaction_adopts_legacy(void) { static void test_schema_compaction_adopts_legacy(void) {
@ -3680,6 +3710,7 @@ int main(void) {
test_schema_diff_verdicts(); test_schema_diff_verdicts();
test_schema_roundtrip(); test_schema_roundtrip();
test_schema_fresh_log(); test_schema_fresh_log();
test_fold_row_at_tolerates_null_msg();
test_schema_compaction_adopts_legacy(); test_schema_compaction_adopts_legacy();
test_schema_read_absent(); test_schema_read_absent();
test_should_compact_absolute_and_ceiling(); test_should_compact_absolute_and_ceiling();