Skip to content

vir: catch delayed-init reassignment for ghost/tracked locals - #2867

Draft
Marti2203 wants to merge 4 commits into
verus-lang:mainfrom
Marti2203:fix-issue-2865-ghost-mut-check
Draft

Marti2203 wants to merge 4 commits into
verus-lang:mainfrom
Marti2203:fix-issue-2865-ghost-mut-check

Conversation

@Marti2203

@Marti2203 Marti2203 commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2865.

let ghost x = ...;/let tracked x = ...; at exec-top-level (per #1169/#2864) desugars into let x; (no VIR-level initializer) followed by a separate assignment establishing the value - true whether or not the user wrote a surface-level = <expr>. The existing check in simplify_one_expr looks up a ScopeEntry whose init flag is set once, statically, from whether the declaration statement itself had an initializer - so for this desugared shape, init is false forever, and every assignment (not just the first) looks like a legitimate first-time initialization. A real reassignment like:

let ghost x = X { };
proof { x = X { }; }

was silently accepted instead of requiring mut, exactly as @tjhance found reviewing #2864.

Fix: a new DelayedInitChecker (in ast_simplify.rs) walks each function, tracking, per straight-line execution path, whether a no-initializer/non-mut variable has already consumed its one free "this is the initial value" assignment. It runs as its own separate pass rather than extending simplify_one_expr's existing check, specifically so it can be control-flow aware: each branch of an if/match/loop body starts with a fresh, empty "already assigned" set, not a copy of whatever was already true on the path leading into it.

The tradeoff: wrapping a real reassignment in a trivial if true { x = ...; } would dodge this check - acceptable given the existing check's own documented scope ("no longer needed for soundness... nonetheless picks up a few situations... much of the time caught by borrowck anyway").

Updated three existing tests whose comments/TODOs already anticipated this exact fix:

  • modes.rs's decl_init_let_spec_fail2.
  • modes.rs's decl_init_let_spec_fail/_fail3.
  • lifetime.rs's assign_twice_no_lifetime; its sibling test_no_lifetime_mut_check already exercises the identical design intent for a different pattern.

Verified:

  • vstd still verifies fully (2045 verified, 0 errors).
  • Ran the complete rust_verify_test suite (141 of 142 test files - cargo.rs excluded, unrelated environment/sandboxing limitation) twice (once before, once after fixing the branch-independence bug found via proph.rs) with zero regressions both times,
  • plus the full 149-example suite with zero failures.

Assisted-by: Claude Code:claude-sonnet-5

By submitting this pull request, I confirm that my contribution is made under the terms of the MIT license (https://github.com/verus-lang/verus/blob/main/LICENSE).

…dings

Fixes verus-lang#1169: `let tracked x = ...;`/`let ghost x = ...;` written directly
in an exec function's body (not inside an explicit `proof { }` block)
never got real rustc mutability checking, even when `x` was later
mutably borrowed (e.g. via `tracked_swap(&mut x, ...)`) from a nested
proof block.

Root cause: ExecGhostPatVisitor::visit_pat_mut (builtin_macros/src/
syntax.rs) desugars such a binding through a temp variable, redeclaring
the user's binding with a synthesized `let` statement. For the
`inside_ghost == 0` case (exec-fn top level), this synthesized decl
always wrote `let mut #x;`, discarding whatever mutability the user
actually declared -- so real rustc borrowck (which does run over kept,
non-erased tracked/proof code, per verifier.rs's mir_borrowck calls)
saw an unconditionally-mutable binding and never flagged a missing
`mut`. The identical `inside_ghost != 0` path already conditioned on
`id.mutability.is_some()` correctly; the exec-top-level path just never
got that treatment. Same bug existed for the `Tracked(x)`/`Ghost(x)`
wrapper-pattern arm.

Fix: only emit `mut` in the synthesized decl when the user's original
pattern was declared `mut`.

Two existing tests in rust_verify_test happened to encode the buggy
behavior as expected/passing:
- lifetime.rs::tracked_new_issue870/tracked_new2_issue870 destructured
  `Tracked(perm)` without `mut` and relied on later `&mut perm` still
  compiling to reach the actual (unrelated) lifetime error under test;
  added `mut` so they again exercise issue verus-lang#870, not this bug.
- mut_refs_modes.rs::mut_borrow_of_tracked_local_in_proof_block_to_ghost
  declared `let tracked x = X { };` (no mut) and expected `Ok(())`;
  added `mut` to match its already-correct sibling test
  (mut_borrow_of_tracked_local_in_proof_fn) one test up.

Added a new regression test,
mut_refs_modes.rs::mut_borrow_of_tracked_local_missing_mut_issue1169,
covering the exact issue verus-lang#1169 shape; confirmed it fails without the
fix (verifies as Ok) and passes with it (rejected with the real
E0596-equivalent rustc error).

Verified:
- vstd still verifies fully (2045 verified, 0 errors), before and after
- the literal repro from verus-lang#1169 now correctly reports "cannot borrow
  `points_to_opt` as mutable, as it is not declared as mutable";
  adding the missing `mut` verifies cleanly
- mut_refs_modes.rs (109/109), lifetime.rs (112/112), atomics.rs
  (25/25), regression.rs (82/82), struct_with_invariants.rs (4/4),
  mutable_params.rs (15/15), proph.rs (4/4), prophecy.rs (51/51),
  modes.rs (122/122) all pass

Assisted-by: Claude Code:claude-sonnet-5
@tjhance pointed out id.mutability (an Option<Token![mut]>) already
implements ToTokens, so the if/else branches choosing between `let mut
#x;` and `let #x;` can just interpolate it directly. Applied the same
simplification to all four spots with this pattern (the Tracked/Ghost
wrapper arm's two branches, and the plain tracked/ghost ident arm's two
branches), not just the one flagged, since they're identical in shape.

No behavior change - verified mut_refs_modes.rs (109/109) and
lifetime.rs (112/112) still pass unchanged.

Assisted-by: Claude Code:claude-sonnet-5
Full-test CI on PR verus-lang#2864 caught what local spot-checks missed: many
real, checked-in examples (and one test file, cell_lib.rs) destructure
Tracked(x)/`tracked x` without `mut` and then mutably borrow/reassign
x - exactly the pattern this PR's fix now correctly rejects with a
real rustc E0596/E0384, instead of silently allowing it.

Notably, examples/basic_lock1.rs is the *exact* repro from issue verus-lang#1169
itself - confirms the fix works correctly on the issue's own example,
and that the example needed the same one-line update anyone hitting
this in their own code would need.

Fixed all 12 affected examples plus cell_lib.rs's cell_borrow_mut test
(4 functions, same missing-mut pattern). One of these
(counting_to_n.rs) confirmed the fix also correctly extends to tracked
tuple-destructuring patterns nested under wrapper types, not just the
simple cases spot-checked earlier.

Verified via two full local sweeps of all 141 rust_verify_test test
files (0 failures both times) plus the full 149-example suite (0
failures), specifically to catch anything else CI's fail-fast might
not have reached yet.

Assisted-by: Claude Code:claude-sonnet-5
Fixes verus-lang#2865.

let ghost x = ...;/let tracked x = ...; at exec-top-level (per verus-lang#1169/
verus-lang#2864) desugars into `let x;` (no VIR-level initializer) followed by a
separate assignment establishing the value - true whether or not the
user wrote a surface-level `= <expr>`. The existing check in
simplify_one_expr looks up a ScopeEntry whose `init` flag is set once,
statically, from whether the *declaration statement itself* had an
initializer - so for this desugared shape, `init` is `false` forever,
and every assignment (not just the first) looks like a legitimate
first-time initialization. A real reassignment like:

    let ghost x = X { };
    proof { x = X { }; }

was silently accepted instead of requiring `mut`, exactly as tjhance
found reviewing verus-lang#2864.

Fix: a new DelayedInitChecker (in ast_simplify.rs) walks each function
tracking, per straight-line execution path, whether a no-initializer/
non-mut variable has already consumed its one free "this is the
initial value" assignment. It runs as its own separate pass rather
than extending simplify_one_expr's existing check, specifically so it
can be control-flow aware: each branch of an if/match/loop body starts
with a fresh, empty "already assigned" set, not a copy of whatever was
already true on the path leading into it.

That branch-independence is required for correctness, not just nicer
diagnostics - vstd::proph's whole pattern is "assign a placeholder,
then resolve it to the real value in exactly one of several branches"
(confirmed directly: an early version of this fix, using clone/restore
around branches instead of a fresh reset, broke proph.rs's own
prophecy_expected_use_1 test, since the placeholder assignment before
the if/else had already consumed the free slot on the straight-line
path leading into it). The tradeoff: wrapping a real reassignment in a
trivial `if true { x = ...; }` would dodge this check - acceptable
given the existing check's own documented scope ("no longer needed for
soundness... nonetheless picks up a few situations... much of the time
caught by borrowck anyway").

Updated three existing tests whose comments/TODOs already anticipated
this exact fix:
- modes.rs's decl_init_let_spec_fail2 had a literal `// TODO should
  probably require this to be mut` on exactly this pattern.
- modes.rs's decl_init_let_spec_fail/_fail3 are the same shape for
  spec-mode and `proof fn` locals (not just the `ghost` keyword).
- lifetime.rs's assign_twice_no_lifetime already documented "It would
  also be fine to error here" for the --no-lifetime case where real
  rustc borrowck doesn't run; its sibling test_no_lifetime_mut_check
  already exercises the identical design intent for a different
  pattern (missing mut on a `&mut` borrow instead of a second
  assignment).

Verified: vstd still verifies fully (2045 verified, 0 errors). Ran the
complete rust_verify_test suite (141 of 142 test files - cargo.rs
excluded, unrelated environment/sandboxing limitation) twice (once
before, once after fixing the branch-independence bug found via
proph.rs) with zero regressions both times, plus the full 149-example
suite with zero failures.

Assisted-by: Claude Code:claude-sonnet-5
@tjhance

tjhance commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

I have to think about this one ...

This analysis is difficult to do accurately; it's not really possible with this kind of straightforward traversal. You need a full-fledged CFG like resolution_inference uses, but I also don't want to modify resolution_inference for this bug, since resolution_inference is already very complex and soundness-critical, whereas this issue simply isn't very important.

I'm tempted to say "good is better than perfect" on this one, but I also want to think about exactly what that entails ... I'd advise not spending much time on this until I come to a conclusion.

@Marti2203

Copy link
Copy Markdown
Contributor Author

Hi @tjhance! I can leave it as a draft PR if you want to make it clear for subsequent views. I fully understand not spending time on this; this is just a minimal check implementation, and if borrowck can capture it, it is not too bad. Thanks for the feedback!

@parno
parno marked this pull request as draft September 3, 2026 18:23

This branch has not been deployed

No deployments
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.

let ghost x = ... without mut doesn't get real mutability checking (unlike tracked)

2 participants