8000 fix(postgres): close soundness holes around user-defined objects by colinhacks · Pull Request #119 · colinhacks/reylo · GitHub
[go: up one dir, main page]

Skip to content

fix(postgres): close soundness holes around user-defined objects - #119

Merged
colinhacks merged 14 commits into
mainfrom
fix-trust-followups
Oct 25, 2026
Merged

colinhacks merged 14 commits into
mainfrom
fix-trust-followups

Conversation

@colinhacks
@colinhacks colinhacks commented Oct 24, 2026 •
Copy link
Copy Markdown
Owner

The rest of the soundness work around user-defined operators, functions, casts and search paths that #112 started. An adversarial review of that change turned up holes main already had; this closes them, and hardens every catalog query the way #112 hardened the trust query.

Inference

  • IS [NOT] DISTINCT FROM is read as the call of = it runs: a user = that answers NULL for two non-null values makes it NULL, so it is non-null only where = resolves to the built-in (or a side is a NULL literal, where no = runs). New case trust_distinct.
  • A unique key is pinned only under the equality its index compares with. The untyped (ValueType::Adapts) and exact-type (t == column_type) shortcuts claimed at most one row for where id = 5::int8 or where id = :p even when a user =(int8, int8) placed first by the search path answers true for every row. The resolved = must now be the built-in or the btree equality of the unique index's operator family, which keeps a citext key's where email = :e at most one row, and an enum key keeps it under the built-in =(anyenum, anyenum) where no user = reaches the enum. A unique index whose operator class compares more finely than the type's default btree equality (record_image_ops, which holds (1.0) and (1.00) apart) pins nothing. New cases trust_key_user_equality and trust_key_operator_class.
  • A call that may be a column of its row argument claims nothing. With no overload that takes the row, length(t) is t.length (Postgres's projection syntax), so length(t), md5(t), abs(t) could be NULL where they were typed non-null, and sum(s) over a table with a column sum returns a row per row where the cardinality said one. A one-argument call keeps its claim only where its argument can't be a row, its type is known to be no row, or nothing gives a row a field of that name: no column the statement shows (alias column lists included), no attribute of a catalog row type, and no OUT, INOUT or TABLE parameter of a function. sum(col), count(*) and coalesce(sum(n), 0) keep theirs, in scalar subqueries and JSON too, where a column's type is now known. New cases in trust_projection and trust_out_parameters.

Catalog queries

  • Every catalog query, and the loader's schema fingerprint, reads only pg_catalog's objects: OPERATOR(pg_catalog.…), qualified functions, types and tables, typed literals, no conversion through text that a CREATE CAST could take, no polymorphic function that an overload in pg_catalog could beat, and no construct that looks = up by name. Before, a user's = or || under search_path = public, pg_catalog silently emptied or errored these queries (a BEFORE trigger went unseen, so an insert … returning claimed one row), and under a hostile || the fingerprint stopped changing. A lint test (catalog_queries.rs) fails on any unqualified name, tests/catalog-queries.test.ts replays every query against about 600 hostile objects and asserts the same rows, and another test prepares every catalog query with debug_print_parse and fails on any implicit conversion in its parse tree (it found one: an oid compared with a regproc in the trust query). Array parameters are bound by count, so several queries take a new count parameter.
  • The build-time describer connects with fetch_types: false and reads arrays itself: postgres.js's own type query on connect compares with a bare =, which a user's = captured, failing every array parameter.

The whole oracle passes with --strict-coverage (153 cases), and no existing expected.txt line changed. A known limit: a superuser's overload pg_catalog.generate_series(int4, int4, text default '') makes the queries' exact generate_series(int4, int4) call ambiguous, so the analysis fails with the server's error rather than returning a wrong type, as a defaulted overload of unnest did before.

Copilot AI balanced review requested due to automatic review settings October 24, 2026 04:24
@vercel
vercel Bot commented Oct 24, 2026 •
Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
reylo Ready Ready Preview Oct 25, 2026 4:11am UTC

Request Review

Copilot AI left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The projection rule for aggregates still misses two ways a row gets a column named like an aggregate. In both, sum(x) is claimed one row where Postgres returns a value per row. There are also two precision regressions that are worth a look. Details are inline.

Reviewed changes

I read the full diff of the eight commits: the inference rules, the hardened catalog queries and their lint, the loader's fingerprint and describer, and the new oracle cases.

  • IS [NOT] DISTINCT FROM: this is now read as the = call it runs, and is non-null only where that = resolves to the built-in or a side is a bare NULL.
  • Unique key pinning: the untyped and exact-type shortcuts now need the resolved = to be the built-in one, or the btree equality of the unique index's opfamily (a new trust-query key row, plus Catalog.declared_types).
  • Column projection (f(row) read as row.f): the new Expr::NotRow keeps a one-argument call non-null only over a type known to be no row. aggregate_for_certain drops the aggregate claim of sum/max/… where a row may have a column of that name. This uses the new trust-query rows rows, scalars and column, plus row_fields.
  • Catalog queries: every query now names only pg_catalog objects, with typed literals and no unnest/array_agg. Arrays are read by subscript over generate_series with new count parameters, and every call site is updated. A parse-tree lint (catalog_queries.rs) and a live test against hostile objects (tests/catalog-queries.test.ts) enforce the rules.
  • Loader: the schema fingerprint is built with pg_catalog's own || and with textin(…out(…)). The describer connects with fetch_types: false and serializes and parses catalog arrays itself.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

Comment thread engine/postgres/src/infer.rs Resolved
Comment thread engine/postgres/src/postgres.rs Resolved
Comment thread engine/postgres/src/cardinality.rs Resolved
Comment thread engine/postgres/src/infer.rs Resolved
IS [NOT] DISTINCT FROM compares two non-null values with =, and is NULL
where that = answers NULL, as a user's of the name may. It is now
non-null only where = resolves to the built-in for its operands' types,
or one side is a NULL, where no = runs.
An untyped value beside a unique column, or one of its type, pinned the
key whatever = it resolved to: a user's = on a domain, or one the search
path puts first. Such a comparison now pins where = resolves to a
built-in, or for a type of a user's where the trust query finds that =
to be the btree equality of every unique index's key of that type.
Under a search path that puts a user's schema before pg_catalog, a
user's `=` or `||` answered the catalog queries, and an overload in
pg_catalog itself or a user's cast could beat a polymorphic call or an
I/O conversion. Every operator, function, type and table is now
pg_catalog's by name, every call an exact match of a non-polymorphic
built-in, and a test reads each query's parse tree to keep it so.
postgres.js reads the array types at connect with bare operators, which
a user's `=` answers under a search path that puts their schema first;
arrays then travelled as text both ways. The describer now writes and
reads the catalog queries' arrays itself, and a test runs every catalog
query beside hostile copies of what it names.
Under a search path that puts a user's schema first, a user's `||`
that answers NULL left every row of the fingerprint query NULL, so the
fingerprint stopped moving and .reylo/ went stale. The query now names
only pg_catalog's operators, functions and types, and its value on a
database with none of the user's is unchanged.
@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found. All four findings from the last review are fixed, and each has an oracle case.

Reviewed changes

This covers the six commits since 2336e65.

  • Alias column lists: row_fields now reads the alias field of FROM items and joins. So (…) q (bool_and), t AS x (bit_and) and (… JOIN …) AS j (min) count as row columns.
  • OUT, INOUT and TABLE parameters: the trust query's column row now also reads proargnames where the mode is o, b or t. So sum(r) over g() r with RETURNS TABLE (sum int) is many.
  • Enum keys: the key row falls back to the built-in =(anyenum, anyenum) for an enum, or a domain over one. It does so only when no user = takes the enum (exactly, through a domain, or through a polymorphic pseudo-type) and no implicit cast converts it. A user =(mood, text) still blocks the pin.
  • Typed columns for aggregates: Columns now keeps the type oids that COLUMNS_QUERY already returned. Level::column_type gives an argument's type in the scalar-subquery, jsonshape and aggregating paths, which ends the precision loss for coalesce(max(t.n), 0).
  • Operator classes: UNIQUE_KEYS_QUERY now drops a unique index if any part's operator class uses a different strategy-3 equality than its type's default btree class. This covers record_image_ops and a user's operator class over numeric.
  • No implicit operand conversions: o.oprcode is now cast to oid, and the "char" literals are typed. A new live test fails on any implicit coercion node in a catalog query's debug_print_parse tree. The new case trust_catalog_exact_operands uses a superuser's pg_catalog.=(oid, regproc).
  • Artifacts: all three engine.wasm files are rebuilt.

Pullfrog  | View workflow run | Using claude-opus-5-5 | 𝕏

@colinhacks
colinhacks merged commit 57467aa into main Oct 25, 2026
11 checks passed
@colinhacks
colinhacks deleted the fix-trust-followups branch October 25, 2026 04:18
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

0