rust-lang / rust-lang/rust

Assignment in match guard leads to unsoundness

Open
#160,599 4 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

A-borrow-checker A-MIR A-patterns C-bug I-prioritize I-unsound T-compiler T-lang T-types
Dominant language
Rust
Stars
119k
Forks
16.1k
PR merge metrics
PR metrics pending

Description

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:

  1. Access check (check_access_for_conflicteach_borrow_involving_pathborrow_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.

  2. 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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with rustc_borrowck/src/dataflow.rs, especially kill_borrows_on_place, then compare the matching logic in polonius/legacy/loan_kills.rs and places_conflict.rs. Run the reported nightly reproducer and check the listed controls. Done means the unsound program is rejected while ordinary borrow-checking behavior remains unchanged.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
compilers
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.