rust-lang / rust-lang/rust

is_active can be unsound when the activation does not dominate the reservation

Open
#160,598 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

A-borrow-checker C-bug I-prioritize I-unsound T-compiler 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 v = Some(0i32);
       let mut r: Option<&mut i32> = None;
       'b: {
           r = Some(v.insert(match true { false => break 'b, true => 1 }));
       }
       let s = &v;              // accepted; should be E0502
       let before = *s;
       *r.unwrap() = 2;
       println!("{:?} {:?}", before, *s);
   }

I expected to see this happen: it doesn't typecheck

Instead, this happened: it does typecheck and prints Some(1) Some(2)

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: rustc_borrowck::path_utils::is_active assumes a two-phase borrow's activation post-dominates its reservation. A break inside a method argument violates that, and a later &place is misclassified as reading a "mere reservation" while an activated &mut is live.

Reproducer
#![forbid(unsafe_code)]
fn main() {
   let mut v = Some(0i32);
   let mut r: Option<&mut i32> = None;
   'b: {
       // R: two-phase `&mut v` is reserved for the receiver, then the
       // argument is evaluated — and it may `break 'b` before the call (A).
       r = Some(v.insert(match true { false => break 'b, true => 1 }));
   }
   let s = &v;          // accepted; should be E0502
   let before = *s;
   *r.unwrap() = 2;     // write through the activated &mut while `s` is live
   println!("{:?} {:?}", before, *s);   // prints: Some(1) Some(2)
}

A &Option<i32> observes its referent change. Miri (Tree Borrows) reports Undefined Behavior: write access through <tag> … is forbidden. The same shape works with loop { v.insert({ if c { break … } 5 }) }, HashMap::entry, etc. The break edge doesn't need to be taken at runtime — its presence in the CFG is enough.

What goes wrong

is_active decides whether a two-phase loan is still a mere reservation at location using only dominators:

if activation_location.dominates(location, dominators) { return true; }
let reserve_location = borrow_data.reserve_location.successor_within_block();
if reserve_location.dominates(location, dominators) { false } else { true }

The comment above it states the assumption this relies on:

  • the reservation R dominates the activation A
  • the activation A post-dominates the reservation R (ignoring unwinding edges).

This means that there can't be an edge that leaves A and comes back into that diamond unless it passes through R.

The second bullet is false. Two-phase borrows exist so the autoref'd receiver is reserved before the remaining arguments are evaluated, and an argument can break / break 'label out of the enclosing loop or block. That edge runs from between R and A to a join point J outside, so A does not post-dominate R.

At J (let s = &v above):

  • succ(R) dominates J — every path to J passes the reservation;
  • A does not dominate J — the break 'b path skips the call.

So is_active returns false, even though J is also reached from A along the fall-through path carrying the activated &mut in r. The caller in check_access_for_conflict then skips the conflict:

(Read(kind), BorrowKind::Mut { .. }) => {
   // Reading from mere reservations of mutable-borrows is OK.
   if !is_active(this.dominators(), borrow, location) {
       assert!(borrow.kind.is_two_phase_borrow());
       return ControlFlow::Continue(());
   }

(loan_invalidations.rs has the identical check.)

The Borrows dataflow is fine — the loan is in scope at J; only the "merely reserved?" refinement is wrong. Two controls confirm that:

  • replace the read &v with a write v = None → E0506 (writes never consult is_active);
  • hoist the break above the call so A post-dominates R again → E0502 at let s = &v.
Fix direction

"Reserved but not active at p" should mean p is not reachable from A without passing back through R. succ(R) dom p ∧ ¬(A dom p) is only equivalent to that under the post-dominance assumption. Either:

  1. replace the second test with a real reachability check — active at p iff p.block is reachable from A.block without entering R.block (and p in R.block after R ⇒ inactive, which keeps v.push(v.len()) cheap); or
  2. precompute the reserved-only region per two-phase borrow during BorrowSet construction (forward walk from R stopping at A) and have is_active consult it.

Both only flip answers from false to true, so nothing new is accepted. Ordinary two-phase patterns (v.push(v.len()), if/match/nested calls in arguments, arguments that return/panic!) are unaffected: either A still post-dominates R, or the early exit diverges and no join point sees both paths.

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 by running the supplied reproducer and reading rustc_borrowck/src/path_utils.rs, especially is_active, then compare its callers in rustc_borrowck/src/lib.rs and polonius/loan_invalidations.rs. The work is done when the reproducer is rejected with E0502 and regression coverage preserves ordinary two-phase borrow behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
compilers
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.