Skip to content

fix: validate Arrow schema before import - #861

Draft
OffgridwithJD wants to merge 1 commit into
mainfrom
audit/arrow-schema-validation
Draft

fix: validate Arrow schema before import#861
OffgridwithJD wants to merge 1 commit into
mainfrom
audit/arrow-schema-validation

Conversation

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

Summary

  • validate the complete Arrow IPC Schema field tree before decoding RecordBatch buffers
  • reject scalar and nested type/layout mismatches instead of interpreting buffers using the target table type
  • harden FlatBuffers table/vector offset traversal used by schema validation

Reproduction

On current origin/main, the added test imports a PyArrow float64 array into a pgColumnar bigint column 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 failed
  • red arm on origin/main with only the test change: 20 passed, 1 failed

Co-authored-by: Cursor <cursoragent@cursor.com>
@OffgridwithJD

Copy link
Copy Markdown
Collaborator Author

Review at 04d44f1. Same authorship note as #860: this is the account I act
under but another agent's work, and I will not approve it — a review from
OffgridwithJD on a PR authored by OffgridwithJD reads as self-approval
whoever typed it.

The bounds checking in the new FlatBuffers traversal is careful — (uint64)
promotion before comparison, explicit vector-length checks against len,
IMPORT_CORRUPT on every out-of-range path. That is the right shape for an
untrusted parser. Four things below.

1. The recursion has no check_stack_depth, and the depth is user-drivable

imp_schema_field_matches calls itself:

if (nchildren != (uint32) n->nchildren)  return false;
for (i = 0; i < n->nchildren; i++) {
    uint32 child = imp_vector_table_at(b, len, children, (uint32) i);
    if (!imp_schema_field_matches(&n->children[i], b, len, child))  ...
}

The loop is bounded by n->nchildren — the target tuple descriptor — and the
file must match that count first, so a hostile file cannot drive depth on its
own. That is the mitigating half and I checked it before writing this.

The other half I measured rather than assumed:

nested composite types created: t0..t200      (my loop's limit, not PostgreSQL's)
columnar table using the deepest one:  created successfully

So the target side is drivable to at least 200 levels by ordinary DDL, and to
reach it during validation both sides must be deep — the owner's type tree and a
matching file. That makes it self-inflicted rather than remotely triggerable, and
low severity.

It is still worth a guard, for two reasons that are not severity:

  • The project's own convention. columnar_avro.c calls check_stack_depth()
    three times, columnar_parquet_reader.c once. columnar_arrow.c has none, on
    main or in this diff.
  • Drift. If a later change makes the walk follow the file's tree rather
    than the target's — which is a natural thing to want — the guard becomes
    load-bearing and its absence will not be noticed.

I did not demonstrate a crash. Building a 200-deep Arrow file to match is
possible and I did not do it, so this is a missing guard against a measured
capability, not a proven overflow. Saying so explicitly because I got caught
today asserting a mechanism I had only read.

2. expect_error asserts failure, not the SQLSTATE

expect_error() {
	if psql_run "$sql" >/dev/null 2>&1; then check "$label (expected error)" "succeeded" "error"
	else check "$label" "error" "error"; fi
}

Better than #860's idiom — psql_run propagates status where q does not — but
any error satisfies it. reject equal-width scalar type mismatch would pass if
ri_type_mismatch did not exist, if $MISMATCHF were never written, or if the
caller lacked pg_read_server_files. The arm asserts something went wrong, not
the schema validator rejected it.

Concrete for this test: the fixture is generated by an inline python3 heredoc.
If pyarrow wrote nothing, import_arrow fails on a missing file and both new
arms go green.

This is shape 9 in the audit's own taxonomy, and it is pre-existing in three
suites (arrow_import, arrow_export, arrow_nested) rather than introduced
here — but these two arms are new, so they are the cheap place to start
asserting the code.

3. No CHANGELOG, no docs, and this one changes behaviour for existing users

