I've been using LLMs to find soundness issues in various programs. I believe I have found a few in rust. This one in particular was discussed on zulip, so filing it now -- I will likely file others later.
I tried this code:
#![forbid(unsafe_code)]
fn main() {
let mut a = (Some(&42u64), 0u8);
let mut b = (None::<&u64>, 0u8);
let mut p = &mut a;
match p.0 {
Some(_) if { p.1 = 1; p = &mut b; false } => unreachable!(),
Some(r) => println!("Some arm; p.0 = {:?}; &u64 at {:p}", p.0, r),
None => unreachable!(),
}
}
I expected to see this happen: it doesn't typecheck
Instead, this happened: it does typecheck and prints "Some arm; p.0 = None; &u64 at 0x0 "
Meta
Nightly channel
Build using the Nightly version: 1.99.0-nightly
(2026-08-03 https://github.com/rust-lang/rust/commit/504869653f510b279c542e65ccd1ea9710c119ba)
LLM-written explanation of what it thinks is happening
The remainder of this here might be complete slop. I tried to check it for accuracy but it's way beyond me. But in case it's useful for someone, I'm providing my LLM's explanation of why it thinks this bug happened. (To be clear, I've personally verified that I believe this is a bug. I have not checked this explanation and am providing it only in the hope that it may be useful. Please disregard if it's not---and let me know and I can not provide them in future issues!)
Summary: Borrows::kill_borrows_on_place tests loans for conflict with an assigned place as if every loan were &mut + Deep, ignoring the loan's real BorrowKind. For a Fake(Shallow) loan this is stronger than the access check, so a guard write to a sibling field through the scrutinee's &mut base is (correctly) not an error but (incorrectly) kills the fake loan on the base local. The guard can then reassign that base — an E0510 bypass — and subsequent arms match/bind against a different place than the one whose discriminant was tested.
Reproducer
#![forbid(unsafe_code)]
fn main() {
let mut a = (Some(&42u64), 0u8);
let mut b = (None::<&u64>, 0u8);
let mut p = &mut a;
match p.0 {
// sibling write ─┐ ┌─ should be E0510, is accepted
Some(_) if { p.1 = 1; p = &mut b; false } => unreachable!(),
Some(r) => println!("Some arm; p.0 = {:?}; &u64 at {:p}", p.0, r),
None => unreachable!(),
}
}
Output: Some arm; p.0 = None; &u64 at 0x0 — a &u64 null reference bound out of a None. The guard fails, matching continues on the reassigned *p, and the second Some(r) arm reuses the discriminant test already done for the first arm. Deleting p.1 = 1; gives the expected error[E0510]: cannot assign p in match guard. Miri flags UB (a variant with a u8 payload reads uninitialised memory instead).
What goes wrong
For scrutinee (*p).0 match lowering emits two fake borrows for the guard:
_7 = &fake shallow _5; // the Deref base local `p`
_8 = &fake shallow ((*_5).0); // the tested place
The fake on _5 is what makes p = &mut b in a guard E0510 (the one on (*_5).0 can't: against a shallow write to _5 it hits the "shallow access behind ptr" escape).
The guard statement (*_5).1 = 1 is then looked at twice with different conflict semantics:
-
Access check (check_access_for_conflict → each_borrow_involving_path → borrow_conflicts_with_place) uses the loan's real kind. For the base fake (_5, 0 projections) vs. access (*_5).1 (2 projections), the final Fake(Shallow) test returns no-conflict. Correct — writing through p doesn't change what p.0 denotes.
-
Loan kill (kill_borrows_on_place, from StatementKind::Assign) goes through the places_conflict wrapper, which hardwires
BorrowKind::Mut { kind: MutBorrowKind::TwoPhaseBorrow },
AccessDepth::Deep,
With a Mut kind the Fake(Shallow) escape doesn't apply, the comparison reports a conflict, and the base fake on _5 is killed.
So an assignment that is not an error against a loan nevertheless removes that loan from the dataflow state. For real loans this asymmetry is invisible (any such access is itself an error). Fake(Shallow) is exactly the case where the access is legitimately fine — and there the surviving loan is load‑bearing. The next guard statement p = &mut b is checked against a state with no fake on _5 and is accepted.
polonius/legacy/loan_kills.rs uses the same wrapper for its kills.
Controls (all on the unfixed compiler):
| change to the guard |
result |
drop the sibling write: { p = &mut b; false } |
E0510 |
sibling read instead: { let _ = p.1; p = &mut b; false } |
E0510 (no Assign, no kill) |
same write via a temp: { let q = &mut p.1; *q = 1; p = &mut b; false } |
E0510 (Assign LHS is *q, so loans on _5 aren't scanned) |
The last one is the same program semantically; only whether the Assign LHS is rooted at _5 differs.
Fix direction
Have kill_borrows_on_place (and the polonius‑legacy mirror) call borrow_conflicts_with_place with self.borrow_set[i].kind instead of the hardwired Mut, keeping AccessDepth::Deep. borrow_kind is consulted only at that one Fake(Shallow) test, so behaviour is bit‑identical for every other loan kind; the only change is that a Fake(Shallow) loan on a strict prefix of the assigned place is no longer killed. The kill set only shrinks ⇒ only more errors are possible, and new errors can only arise from fake loans, i.e. guard code that writes through the scrutinee's base and then mutates the scrutinee path — the unsound shape. The bare‑local fast path (place.projection.is_empty() ⇒ kill all, also used for StorageDead) is unaffected.
I've been using LLMs to find soundness issues in various programs. I believe I have found a few in rust. This one in particular was discussed on zulip, so filing it now -- I will likely file others later.
I tried this code:
I expected to see this happen: it doesn't typecheck
Instead, this happened: it does typecheck and prints "Some arm; p.0 = None; &u64 at 0x0 "
Meta
LLM-written explanation of what it thinks is happening
The remainder of this here might be complete slop. I tried to check it for accuracy but it's way beyond me. But in case it's useful for someone, I'm providing my LLM's explanation of why it thinks this bug happened. (To be clear, I've personally verified that I believe this is a bug. I have not checked this explanation and am providing it only in the hope that it may be useful. Please disregard if it's not---and let me know and I can not provide them in future issues!)
Summary:
Borrows::kill_borrows_on_placetests loans for conflict with an assigned place as if every loan were&mut+Deep, ignoring the loan's realBorrowKind. For aFake(Shallow)loan this is stronger than the access check, so a guard write to a sibling field through the scrutinee's&mutbase is (correctly) not an error but (incorrectly) kills the fake loan on the base local. The guard can then reassign that base — an E0510 bypass — and subsequent arms match/bind against a different place than the one whose discriminant was tested.Reproducer
Output:
Some arm; p.0 = None; &u64 at 0x0— a&u64null reference bound out of aNone. The guard fails, matching continues on the reassigned*p, and the secondSome(r)arm reuses the discriminant test already done for the first arm. Deletingp.1 = 1;gives the expectederror[E0510]: cannot assignpin match guard. Miri flags UB (a variant with au8payload reads uninitialised memory instead).What goes wrong
For scrutinee
(*p).0match lowering emits two fake borrows for the guard:The fake on
_5is what makesp = &mut bin a guard E0510 (the one on(*_5).0can't: against a shallow write to_5it hits the "shallow access behind ptr" escape).The guard statement
(*_5).1 = 1is then looked at twice with different conflict semantics:Access check (
check_access_for_conflict→each_borrow_involving_path→borrow_conflicts_with_place) uses the loan's real kind. For the base fake (_5, 0 projections) vs. access(*_5).1(2 projections), the finalFake(Shallow)test returns no-conflict. Correct — writing throughpdoesn't change whatp.0denotes.Loan kill (
kill_borrows_on_place, fromStatementKind::Assign) goes through theplaces_conflictwrapper, which hardwiresWith a
Mutkind theFake(Shallow)escape doesn't apply, the comparison reports a conflict, and the base fake on_5is killed.So an assignment that is not an error against a loan nevertheless removes that loan from the dataflow state. For real loans this asymmetry is invisible (any such access is itself an error).
Fake(Shallow)is exactly the case where the access is legitimately fine — and there the surviving loan is load‑bearing. The next guard statementp = &mut bis checked against a state with no fake on_5and is accepted.polonius/legacy/loan_kills.rsuses the same wrapper for its kills.Controls (all on the unfixed compiler):
{ p = &mut b; false }{ let _ = p.1; p = &mut b; false }Assign, no kill){ let q = &mut p.1; *q = 1; p = &mut b; false }AssignLHS is*q, so loans on_5aren't scanned)The last one is the same program semantically; only whether the
AssignLHS is rooted at_5differs.Fix direction
Have
kill_borrows_on_place(and the polonius‑legacy mirror) callborrow_conflicts_with_placewithself.borrow_set[i].kindinstead of the hardwiredMut, keepingAccessDepth::Deep.borrow_kindis consulted only at that oneFake(Shallow)test, so behaviour is bit‑identical for every other loan kind; the only change is that aFake(Shallow)loan on a strict prefix of the assigned place is no longer killed. The kill set only shrinks ⇒ only more errors are possible, and new errors can only arise from fake loans, i.e. guard code that writes through the scrutinee's base and then mutates the scrutinee path — the unsound shape. The bare‑local fast path (place.projection.is_empty()⇒ kill all, also used forStorageDead) is unaffected.