Two Live Borrows, One Allocation: The Alias the Compiler Never Saw
Undefined behavior in Rust rarely looks reckless. It looks like a cache with a &self method, a pointer snapshotted "just in case", and two perfectly reasonable functions that were never called in the same breath — until a reviewer asks who else can be looking at that memory while you write.
struct Cache {
data: *mut u8,
len: usize,
}
impl Cache {
// Pretends to be safe: &self says "shared, nothing changes"
fn store(&self, bytes: &[u8]) {
assert!(bytes.len() <= self.len);
// SAFETY: (never checked that no reader is live)
unsafe { std::ptr::copy_nonoverlapping(bytes.as_ptr(), self.data, bytes.len()); }
}
fn view(&self) -> &[u8] {
unsafe { std::slice::from_raw_parts(self.data, self.len) } // promises stable bytes
}
}
view() hands out a &[u8], which guarantees its bytes will not change while the borrow lives. store() then changes them — through a raw pointer the borrow checker never tracked. Hold the slice, call store, and the program is undefined: not a race the OS will catch, but a lie baked into every optimization the compiler made on the assumption that &[u8] means stable bytes. The same struct is unsound even single-threaded, because the optimizer may cache a load across the write it cannot see.
Three related shapes account for most filed bugs:
| Pattern | Why it is UB | Sound alternative |
|---|---|---|
Two &mut derived from one *mut |
exclusivity broken the moment the second is formed | one borrow at a time; re-derive after each use |
Mutation through a raw pointer under &self |
&T promises stable memory |
UnsafeCell, or &mut self, or interior-mutability types |
| Pointer snapshotted, then the container grows or moves | pointer escapes into freed/reallocated memory | re-derive after mutation, or pin the buffer |
The sound version of Cache makes the same facts explicit, and it changes two lines:
use std::cell::RefCell;
struct Cache { data: RefCell<Vec<u8>> } // mutation declared, not smuggled
impl Cache {
fn store(&self, bytes: &[u8]) { // &self is honest now: runtime-checked writes
let mut g = self.data.borrow_mut();
g.clear();
g.extend_from_slice(bytes);
}
fn snapshot(&self) -> Vec<u8> { // an owned copy — no borrow escapes
self.data.borrow().clone()
}
}
Either declare the mutation (UnsafeCell underneath, Cell/RefCell on top) or make sure no borrow outlives the call. Smuggling the write through a raw pointer does neither, and the reason it still "works" is worth stating precisely: the optimizer is entitled to assume the bytes behind &[u8] never change, so it may hoist the load out of a loop. Your hidden write lands, the loop keeps reading the cached value, and you get plausible inputs, wrong outputs, and no crash.
The model that explains all three shapes is worth knowing by name. Every allocation carries a stack of permissions (Stacked Borrows; Tree Borrows is its successor, selectable with -Zmiri-tree-borrows): each &mut pushes a fresh grant, writes invalidate older ones, and using a pointer whose grant was invalidated is undefined behavior even if the bytes still exist. That last clause is why these bugs feel unfair — the memory is right there, the values print correctly, and the program is still undefined. It is also why crates like owning_ref were declared unsound: a shared handle and a mutating handle could be live over the same allocation, something no safe Rust can express.
The review discipline is a single question asked of every write: what references to this memory might be live right now, and did any of them come from a path I cannot see? If the answer involves &self plus a write, you needed UnsafeCell before you needed unsafe. The second family of bugs does not involve aliasing at all — it involves borrows that outlive the memory they point at.
Lifetime Lies: Returning a Borrow of Something Already Dropped
The borrow checker's signature achievement is that no safe function can return a reference to its own local. The compiler says so directly:
fn get() -> &str {
let s = String::from("hello");
&s
}
error[E0515]: cannot return reference to local variable
= note: `s` is a local variable and will be dropped when this function returns
Unsafe code removes the referee, not the rule. The same program with the borrow laundered through a raw pointer compiles, then dangles:
fn get<'a>() -> &'a str {
let s = String::from("hello");
let p: *const str = s.as_str(); // raw pointer: safe to form
unsafe { &*p } // chosen lifetime 'a — free to be anything
} // s dropped here → returned reference points at freed memory
let r = get();
println!("{r}"); // use-after-free: reads a freed String
Nothing in that function panics. In practice the first println! often prints hello correctly, because the allocator has not reused the block yet — the bug is a delayed one, living in code that has already moved on, and it detonates when the heap is next churned. Three variants show up constantly:
- Lifetime transmutation.
std::mem::transmute::<&'a str, &'b str>compiles whenever the sizes match (references always do) and lets the caller name'bas anything — the type system's strongest guarantee, erased by a type-level rename. - Duplicated
&mutvia pointer round-trip.let r2 = unsafe { &mut *(&mut *r as *mut i32) };looks like a reborrow and is actually a second exclusive reference. The compiler assumed exclusivity forrand optimized accordingly. - Reference held across invalidation. Form a
&mutinto aVec, thenpushenough to reallocate, orset_lento shrink past it: the allocation the reference describes no longer exists, and the borrow is dead in every sense the type system cares about.
The repairs all share one property — the returned borrow is tied to an owner that outlives the call, so the compiler gets to check it again:
// 1. borrow from a parameter: the lifetime belongs to the caller's data
fn first<'a>(items: &'a [String]) -> &'a str { &items[0] }
// 2. return something owned: nothing outlives anything
fn snapshot(&self) -> Vec<u8> { self.buf.borrow().clone() }
// 3. return a handle into a structure you also own; re-borrow on each use
fn get(&self, key: &str) -> Option<&[u8]> { self.map.get(key).map(Vec::as_slice) }
The test to run on any signature: if you cannot name the owner that backs the lifetime, you are asking the type system to trust you — which is precisely what an unsafe block removed. No local check can confirm duration; only ownership, tied into the signature, can.
The common thread: an unbounded lifetime is a blank check. ptr.as_ref(), &*ptr, and transmute all hand you the power to assert duration, and duration is the one property that cannot be validated at the point of use — only maintained by ownership discipline established elsewhere. That is why the fix is never "add a lifetime": it is to make the borrow share the lifetime of something that owns the data (return an index, return a handle keyed to a collection, or return owned data), so the compiler can check it again.
The third family of unsoundness does not need dangling pointers or aliasing — only a wrong belief about how bytes are laid out.
Niche Assumptions, Uninitialized Bytes, and How Miri Catches All Three
Rust's favorite size trick is niche optimization: Option<&T> is a single pointer because null is an unused bit pattern, and the language guarantees this for Option<extern "C" fn()> (null function pointer = None) and Option<NonNull<T>>. The guarantee stops there. Assuming it for your own type is an implementation detail, and assuming it for a #[repr(C)] type is a good way to write bytes C reads as garbage:
#[repr(C)]
struct Callback { run: extern "C" fn(*mut c_void), user: *mut c_void }
// NOT guaranteed pointer-or-null sized: repr(C) pins the layout of Callback,
// so the niche inside it may no longer exist for Option<Callback> to reuse.
const _: () = assert!(std::mem::size_of::<Option<Callback>>()
== std::mem::size_of::<Callback>()); // may fail on some targets
If you need null-means-absent across FFI, say so explicitly — use Option<extern "C" fn()>, or a nullable pointer plus a checked accessor. When a layout assumption matters, pin it with size_of/offset_of assertions rather than a comment; the assertion is the part that keeps working after the refactor.
Validity assumptions are the sharper edge, because they concern what a type means, not how big it is:
let b: bool = unsafe { std::mem::transmute(2u8) }; // UB: bool must be 0 or 1
let u: u32 = unsafe {
std::mem::MaybeUninit::<u32>::uninit().assume_init() // UB: uninitialized read
};
Transmute between same-sized types happily compiles and transfers an invalid bit pattern; MaybeUninit::<T>::assume_init() on memory never written produces a T that never existed. Structs make it subtler — assume_init on a struct where one field was left uninitialized is UB even if you never read that field, and copying padding bytes through ptr::read on a type that cares is UB. Unions add the mirror case: reading the field that was not the last one written is UB, so a tag check or ManuallyDrop must come first. The rule to memorize: every byte of a value must be initialized and every bit pattern must be one the type permits, at the moment the value is created.
You cannot audit that by eye across a real crate, which is where Miri comes in. It interprets your test binary instead of compiling it to machine code, so it can watch every memory operation:
rustup component add miri --toolchain nightly
cargo +nightly miri setup
cargo +nightly miri test # run your #[test] suite under the interpreter
MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri test # newer aliasing model
MIRIFLAGS="-Zmiri-disable-isolation" cargo +nightly miri test # allow host filesystem access
Miri reports, with a backtrace to your line, messages such as using uninitialized data, but this operation requires initialized memory, attempting a read access ... whose tag has been invalidated, out-of-bounds and misaligned accesses, invalid enum discriminants, and data races. What it cannot do is execute your C library — FFI calls hit interpreter shims or fail outright — so those paths get targeted tests on real hardware, plus -Zsanitizer=address / -Zsanitizer=thread builds (nightly) for the memory and race detection Miri cannot reach through the boundary.
Make it a habit rather than a fire drill: the PR that adds an unsafe block is the PR that adds cargo miri test to CI. Aliasing bugs, lifetime lies, and layout guesses all surface on the first run — which is the payoff of everything in this phase, from the four powers in The unsafe Keyword through the documented invariants of Encapsulating Unsafe with Invariants. Next you prove the safe half of the story works: Unit Tests and Assertions.
Quiz
1. Which situation is undefined behavior even though every byte of the allocation is live and holds the value you expect?
2. A function builds a String local, takes a raw pointer to it, and returns `unsafe { &*p }` with a caller-chosen lifetime. What happens?
3. You need Option<YourReprCStruct> to occupy the same space as the struct itself when passing it to C. What is true?
4. What does `cargo +nightly miri test` actually do?
Flashcards
Question
Why is writing through a raw pointer under `&self` unsound?
Click to reveal answer
Answer
A &T or &[u8] promises its memory stays stable while the borrow lives, and the optimizer relies on that. Mutation must therefore go through UnsafeCell (the sanctioned path behind Cell/RefCell) or take &mut self. Writing behind a shared reference is UB even single-threaded — the program may still print correct values while the compiler has already transformed code on the false assumption.
Question
How do you return a dangling reference without triggering any compiler error?
Click to reveal answer
Answer
Route through a raw pointer or an unbounded lifetime: take *const of a local, return `unsafe { &*p }` with a caller-chosen 'a, or transmute between reference lifetimes (always same-sized). The local drops on return, so the borrow dangles — typically invisible until the heap block is reused. Fix by returning an index, a handle tied to the owner, or owned data.
Question
Which Option niche optimizations are documented, and which are assumptions?
Click to reveal answer
Answer
Documented: Option<extern "C" fn()> and Option<NonNull<T>> are pointer-sized with None = null — the FFI-safe spellings. References are never null so Option<&T> is pointer-sized in practice. Assumptions (do not rely): Option<*mut T> is actually two words, and niche elision inside your own repr(C) structs — assert size_of/offset_of if a size matters.
Question
What does Miri detect, and what does it miss?
Click to reveal answer
Answer
Detects, per test run with backtraces: uninitialized reads, reads through invalidated pointer tags (aliasing), misalignment, out-of-bounds, invalid enum discriminants, out-of-lifetime accesses, and data races. Misses: real FFI (no C execution — shims only), paths no test exercises, and resource issues like leaks. Configure with MIRIFLAGS (e.g. -Zmiri-tree-borrows, -Zmiri-disable-isolation); use -Zsanitizer=address|thread for FFI-heavy paths.
Question
Name the three families of common unsoundness and the question that catches the first one
Click to reveal answer
Answer
Aliasing (second live &mut, or a write under a shared reference), lifetime lies (asserting duration the owner cannot back), and layout/validity assumptions (unearned niche, invalid bit pattern, uninitialized byte). The review question for every write: what references to this memory might be live right now, and did any arrive via a path I cannot see?
Revision Notes
Key Takeaways
- 1. Aliasing bugs are invisible by construction: using a pointer whose permission tag was invalidated is UB even when the bytes are live and print correctly — which is why print-debugging fails and Miri exists
- 2. Mutation under a shared reference requires UnsafeCell; a raw-pointer write behind a live &[u8] or &T breaks an assumption the optimizer is entitled to make
- 3. An unbounded lifetime (&*ptr, transmute, ptr.as_ref()) asserts duration that cannot be checked at the use site — return an index, handle, or owned value instead
- 4. Only Option<extern "C" fn()> and Option<NonNull<T>> are documented niche guarantees; user repr(C) layouts need size_of/offset_of assertions, and bool/transmute/maybe-uninit are validity UB
- 5. Miri interprets the test suite to catch UB with a backtrace; it cannot execute your C library, so FFI paths get native tests plus ASan/TSan builds
Interview Tips
- • "Give an example of undefined behavior that doesn't crash" — writing through a raw pointer while a &[u8] observes the memory, or returning a reference to a dropped local that still prints correctly until the heap is reused; explain that UB is a promise broken for the optimizer, not an immediate fault
- • "What is Stacked Borrows / why should I care?" — allocations carry stacked permissions; each &mut pushes a grant, writes invalidate older tags, and using an invalidated tag is UB — the model Miri checks, and the reason a snapshotted pointer dies when the container mutates
- • "Is Option<MyStruct> pointer-sized?" — don't guess; only Option<extern "C" fn()> and Option<NonNull<T>> are documented, so I'd write a const size_of assertion and fail the build if it drifts
- • "How do you test unsafe code?" — cargo miri test in CI on nightly for the unsafe-heavy tests (MIRIFLAGS for tree-borrows/isolation), targeted native tests plus -Zsanitizer=address|thread for FFI Miri cannot execute, and every unsafe block carrying a checkable SAFETY comment
Cheat Sheet
Common Unsoundness Patterns — Cheat Sheet
The three families:
| Family | The lie | Sound alternative |
|---|---|---|
| Aliasing | two live &mut, or a write under a &[u8]/&T |
UnsafeCell, &mut self, one borrow at a time |
| Lifetime | &*ptr / transmute / ptr.as_ref() asserting duration |
return an index, a handle, or owned data |
| Layout / validity | niche assumed, invalid bit pattern, uninit byte | size_of/offset_of asserts, MaybeUninit until written |
Canonical repros:
let r2 = unsafe { &mut *(&mut *r as *mut i32) }; // second &mut — UB
fn get<'a>() -> &'a str { let s = String::new(); unsafe { &*(s.as_str() as *const str) } } // dangles
let b: bool = unsafe { std::mem::transmute(2u8) }; // 2 is not a bool — UB
Alias model: allocations carry stacked permissions; each &mut pushes a grant, writes invalidate older ones, and using an invalidated pointer is UB even when the bytes are intact. That is why print-debugging cannot find these.
Niches you may rely on: Option<extern "C" fn()> and Option<NonNull<T>> are documented as pointer-sized (None = null) and are the FFI-safe spellings; Option<&T>/Option<&mut T> are pointer-sized because references can never be null. Raw pointers get no niche (Option<*mut T> is two words — that is why NonNull exists), and niche elision for your own repr(C) types is an implementation detail. Assert size_of when a size matters.
Miri workflow:
rustup component add miri --toolchain nightly
cargo +nightly miri setup
cargo +nightly miri test
MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri test
Catches: uninit reads (using uninitialized data, but this operation requires initialized memory), invalidated-pointer reads, misalignment, OOB, invalid discriminants, data races. Cannot execute your C library — use targeted native tests plus -Zsanitizer=address|thread for FFI paths.
Habit: the PR that adds an unsafe block adds cargo miri test to CI.