Repository navigation
Unsoundness: partially-moved locals are still implicitly dropped, resolving prophecies of moved-out &mut borrows #121
Description
Summary
moved_locals in src/analyze/basic_block/drop_point.rs only treats a move operand as an ownership transfer when the moved place is a whole local (place.projection.is_empty()). When a value is moved out of a projection (a partial move, e.g. let b = s.0;), the source local is still considered dead-and-droppable at the move site, so the analyzer emits an implicit drop for it.
Env::drop_local → dropping_assumption (src/refine/env.rs) then walks the local's full type — including the part that was just moved away — and emits the prophecy-resolving assumption mut_final(t) = mut_current(t) for a mutable reference whose ownership (and drop obligation) actually transferred to the destination of the move. When the program later writes through the moved reference, its prophecy is resolved a second time to a different value, making the environment assumptions inconsistent (1 = 2), after which any assertion verifies.
The result is that Thrust verifies programs that panic at runtime.
Reproduction
fn main() {
let mut a = 1_i64;
let s = ((&mut a,),);
let b = s.0; // partial move: `(&mut a,)` moves out of `s`
*b.0 = 2;
assert!(a == 1); // false at runtime: a == 2
}This program panics when compiled with plain rustc:
$ rustc -C debug-assertions=off partial_move.rs && ./partial_move
thread 'main' panicked at partial_move.rs:6:5:
assertion failed: a == 1but Thrust (at 1d85b18, z3 4.13.4) accepts it:
$ cargo run --quiet -- -Adead_code -C debug-assertions=false partial_move.rs && echo verified safe
verified safeControl: the whole-local move is handled correctly
Replacing the partial move with a whole-local move (which is recorded by moved_locals) makes Thrust reject the program as expected:
fn main() {
let mut a = 1_i64;
let s = ((&mut a,),);
let b = s; // whole-local move: drop of `s` correctly suppressed
*(b.0).0 = 2;
assert!(a == 1);
}$ cargo run --quiet -- -Adead_code -C debug-assertions=false whole_move.rs
error: verification error: UnsatThe only difference between the two programs is whether the move operand has a projection.
Analysis
The optimized MIR of the reproducer is:
bb0: {
_1 = const 1_i64; // a
_4 = &mut _1;
_3 = (move _4,);
_2 = (move _3,); // s
_5 = move (_2.0: (&mut i64,)); // b — partial move out of s
_8 = deref_copy (_5.0: &mut i64);
(*_8) = const 2_i64;
_6 = copy _1;
switchInt(move _6) -> [1: bb2, otherwise: bb1];
}
With RUST_LOG=info, the analyzer logs:
implicitly dropped after statement local=_2 stmt_idx=4
i.e. s (_2) is implicitly dropped immediately after the partial move _5 = move (_2.0).
Two pieces interact:
-
moved_locals(src/analyze/basic_block/drop_point.rs, infn moved_locals):fn visit_operand(&mut self, operand: &mir::Operand<'tcx>, _location: mir::Location) { if let mir::Operand::Move(place) = operand { if place.projection.is_empty() && !self.body.local_decls[place.local].ty.is_ref() { self.locals.insert(place.local); } } }
move (_2.0)has a non-empty projection, so_2is not recorded as moved. Since_2is dead after this statement (perMaybeLiveLocals), it lands in the implicit-drop set forstmt_idx = 4. The doc comment above this function even states the invariant being violated: a moved local's "drop obligation (including resolving any mutable-borrow prophecies it owns) moves to the destination and it must not be dropped at the move site" — but this is only enforced for whole-local moves. -
dropping_assumption(src/refine/env.rs) is type-driven and walks the entire current type of the dropped local. Forsit recursestuple → own → tuple → own → mutand reaches the&mut i64that was just moved intob, emitting:builder.push_formula(term.clone().mut_final().equal_to(term.mut_current()));
i.e.
final(a-borrow) = current(a-borrow) = 1. Because the environment binding for the moved-out field still points at the same temp-var chain thatb's binding was derived from, the later assignment*b.0 = 2resolves the same prophecy to2. The CHC body now contains bothfinal = 1andfinal = 2, so the clause body is unsatisfiable and the false assertiona == 1is "verified".
Note that the more common partial-move shape let b = s.0; where the field type is &mut T itself is not affected, because ReborrowVisitor::visit_operand turns moves of Ref-typed operands into reborrows. The hole is reachable whenever the moved projection's type is a non-reference type that (transitively) owns a mutable borrow — e.g. a tuple/struct field of type (&mut T,), Box<&mut T>, or a struct containing a &mut.
Expected behavior
A local that is partially moved must not have a dropping assumption emitted for the moved-out portion (or, conservatively, for any portion). Either:
- record partially-moved locals in
moved_locals(possibly tracking which projection was moved, so the remaining fields can still be dropped soundly), or - make
dropping_assumptionskip subtrees whose ownership has been transferred away.
Environment
- thrust @ 1d85b18 ("Resolve named struct fields in formula translation (Resolve named struct fields in formula translation #118)")
- rustc nightly-2025-09-08 (per
rust-toolchain.toml) - z3 4.13.4, default solver configuration
Activity
Fix double-drop of partially-moved locals via PlaceSet drop points
Fix double-drop of partially-moved locals via PlaceSet drop points
Fix double-drop of partially-moved locals in drop points
Fix double-drop of partially-moved locals
Fix double-drop of partially-moved locals
Fix double-drop of partially-moved locals
Fix double-drop of partially-moved locals
Fix double-drop of partially-moved locals
Fix double-drop of partially-moved locals
coord-e commentedon Aug 29, 2026
The same hole is reachable through an enum variant field, and #155 as written does not close it
While looking for unsoundness in
&mut-heavy real-world shapes I hit this bug again, but through a projection kind neither this issue nor #122 mentions: aDowncast+Fieldpartial move, i.e. an ordinarymatch/if letthat binds an enum payload by value.This matters because it makes the hole reachable from completely idiomatic code —
if let Some(x) = …where the payload is not itself a reference but owns one. Common std shapes with exactly that type:slice::split_first_mut→Option<(&mut T, &mut [T])>iter_mut().enumerate().next()→Option<(usize, &mut T)>Option<W>/Result<W, E>whereWis a struct or tuple holding a&mutReproductions (thrust @ 35eea46, rustc nightly-2025-09-08, z3 5.0.0)
All three are accepted;
rustc -C debug-assertions=offpanics on the first one.Control — when the payload is
&mut i64itself,ReborrowVisitorturns the move into a reborrow (as this issue already notes) and the program is correctly rejected:Same root cause
rep1's NLL MIR has the partial move as aDowncast+Fieldprojection:and
RUST_LOG=infoconfirms the wholesale drop of the parent right after it:moved_locals'place.projection.is_empty()check misses it exactly as described in this issue, sos's&mut aprophecy is resolved both at the wholesale drop and at*t.0 = 2, and the clause body goes contradictory.I confirmed the root cause by relaxing that one condition locally (dropping the
projection.is_empty()requirement inmoved_locals): all three reproductions then correctly reportUnsat.Why #155 does not fix it
I built PR #155 (a87b28e) and re-ran the three programs — all three are still accepted. The relevant part is the enum branch of
dropping_formula_for_term:Moves::collectdoes record((_3 as Some).0)as a partial move, andelaborated_pathdoes build aPath::Downcast(Local(_3), Some, 0)for it — but since the walk keeps passing the enum's ownPath::Local(_3)down into the variant fields,except.contains(path)can never match and the moved-out field's prophecy is resolved anyway. The carve-out in that comment is a soundness hole, not just an incompleteness.Possible directions, in case they're useful:
Path::Downcast(path, variant, field)(the variant already carriesdiscr/field_idxinFlowBinding::Enum, andPath::Downcastalready exists) into the recursive call soexceptcan match it, orHappy to open this as a separate issue instead if you'd rather track the enum case on its own.
Generated by Claude Code
Fix double-drop of partially-moved locals