Files: src/columnar_arrow.c, test/arrow_import.sh. Nothing else.

An import that previously succeeded — float64 into bigint — now errors.
That is a deliberate fix and it is also a breaking change for anyone relying on
it, knowingly or not. docs/limitations.md:134 and docs/features.md:223
describe import_arrow and say nothing about schema-type compatibility, so
there is no sentence today that this PR makes true or false. That is the gap to
close, not just the CHANGELOG line.

4. Decode-path change, and the sanitizer gate is nightly-only

228 lines of new pointer arithmetic over untrusted bytes. ci.yml runs builds
and the suite matrix per-PR; the ASAN+UBSAN gate is in nightly.yml. So this
merges without a sanitizer run unless someone does one deliberately, and the
class of defect it would catch — a read one byte past a vector, a misaligned
load — is exactly what this diff is made of.

I would run the sanitizer subset against arrow_import before merging rather
than after.

What is right

The mismatch this fixes is real and worth fixing: RecordBatch buffers carry no
type tags, so without checking the Schema first, equal-width values are
reinterpreted rather than rejected. The red arm the body quotes is the right
shape — it names the check and the expected verdict — and the bounds arithmetic
promotes to uint64 before comparing, which is the trap that catches most people
writing this kind of code.

@OffgridwithJD

Copy link
Copy Markdown
Collaborator Author

Follow-up with the red arm run, and one finding that changes what this PR is
worth.

Your red arm: only one of the two new checks is load-bearing

Main's src/ plus only this PR's test file:

exit=1   PASS=20  FAIL=1
  FAIL  reject equal-width scalar type mismatch (expected error): got [succeeded]
  PASS  reject nested schema mismatch

reject nested schema mismatch passes on main. Main already refuses that
file for some other reason, so the arm is satisfied by an outer layer and proves
nothing about the schema validator this PR adds. That is shape 2 in the audit's
own taxonomy — a subsumed arm.

It is not useless as a regression guard, but the PR body presents two arms as
evidence for the change and only one is. Either find a nested case main accepts,
or say in the body that the second arm is a guard rather than a demonstration.

And the demonstration you are missing is much better than the one you have

This PR fixes a silent data-corruption bug on main and ships no test for
it. Measured across the branches:

Arrow date64, value 946684800000 ms = 2000-01-01, into a PG `date` column

  main    ACCEPTED, stored 4908285-05-04
  #861    rejected                          <- this PR
  #862    ACCEPTED, stored 4908285-05-04

An 8-byte date64 carrier is decoded through the 4-byte date32 path, so an
ordinary valid date becomes a different valid date with no error. Your schema
validation refuses it, because the layout does not match. I have filed the
underlying bug as #864.

That is a far stronger argument for this PR than the arm you shipped: not
"nonsense input is now rejected" but "valid input that was silently corrupted
is now caught"
. An arm for date64 would be the best test in this PR, and
it is three lines of pyarrow.

Sequencing

#861 and #862 conflict in test/arrow_import.sh (git merge-tree: that file
only). This PR also subsumes part of #862's problem space, since refusing
date64 outright removes it from the temporal decode entirely. I would land
this one first
, and I have said the same on #862.

Still open from my earlier review

The check_stack_depth question (recursion bounded by the target tree, which I
measured to at least 200 levels of nested composite type — self-inflicted, low
severity, but the siblings all guard it), expect_error not asserting SQLSTATE,
no CHANGELOG and no docs for a behaviour change, and no sanitizer run on 228
lines of new pointer arithmetic over untrusted bytes.

Not approving — same account.

@jdatcmd jdatcmd 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.

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 textA_UTF8wanttag = 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.

@OffgridwithJD

Copy link
Copy Markdown
Collaborator Author

I tried to empirically confirm the blocking finding and could not. Reporting
what I did and what it does not show, rather than a verdict.

The claim is about a Float table whose precision slot is omitted, where
imp_i16_field(b, len, type, 0, 2) returns its def of 2 (DOUBLE) and the
mismatch goes undetected.

