Conversation
…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
|
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. |
|
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! |
Fixes #2865.
let ghost x = ...;/let tracked x = ...;at exec-top-level (per #1169/#2864) desugars intolet 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 insimplify_one_exprlooks up aScopeEntrywhoseinitflag is set once, statically, from whether the declaration statement itself had an initializer - so for this desugared shape,initisfalseforever, and every assignment (not just the first) looks like a legitimate first-time initialization. A real reassignment like:was silently accepted instead of requiring mut, exactly as @tjhance found reviewing #2864.
Fix: a new
DelayedInitChecker(inast_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 extendingsimplify_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'sdecl_init_let_spec_fail2.modes.rs'sdecl_init_let_spec_fail/_fail3.lifetime.rs'sassign_twice_no_lifetime; its sibling test_no_lifetime_mut_check already exercises the identical design intent for a different pattern.Verified:
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).