Rust and Miri: Beyond the Compiler for Memory Safety
In Rust, the compiler is your first line of defense. Ownership, borrowing, and lifetimes prevent most memory errors at compile time. But some errors can still slip through the cracks, especially when using unsafe or raw pointers. This is where Miri comes in.
The Rust Compiler Is Not Always Enough
Rust is renowned for its memory safety, and rightly so. The type system, ownership rules, and borrow checker form a remarkably effective safety net. However, as soon as you use the unsafe keyword, you enter territory where the compiler can no longer verify everything.
unsafe blocks allow operations that the compiler cannot prove to be safe: dereferencing raw pointers, calling C functions through FFI, direct memory manipulation. These operations are sometimes essential in systems code or low-level libraries, but they open the door to Undefined Behavior (UB).
The problem: rustc will compile your code without complaint, even if undefined behavior lurks within.
Miri: An Interpreter for Tracking Undefined Behavior
Miri is a Rust interpreter that executes an internal compiler representation called MIR (Mid-level Intermediate Representation). This is the same representation that the compiler uses for the borrow checker and optimizations.
Instead of running your program natively, Miri simulates it instruction by instruction, checking at each step that memory rules are respected. It can detect UB that the compiler cannot verify statically:
- Use-after-free: accessing memory that has already been freed
- Out-of-bounds access: reading or writing beyond a buffer
- Aliasing violations: mutable and immutable references coexisting on the same data
- Dangling pointers
- Reads of uninitialized memory
A Concrete Example
Consider this Rust code:
let s = String::from("hello"); let r: *const String = &s; unsafe { drop(s); // Rust does not see that r becomes dangling println!("{}", &*r); // UB: use-after-free }
Here, we create a String, take a raw pointer to it, then destroy it with drop. The pointer r becomes a dangling pointer. When we try to dereference it, we access freed memory.
rustccompiles this code without any error or warning.cargo miri runimmediately detects the use-after-free and reports the problem.
When to Use Miri
Miri is particularly useful in the following situations:
- Code using
unsafe: whenever you write anunsafeblock, Miri should be part of your testing pipeline. - Low-level libraries: crates that manipulate memory directly (allocators, lock-free data structures, FFI wrappers) benefit enormously from Miri.
- Pre-production validation: even if your code works in debug and release mode, Miri can reveal latent UB that only manifests under certain conditions.
To use it, simply install the component via rustup and run:
cargo miri run # or for tests cargo miri test
Limitations of Miri
Miri is not a universal solution. It cannot execute real system calls or FFI code calling into C. Its execution is also significantly slower than native execution, since it simulates each instruction. It is therefore complementary to standard tests, not a replacement.
Conclusion
Even in a memory-safe language like Rust, dynamic analysis with Miri is essential whenever unsafe is used. The compiler guarantees the safety of safe code, but Miri extends that guarantee to areas where the compiler cannot reach. For any serious project or library that touches unsafe code, Miri represents an indispensable verification layer that catches bugs before they reach production.