fix: validate Arrow schema before import - #861
Conversation
Co-authored-by: Cursor <cursoragent@cursor.com>
|
Review at The bounds checking in the new FlatBuffers traversal is careful — 1. The recursion has no
|
|
Follow-up with the red arm run, and one finding that changes what this PR is Your red arm: only one of the two new checks is load-bearingMain's
It is not useless as a regression guard, but the PR body presents two arms as And the demonstration you are missing is much better than the one you haveThis PR fixes a silent data-corruption bug on An 8-byte That is a far stronger argument for this PR than the arm you shipped: not Sequencing#861 and #862 conflict in Still open from my earlier reviewThe Not approving — same account. |
jdatcmd
left a comment
There was a problem hiding this comment.
Reviewed adversarially at 04d44f1, three independent lenses plus a refutation pass. Ten findings survived; these are the four that matter.
BLOCKING: the float precision default is HALF, not DOUBLE
case A_FLOAT64:
if (imp_i16_field(b, len, type, 0, 2) != 2) /* default 2 = DOUBLE */
return false;imp_i16_field(..., int16 def) returns def when the field is absent. Arrow's Schema.fbs declares enum Precision:short { HALF, SINGLE, DOUBLE } with no explicit field default, so an omitted precision means HALF (0) — the value a writer omits.
So a float16 column whose precision field is not written passes this check against a float8 target, and the importer then reads 8-byte doubles out of 2-byte data. The check that exists to catch a same-tag mismatch admits the one case where the file says nothing.
0 is the correct default, and the arm should then require 2.
MAJOR: the whole per-kind parameter block has no red arm
src/columnar_arrow.c:1565-1613 — int bit width and signedness, float precision, date unit, time unit and width, timestamp unit and timezone, UUID width, decimal precision/scale/width. Disable all of it and the suite does not notice:
sed -i '1565s/switch (n->kind)/switch ((ArrowKind) -1)/' src/columnar_arrow.c
test/arrow_import.sh -> accounting: 21 passed + 0 failed + 0 unrunnable = 21 PASSED
The mutation is load-bearing rather than inert — the same probe file, imported on both builds:
PR build u64->bigint REJECTED 42804 | ts('ms')->timestamp REJECTED | decimal128(10,2)->numeric(20,4) REJECTED
mutated u64->bigint ACCEPTED "1,2" | ts('ms')->timestamp ACCEPTED, values 1000x wrong
| decimal128(10,2)->numeric(20,4) ACCEPTED, 1.00 stored as 0.0100
That is silent data corruption on three separate types, and the suite stays green through all of it. The single new scalar arm cannot see any of it, because float64-into-bigint differs in the FlatBuffers tag and is caught by the first switch alone. The round-trip arms cannot either — they only ever feed pgColumnar's own schema back to itself, which matches under a relaxed check just as well.
Four fixtures close it, each asserting 42804: uint64 into bigint, timestamp('ms') into timestamp, timestamp(tz) into a naive timestamp, decimal128(10,2) into numeric(20,4).
MAJOR: "reject nested schema mismatch" is green with the whole fix reverted
Your own Tests section says it: 20 passed, 1 failed on origin/main with only the test change. Two checks were added and only one goes red. The nested arm passes on unmodified main because the pre-existing #214 offset-bounds check fires first — XX001 data_corrupted, "string/binary data runs past its buffer" — and expect_error cannot tell XX001 from 42804.
The nested recursion the comment claims it pins is never even reached for that fixture: target column b is text → A_UTF8 → wanttag = Utf8, the file's field is List, so if (tag != wanttag) return false fires before the children loop. The recursion can be deleted wholesale and the arm stays green.
sqlstate_or_hang already exists in this file at line 33 and already returns a bare SQLSTATE. One substitution fixes it:
check "reject nested schema mismatch" \
"$(sqlstate_or_hang "SELECT pgcolumnar.import_arrow('ri_nested_mismatch','$MISMATCHF')")" "42804"That is red on main (XX001 != 42804) and green here.
MAJOR: a dictionary-encoded field is validated as its value type
imp_schema_field_matches reads Field slots 2 (type_type), 3 (type) and 5 (children), and never slot 4 (dictionary). A dictionary-encoded field is therefore checked against its value type while its RecordBatch buffers hold index values. The existing dictionary rejection elsewhere is what saves this today; the new validator does not, and it is presented as complete.
Two smaller ones
Decimal precision is over-strict. The A_DECIMAL128 arm requires the file's Decimal.precision to equal the target's declared precision, but precision has no effect on the Decimal128 buffer layout — 16-byte little-endian int128 at the given scale. Scale and bit width must match; precision equality rejects files that would import correctly.
The third summary bullet has no check. "Harden FlatBuffers table/vector offset traversal" — deleting all five added bounds guards leaves the suite at 21 passed, 0 failed.
What is right
The tag switch itself is correct and the scalar arm does pin it. imp_i16_field/imp_bool_field reading a FlatBuffers default when a field is absent is the right shape — the defect is the value chosen for one of them, not the mechanism. And splitting validation out of the decode path so a mismatch is refused before any buffer is read is the right structure for this fix.
|
I tried to empirically confirm the blocking finding and could not. Reporting The claim is about a What I ran: built a That does not test the finding, for two reasons, and I would rather say so
Producing the case needs a hand-built FlatBuffers stream with the slot left out. What I can say from here:
If it declares none, you are right and Two things from my side that your review does not coverNeither is a criticism — they are findings this PR earns and does not claim:
Measured across branches for both: Combined with your |
|
Cross-reference, not a review of this PR's code: #870 fixes #864/#865 and The interaction is worth settling before either merges, because it is a contract This PR rejects schema/layout mismatches before decoding. #870 makes the importer The point that matters for this PR: What is genuinely this PR's and not #870's: the non-temporal mismatches. The So the two PRs are complementary if this one keeps its non-temporal validation and I have not run this branch's current head, so the above is about the stated scope Posted as OffgridwithJD; not approving, same account as the author. |
Summary
Reproduction
On current
origin/main, the added test imports a PyArrowfloat64array into a pgColumnarbigintcolumn successfully, silently interpreting the IEEE-754 bits as integers. The red arm reports:FAIL reject equal-width scalar type mismatch (expected error): got [succeeded] want [error]Tests
test/arrow_import.sh /usr/bin/pg_config(PostgreSQL 18.6, Ubuntu 26.04): 21 passed, 0 failedorigin/mainwith only the test change: 20 passed, 1 failed