Skip to content

fix(analyzer): resolve alias-introduced operands at the one place resolver - #121

Merged
nelsonduarte merged 11 commits into
mainfrom
fix/e3-alias-introduction
Sep 7, 2026
Merged

nelsonduarte merged 11 commits into
mainfrom
fix/e3-alias-introduction

Conversation

@nelsonduarte

Copy link
Copy Markdown
Owner

Closes a class of silent double-free and use-after-consume defects in the linear discipline.

The defect

Rules that enforce "a value may be used at most once" resolved their operand by syntax. The language has alias-introduction forms that syntax does not reach: a match arm binding, a struct-pattern destructuring let, and a value selected by an if or a match. A resource reached through one of those was treated as a different value from the one it aliases, so it could be released twice with no diagnostic, on all three backends.

The change

Two facts are recorded at binding time, each with one producer, and fed to the one resolver every position-driven rule already calls, so all of them inherit the fix together rather than each learning it separately. A position-independent gate and three helpers are deleted, making this a net reduction in special-casing.

A new test fails when any single-use rule decides its operand syntactically. Pointed at the pre-fix source it flags eight sites across five modules, including the two rules that caused the largest findings during review.

Measured

  • python -m unittest discover tests: 5899 tests, OK, 23 skipped. pytest -q: 5888 passed, 23 skipped, 3575 subtests.
  • Zero diagnostic differences across 233 in-repo and 1338 downstream Capa programs.
  • Six downstream projects through the CLI: stdout, stderr and exit code byte-identical.
  • Every accepted program byte-identical across the legacy, --ir and --wasm backends.
  • Nine shipped tests move, each verified honest: eight now assert a more specific diagnostic, one becomes a must-accept case.

Scope, stated honestly

This closes the sixth known rule of this shape, not the last. The guard detects one textual signature and only sees rules touching the state sets it names; a rule outside those is invisible to it. Capability-side laundering is deliberately out of scope here and remains open.

Not yet run outside win32, which is what this PR is for.

…seam

A function whose return type is a bare type variable can hand back a value
it was given rather than a fresh one, and the caller's obligation was not
moved when it did: `let h2 = id(h)` armed a second independent obligation
on the same runtime value, so `close(h)` and `close(h2)` both type-checked
and double-freed.

Resolve what a call's result aliases in one place. `_return_origin.py`
summarises, per callable, which parameters a bare-type-variable return can
originate from, failing closed to every parameter when the body cannot be
resolved; `_e3.py` adds the args-based call-site backstop for the callables
the summary cannot reach. `_call_result_alias_args` is the single seam both
feed, and `_move_linear_operand` consults it, so every operand position that
already moves a bare identifier now moves through a laundering call as well
and a chained `id(id(c))` recurses.

Three structural changes ride with it because the fix needs them:

- `_discharge_return_operand` is extracted from `_check_return`, giving the
  return operand one seam instead of a branch list inline in the statement
  checker;
- the `consume self` receiver routes every operand shape through the move
  seam rather than an open-coded `Ident`/`FieldAccess` pair, which let an
  unlisted receiver shape fall through silently: `probes/m37` binds the
  receiver through a call and printed `closing 7` twice on all three
  backends;
- `_callables.py` holds the `("fun", name)` / `("method", ty, name)` /
  lambda summary-key constructors that `_ifc*` and the new return-origin
  table both key on, so the two do not spell the same tuple independently.

Ten corpus programs move, all OK to ERR. Eight are genuine double-frees on
at least two backends. The remaining two, `atk7/ho_combinator_owned` and
`atk7/ho_trait_transform`, are correct programs refused by the fail-closed
default on a callee whose origin cannot be resolved; that cost was accepted
when the fail-closed direction was chosen.
…esolver

A rule that enforces "used at most once" must first resolve its operand to a
place, and every such rule recognised exactly two spellings: a bare `Ident`
and an `Ident`-rooted `FieldAccess`. The language has two more ways to name
an existing value, and they compose: a pattern BINDING (a `match` arm binder,
a destructuring `let`) and a SELECTION (an `if` / `match` expression yielding
an existing place without naming it). Both were invisible to every
position-driven rule while the value still flowed at runtime, so the direct
spelling of a double-free was rejected and the alias spelling of the same
program was accepted.

Record two binding-time facts, each with exactly one producer, and feed them
to `_path_of`, the resolver those rules already share:

