Skip to content

fix(serde): validate table/dict counts and lengths on decode - #452

Merged
singaraiona merged 5 commits into
RayforceDB:devfrom
belowzeroff:fix/serde-decode-hardening
Sep 2, 2026
Merged

fix(serde): validate table/dict counts and lengths on decode#452
singaraiona merged 5 commits into
RayforceDB:devfrom
belowzeroff:fix/serde-decode-hardening

Conversation

@belowzeroff

@belowzeroff belowzeroff commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #444, addressing the review notes on the same decode paths.

How it looks from the user's side

Before this PR, de and object/file-load decode could accept corrupt serialized TABLE/DICT payloads and build plausible but broken in-memory objects. A user would not see an error at load time; the failure surfaced later as bad rows, an out-of-bounds read, or an ASan crash.

Concrete cases:

  • A TABLE whose schema and column count differ could silently drop data.
  • A TABLE with atom "columns" could report rows from the atom payload even though there is no column vector data to read.
  • A TABLE with ragged columns could report column 0's length and let row-wise code read past a shorter column.
  • A DICT with more keys than values could find a key index and then read past the shorter value block.

After this change, corrupt payloads fail where they enter the system:

error: domain: deserialize table: schema/column count mismatch (2 names, 1 columns)
error: domain: deserialize table: column must be list/vector-like, got RAY_I64
error: domain: deserialize table: ragged columns (column 1 has 1 rows, expected 2)
error: domain: deserialize dict: key/value count mismatch (2 keys, 1 values)

The same checks now apply to recursive on-disk column/table decoding, so a crafted .col/splayed file cannot bypass the serde guard.

Fix

  • Keep TABLE schema/column count validation in the serde decoder.
  • Require table columns added through ray_table_add_col to be list/vector-like, so atom columns are rejected through the shared constructor.
  • Keep ray_table_add_col's ownership contract consistent on the new invalid-column error path: the input table ref is consumed, matching the later append-failure paths and avoiding leaks in recursive on-disk decode.
  • Add ray_table_validate_rectangular and call it from both serde TABLE decode and recursive on-disk TABLE decode, so ragged decoded tables are rejected by shared table logic without breaking existing runtime table use cases that intentionally construct short/broadcast columns.
  • Enforce DICT invariants in ray_dict_new: keys and values must be list/vector-like and their lengths must match. This closes serde, recursive on-disk decode, snapshot construction, and future callers through one constructor path.
  • Remove the out-of-scope pool test change from the final PR diff.

Tests

  • Strengthened store/serde_container_count_mismatch to assert both error class and message substring.
  • Added atom-column coverage for serde TABLE decode.
  • Added recursive on-disk guards for atom TABLE columns, ragged TABLE columns, and DICT key/value mismatch.
  • Fixed the serde splice helper to set RAY_SERDE_ENDIAN, check ray_vec_new, and release inline-appended vectors.

Full ASan+UBSan suite:

=== 3720 of 3721 passed (1 skipped, 0 failed) ===

Follow-up to the schema/column-count guard, hardening the same decode paths
against crafted or truncated frames (review of RayforceDB#444):

- Table: drop the now-dead `&& i < schema->len` loop bound (the count guard
  makes ncols == schema->len), so the guard reads as the single mechanism.
- Table: reject ragged columns — every column must have the same length (the
  row count), else ray_table_nrows reports column 0's length while row-wise
  ops read past the shorter columns.
- Dict: reject a keys/values count mismatch. A probe finds a key index in
  [0, keys->len) and reads the value at that index, so more keys than values
  is an out-of-bounds read/release of the value block. vals may be a LIST of
  columns or a flat value vector, so only the length is checked and no valid
  dict is rejected.

Tests: adds store/serde_container_count_mismatch in test_store.c, which builds
frames from real serialized sub-objects via sizeof(ray_ipc_header_t) — table
count mismatch, ragged columns, dict key/value mismatch — and removes the
brittle wire-layout-hard-coded .rfl case it replaces.

@singaraiona singaraiona left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good direction — this closes the exact gaps flagged in the #444 review — but the central check doesn't yet establish its own invariant, and a couple of the test changes weaken coverage:

  1. The ragged-column check is bypassable by atom columns (serde.c:819): it compares cps[i]->len without requiring the column to be a vector. In a ray_t, len aliases the value union, so a crafted TABLE frame whose column LIST is all I64 atoms with identical payload V passes the loop (every "length" is V), ray_table_add_col accepts atoms (it rejects only NULL/error), and ray_table_nrows reports V rows over zero data — any row-wise op then reads ~8·V bytes out of bounds. Require cps[i]->type > 0 for every column, index 0 included.

  2. The on-disk decoder stays open (col.c:578): col_read_recursive decodes the same TABLE/DICT shapes from files with none of the three new checks — it reads nrows and discards it ((void)nrows), adds columns with no length comparison, and hands unchecked keys/vals to the dict constructor. A corrupt or crafted column/splay file yields exactly the ragged table this PR forbids over the socket. The durable fix is to put the invariants in the shared constructors (ray_table_add_col, ray_dict_new) so both decoders — and any future one — are closed at once.

  3. The migrated tests assert only RAY_IS_ERR (test_store.c:3232): any decode failure passes, including a bad splice or a header-stage rejection, so the tests can go green without reaching the new guards — the deleted .rfl test asserted the domain class, so this is strictly weaker. Assert the class plus a message substring. Related in the same helper: hdr->endian = 0 should be RAY_SERDE_ENDIAN (sibling at test_store.c:1939 does it right), and serde_splice_container dereferences ray_vec_new's result unchecked.

  4. test_pool.c:640 is out of scope and now tautological: mask != 0 is implied by the calls == 3 assert two lines above, so the worker-id coverage the old (mask & 0x1u) check provided is gone. If the old form was flaky, (mask & ~0x3u) == 0 keeps the invariant without the flake — but either way this belongs in its own PR.

Minor: the column vectors appended inline in the new tests leak their caller reference (ray_list_append retains; file convention is name-then-release), and cps/col_ptrs derive the same pointer array twice ~20 lines apart.

@singaraiona singaraiona left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the rework: the invariants now live in the shared constructors — table_col_is_valid in ray_table_add_col closes the atom-column bypass (len aliasing the value union), ray_dict_new enforces shape and key/value count, and ray_table_validate_rectangular covers count + column type + row-length agreement for BOTH decoders, the wire path and the on-disk col load table path. The out-of-scope test_pool.c edit is gone. This is the durable version of the fix — approving; will merge after the branch updates against dev.

@singaraiona
singaraiona merged commit 612c0af into RayforceDB:dev Sep 2, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants