fix(dbconn): strip explicit pg_catalog from pooled search_path - #93
Conversation
PostgreSQL searches pg_catalog implicitly before every search_path entry unless the path names it explicitly, in which case it is searched at that position and an earlier user schema can shadow catalog names. A role or database configured with `search_path = app, pg_catalog` made every pooled session resolve unqualified `pg_class` to `app.pg_class`, so a decoy table could turn an introspection read into a wrong answer. An AfterConnect hook now reads the session search_path and removes every explicit pg_catalog entry (bare or quoted), leaving all other entries in their configured order. This restores the implicit-first rule without changing current_schema(), so unqualified DDL still targets the schema the caller configured.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
🤖 Reviewed The core decision is right and the reasoning in the description is the part I'd keep verbatim. Stripping the explicit entry rather than prepending Eleven of eighteen mutants died. Three of the five survivors are worth acting on. 1.
|
|
🤖 Comment 2 of 2 on 5. The one documented behavior change is the one case the test cannot express
Two things about that sentence. First, when there is no next existing entry it does not become one — it becomes NULL: So for Second, this is the only behavior change the PR documents and there is no case for it. It cannot be added as-is either: 6. The pooler caveat is right that the setting does not follow the client, and understates where it goes instead
The comparison is not quite like for like, and the difference runs the other way from what the sentence implies.
The blast radius is small and worth saying so: the value that leaks is the unshadowed path, so the recipient is strictly better defended than before, and only a client deliberately relying on a user schema shadowing the catalog would notice. But "does not follow the client" and "is left behind for someone else" are different statements, and the caveat currently makes only the first. One clause covers it. 7. Two survivors that are fine as-is, and one test whose name promises more than it checks
8. This change earns an invariant entry
Four statements of one rule, with no single place that says it and nothing that fails when a fifth site forgets. An entry in the CO family — every read pg-sprite uses to make a decision resolves to the real catalog, upheld at the connection layer by 9. What I'd keep unchanged
None of the above blocks. Findings 1 and 2 are the two I would land before merge; the rest are precision. This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving. The core decision — stripping the explicit pg_catalog entry rather than prepending it — is the only variant that leaves current_schema() and pkg/migrate's target resolution alone, keeping a leading pg_catalog is load-bearing and tested, and the hook fails closed. Eleven of eighteen mutants died; the two survivors worth landing before merge are the untested pg_catalog.set_config qualification and the quoted-comma case that only exercises the unchanged-path return. Details in the two comments above.
This stamp was left by Claude Code (claude-opus-5).
… rewrite and register CO-9
|
🤖 Adversarial review response — created by Kiran's code review agent (Amp, Claude Opus 4.6) — pull/93, follow-up commit All nine findings addressed in
Decisions to veto: CO-9 is registered as a Correctness invariant rather than a Refusals one, since it protects the answer of a read rather than a refusal path. Source: #93, review comment 5610284689 and review comment 5610285291 at head |
Removes a
pg_catalogentry from every pooled session'ssearch_pathwhen a user schema is listed ahead of it, so that schema no longer shadows the catalog, while every other entry is left as configured. A leading or solepg_catalogis left alone, and every transaction-localsearch_pathpg-sprite sets is built through the same rewrite.Why
PostgreSQL searches
pg_catalogimplicitly before everysearch_pathentry unless the path names it explicitly. Then it is searched at that position, and a schema listed earlier shadows catalog names. A role or database configured withsearch_path = app, pg_catalogmade every pooled session resolve unqualifiedpg_classtoapp.pg_class: a decoy table could turn an introspection or preflight read into a confidently wrong answer. Most catalog reads inpkg/preflightandpkg/schemadiffalready qualify withpg_catalog., but the connection layer is where the guarantee belongs, so a future unqualified read cannot reintroduce the hole. The guarantee is now registered as invariant CO-9 — Decision reads resolve to the real catalog, upheld at two layers: the connection layer rewrites the path, and reads that run under a path pg-sprite did not set qualify every catalog name.Stripping the explicit entry, rather than prepending
pg_catalog, is deliberate: prepending would makecurrent_schema()returnpg_catalog, so unqualifiedCREATE TABLEandmigrate's target resolution (which resolves unqualified names through the sessionsearch_pathon purpose) would change meaning. Removing the entry restores PostgreSQL's implicit-first rule and touches nothing else.What
pkg/dbconn: newAfterConnecthookunshadowCatalogruns once per physical connection. It readsSHOW search_path, removes everypg_catalogentry (bare, case-insensitive; or double-quoted, exact, with""as the escaped quote; commas inside quotes are respected) that has a non-catalog entry before it, and callspg_catalog.set_config('search_path', …, false)only when something was removed. Paths without a shadowed entry cost oneSHOWand no write. The creation schema changes only when every entry ahead of the removedpg_catalognames a nonexistent schema; it becomes the next existing entry, or NULL when there is none, in which case an unqualifiedCREATEfails and preflight reportsErrNoCreationSchema.dbconn.LocalSearchPath(schemas ...string)builds theSET LOCAL search_pathstatement for a transaction: identifiers sanitized, the same rewrite applied.schemadiff/introspect.go,schemadiff/desired.go, andexecutor/optimistic.gouse it; a test walks the production sources underpkg/and fails if any other file setssearch_path.docs/invariants.mdgains CO-9 and a Build-phase row;SAFETY.mdlists it forpkg/dbconnandpkg/executor; thepg_catalog.-qualification comments inpkg/executor,pkg/progress, andpkg/schemadiffcite it.NewPoolgodoc and the README connection paragraph state the behavior.pg_catalog; quoted commas on a rewritten path; empty entries) and overLocalSearchPath; integration test on a throwaway database (ALTER DATABASE … SET search_path) that creates a decoypg_classand a decoyset_configin a schema listed beforepg_catalog, then asserts the pooled session's path has the entry removed,current_schema()is unchanged (or NULL when only a missing schema is ahead), andcount(*) FROM pg_classreads the real catalog, plus unchanged-path cases forpg_catalog, decoyandpg_catalogalone;buildPoolConfigtest asserts the hook is installed.internal/testutil.NewCatalogShadowingPool: a rawpgxpoolwith a shadowingsearch_pathfor the executor and schemadiff tests that prove catalog reads resist shadowing; those tests would otherwise be disarmed by the hook.Scope: the guarantee is per session. Only an explicit
pg_catalogentry is rewritten, not the implicitpg_tempsearch ahead of it — pg-sprite creates no temporary objects, so nothing it runs can populate apg_tempthat shadows its own session. The hook is a runtimeset_configon the server connection, so behind a transaction-mode pooler (for example PgBouncer intransactionmode) the rewritten path may outlive the client that triggered it and be seen by the next one; the value it leaves behind is the stricter one, and that deployment already has the same limitation forlock_timeoutandstatement_timeout.Before / after
search_path = app, pg_catalogis configured on the database,app.pg_classis a one-row decoy, and preflight runsSELECT count(*) FROM pg_class:🤖 Drafted with Amp (Claude Opus 4.6); reviewed and edited by the author.