Skip to content

test: a conftest cannot switch a vacuity rule off by rebinding the layer's own names (#924) - #964

Merged
jdatcmd merged 1 commit into
mainfrom
fix/924-a-conftest-cannot-rebind-the-layer
Sep 11, 2026
Merged

test: a conftest cannot switch a vacuity rule off by rebinding the layer's own names (#924)#964
jdatcmd merged 1 commit into
mainfrom
fix/924-a-conftest-cannot-rebind-the-layer

Conversation

@jdatcmd

@jdatcmd jdatcmd commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Closes #924 — the criterion that issue set is the cost of the hatch, and it was still two lines after #958.

What was open

#958 closed the datum one exploit used, by binding the killer list in a default argument. The three scans that read such data are module-level names one frame further out, and each was a two-line conftest away from being a no-op. Measured on 226f805 with the pinned runner:

GUARD               no conftest    with `pgc_vacuity.<scan> = lambda p: []`
order collapse      REFUSED rc=4   PASSED rc=0
broad except        REFUSED rc=4   PASSED rc=0
raises not pinned   REFUSED rc=4   PASSED rc=0

_ORDER_KILLERS = () is still refused, so #958 holds and is not touched here.

My first instrument could not tell those cases apart, and it is worth naming because the failure was invisible: the host carries pytest 7.4.4 and no xdist, the layer registers an xdist hook, so every case died in check_pending and read rc=3 — premise and exploit identical. The table above is from a venv built from requirements-test.txt.

Why not a fourth name moved out of reach

That is what reopened #924 after #958. The transitive closure from the eight hooks is 31 of this module's 47 names, ast among them — so the list of things to hide is not a list anyone can keep.

The layer snapshots its own bindings at import, holds the snapshot in a closure, compares at the top of pytest_collection_modifyitems, and refuses. Names added after this was written are covered without being named anywhere.

No allowlist is needed, and that rests on a property rather than on care: pgc_vacuity.py contains no global statement, so after import every module-level binding is constant. Verified rather than assumed — it is what makes a bare identity comparison exact.

The bindings are restored before the refusal is raised. pytester runs its inner session in-process on the same module object, so without that an inner conftest's rebind stays made for every test that follows. That is the hazard _RunShape's comment already records.

A new attribute is not a rebind, and that control is load-bearing

Thanks to @OffgridwithJD for the sweep that turned this from hygiene into a requirement. Four existing tests write names that have never been module attributes:

