Skip to content

fix(layout): check list offsets before the list reader uses them - #68

Merged
ChristopherWirt merged 2 commits into
developfrom
rasheed/list-layout-validate
Nov 3, 2026
Merged

ChristopherWirt merged 2 commits into
developfrom
rasheed/list-layout-validate

Conversation

@gitRasheed

Copy link
Copy Markdown
Collaborator

Summary

Closes #56. Part of #55.

The list reader trusted the offsets it decoded. When offsets decrease, go negative or point past the elements, most reads return with no error, and a sparse read of decreasing offsets panics in vortex-mask. Readers now check offsets as they read them, so a malformed file fails the read with an error.

Changes

  • ListReader::fetch_raw_offsets checks the offsets it returns. They must be the expected count, castable to u64 (negative values fail), non-decreasing, and the last one can't be past the elements child's row count. Every read path gets its offsets from here, so the range arithmetic, kept_elements and ListArray::new_unchecked after it can rely on that. Only the offsets a read uses get checked, so pruning doesn't change and nothing scans the whole column on open. The row_range.end + 1 there is now checked too.
  • ListLayout::new becomes ListLayout::try_new. It checks the children the way deserialization does: elements match the list's element dtype, offsets are non-nullable integers with at least one row, and validity is non-nullable bool with one row per list. Before, it used saturating subtraction and vortex_expect.
  • The writer adds each chunk's element count with checked_add.

Not in this PR: which integer types the frozen format accepts for offsets. That's a #54 decision. Readers still accept any integer type that casts to u64.

Testing

  • malformed_offsets_fail_the_read covers decreasing, past-the-end and negative offsets on full, bounded, sparse and length-only reads. Without the check, 8 of the 12 cases return with no error, 2 fail later with unrelated errors (an out-of-bounds slice and a filter length mismatch), and 1 panics. Only one, a sparse read of negative offsets, already failed cleanly.
  • try_new_rejects_mismatched_children covers the wrong elements dtype, float offsets, nullable offsets, no offsets and a validity row count that doesn't match.
  • cargo test -p vortex-layout -p vortex-file and cargo clippy -p vortex-layout -p vortex-file --all-targets.
  • Read a real list-layout file of nested event data through the data source with the check in place. The bytes read and rows came out the same as before.

The list reader trusted decoded offsets: offsets that decrease or run past
the elements built wrong lists without an error, or panicked. Check them
where they are read, build list layouts through a fallible constructor that
checks children the way deserialization does, and add the element count with
an overflow check.

Signed-off-by: Rasheed Khoshnaw <[email protected]>
@ChristopherWirt

Copy link
Copy Markdown
Collaborator

Reviewed head ee5b58e2a with four independent passes for reuse, maintainability, efficiency and correctness.

I found no upstream PR carrying this validation change. vortex-data/vortex#11082 is under active review, but addresses the separate sliced-list writer fix already in QC develop, so I do not think we need to wait on it for this work.

One correctness issue to address before merging: check_offsets casts to u64 before checking the actual signed values. Primitive casting can trust cached exact min/max statistics, and deserialization installs persisted statistics. With signed offsets [-2, -1], forged Min=0/Max=1, and elements_rows=u64::MAX, the cast produces [u64::MAX - 1, u64::MAX], which passes both monotonicity and terminal bounds. A length-only read can therefore accept negative offsets; the large logical elements count does not require allocating its payload for that read.

Decode the primitive offsets and check actual signed values for negativity before casting, then retain the existing monotonicity and bounds checks. Also tighten the existing negative-offset fixture: its expected message is "", so it currently accepts any unrelated error rather than proving this validation boundary was reached.

A local, unpushed fix is prepared. Both new forged-statistics regressions (same-width and widening casts) fail on this PR head and pass with the fix. With the fix: 160 list-layout tests and 2 physical-plan tests pass, as do scoped all-target/all-feature Clippy with -D warnings, pinned Rust formatting and whitespace checks. Full workspace tests were not run. No other worthwhile findings remained after reconciliation.

@gitRasheed

gitRasheed commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator Author

Good catch on cached stats letting negative offsets through the cast. Could you push your fix to this branch? It sounds complete with the two forged-stats regressions, and there's no point in me redoing it. I'll tighten the empty expected message on the negative-offset fixture if you haven't already.

Decode offsets to primitive values and reject negative signed offsets before the u64 cast can trust persisted min/max statistics. Preserve the monotonicity and elements-bound checks.

Cover forged exact statistics for same-width and widening casts, and require the malformed negative-offset fixture to report the negative-offset validation error.

Signed-off-by: Christopher Wirt <[email protected]>
@ChristopherWirt
ChristopherWirt merged commit c91c8eb into develop Nov 3, 2026
1 check 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.

[List layout] Validate decoded offsets before range calculation and unchecked reconstruction

2 participants