Skip to content

Assignment in match guard leads to unsoundness #160599

Description

@carlini

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-MIRArea: Mid-level IR (MIR) - https://blog.rust-lang.org/2016/04/19/MIR.htmlA-borrow-checkerArea: The borrow checkerA-patternsRelating to patterns and pattern matchingC-bugCategory: This is a bug.I-prioritizeIssue needs a team member to assess the impact. Will be replaced by P-{low,medium,high,critical}I-unsoundIssue: A soundness hole (worst kind of bug), see: https://en.wikipedia.org/wiki/SoundnessT-compilerRelevant to the compiler team, which will review and decide on the PR/issue.T-langRelevant to the language teamT-typesRelevant to the types team, which will review and decide on the PR/issue.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions