RustArena

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:

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