- `_linear_alias` maps a pattern binder to the place it VIEWS, produced only
  by `_linear_bind_pattern_view`, saved and restored around each arm like the
  arm's own name scope. It is not a second obligation: the obligation stays
  under the scrutinee's name.
- `_selection_place` maps an if/match node to `("fresh",)`,
  `("place", place, ty)` or `("reject",)`, produced only by the two selection
  checkers at the point where the arms have been checked and their binders
  are still in scope. It carries the resolved place STRING, never a node: a
  node only means the right thing while the arm scope is live, and the move
  position that consumes a selection runs after that scope has popped.

With the operand resolved, the position-independent bar
`_check_linear_conditional_alias` and its two helpers are deleted. It could
not tell a read from a move or one place from two, so it rejected correct
programs; the rules it stood in for now fire individually and say which value
is at fault.

The rules folded onto the resolved place rather than growing a case each:

- `_move_linear_place` is the one body behind all eleven move positions,
  splitting on the place rather than the operand's syntax;
- `_move_transfer_operand` owns the borrowed-transfer reject for the return,
  become, consume-argument, consume-self-receiver and laundering-call
  positions, which each open-coded it;
- `_linear_check_borrowed_escape` resolves the place at the aggregate-pack
  sites, and its call moves to after the operand is typed at the struct- and
  typestate-literal sites, because a selection has no recorded place until it
  has been checked;
- `_transfer_borrowed_marker` decides, once, whether a let/var/assign target
  inherits the BORROWED marker. Decided per RHS shape, an alias spelling
  reached the bind with no marker and the target was armed as a fresh owner,
  laundering the caller's still-owned value; and because the marker was lost
  one statement early, the pack-site escape rule then saw a name that was no
  longer borrowed;
- the anonymous drop and the assign-target poison lift key on the resolved
  place, so an alias spelling pops the same binding instead of leaving it
  live and reporting the leak a second time;
- the lambda RESULT becomes a transfer position, so the expression-bodied and
  block-bodied spellings of one closure agree.

Measured against main on a 209-program corpus: 63 verdict movers, 47 OK to
ERR and 16 ERR to OK. Of the 47, 45 double-free or leak on at least two
backends; the two correct ones are `atk7/ho_combinator_owned` and
`atk7/ho_trait_transform`, already accounted for by sibling A's fail-closed
default. The 16 newly accepted are false-alarm removals, each byte-identical
across the legacy, `--ir` and `--wasm` backends with every resource closed
once. Outside the corpus: zero diagnostic differences on 234 in-repo and 1338
downstream `.capa`, and `--check` plus `--manifest` byte-identical on six
downstream projects through the real CLI.

`TestLinearConditionalAlias` moves and is rewritten honestly: eight of its
nine still reject and now assert the specific diagnostic, and
`test_nested_wrapper_form` becomes a must-accept test because every arm hands
over the same value and it is consumed exactly once, which runs and closes
its resource once on all three backends.
…ntax

Three review rounds each found the same defect at a different rule: a rule
that enforces "used at most once" resolving its operand by SYNTAX rather than
through `_path_of`. The `consume self` receiver open-coded an
`Ident`/`FieldAccess` branch list; `_linear_check_borrowed_escape` tested
`isinstance(expr, Ident)` at five pack sites and accepted six laundered
double-frees; the borrowed propagation tested `isinstance(value, Ident)` and
`value.name` at four bind sites and accepted sixteen. Each was found by
someone thinking to construct the right program, which is why each round found
one and none found the next.

All three share one signature, so the class is machine checkable rather than
only sample checkable. This guard walks the analyzer's own source and reports
any function that keys `_live_linear`, `_borrowed_linear`, `_consumed`,
`_linear_names` or `_linear_field_moved` on an operand's `.name`, or on a
value narrowed with `isinstance(x, A.Ident)`, without calling the resolver.

The detector is scoped per statement, with an `isinstance` narrowing
propagating into the branch it guards, because that is how the receiver defect
was written: it narrowed and branched without ever reading `.name`. Scoping it
that way is also what keeps it quiet on the code that is fine, and the
negatives here pin each of those shapes: bookkeeping over an already-resolved
place, a `.name` read with no single-use state, and resolve-then-decide.