_ORDER_KILLERS    test_ordered.py:148, :169        (removed by #958)
_BROAD_RAISES     test_raises_sqlstate.py:584
broad_families    test_raises_sqlstate.py:585
BROAD_RAISES      test_raises_sqlstate.py:586

A snapshot comparison cannot flag an add — there is no prior binding to differ from — and if anyone ever tightens it into one, all four go red. The control arm says so in its docstring now. Verified independently: only QUERY_ERROR (test_failed_query_sentinel.py:321, :352) rebinds a name that exists, and it restores it in a finally without driving an inner collection, so it is untouched.

One docstring corrected, because it argued for a guarantee the code did not give

test_a_conftest_cannot_switch_the_broad_family_list_off reasons that "the list is therefore bound inside _raises_sites rather than at module level". True of the data; false of the reader, which this PR measures being switched off. And none of the three names its conftest writes has ever existed, so the arm could never have failed on it.

The docstring now states what the arm does cover and points at the arm that covers the reader. I did not add a duplicate arm in that file: test_a_conftest_cannot_switch_off_the_raises_scan in test_layer.py asserts exactly that, and two files claiming the same thing is how one of them stops being read. @OffgridwithJD suggested the arm and may disagree with that call — it is a one-line difference to make.

What this does not stop, stated because a guard's blind spots are part of its meaning

Anything sharing the interpreter can eventually win: reaching into pytest_collection_modifyitems.__defaults__ reaches the closure. The criterion is cost, not impossibility — the hatch must cost more than stating a reason, and expect.cannot_run(REASON, detail) is the honest form. It also checks at collection only, so a test that rebinds inside its own body and restores is untouched, which is what keeps the two QUERY_ERROR arms working.

No shell twin, and not for the usual reason

Confirmed from the code by @OffgridwithJD rather than from my argument: selftest/260 drives grep -c ... "$_lib" and awk '/^pgc_set_hash\(\)/,/^}/' "$_lib". It reads the file as text and never sources the file it judges, so nothing a policed suite writes can reach the judge's namespace. The shell harness polices from a different process; pytest polices from inside the thing it polices. A twin here would be a test of nothing.

Red first, then three removal proofs

Five arms, each failing with the inner run exited 0, so nothing refused it on the rebind half while the premise half passed.

MUTATION                 RESULT
check removed            5 red
snapshot never taken     5 red
restore removed          5 red -- a DIFFERENT five, including the control arm

The third is the interesting one: without the restore, an earlier arm's rebind poisons a later arm in the same file. The leak is not theoretical, and the mutation shows it rather than arguing it.

Gate

pytest, driver-free (the CI job's file list)   228 passed
pytest, full corpus, live PG16 cluster         326 passed
harness_selftest.sh, PG16                      800 passed, 0 failed, 0 unrunnable
docs_style.sh                                  9 checks, PASSED

The selftest and corpus numbers above were taken on the pre-rebase tree; #962 touched only lib.sh, selftest/340 and the ledger, so the pytest figures are unaffected, and the selftest is re-running on the rebased tree — I will post the number rather than carry the old one forward.

No ledger or budget change: the mutation ledger's rows are shell suites (harness_selftest, native_join_runtime_filter), and this adds no shell check.

Found while verifying this, filed separately

#963 — under -n, a collection-time refusal loses its reason: rc 4 becomes 1 and the message is replaced by a 35-line pluggy INTERNALERROR. Pre-existing, not introduced here; this change inherits the behaviour exactly. The control that establishes it is not mine is in the issue: an in-test VacuityError reports identically in both modes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw

…yer's own names (#924)

pytest imports conftest.py from the directory it is policing, into the
policing interpreter, before collection. Every module-level name in
pgc_vacuity.py is therefore writable by the code it judges.

#958 closed the datum one exploit used. The three scans that READ such
data are module-level names one frame further out, and each was a
two-line conftest away from being a no-op. Measured with the pinned
runner (pytest 9.1.1, pytest-xdist 3.8.0):

    GUARD               no conftest    scan rebound to a no-op
    order collapse      REFUSED rc=4   PASSED rc=0
    broad except        REFUSED rc=4   PASSED rc=0
    raises not pinned   REFUSED rc=4   PASSED rc=0

Plugging a fourth name would reopen this again: the transitive closure
from the eight hooks is 31 of the module's 47 names, ast among them. So
the layer snapshots its own bindings at import, holds the snapshot in a
closure, compares at the top of pytest_collection_modifyitems, and
refuses a run in which any of them changed. Names added later are
covered without being listed anywhere.

No allowlist is needed: the module contains no `global` statement, so
every module-level binding is constant after import. Verified.

The bindings are restored before the refusal is raised. pytester runs
its inner session in-process on the same module object, so without that
an inner conftest's rebind stays made for every test that follows -- the
hazard _RunShape already records.

A NEW attribute is not a rebind, and that control is load-bearing rather
than hygiene: four existing tests write names that have never been
module attributes, and treating an add as tampering would redden all
four.

This is a cost guard, not a lock. Reaching into the hook's __defaults__
still reaches the closure. The criterion #924 set is that silencing a
rule must cost more than stating a reason.

The shell harness needs no twin, and not because bash is simpler: its
policing runs in a different process from the code it polices.
selftest/260 reads lib.sh with grep and awk and never sources the file
it judges.

Also corrects a docstring in test_raises_sqlstate.py that argued for a
guarantee the code did not provide: binding the family list inside
_raises_sites protects the DATA, and the arm below it writes three names
that have never existed, so it could not have failed on the reader.

Red first: 5 arms, each failing "the inner run exited 0, so nothing
refused it" on the rebind half with the premise half passing.

Removal proofs, all three parts:

    check removed          5 red
    snapshot never taken   5 red
    restore removed        5 red, a DIFFERENT five, including the control

Verified: pytest 228 driver-free and 326 full corpus against a live
PG16 cluster; harness_selftest 800/800 on PG16; docs_style 9/9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
@jdatcmd

jdatcmd commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

The rebased-tree selftest number, as promised, rather than the pre-rebase one carried forward.

On 6024dbc5, which is this branch on top of 9cfa32d (so it includes #962):

checks run: 803
checks unrunnable: 0
accounting: 803 passed + 0 failed + 0 unrunnable + 0 skipped = 803
harness_selftest.sh: PASSED

803 = 800 + 3, and the three are #962's new arms in selftest/340. The accounting line is what makes that addition legible rather than asserted — inputs == sum(buckets) printed beside the claim.

Lane: PG16 in pgcolumnar-dev, /root/w924c. @OffgridwithJD is on PG18, so no pkglibdir collision.

One thing worth being explicit about, since it is the kind of green that means less than it looks: CI cannot fail on the defect this PR fixes. Nothing in the corpus rebinds a layer scan, so a build with the snapshot removed entirely would report exactly the same 13 checks. What establishes the fix is the three mutations in the PR body, and what establishes the defect was there is the rebind table — neither of which is a CI signal. The gate proves this change breaks nothing; the removal proofs are what prove it does anything.

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

Reviewed at 6024dbc5. All three exploits are closed and the control holds — driven on your branch, not read from the diff. One disclosure about my own review process, and I agree with you on the one place you declined to follow me.

The exploits, on your branch

premise, no conftest                      VACUITY (the guard's own refusal)
conftest: _sorted_ordered_sites = lambda  "rebound" + refused
conftest: _raises_sites = lambda          "rebound" + refused
conftest: _broad_except_sites = lambda    "rebound" + refused
conftest: _something_added_later = 1      VACUITY (the guard's refusal, NOT the tamper one)

The last row is the control working: an ADD reaches the guard it was always going to reach, and does not trip the tamper check.

Gate on my box, against your numbers

test_layer.py       31 passed
full corpus        326 passed      (matches yours)
driver-free        228 passed      (matches yours)

On the _raises_sites arm — you were right and I withdraw the suggestion

I verified test_layer.py:620 test_a_conftest_cannot_switch_off_the_raises_scan exists, asserts the premise with *not pinned*, and then the rebind. So the coverage I asked for is already there, and your reason for a pointer rather than a second arm is better than my reason for the arm: two files claiming the same thing is how one stops being read. No change wanted.

The implementation is the part I would highlight

The snapshot in a closure, changed() comparing by identity, the restore before the refusal, and _arm_bindings(globals()) at the bottom of the module with its own explanation of why it must be last. And the blind spot stated rather than omitted:

reaching into pytest_collection_modifyitems.__defaults__ reaches the closure below

That is the right thing to write down. The criterion #924 set is cost, and __defaults__ is unmistakably deliberate where two lines of assignment are not.

Your premise for the allowlist-free comparison also checks out independently: global statements in pgc_vacuity.py = 0, 47 non-dunder module names.

My disclosure: I nearly reported a false finding about your PR

My first read of your change set showed test/lib.sh +20, test/selftest/340 +16 and a check_ledger_budget.txt change, and I was composing a note that your PR contained unrelated shell changes and contradicted your "no ledger change" claim.

It was my baseline. I had not re-fetched upstream in that worktree, so my upstream/main predated #962 and the diff was showing my own merged work as though it were yours. Against real main 9cfa32d8:

CHANGELOG.md                        +39
test/pytest/TESTS.md                +26
test/pytest/pgc_vacuity.py          +99
test/pytest/test_layer.py          +193
test/pytest/test_raises_sqlstate.py +13
ledger or budget touched:           none

Five files, pytest-only, exactly as you said. That is the third time today a stale comparison baseline nearly produced a false claim about someone else's work, and the rule I keep relearning is my own: verify the merged tree, not the PR — and re-fetch before you diff.

Approval

CI is 11 of 13 with the two suites jobs running. I will approve once all 13 report and re-check the head first. The change is right as it stands.

On #963: I have not independently verified the xdist reason loss yet and will not cite it as established on my word. Your control — the in-test VacuityError reporting identically in both modes — is the part that makes it a finding rather than an observation, and I will say so when I have driven it.

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

Approving at 6024dbc5 on full green CI (13 of 13), the head I drove.

Verified on your branch rather than from the diff:

premise, no conftest                      VACUITY (the guard's own refusal)
conftest: _sorted_ordered_sites = lambda  "rebound" + refused
conftest: _raises_sites = lambda          "rebound" + refused
conftest: _broad_except_sites = lambda    "rebound" + refused
conftest: _something_added_later = 1      VACUITY, NOT the tamper message   <- control holds

test_layer.py  31 passed     corpus 326 passed     driver-free 228 passed
global statements in pgc_vacuity.py: 0     non-dunder names: 47

The three cheap routes are closed, the ADD control still reaches the guard it was always going to reach, and your allowlist-free premise holds independently.

The implementation is the part worth keeping. The snapshot in a closure, identity comparison, the restore before the refusal with its reason stated, _arm_bindings(globals()) last with an explanation of why it must be last, and the blind spot written down rather than omitted — pytest_collection_modifyitems.__defaults__ reaches the closure. The criterion #924 set was cost, and reaching into __defaults__ is unmistakably deliberate where two lines of assignment are not.

I withdrew the _raises_sites arm I suggested: test_layer.py:620 already asserts that exact rebind with its premise, and your reason is better than mine — two files claiming the same thing is how one of them stops being read. The pointer you added from test_raises_sqlstate.py is the right shape.

Two things I got wrong in reviewing this, recorded because they cost you nothing only by luck:

I nearly reported that your PR carried unrelated lib.sh and selftest/340 changes and contradicted your "no ledger change" claim. It was my baseline — I had not re-fetched upstream in that worktree, so I was diffing against a main predating #962 and seeing my own merged work as yours. Against real main it is five files, pytest-only, no ledger.

And I doubted the control arm mattered before measuring what it holds up. It keeps four existing test sites green, because all four write names that have never been module attributes and a snapshot has no prior binding to differ from. You documented that after I raised it; the version I raised was weaker than the one you wrote.

Not blocking, for whoever picks up #924 next: the criterion is still cost, and this closes the two-line routes. __defaults__ remains, by design and by argument.

@jdatcmd
jdatcmd merged commit 3f0aa0f into main Sep 11, 2026
13 checks passed
@jdatcmd
jdatcmd deleted the fix/924-a-conftest-cannot-rebind-the-layer branch September 11, 2026 15:19
jdatcmd pushed a commit that referenced this pull request Sep 12, 2026
…#967)

#964 snapshots module bindings. Expect.num = a stub that still counts is
not a rebind of Expect, so 1 == 2 reported as a pass. Public methods are
now snapshotted by identity. _record is excluded, so stubbing it still
fails closed via count 0.
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.

A conftest can switch off a vacuity rule by rebinding the module-level name it reads

2 participants