Skip to content

Alpha gate: fuzz the hand-rolled Parquet and Arrow parsers #214

Description

@jdatcmd

Alpha gate 1 of 4, from the pre-alpha review note. Ranked first there as the
highest-leverage item. @ChronicallyJD for review of the plan below before any
code is written.

What is true today

There is no byte-level fuzzing of any parser in this tree. grep -rniE 'LLVMFuzzer|afl-|libfuzzer|-fsanitize=fuzzer' over the whole repository returns
nothing.

test/fuzz.sh is not a parser fuzzer despite the name. It is a seeded
randomized differential suite at the SQL level: it varies row count, null
density, chunk and stripe limits, codec and level, loads a heap mirror with the
same data, and compares result-set hashes. It fuzzes data shapes, not byte
streams, and it never presents malformed input to a decoder.

Coverage of malformed input today is entirely example-based, one hand-written
case per known defect:

suite what it feeds
test/native_parquet_hardening.sh out-of-range physical values, wrong column-chunk count
test/native_parquet_stack.sh the two #210 recursion vectors
test/hardening.sh native on-disk bytes
test/corruption.sh native catalog fields

The surface those cases cover a fraction of:

file lines contents
src/columnar_parquet_reader.c 4,098 Parquet footer, schema walk, page decode, FLBA, FDW
src/columnar_arrow.c 1,899 Arrow IPC, FlatBuffers
src/columnar_thrift.c - hand-written Thrift parser

#210 was found by reading code and grepping for an absent guard, not by a
machine. Before #211 landed, grep check_stack_depth src/ returned zero across
the entire tree. That is the evidence that this surface has not been searched
systematically: a coverage-guided fuzzer finds unbounded recursion in minutes,
and this one survived until someone read the file.

Scope correction from the review note

The note assumed these entry points are reachable by any user. They are not.
Every file-touching entry point is gated on superuser() in C. See #216 for the enumeration. That lowers the severity from remote attack
surface to "a superuser reads a file they did not produce", which is still the
normal data-lake case and still worth closing, but it is not an unauthenticated
surface and should not be described as one.

Plan

Two harnesses, in this order.

Stage A, file mutation through a live backend. Build a seed corpus of valid
Parquet and Arrow files from the existing export suites, mutate them (bit flips,
truncation, length-field and count-field substitution, structural nesting), and
feed each through pgcolumnar.parquet_schema, pgcolumnar.read_parquet,
pgcolumnar.import_parquet and the Arrow import path. The pass condition is
that the backend either succeeds or raises an ERROR, and never dies. Any signal
death, assertion failure, or sanitizer report is a finding, and the input is
saved to a corpus directory named by seed.

This stage is first because it needs no decoupling of the parsers from the
backend. They use palloc, ereport and memory contexts throughout, so a
standalone harness is real work; driving a real backend is not. #210 would have
been caught by this stage.

Stage B, coverage-guided in-process fuzzing. A libFuzzer or AFL++ harness
linking the parser translation units against a minimal memory-context and error
environment, for depth and execs per second that stage A cannot reach. Scope
this only after stage A stops finding things, and treat the decoupling cost as
the reason it is second rather than first.

Both stages need a build the existing matrix does not have. /usr/local carries
pgNN (assert), pgNN_nc (non-assert), pg18_uring and pg20. There is no
ASAN or UBSAN build. Memory-safety bugs in a decoder are exactly what a plain
assert build misses, so an instrumented build is part of this work, not an
optional extra.

Todo

  • Build an ASAN and UBSAN enabled PostgreSQL (18.4 to start) in the dev
    container as pg18_san, and confirm the extension builds and the existing
    Parquet suites pass under it. Any pre-existing sanitizer report found here
    is a finding in its own right and gets its own issue.
  • Add test/parquet_corpus.sh or equivalent to emit a seed corpus of valid
    files covering every physical type, encoding, codec, nesting depth and
    multi-file or partitioned layout the reader claims to support.
  • Write the stage A mutator and driver as test/fuzz_parquet.sh, seeded and
    reproducible with PGC_SEED in the same style as test/fuzz.sh, so a
    failure reproduces exactly.
  • Extend the same driver to the Arrow and FlatBuffers path.
  • Run a soak, not a single pass. Record wall-clock hours, executions, and
    distinct crashes. A single green pass is not evidence.
  • File one issue per distinct crash found, with the minimized input attached.
  • Add the suite to the SUITES array in test/run_all_versions.sh at a
    bounded iteration count. A suite not in that array is never gated, which is
    how the multi-file Parquet path went ungated between Phase G: read a directory or glob of Parquet files as one relation #111 and Phase G multifile: skip non-regular-file matches; add the suite to the matrix #113.
  • Decide, and write down, whether stage B is worth its decoupling cost given
    what stage A found.

What closes this

Stage A running in the matrix, a soak of stated duration with its findings
filed, and every crash it found either fixed or documented as accepted. Not a
single green run.

The set

Alpha gates from the pre-alpha review note, ranked cheapest-highest-value first:
#214 fuzz the parsers, #215 limitations and release status, #216 untrusted-input
boundary, #217 backend-crash blast radius.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions