Step 5 of 6
Undefined behavior and Miri
Undefined behavior (UB) is what happens when unsafe code breaks one of Rust's rules. The name is literal: the language makes no promise about what the program does next. The optimizer assumes UB never happens and rearranges code based on that assumption, so a program with UB can:
- work perfectly in a debug build and fail in release,
- work today and break after a compiler update,
- corrupt unrelated data, which then crashes somewhere far away from the bug,
- turn into a security hole (most memory-safety CVEs in C and C++ are UB).
That's why "I ran it and it worked" proves nothing about unsafe code.
The rules unsafe code must keep
The most common ways to cause UB:
| UB | Example |
|---|---|
| dangling, null or misaligned pointer dereference | using a pointer to a dropped local; reading a u32 from an odd address |
| out-of-bounds access | get_unchecked(len), ptr.add(i) past the end |
| breaking the aliasing rules | two live &mut to the same value, or writing through a pointer while a & to it is in use |
| invalid values | a bool that's not 0 or 1, a char that isn't a Unicode scalar value, an enum with no matching variant, a null &T |
| reading uninitialized memory | reading a MaybeUninit before writing it |
| data races | two threads writing a static mut |
| wrong FFI signature | declaring a C function with the wrong types |
Debug builds catch a few
Debug builds (like this site's) check some of std's unsafe preconditions at runtime and abort if they're violated:
fn main() {
let v = vec![1, 2, 3];
let x = unsafe { *v.get_unchecked(10) };
println!("{x}");
}
thread 'main' (4242) panicked at src/main.rs:3:25:
unsafe precondition(s) violated: slice::get_unchecked requires that the index is within the slice
This indicates a bug in the program. This Undefined Behavior check is optional, and cannot be relied on for safety.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
thread caused non-unwinding panic. aborting.
Similar checks catch misaligned pointer reads and invalid char and enum values. Read the warning in the message again ("optional, and cannot be relied on"): release builds remove these checks, and many kinds of UB are never checked at all. They're a safety net, not a guarantee.
Miri: the UB detector
Miri is an interpreter for Rust's compiled intermediate code that checks every memory access against Rust's rules. It catches out-of-bounds and use-after-free accesses, invalid values, aliasing violations, data races and memory leaks, and tells you exactly where:
$ rustup +nightly component add miri
$ cargo +nightly miri test
error: Undefined Behavior: constructing invalid value: encountered 0x02, but expected a boolean
--> src/lib.rs:8:20
|
8 | let b: bool = unsafe { std::mem::transmute::<u8, bool>(raw) };
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Undefined Behavior occurred here
Run it on the tests of any module that contains unsafe code, ideally in CI. It's slow (often 100 times slower than a normal run) and can't run most calls into C, so keep those tests small. The Rust Playground has it under Tools > Miri. For FFI-heavy code, sanitizers like AddressSanitizer fill the gap.
Fixing UB
The best fix is usually to remove the unsafe code: almost every unchecked operation has a checked, safe twin.
fn main() {
println!("{:?}", char::from_u32(0x41));
println!("{:?}", char::from_u32(0xD800));
let v = [1, 2, 3];
println!("{:?} {:?}", v.get(1), v.get(10));
let raw: Vec<u8> = vec![0xff, 0xfe];
println!("{}", String::from_utf8(raw).is_err());
}
Some('A')
None
Some(2) None
true
When you must keep the unsafe call (measured performance, FFI), add the check that makes the SAFETY comment true.
Your task
char_from_code claims to turn any code point into a char, but 0xD800 (a UTF-16 surrogate) and anything above 0x10FFFF aren't valid chars, so the unchecked call is UB for them. Rewrite it with no unsafe, returning None for invalid codes.
Challenge 2
This time the unsafe call stays (imagine profiling showed the bounds check matters). get_or_zero promises to return 0 for an out-of-range index, but its SAFETY comment is a lie: nothing checks the index. Add the check that makes the comment true, and keep using get_unchecked.
Challenge 3
to_level turns a byte into a Level by reinterpreting it with transmute. Its range check has an off-by-one bug, so 3 creates an enum value with no matching variant: UB (a debug build aborts). Rewrite it with a match and no unsafe: 0, 1 and 2 map to Low, Mid and High; anything else is None.
Previous: Global state without static mut Next: When (not) to use unsafe at work