fix(table): make the public table accessors total over ray_t - #568
Conversation
ray_table_nrows and ray_table_ncols segfault when handed a value that is not a table (#567). They guard NULL and errors but not the type tag, then decode the argument's payload as the two-slot {schema, cols} layout and dereference whatever comes out. The reporter framed this as a quirk of IPC-deserialized atoms, since a locally built ray_i64(42) was benign. The real trigger is just payload bytes that dereference badly — a plain I64 vector crashes deterministically: ray_t* v = ray_vec_from_raw(RAY_I64, poison, 4); ray_table_ncols(v); /* SIGSEGV at table.c:274 */ These are not holding a deliberate line. The rest of the layer already checks its own tag — ray_dict_keys rejects a non-RAY_DICT, ray_parted_nrows rejects a non-parted vector — so the table accessors are the outlier, and the same hole is in all eight of them, not just the two reported. An embedder sees one opaque ray_t* and has nothing to check against but the tag the accessor already holds. Add tbl_is_table() and route every public accessor through it: wrong tag gets the empty answer (0 / NULL / -1), mutators become no-ops, and none of them reads the payload as slots. Document the guarantee in rayforce.h, which previously said nothing either way, and point callers who need a diagnosable rejection at ray_table_validate_rectangular. No cost: the call sites are per-operation, not per-row (agg_engine.c:128's parallel-size test is the hot one), and the compare hits a field in the same cache line as the NULL check already there. Interleaved A/B over ClickBench q13/16/17/20/22/28 on the 10M splayed store, min-of-3 x 2 passes, shows no movement outside run-to-run spread.
Rayforce targeted audit passedThe required Rayforce audit gate passed on the latest run. Workflow run: https://github.com/RayforceDB/rayforce/actions/runs/35329164918 |
…n type The audit caught a real hole in the first commit. The header block I added promised totality for every declaration below it, and ray_table_add_col is in that list but was not converted — so the documentation invited exactly the call that corrupts memory: a non-table gets ray_cow'd, then new schema and cols pointers are written straight into the argument's payload. Worse than the crash #567 reported, and now advertised as safe. Guard it. It consumes its tbl ref and owns its result, so the wrong-tag answer is a typed "type" error rather than an empty one, releasing tbl on that path exactly as the bad-column path beside it already does. Rewrite the header block to say what each function actually returns on a wrong tag instead of asserting one blanket answer for a list whose members have different return contracts, and to spell out that add_col consumes tbl on the error path. Tests: table/add_col_wrong_type, written first and watched to fail in ray_vec_append on the decoded 0x4141... payload. Broadened table/accessors_reject_dict from three functions to the whole set, since a dict carries real pointers and was certifying less than the header claimed. Also corrects a stale comment the audit flagged in passing: table.h:30 said RAY_TABLE was 13, it is 98. make test: 3890 of 3890 passed.
|
Both blocking findings were correct and are fixed in Finding 1 — Took the guard rather than the carve-out. As the finding notes, the shape was already there — it consumes if (tbl->type != RAY_TABLE) {
ray_t* err = ray_error("type", "table add_col: expected table, got %s",
ray_type_name(tbl->type));
ray_release(tbl);
return err;
}The header block was the other half of the problem, so it's rewritten: instead of one blanket claim over a list whose members have different return contracts, it now states the wrong-tag answer per function ( Finding 2 — coverage narrower than the claim. Also right, and the omission was self-selecting: It asserts the typed error, the error code, and that both poisoned payload words survive untouched.
Also took the stale comment from the non-blocking list — The rest of the non-blocking section matches what I found independently: the ambiguous call sites all gate on the tag first, and the dict-shares-the-layout case was the real regression risk, which is why that test is in the PR. |
Fixes #567.
What
ray_table_nrows/ray_table_ncolsguard NULL and errors but not the type tag, then decode the argument's payload as the two-slot{schema, cols}layout and dereference whatever comes out.The reporter framed it as a quirk of IPC-deserialized atoms, since a local
ray_i64(42)was benign. The real trigger is just payload bytes that dereference badly — a plain I64 vector does it deterministically:Why option 1, and why all eight
These accessors aren't holding a deliberate line that the rest of the layer crosses — they're the outlier in it:
ray_dict_keys/ray_dict_vals(dict.c:112)d->type != RAY_DICT→ NULLray_parted_nrows(table.c:292)v->lenray_table_validate_rectangular(table.c:167)So
ray_dict_lenreturning 0 in the reporter's probe was never luck — that path was already total. Making the table side match is consistency, not a new tax.The issue named two functions; the identical hole is in all eight that touch
tbl_slots.ray_table_schema,get_col,get_col_idx,col_name,set_col_idxandset_col_nameall crash or corrupt the same way, so fixing two would have left six public functions with the reported defect.How
One
tbl_is_table()predicate next to the slot accessors it protects; every public accessor routes through it. Wrong tag → empty answer (0/NULL/-1), mutators → no-ops, no read through a decoded payload.Also did the reporter's option 2:
rayforce.hnow documents the guarantee, and points callers who want a diagnosable rejection atray_table_validate_rectangular— these accessors answer0/NULLindistinguishably from a genuinely empty table, which is worth saying out loud.Tests
Two new cases in
test/test_table.c, written before the fix and watched to fail:table/accessors_wrong_type— poisoned I64 vector through all eight, including asserting the mutators left the payload untouched. Before the fix this aborts the suite attable.c:274; after, it passes.table/accessors_reject_dict— a dict shares the table's two-slot layout, so the accessors would read real pointers out of it and answer with a dict's shape. The tag is the only thing separating them.make test: 3889 of 3889 passed (0 skipped, 0 failed). Nothing else moved, which is the useful signal across all 324 internal call sites — none depended on the unchecked behaviour.Performance
The call sites are per-operation, not per-row —
agg_engine.c:128's parallel-size test is the hot one the issue worried about, and it runs once per op. The compare hits a field in the same cache line as the NULL check already there.Measured anyway, interleaved A/B on the 10M splayed store, min-of-3, two passes (ms):
Differences fall inside run-to-run spread in both directions.