Skip to content

fix(reader): harden RunEnd/Constant/Zoned/Pco against malformed input - #330

Merged
dfa1 merged 3 commits into
mainfrom
security/adversarial-tests-batch2
Aug 6, 2026
Merged

fix(reader): harden RunEnd/Constant/Zoned/Pco against malformed input#330
dfa1 merged 3 commits into
mainfrom
security/adversarial-tests-batch2

Conversation

@dfa1

@dfa1 dfa1 commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Summary

  • Closes out TODO.md's "Per-encoding adversarial tests" security item: RunEnd, Constant, Zoned (zone-map), and Pco now always fail as VortexException on crafted/malformed input, never a raw JDK exception.
  • RunEnd: rejects negative/zero num_runs against a non-empty row count, and validates ends per the format spec (strictly increasing, ends[0] >= offset, ends[numRuns-1] >= offset + n).
  • Constant: rejects a Decimal-typed constant scalar missing bytes_value instead of NPE-ing.
  • Zoned (ScanIterator.decodeZoneTable): bounds the zone-map table's declared row count before it sizes a collection, and avoids a single huge pre-sized allocation.
  • Pco: sizes the degenerate (zero-bin) ANS table correctly, rejects offsetBits > 64, and validates declared per-page value counts against the actual row count before any latent-buffer writes.

Test plan

  • ./mvnw -pl fsst,core,reader -am test
  • ./mvnw -pl writer -am test
  • ./mvnw -pl integration -am verify (includes real Rust-written files with Pco/RunEnd columns)
  • Independent subagent review of the diff, findings addressed in a follow-up commit

🤖 Generated with Claude Code

dfa1 added 3 commits August 6, 2026 07:57
Part of TODO.md's "Per-encoding adversarial tests" security item (CLAUDE.md
§Security contract): a malformed file must always throw VortexException,
never a raw JDK exception.

- RunEndEncodingDecoder: reject negative num_runs and zero runs paired with a
  non-empty row count — previously decoded "successfully" into a lazy array
  backed by an empty ends/values child, then threw a raw
  IndexOutOfBoundsException/ArithmeticException on first read.
- ConstantEncodingDecoder: reject a Decimal-typed constant whose scalar oneof
  doesn't carry bytes_value (raw NullPointerException reading its length).
- ScanIterator.decodeZoneTable: bound the zone-map table's declared row count
  (an unvalidated layout field, deliberately decoupled from the data layout's
  chunk count) before it sizes an ArrayList — a negative value threw a raw
  IllegalArgumentException and a value just over Integer.MAX_VALUE wrapped
  negative on the int cast.
- PcoTansDecoder.build: size the degenerate (zero-bin) decode table to
  tableSize instead of a fixed 1-state table — a page's initial ANS state
  indices are read with ansSizeLog bits regardless of bin count, so a
  corrupt file pairing zero bins with a nonzero ansSizeLog indexed a stale
  1-entry array out of bounds.
- PcoEncodingDecoder: reject a bin offsetBits > 64 (wider than any latent);
  validate that declared per-page value counts are non-negative and sum to
  the expected valid row count before allocating/writing latent buffers
  sized or offset by them.

TODO.md: mark RunEnd, Constant, Zoned, and Pco done — all eleven
per-encoding gotchas now closed.
Independent subagent review of the prior commit (12d7466) surfaced two
gaps:

- RunEndEncodingDecoder only rejected the degenerate zero-run case; the
  spec (encoding-format/dict-runend-sparse.md §RunEnd) is explicit that
  a conformant reader SHOULD itself validate what the reference writer
  guarantees but the reference reader does not enforce: `ends` strictly
  increasing, `ends[0] >= offset` when sliced, and `ends[numRuns-1] >=
  offset + n`. None of these crashed (the binary search stays in-bounds
  regardless of content), but they silently resolved rows against the
  wrong run instead of failing. Added one O(numRuns) validation pass;
  the coverage check is `>=` not `==` since a sliced window's trailing
  run legitimately extends past it (see the new
  trailingRunPastWindow_decodesNormally test, the spec's own worked
  example).
- ScanIterator.decodeZoneTable's nZones bound (added in 12d7466) still
  let a value up to Integer.MAX_VALUE through to `new ArrayList<>((int)
  nZones)` — a single allocation at that scale is itself an
  OutOfMemoryError vector. Switched to an unsized ArrayList that grows
  with what the loop actually produces.
@dfa1
dfa1 merged commit 37d1963 into main Aug 6, 2026
6 checks passed
@dfa1
dfa1 deleted the security/adversarial-tests-batch2 branch August 6, 2026 06:46
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.

1 participant