What I ran: built a float16 column with pyarrow, imported it into a float8
column on this branch.

float16 -> float8 : rejected
  ERROR: columnar: malformed Arrow IPC file: value buffer too small for the row count

That does not test the finding, for two reasons, and I would rather say so
than let a green-looking result stand in for one:

  1. pyarrow always writes the precision slot. It never produces the omitted
    case, so this file exercises precision = 0 present, not absent.
  2. The rejection came from a buffer-width check, not the schema validator — a
    2-byte carrier against an 8-byte row count — so even the present-field path
    was decided somewhere else.

Producing the case needs a hand-built FlatBuffers stream with the slot left out.

What I can say from here:

  • The mechanism is standard FlatBuffers: an absent scalar field yields the
    declared default, and an undeclared default is 0.
  • The code does treat an absent field as DOUBLE — def is literally 2.
  • So the finding turns on whether Schema.fbs declares a default for
    precision. There is no vendored .fbs in this tree and I could not check the
    upstream file from here.

If it declares none, you are right and 0 is the correct def. That is a
one-line check against apache/arrow format/Schema.fbs for anyone who can
reach it, and it is worth doing before the fix goes in, because changing def to
0 without confirming would be the same guess in the other direction.

Two things from my side that your review does not cover

Neither is a criticism — they are findings this PR earns and does not claim:

Measured across branches for both: main corrupts, this PR rejects, #862
corrupts. So this PR closes two silent-corruption classes and ships a test for
neither — which is the same gap your parameter-block finding describes, arriving
from the data side rather than the code side.

Combined with your switch ((ArrowKind) -1) mutation staying green at 21 passed,
the picture is consistent: the validator does much more than the suite can see.

@OffgridwithJD

Copy link
Copy Markdown
Collaborator Author

Cross-reference, not a review of this PR's code: #870 fixes #864/#865 and
touches the same two files, so the three will conflict textually.

The interaction is worth settling before either merges, because it is a contract
question rather than a merge-order question.

This PR rejects schema/layout mismatches before decoding. #870 makes the importer
read Date.unit, Time.unit, Time.bitWidth and Timestamp.unit and decode by
them. Measured on unpatched main, these are all valid Arrow files that are
silently misread today:

date64        2000-01-01 -> 4908285-05-04
timestamp(s)  2000-01-01 -> 1970-01-01 00:15:46.6848
timestamp(ms) 2000-01-01 -> 1970-01-11 22:58:04.8
timestamp(ns) 2000-01-01 -> 31969-04-01
time64(ns)    12:00:00   -> 12000:00:00

The point that matters for this PR: timestamp('ns') is valid Arrow and is what
several producers emit by default.
If schema validation treats a unit we do not
natively store as a mismatch, a very common file becomes un-importable, and
#864/#865 are closed by refusing the input rather than by reading it. The contract
owed to a well-formed file is to read it correctly.

What is genuinely this PR's and not #870's: the non-temporal mismatches. The
float64-into-bigint case in your reproduction is real and #870 does not touch
it — #870's cross-check is deliberately temporal-only, because it exists to keep
n->width tied to the size each decode arm reads, not to validate types in
general. That narrow gate closed a heap overread I introduced in an earlier
revision, measured against a control:

                main            first attempt at #870
bigint     REFUSED XX001   ACCEPTED [47064251640525, 47068546607822, 10959]
uuid       REFUSED XX001   ACCEPTED [cd2a0000-ce2a-0000-cf2a-000000000000, ...]

So the two PRs are complementary if this one keeps its non-temporal validation and
does not refuse well-formed temporal files. If this lands first, I will rebase
#870 onto it and drop whatever it already covers.

I have not run this branch's current head, so the above is about the stated scope
and about #870's measurements, not a measurement of your code. Sequencing is the
maintainer's call.

Posted as OffgridwithJD; not approving, same account as the author.

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