11 KiB
Miri, Sanitizers, Loom, and Fuzzing — The UB Detection Arsenal
Miri is the primary weapon. Everything else is supplementary for the gaps Miri cannot reach.
Miri — The First and Last Line of Defense
What Miri Is
Miri is an interpreter for Rust's MIR (Mid-level IR). It executes your test suite inside a virtual machine that tracks every byte of memory for validity, provenance, alignment, initialization, and aliasing. It is deterministic — same inputs, same result — and it can find UB that no amount of testing on real hardware will ever trigger.
Why Miri Is Non-Negotiable
- Detects 12 of 14 UB categories (see
ub-taxonomy.md). - Catches aliasing violations that compile and run correctly on every platform today but are UB that future compiler optimizations will exploit.
- Catches data races under a configurable scheduling model.
- Catches provenance violations that are impossible to observe on real hardware.
- Zero false positives — if Miri says it is UB, it is UB. Period.
Installation
rustup install nightly
rustup component add miri rust-src --toolchain nightly
Verify:
cargo +nightly miri --version
Running Miri
Default run (Stacked Borrows, standard checks):
cargo +nightly miri test
With nextest (recommended for projects already using nextest):
cargo +nightly miri nextest run
Specific test:
cargo +nightly miri test -- test_name
Run a binary:
cargo +nightly miri run
MIRIFLAGS — The Dial-Up Knobs
These flags are set via the MIRIFLAGS environment variable. The agent should use ALL of the strictness flags during a UB audit.
Aliasing Model
# Default: Stacked Borrows (strict)
cargo +nightly miri test
# Tree Borrows (newer, more permissive — use as a second pass)
MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri test
Protocol: Run Stacked Borrows first. If it fails, fix it. Then run Tree Borrows to confirm. Code that passes Stacked Borrows is sound under both models.
Strict Provenance
MIRIFLAGS="-Zmiri-strict-provenance" cargo +nightly miri test
Catches ptr as usize as *const T roundtrips where provenance is lost. Should be ON for every audit.
Symbolic Alignment Checks
MIRIFLAGS="-Zmiri-symbolic-alignment-check" cargo +nightly miri test
Catches alignment UB that happens to be aligned on your machine but is not guaranteed by the type system.
Data Race Detection Tuning
# Increase preemption rate to stress-test race conditions
MIRIFLAGS="-Zmiri-preemption-rate=0.5" cargo +nightly miri test
# Disable preemption (sequential scheduling — fewer races found but deterministic)
MIRIFLAGS="-Zmiri-preemption-rate=0" cargo +nightly miri test
The Full Paranoia Sweep (Use This for Audits)
MIRIFLAGS="\
-Zmiri-strict-provenance \
-Zmiri-symbolic-alignment-check \
-Zmiri-preemption-rate=0.1 \
-Zmiri-backtrace=full \
-Zmiri-disable-isolation" \
cargo +nightly miri test
Then a second pass with Tree Borrows:
MIRIFLAGS="\
-Zmiri-tree-borrows \
-Zmiri-strict-provenance \
-Zmiri-symbolic-alignment-check \
-Zmiri-preemption-rate=0.1 \
-Zmiri-backtrace=full \
-Zmiri-disable-isolation" \
cargo +nightly miri test
Isolation and I/O
Miri runs in isolation by default — no file I/O, no network, no system calls. If your tests need the filesystem:
MIRIFLAGS="-Zmiri-disable-isolation" cargo +nightly miri test
Use sparingly — isolation is a feature, not a limitation. Tests that need I/O should have a separate #[cfg(not(miri))] path.
Miri Limitations
| Cannot do | Workaround |
|---|---|
| Execute FFI / C code | ASAN, MSAN, Valgrind |
| Run I/O-heavy tests (default) | -Zmiri-disable-isolation or #[cfg(not(miri))] |
| Exhaustive interleaving exploration | loom |
| Find performance bugs | criterion, flamegraph |
| Run inline assembly | skip with #[cfg(not(miri))] |
| Test OS-specific behavior | real hardware + sanitizers |
Miri in CI
# GitHub Actions example
miri:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
with:
components: miri, rust-src
- name: Miri test (Stacked Borrows + strict provenance)
run: |
MIRIFLAGS="-Zmiri-strict-provenance -Zmiri-symbolic-alignment-check -Zmiri-backtrace=full" \
cargo +nightly miri test
- name: Miri test (Tree Borrows)
run: |
MIRIFLAGS="-Zmiri-tree-borrows -Zmiri-strict-provenance -Zmiri-symbolic-alignment-check -Zmiri-backtrace=full" \
cargo +nightly miri test
Miri-Incompatible Test Gating
#[test]
#[cfg_attr(miri, ignore)] // Miri cannot run this (FFI, I/O, inline asm)
fn test_requires_real_hardware() {
// ...
}
// Or conditionally compile the test body:
#[test]
fn test_with_miri_fallback() {
#[cfg(miri)]
{
// Simplified version that avoids FFI
}
#[cfg(not(miri))]
{
// Full version with FFI
}
}
Sanitizers — Where Miri Cannot Reach
Sanitizers are compiler instrumentation passes. They run your actual binary on real hardware with extra checks injected. Use them for FFI, I/O-heavy code, and integration tests.
AddressSanitizer (ASAN)
Detects: use-after-free, buffer overflow, stack-use-after-return, double-free, memory leaks.
RUSTFLAGS="-Zsanitizer=address" cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu
On macOS:
RUSTFLAGS="-Zsanitizer=address" cargo +nightly test -Zbuild-std --target aarch64-apple-darwin
ThreadSanitizer (TSAN)
Detects: data races on non-atomic accesses across threads.
RUSTFLAGS="-Zsanitizer=thread" cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu
When to use over Miri: Integration tests involving real threads + real I/O + FFI. Miri's data-race detector is superior for pure-Rust code.
MemorySanitizer (MSAN)
Detects: reads of uninitialized memory.
RUSTFLAGS="-Zsanitizer=memory -Zsanitizer-memory-track-origins" cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu
When to use over Miri: FFI code where C/C++ may return uninitialized memory into Rust.
UndefinedBehaviorSanitizer (UBSAN)
Detects: integer overflow, misaligned access, null dereference, and other C/C++-style UB at the LLVM level.
RUSTFLAGS="-Zsanitizer=undefined" cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu
Sanitizer Limitations
- Require nightly +
-Zbuild-std(rebuilds the standard library with instrumentation). - MSAN requires ALL dependencies (including C libs) to be instrumented — practically hard.
- Cannot catch aliasing violations (that is Miri's domain).
- Significant runtime overhead (2-15x slower).
- Linux has the best support; macOS works for ASAN; Windows support is minimal.
Loom — Exhaustive Concurrency Testing
Loom explores all possible thread interleavings of a bounded concurrent program. It is mandatory for lock-free and wait-free primitives.
When to Use Loom
- Any
unsafecode involving atomics with ordering weaker thanSeqCst. - Custom lock implementations.
- Lock-free queues, stacks, or other concurrent data structures.
- Any code where you chose
Relaxed,Acquire, orReleaseordering.
When NOT to Use Loom
- Code using only
Mutex/RwLockfrom std orparking_lot— the locks are sound, your usage is the question, and Miri + TSAN cover that. - Async code (loom does not model async runtimes — use
tokio::test+ Miri instead).
Setup
[dev-dependencies]
loom = "0.7"
Loom Test Pattern
#[cfg(loom)]
mod loom_tests {
use loom::sync::atomic::{AtomicUsize, Ordering};
use loom::sync::Arc;
use loom::thread;
#[test]
fn concurrent_increment_is_sound() {
loom::model(|| {
let counter = Arc::new(AtomicUsize::new(0));
let threads: Vec<_> = (0..2).map(|_| {
let c = counter.clone();
thread::spawn(move || {
c.fetch_add(1, Ordering::SeqCst);
})
}).collect();
for t in threads {
t.join().unwrap();
}
assert_eq!(counter.load(Ordering::SeqCst), 2);
});
}
}
Conditional Compilation for Loom
#[cfg(loom)]
use loom::sync::atomic::{AtomicUsize, Ordering};
#[cfg(not(loom))]
use std::sync::atomic::{AtomicUsize, Ordering};
Running Loom Tests
# Loom tests only (use cfg flag)
RUSTFLAGS="--cfg loom" cargo test --lib -- loom_tests
# With release optimizations (loom is slow)
RUSTFLAGS="--cfg loom" cargo test --lib --release -- loom_tests
Loom + Miri Interaction
Loom and Miri solve different problems:
- Miri checks a single execution for UB (aliasing, validity, provenance).
- Loom checks all interleavings for correctness (ordering, atomicity).
Run BOTH on lock-free code:
# Step 1: loom for interleaving correctness
RUSTFLAGS="--cfg loom" cargo test --lib --release -- loom_tests
# Step 2: Miri for UB in each path
cargo +nightly miri test -- concurrent_tests
Cargo-Fuzz — Property-Based UB Hunting
Fuzzing generates random inputs to maximize code coverage and find crashes, panics, and UB.
Setup
cargo install cargo-fuzz
cargo fuzz init
Fuzz Target
// fuzz/fuzz_targets/parse_input.rs
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
// Your parsing/deserialization/processing code here.
// If it panics or triggers UB, the fuzzer catches it.
let _ = my_crate::parse(data);
});
Running
# Run until interrupted
cargo +nightly fuzz run parse_input
# Run with ASAN (catches memory bugs in unsafe code)
cargo +nightly fuzz run parse_input -- -rss_limit_mb=4096
# Minimize a crashing input
cargo +nightly fuzz tmin parse_input artifacts/parse_input/crash-xxxxx
Fuzz + Miri Pipeline
When the fuzzer finds a crashing input:
- Minimize it with
cargo fuzz tmin. - Add it as a regression test.
- Run the regression test under Miri to classify whether it is a panic (safe) or UB (must fix).
# After adding the input as a test case:
cargo +nightly miri test -- test_fuzz_regression_001
Tool Selection Decision Tree
Start
│
├── Is it pure Rust (no FFI, no I/O)?
│ YES → Miri (full paranoia flags)
│ │ └── Also: loom (if atomics/lock-free)
│ │ └── Also: proptest (if parsing/serialization)
│ │ └── Also: cargo-fuzz (if untrusted input)
│ │
│ NO → Does it involve FFI?
│ YES → ASAN + MSAN on integration tests
│ │ └── Miri on the Rust-side handling
│ │ └── cbindgen in CI for layout verification
│ │
│ NO → Is it I/O-heavy?
│ YES → TSAN for thread safety
│ │ └── Miri with -Zmiri-disable-isolation where possible
│ │
│ NO → Miri (full paranoia flags)
│
└── Always: Miri is the default. Other tools supplement.
The One Rule
When in doubt, run Miri. If Miri cannot run it, write a version it can run, and test that under Miri. Then test the real version under sanitizers. Never ship
unsafecode that has not passed Miri.