Skip to content

A two-line conftest can stub an Expect method so a false claim reports as a pass: #964's snapshot sees bindings, not attributes of the objects they point at #967

Description

@jdatcmd

A two-line conftest.py makes a false claim report as a pass:

import pgc_vacuity
pgc_vacuity.Expect.num = lambda self, got, want, name: self._record(name)

With that file present, a test asserting 1 == 2 prints 1 passed and exits 0.

The test body is:  expect.num(1, 2, "one equals two, which it does not")

  no conftest                                    failed, correctly
  Expect.num stubbed to count but not compare    PASSED -- A FALSE CLAIM WENT GREEN
  Expect._record stubbed                         failed, correctly (count 0 is refused)

This is strictly worse than #924. Those hatches switched off meta-rules — the order-collapse scan, the broad-except scan — so a test that was wrong in a particular way stopped being refused. This switches off the assertion itself: the comparison never happens, the count still increments, and every guard downstream is satisfied by a test that concluded nothing.

Found while composing #964 and #966 locally, which is the only reason it surfaced: #966 adds Expect._record, and checking whether #964's snapshot covered it showed that it does not, because it is not a module-level name.

Why #964's guard does not see it, and what it does see

#964 snapshots the module's bindings at import and refuses a run in which any binding changed. That is exactly what it claims, and it holds:

pgc_vacuity.Expect = <a subclass with num() stubbed>     REFUSED, names Expect
pgc_vacuity._Record = None                               REFUSED, names _Record
pgc_vacuity._broad_except_sites = lambda p: []           REFUSED
pgc_vacuity._sorted_ordered_sites = lambda p: []         REFUSED

Rebinding Expect wholesale is caught. Mutating an attribute on Expect is not, because the binding still points at the same object — only that object's __dict__ changed. An identity comparison cannot see through the reference it compares.

So this is not a defect in #964. It is the next frame out, and it is the third instance of one pattern:

#924 / #958   the DATUM a rule reads            _ORDER_KILLERS
#924 / #964   the FUNCTION that reads it        _sorted_ordered_sites
this issue    an ATTRIBUTE OF a bound object    Expect.num

Each fix moved the hatch one level, and each time the level above was reachable for the same two lines.

The surface

18 public assertion methods on Expect, each an independent hatch:

at_least  cannot_run  differ  hash  num  ordered_rows  ordering_observable
outcomes  plan_marker  plan_node  refusal  row_set  rowcount  rows
run_failed  sqlstate  text  wrote

Stubbing any one of them silences every assertion of that kind in the directory, and the honest-looking self._record(name) in the stub is what keeps the run green — without it, the count stays 0 and pytest_runtest_call refuses the test. The exploit has to be slightly careful, and being careful is what makes it silent.

What it does NOT reach

Expect._record stubbed fails closed: the count never rises, so every test in the directory is refused with made no counted assertion. So the recorder itself is safe by accident of what depends on it, and that accident is worth keeping in mind rather than relying on.

Not proposed here

I have not decided between snapshotting the class dictionaries as well, making Expect resistant to attribute assignment, or accepting this as the honest floor and documenting where the floor is. The third option is genuinely on the table: the criterion #924 set is cost, not impossibility, and a reviewer reading a conftest.py that stubs pgcolumnar's assertion class would see it immediately — which is not true of _sorted_ordered_sites = lambda p: [].

What is established is the table at the top: it is two lines, it is silent, and it makes a false claim pass.

Verification for whatever lands

The probe is the test. A fix must make row 2 read REFUSED, with rows 1 and 3 unchanged — row 3 being the control that shows the recorder's own protection is a different mechanism and was not what fixed this.

Measured on main composed with #966 (37e211a); #964 is on main as of 3f0aa0f.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions