You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Unsound: at -C opt-level >= 1 GVN rewrites the Box deref temp from deref_copy to copy, so unelaborate_derefs misses it and every write through a nested Box lands on a copy — panicking programs verify as safe #244
unelaborate_derefs/extract_elaborated_deref (src/analyze/local_def.rs) recognizes an elaborated Box deref chain in two steps: a deref temp _t = deref_copy P (Rvalue::CopyForDeref), followed by the ElaborateBoxDerefs shape _p = (_t.0.0 as *const T) Transmute. Both temps are NOP'd and replaced by P, so *_p is analyzed as a deref of the original place.
rustc's GVN pass rewrites _t = deref_copy P into _t = copy P — a plain Rvalue::Use(Operand::Copy). GVN runs from mir_opt_level >= 2, i.e. from -C opt-level=1 upward. extract_elaborated_deref only matches Rvalue::CopyForDeref, so _t is no longer replaced. The Transmute pattern that follows still matches, but its rest_place is now the surviving temp _t instead of P.
Thrust models Box<T>by value (PointerType::own), so _t = copy P binds _t to an independent copy of the box's contents. The mutable borrow and the write therefore land on that copy, while P keeps its pre-write value. Every later read of P sees the stale value.
Both failure directions follow, and both are silent:
a program whose assertion is false at run time verifies as safe — unsound;
a program whose assertion is true is rejected with Unsat — over-rejection.
Thrust's verdict therefore depends on the optimization level, and in the direction that matters: the same source is correctly rejected in a debug build and verifies as safe in a release build.
A single Box local (let mut x = Box::new(1i64); *x += 1;) is unaffected: there is no preceding deref temp, so the Transmute pattern matches on the box local itself. The bug needs the box to sit behind one more projection or deref — which is every way a Box actually appears in a program: a struct field, a tuple element, an enum payload, a Vec element, another Box, or a &mut Box<T> parameter.
Minimal reproducer
No annotations needed — a Box in a tuple:
fnmain(){letmut t = (Box::new(1_i64),0_i64);*t.0 += 1;assert!(*t.0 == 1);// false: *t.0 is 2}
$ cargo run -- -Adead_code -C debug-assertions=false min.rs &&echo safeerror: verification error: Unsaterror: aborting due to 1 previous error
$ cargo run -- -Adead_code -C debug-assertions=false -C opt-level=2 min.rs &&echo safesafe
The program panics when run:
$ rustc -O -o min min.rs && ./minthread 'main' panicked at min.rs:4:5:assertion failed: *t.0 == 1
The same holds for a struct field:
structS{b:Box<i64>}fnmain(){letmut s = S{b:Box::new(1_i64)};*s.b += 1;assert!(*s.b == 1);// false: *s.b is 2 — `safe` at -C opt-level >= 1}
and for a nested Box — which is tests/ui/pass/box_nested.rs with the assertion negated:
fnmain(){letmut x = Box::new(Box::new(1_i64));**x += 1;assert!(**x == 1);// false: **x is 2 — `safe` at -C opt-level >= 1}
tests/ui/pass/box_nested.rs itself (the honest assert!(**x == 2)) is the over-rejection side: it is safe at the default optimization level and Unsat from -C opt-level=1 upward.
Behavior matrix (all with -Adead_code -C debug-assertions=false)
program
0 (default)
1
2
3
s
z
tuple, assert!(*t.0 == 1) — false
Unsat ✔
safe ❌
safe ❌
safe ❌
safe ❌
safe ❌
tuple, assert!(*t.0 == 2) — true
safe ✔
Unsat ❌
Unsat ❌
Unsat ❌
Unsat ❌
Unsat ❌
struct field, assert!(*s.b == 1) — false
Unsat ✔
safe ❌
safe ❌
safe ❌
safe ❌
safe ❌
struct field, assert!(*s.b == 2) — true
safe ✔
Unsat ❌
Unsat ❌
Unsat ❌
Unsat ❌
Unsat ❌
nested box, assert!(**x == 1) — false
Unsat ✔
safe ❌
safe ❌
safe ❌
safe ❌
safe ❌
tests/ui/pass/box_nested.rs
safe ✔
Unsat ❌
Unsat ❌
Unsat ❌
Unsat ❌
Unsat ❌
let mut x = Box::new(1i64); *x += 1; (single box)
safe ✔
safe ✔
safe ✔
safe ✔
safe ✔
safe ✔
Every other shape that puts a Box behind one more projection reproduces the unsoundness at -C opt-level=1 too — an enum payload (enum E { A(Box<i64>) }, mutated through match &mut e), a Vec<Box<i64>> element, a Box field mutated through a &mut self method, a &mut Box<i64> parameter, and a write performed by a callee (fn incr(x: &mut i64) applied to &mut **x). Some of these additionally hit an unrelated Option::unwrap() panic in src/refine/env.rs:399 at -C opt-level=2/3 (const-propagated aggregate constants), which only masks the wrong answer at those two levels; at 1, s and z they all report safe.
Root cause
ElaborateBoxDerefs lowers *boxed into a Unique/NonNull/Transmute chain, and Derefer hoists the base of that chain into a deref temp. For *t.0 the runtime MIR at -C opt-level=0 is:
unelaborate_derefs NOPs the first statement and replaces _6 with (_1.0); the second then matches the Box pattern with rest_place = (_1.0) and is NOP'd too, replacing _8 with (_1.0). The write is analyzed as a mutable borrow of (_1.0) — correct.
GVN rewrites exactly those two statements (3-2-013.GVN diff for the struct-field variant):
so _6 survives as an ordinary local, and the Transmute statement is unelaborated against rest_place = _6. The analyzed write becomes a borrow of *_6, where _6 was bound by analyze_assignment to a fresh copy of (_1.0)'s value — Box<i64> is Own(Int) in rty, so copying it copies the pointee rather than aliasing it. (_1.0) is never borrowed, so its value is never replaced by a prophecy, and the read-back through _7 (another independent copy) returns the original value.
CHC evidence
For the tuple reproducer, THRUST_OUTPUT_DIR at -C opt-level=1 gives (c1, the assertion-holds clause; v10 is t):
(= v10 (tuple<Int-Int> v11 0)) ; t = (v11, 0) with v11 = 1
(= v4 (tuple_proj<Int-Int>.0 v10)) (= v5 v4) (= v14 v5) ; copy #2 of the box -> read back
(= v6 (tuple_proj<Int-Int>.0 v10)) (= v7 v6) (= v15 v7) ; copy #3 of the box -> written
(= (mut<Int> v18 v17) (mut<Int> v15 v16)) ; &mut of copy #3
(= (mut_final<Int> (mut<Int> v18 v17)) (+ v9 1)) ; the write: v17 = 2
(= v1 v14) (= v12 v1) (= v12 1) ; the assert reads copy #2
v10 is never rebuilt from a prophecy, so v12 = v14 = tuple_proj.0(v10) = v11 = 1. The panic clause c2 carries (not (= v12 1)) in its body with head false, so it is discharged vacuously and the system is sat — reported as safe.
The same clause at -C opt-level=0 shows the correct threading: the borrow is taken on the tuple's own field and the tuple is rebuilt around the prophecy,
so the false assertion is correctly rejected there.
Confirmed by experiment
Teaching extract_elaborated_deref the post-GVN shape — a copy of a Box-typed place, which in MIR is only ever the pointer copy of such a deref temp, since Box is not Copy:
if let mir::Rvalue::CopyForDeref(place) = &rvalue {
return Some((lhs_local, *place));
}
++ if let mir::Rvalue::Use(mir::Operand::Copy(place)) = &rvalue {+ if place.ty(&self.body.local_decls, self.tcx).ty.is_box() {+ return Some((lhs_local, *place));+ }+ }
makes every reproducer above behave identically at -C opt-level=0..3, s and z: the false assertions are rejected at every level, the true ones (including tests/ui/pass/box_nested.rs) verify at every level, and the whole tests/ui/pass suite still passes at the default optimization level. This is offered as evidence for the diagnosis, not as a proposed patch — matching the rewritten shape in extract_elaborated_deref may not be the right place, and the guard wants more thought than "the place is a box".
Notes
Not vacuity on the accepting side: the accepted programs are genuinely mis-verified, not proved from an inconsistent environment. The negated tuple reproducer (assert!(*t.0 == 2), which is what actually holds) is rejected at the same optimization levels, so exactly one of the two contradictory assertions is accepted at each level — the environment says *t.0 == 1 after the write.
Numerical-range / overflow concerns are not involved.
tests/ui/pass/box_nested.rs already fails at -C opt-level >= 1 on main, so the bug is reachable from the existing suite; CI never sees it because the suite runs at the default optimization level, even though tests/ui/pass/list_sum_const.rs shows -C opt-level=3 is a supported configuration.
A sweep of tests/ui/pass at -C opt-level=1 turns up one further regression, ghost_field.rs, which is not this bug: there GVN const-folds the ZST __ghost_marker result into const Ghost::<..>(PhantomData), so the ghost assignment loses its link to the marker call. It survives the experimental patch above and wants its own issue.
Summary
unelaborate_derefs/extract_elaborated_deref(src/analyze/local_def.rs) recognizes an elaboratedBoxderef chain in two steps: a deref temp_t = deref_copy P(Rvalue::CopyForDeref), followed by theElaborateBoxDerefsshape_p = (_t.0.0 as *const T) Transmute. Both temps are NOP'd and replaced byP, so*_pis analyzed as a deref of the original place.rustc's GVN pass rewrites
_t = deref_copy Pinto_t = copy P— a plainRvalue::Use(Operand::Copy). GVN runs frommir_opt_level >= 2, i.e. from-C opt-level=1upward.extract_elaborated_derefonly matchesRvalue::CopyForDeref, so_tis no longer replaced. TheTransmutepattern that follows still matches, but itsrest_placeis now the surviving temp_tinstead ofP.Thrust models
Box<T>by value (PointerType::own), so_t = copy Pbinds_tto an independent copy of the box's contents. The mutable borrow and the write therefore land on that copy, whilePkeeps its pre-write value. Every later read ofPsees the stale value.Both failure directions follow, and both are silent:
safe— unsound;Unsat— over-rejection.Thrust's verdict therefore depends on the optimization level, and in the direction that matters: the same source is correctly rejected in a debug build and verifies as
safein a release build.A single
Boxlocal (let mut x = Box::new(1i64); *x += 1;) is unaffected: there is no preceding deref temp, so theTransmutepattern matches on the box local itself. The bug needs the box to sit behind one more projection or deref — which is every way aBoxactually appears in a program: a struct field, a tuple element, an enum payload, aVecelement, anotherBox, or a&mut Box<T>parameter.Minimal reproducer
No annotations needed — a
Boxin a tuple:The program panics when run:
The same holds for a struct field:
and for a nested
Box— which istests/ui/pass/box_nested.rswith the assertion negated:tests/ui/pass/box_nested.rsitself (the honestassert!(**x == 2)) is the over-rejection side: it issafeat the default optimization level andUnsatfrom-C opt-level=1upward.Behavior matrix (all with
-Adead_code -C debug-assertions=false)0(default)123szassert!(*t.0 == 1)— falseassert!(*t.0 == 2)— trueassert!(*s.b == 1)— falseassert!(*s.b == 2)— trueassert!(**x == 1)— falsetests/ui/pass/box_nested.rslet mut x = Box::new(1i64); *x += 1;(single box)Every other shape that puts a
Boxbehind one more projection reproduces the unsoundness at-C opt-level=1too — an enum payload (enum E { A(Box<i64>) }, mutated throughmatch &mut e), aVec<Box<i64>>element, aBoxfield mutated through a&mut selfmethod, a&mut Box<i64>parameter, and a write performed by a callee (fn incr(x: &mut i64)applied to&mut **x). Some of these additionally hit an unrelatedOption::unwrap()panic insrc/refine/env.rs:399at-C opt-level=2/3(const-propagated aggregate constants), which only masks the wrong answer at those two levels; at1,sandzthey all reportsafe.Root cause
ElaborateBoxDerefslowers*boxedinto aUnique/NonNull/Transmutechain, andDereferhoists the base of that chain into a deref temp. For*t.0the runtime MIR at-C opt-level=0is:unelaborate_derefsNOPs the first statement and replaces_6with(_1.0); the second then matches theBoxpattern withrest_place = (_1.0)and is NOP'd too, replacing_8with(_1.0). The write is analyzed as a mutable borrow of(_1.0)— correct.GVN rewrites exactly those two statements (
3-2-013.GVNdiff for the struct-field variant):extract_elaborated_derefreturnsNonefor_6 = copy (_1.0):so
_6survives as an ordinary local, and theTransmutestatement is unelaborated againstrest_place = _6. The analyzed write becomes a borrow of*_6, where_6was bound byanalyze_assignmentto a fresh copy of(_1.0)'s value —Box<i64>isOwn(Int)inrty, so copying it copies the pointee rather than aliasing it.(_1.0)is never borrowed, so its value is never replaced by a prophecy, and the read-back through_7(another independent copy) returns the original value.CHC evidence
For the tuple reproducer,
THRUST_OUTPUT_DIRat-C opt-level=1gives (c1, the assertion-holds clause;v10ist):v10is never rebuilt from a prophecy, sov12 = v14 = tuple_proj.0(v10) = v11 = 1. The panic clausec2carries(not (= v12 1))in its body with headfalse, so it is discharged vacuously and the system issat— reported assafe.The same clause at
-C opt-level=0shows the correct threading: the borrow is taken on the tuple's own field and the tuple is rebuilt around the prophecy,so the false assertion is correctly rejected there.
Confirmed by experiment
Teaching
extract_elaborated_derefthe post-GVN shape — acopyof aBox-typed place, which in MIR is only ever the pointer copy of such a deref temp, sinceBoxis notCopy:if let mir::Rvalue::CopyForDeref(place) = &rvalue { return Some((lhs_local, *place)); } + + if let mir::Rvalue::Use(mir::Operand::Copy(place)) = &rvalue { + if place.ty(&self.body.local_decls, self.tcx).ty.is_box() { + return Some((lhs_local, *place)); + } + }makes every reproducer above behave identically at
-C opt-level=0..3,sandz: the false assertions are rejected at every level, the true ones (includingtests/ui/pass/box_nested.rs) verify at every level, and the wholetests/ui/passsuite still passes at the default optimization level. This is offered as evidence for the diagnosis, not as a proposed patch — matching the rewritten shape inextract_elaborated_derefmay not be the right place, and the guard wants more thought than "the place is a box".Notes
assert!(*t.0 == 2), which is what actually holds) is rejected at the same optimization levels, so exactly one of the two contradictory assertions is accepted at each level — the environment says*t.0 == 1after the write.v.len()on a&mut [T]leaves an unresolved reborrow that havocs the referent, so every safe&mutslice program that guards an index withlen()is rejected at any-C opt-level >= 1#240, the other optimization-level-dependent bug: there the trigger isPtrMetadataon a&mut [T]and the mechanism is aReborrowVisitor-created reborrow whose prophecy is never resolved, which havocs the referent and only ever over-rejects. Here no reborrow is discarded and nothing is havoc'd — aBoxderef temp is left un-unelaborated, the write is redirected to a copy, and the result is a wrongsafe. The reproducers here contain no slices and nolen().&mutstored in aBoxhas its prophecy resolved only at theBox's drop, so reading the referent before the box is dropped wrongly rejects safe programs #175 (a&mutstored in aBoxhas its prophecy resolved only at the box's drop): the&muthere is a short-lived borrow of the box's pointee, and the value that goes missing is the box's own contents.Box<T>(Own) is related invariantly in subtyping, wrongly rejecting valid refinement weakening through a box #157 (Ownrelated invariantly in subtyping) and Refinements onBox(own-pointer) pointee in return position are not enforced — unsound #97 (Boxpointee refinements in return position); no subtyping or annotation is involved — the reproducers carry no annotations at all.tests/ui/pass/box_nested.rsalready fails at-C opt-level >= 1onmain, so the bug is reachable from the existing suite; CI never sees it because the suite runs at the default optimization level, even thoughtests/ui/pass/list_sum_const.rsshows-C opt-level=3is a supported configuration.tests/ui/passat-C opt-level=1turns up one further regression,ghost_field.rs, which is not this bug: there GVN const-folds the ZST__ghost_markerresult intoconst Ghost::<..>(PhantomData), so the ghost assignment loses its link to the marker call. It survives the experimental patch above and wants its own issue.Environment
main@cd6b330