`_check_fun`'s parameter seeding is the only allowed entry, and it is a
producer rather than a rule: a parameter's name IS its place, there is no
operand expression to resolve. The allowance is pinned to the function name,
so moving the seeding or adding a second producer fails rather than inheriting
it, and a separate test fails if an ALLOWED entry ever names a function that
no longer exists.

A source-reading guard is worth nothing if it has only been shown to run, so
three tests prove it goes red: on the reintroduced syntactic shape, on a
narrowing branch list with no `.name` read, and on the SHIPPED source of
`_transfer_borrowed_marker` with its resolution reverted to the syntactic form
it used to have. Confirmed out of band as well by mutating that rule on disk:
the guard named `_linear.py:635 _transfer_borrowed_marker`, ten behaviour tests
went red, the sixteen borrowed-alias double-frees re-opened, and restoring the
file returned all of it to green with the digest matching the baseline.

Running the guard turned up two functions the hand enumeration had missed, and
both were genuine: `_linear_transfer_if_alias` still spelled an
already-resolved place as `value.name`, and the type read beside it duplicated
`_operand_leaf_ty`. Both now use the resolved place, which is behaviour
neutral on the 209 program corpus and says what the code means.
…ot the function

The guard shipped with a false negative aimed at exactly the case it exists
to catch, and it was found by testing it the only way that counts: revert a
real fix in a throwaway build, confirm the build is behaviourally distinct,
then run the guard.

It exempted a whole FUNCTION when that function called `_path_of` anywhere.
`_move_linear_operand` resolves further down its own body, so reverting the
laundering-call branch at its top to the syntactic `isinstance(arg, Ident)`
plus `arg.name` form produced a build that ACCEPTS three double-frees on all
three backends while this guard reported ten passing tests.

The exemption was function-scoped and the defect is branch-scoped, and that
is backwards for this class specifically: the functions most likely to hide a
stray syntactic branch are the big resolve-then-decide seams, which by
construction resolve somewhere. `_scan` now carries the exemption down the
branch structure alongside the narrowing it already carried, so a statement
is excused only when it, or a branch enclosing it, actually resolved.

Dropping the whole-function exemption costs nothing: zero flagged functions
across the analyzer package, so the allow-list stays at its single parameter
seeding entry.

The three existing bite tests all passed on the defeated build, because each
used a shape the broken exemption happened not to cover. They stay, but they
are no longer the evidence. `test_the_guard_bites_on_the_case_that_defeated_it`
reverts the SHIPPED source of the laundering-call fold and asserts the flag,
so widening the exemption again turns it red while the other three stay green.
`_calls_resolver` is deleted with its last caller.
Running each mutation against the TEST SUITE rather than against a probe
corpus answers a different question, and the answer was worse than expected.
A corpus says whether a rule is load-bearing for those programs. It does not
say whether a test would go red if the rule were neutralised, and eight of
seventeen mutations turned out to be caught only by files in a gitignored
scratch directory. For regression purposes that is unguarded.

Six of the eight were load-bearing rules with real corpus witnesses, and the
gap between them was one shape. Every binder test in this module spelled the
alias `match 0 { _ -> a }`, a binder over an UNRELATED scrutinee, whose
outcome is recorded by `_selection_place`. None spelled `match a { v -> ... }`,
a binder that VIEWS its scrutinee, recorded by `_linear_alias` and resolved
through `_path_of`. Neutralising that resolution moves 32 corpus programs and
re-opens eight double-frees, and every test here still passed.

The twelve members added take their shapes from the witness programs rather
than being written to trip a mutation:

- the view binder at four positions, a bare value, a carrier field, a borrowed
  scrutinee and a `consume self` receiver, plus the consumed-once negative;
- a binder over a FIELD scrutinee, which pins that the resolved PLACE is
  carried rather than the arm's own name, with the two-matches-sharing-a-name
  negative that fails if the name were carried instead;
- the two unresolvable bindings, an or-pattern and a generic-passthrough
  scrutinee, which must fail closed;
- a non-fresh call arm, a mixed place-and-call selection, and the
  provably-fresh factory negative.

Measured after: M3, M4, M6, M7, M9, M11 and M13 all go red on the suite where
all seven previously passed. M5 remains the only survivor and its reason is
now measured rather than argued: its branch IS reached, by an indexed arm, but
neutralising it moves no verdict because the container-of-linear rule rejects
that program first. No claim rests on it.
…raversal

The test that exists to stop the guard scanning nothing did not look at the
guard. It re-implemented its own `os.listdir` plus `.py` filter, then compared
that against itself, so it asserted a property of its own copy of the walk and
never touched the detector.

That is a second hand-synced copy of the enumeration, which is the defect this
release exists to remove, sitting inside the guard built against that defect.

Measured rather than argued, in both directions:

- narrowing the detector's own extension filter so it traverses ZERO modules
  left all eleven tests in the module GREEN, including this one. The guard was
  fully vacuous under that mutation and said nothing;
- truncating the detector's walk to two of the seventeen modules also left the
  old test green.

The replacement observes instead of re-deriving. It spies the per-file entry
point, invokes the top-level scan for real, and asserts that the set of module
names the detector was actually called with EQUALS the set of Python files in
the analyzer directory. The expected set is still read from the directory
rather than listed in the test, so a module added to the package cannot
silently go unscanned, and the required-modules check now runs against what
was visited rather than against a second listing.

Both mutations above now fail, and only this test fails in each, so the bite
is attributable. On shipped source it observes all seventeen modules.
…osed class

The module docstring ended on "the fourth instance cannot be added without a
test going red". That is not true of this detector and the sentence invited
exactly the wrong reading of a green result.

Replaced with the bound in three parts, each of which is load-bearing:

- it sees ONE textual signature, so a rule that decides a single-use question
  any other way is outside it entirely;
- it only sees rules touching one of the names in SINGLE_USE_SETS, so a
  discipline that grows new state is invisible here until that list is
  widened, and nothing enforces widening it;
- eleven known spellings evade it today. 25 candidate evasions were
  constructed and run against the detector: 14 flagged, 11 not. The eleven
  are named individually rather than counted, because a guard whose holes are
  unwritten invites the belief that it has none.

All eleven were checked against the shipped analyzer and none occurs, so they
are latent rather than live. The `getattr` hits that do exist are on types,
not on operands near single-use state, and there is no `match` statement, no
module-level function and no staticmethod touching those sets.

The reproducible list is cited so a future change to the detector can be
re-scored, with a drop in the flagged count called out as a regression.

import unittest

from tests.analyzer._helpers import check, errors_of

import unittest

from tests.analyzer._helpers import check, errors_of
expected = {n for n in os.listdir(adir) if n.endswith(".py")}
self.assertGreater(len(expected), 5, sorted(expected))

import tests.analyzer.test_single_use_resolution_guard as _self
…ng call

`_path_of` resolved an `Ident`, a `FieldAccess` and an `IfExpr`/`MatchExpr`
receiver. A `Call`/`MethodCall` receiver resolved to None, so `idc(h).c`
denoted no place, the move seam moved nothing, and the projected field
stayed consumable through its carrier as well. Three lines double-freed and
a four-line variant triple-freed, identically on all three backends.

The projection receiver now goes through `_receiver_path_of`, which resolves
a call through the E3 origin seam's new pure half (`_call_result_alias_operand`)
and recurses through itself, so `idc(idc(h)).c` unwraps every layer. Because
the resolution lands in `_path_of`, all nine move positions and all five
value kinds inherit it at once rather than each growing a case; and because
it RESOLVES rather than failing closed, `close(idc(a).c)` alone stays
accepted, which is the same program as the already-accepted `close(a.c)`.

Resolving alone still fails OPEN on an AMBIGUOUS receiver: a callee whose
result may alias either argument resolves to no place, which the move seam
reads as nothing to move, so `pick2(h, h2).c` ran a double free while the
whole-value `sink(pick2(h, h2))` was already rejected fail-closed. That was
found by mutation-testing the resolution above, not by reading it. The
fail-closed reject site now keys on the projection ROOT for both shapes it
can take, selection and call, with one reporting helper and one root walk
(`_projection_root_of`) shared with the borrowed-propagation bind.

Tests: `TestCallReceiverProjection` pins the nine positions and five value
kinds as a matrix over three receiver spellings, the repro programs, the
laundering callee shapes, and the negatives that keep the measured-correct
boundary where it is.
A pentest keyed a purely syntactic single-use rule on `_drop_exempt_linear`,
a set not in `SINGLE_USE_SETS`. The resulting analyzer was unsound -- a
`let tmpa = open()` never consumed was accepted -- while this guard reported
eleven tests OK and the whole suite passed. That is the bound part 2 of the
module docstring already stated, so the hole is not new; the exploit is what
shows the maintenance the bound calls for was overdue.

Six sets are now watched: `_drop_exempt_linear`, `_linear_alias` (which the
release that added this guard introduced and left outside it),
`_moved_subpath_sets`, `_linear_conditional_reported`,
`_linear_container_reported`, `_struct_aliases`. That widening alone does not
catch the exploit, because it decides from a name's TEXT and so reads no
`.name` and narrows nothing: testing a string against a literal other than
the path separator is added as a fourth signature. Measured against the
shipped analyzer that signature has zero hits and the exploit has one, and
the separator exclusion is what keeps `place.split(".", 1)[0]` quiet.

Three walk gaps close with it. `ast.Raise` and `ast.Delete` join the decision
statements, which matters because `del self._live_linear[expr.name]` is a
shipped discharge spelling. `for`/`with` HEADERS are scanned, not only their
bodies. And the analyzer receiver no longer has to be spelled `self`, so
`me = self` and a helper handed the analyzer no longer evade; that also
removed two entries from the known-evasion list, 14/25 flagged to 16/25.

The two decision predicates the scan carried, one for an `if` test and one
for a statement, are folded into `_judge`, and the two propagating signatures
into `_syntactic_test`. Without that fold the text signature would have been
written into one copy and not the other, and the exploit's two-statement
spelling would still have walked through -- which is exactly what happened in
the first draft, caught by its own new test going red.

The docstring's stated bound is corrected rather than quietly improved: four
signatures not one, nine known evasions not eleven, and the note that
"keying on a set not in the list" can never be closed by widening the list.
The Ident-target re-arm in _check_assign picked its preserve-husk fork
with `isinstance(s.value, A.Ident) and s.value.name == s.target.name`,
so only the literal spelling `a = a` preserved the target's moved-out
sub-paths. Every wrapped spelling of the same self-assignment (a
selection, a match binder, a single-origin laundering call, and their
compositions) took the clear-then-transfer path, whose
_clear_moved_subpaths wiped the husk state and re-armed a partially
consumed value: a double-free with a clean --check on all three
backends (fork-contest probes f4e / f4i / f4n).

The fork now asks _receiver_path_of, the one operand-place resolver,
whether the RHS resolves to the target's own place; the resolver's
docstring names the second asker. Consequences, all measured:

- ten wrapped husk self-assign spellings and the three laundered-close
  compositions flip to the direct spelling's rejection, and f4c (the
  direct-partial composition, inherited open on main) closes with them;
- a LIVE wrapped self-assign now takes the drop rule like the direct
  spelling, matching main's verdict and wording; the round-2 record had
  blessed that acceptance, and it was the accepting half of the exact
  laundered cell this fix removes;
- the legitimate re-arm (`close(a.c); a = mkh()`), the other-variable
  moves, and the fail-closed unresolvable spellings are unchanged; the
  205-example sweep, the 60-cell matrix, the qa8 bind/kind corpora, the
  capture probes and the ninth fams all show zero movers.

New tests: TestSelfAssignResolvedPlace, the self-assign spelling family
at the assign position (RED-first: 15 subtest failures on the unfixed
tree, from the same family the branch fixed at the other positions),
plus boundary pins for the fresh re-arm, the other-target move, the
fresh-name let, the field-target sibling, and the fail-closed members.
The qa gate's two comparison-shape mutants of the resolved-place
predicate survived the full 5845-test suite: replacing
`rhs_place == _tpath` with `_tpath.startswith(rhs_place)` or with a
first-character comparison changed no test, because every name pair in
TestSelfAssignResolvedPlace was a single letter, so no test had a
target and an RHS place that were different but prefix-related.

Both mutants are real, not equivalent: on `var a; var ab; sink(ab);
ab = (if true then a else a); sink(ab)` the healthy tree accepts (the
RHS resolves to `a`, a different place, an honest re-arm that runs
`closing 2` / `closing 1` byte-identically on all three backends) and
both mutants take the preserve fork, skip the move off `a`, and reject
with a spurious leak of `a`.

test_prefix_named_other_target_still_moves pins that acceptance.
RED-first against each mutant in memory: exactly this one test fails
under D and under E (failures=1 both times, the leak wording), and the
restore re-hashed to the tip package hash. No analyzer code changes.
@nelsonduarte
nelsonduarte merged commit 1407f25 into main Sep 7, 2026
14 checks passed
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