unsafe and safety contracts
Safe Rust can’t cause undefined behavior, but some things
are beyond what the compiler can check, such as whether a pointer handed over by
a C library still points to live memory. For those, Rust has the unsafe
keyword. It marks the places where you, instead of the compiler, are responsible
for keeping the program sound.
What unsafe unlocks
unsafe doesn’t switch off the borrow checker or any other check. It lets you
perform a small set of extra operations the compiler can’t verify:
- dereferencing a raw pointer
- calling an
unsafefunction, which includes most functions implemented in C - reading or writing a
static mutor anextern static - reading a field of a
union - implementing an
unsafetrait such asSendorSync
Everything else inside an unsafe block is checked exactly as it would be
outside of one.
Undefined behavior
The rules these operations can break all lead to undefined behavior (UB). If a program has UB, Rust no longer defines what it does: it might crash, give wrong results, or seem to work fine. The compiler assumes UB never happens and optimizes based on that, so a program that seems fine today can break with the next compiler version.
The Rust Reference keeps the full list of what counts as UB. The entries you’ll meet most often in FFI code are:
- dereferencing a null, dangling, or misaligned pointer
- producing an invalid value, such as a
boolholding3or an enum with a discriminant that doesn’t match any variant - breaking the aliasing rules, for example mutating memory while a shared reference to it exists
- reading uninitialized memory
- data races
Safety contracts
An unsafe operation always comes with conditions that make it sound. We call
those conditions the operation’s safety contract. Rust splits the contract
into two halves.
An unsafe fn declaration says “calling me is only sound if you, the caller,
uphold these conditions”. The conditions go into a # Safety section of the doc
comment:
/// Reads the `u32` that `ptr` points to.
///
/// # Safety
///
/// `ptr` must be non-null, aligned for `u32`, and point to an initialized `u32`
/// that stays alive and remains unmodified for the duration of the call.
pub unsafe fn read_u32(ptr: *const u32) -> u32 {
// SAFETY: the caller guarantees that `ptr` points to a valid `u32`.
unsafe { *ptr }
}
An unsafe block says “I checked the conditions here”. The justification goes
into a // SAFETY: comment right above it:
/// # Safety
/// `ptr` must be non-null, aligned, and point to an initialized `u32`.
unsafe fn read_u32(ptr: *const u32) -> u32 { unsafe { *ptr } }
let x = 41_u32;
// SAFETY: `&x` is non-null and aligned, and `x` is an initialized `u32` that
// outlives the call.
let y = unsafe { read_u32(&x) };
A // SAFETY: comment explains why the code upholds each condition, or, inside
an unsafe fn, points to the condition its caller already promised to uphold.
Safe abstractions and soundness
It’s common for unsafe code to end up wrapped in a safe function. The safe
function checks whatever the contract requires, then performs the unsafe
operation. That wrapper is only correct if no input, however strange, can cause
UB. A safe function that can cause UB is called unsound, and it’s a bug even
if no caller triggers it today.
Here’s an unsound one:
/// Returns the first byte of `bytes`.
pub fn first(bytes: &[u8]) -> u8 {
// SAFETY: ??? An empty slice has no index 0.
unsafe { *bytes.get_unchecked(0) }
}
Calling first(&[]) reads out of bounds, and nothing in the signature warns the
caller about it. The sound version checks the condition before relying on it:
/// Returns the first byte of `bytes`, or `None` if `bytes` is empty.
pub fn first(bytes: &[u8]) -> Option<u8> {
if bytes.is_empty() {
return None;
}
// SAFETY: `bytes` is not empty, so index 0 is in bounds.
Some(unsafe { *bytes.get_unchecked(0) })
}
In real code you would write bytes.first().copied(), which does the same check
without any unsafe. The example uses get_unchecked only to show the pattern:
check, then justify, then perform the operation. Writing the // SAFETY:
comment is often how you find out that a check is missing. If you can’t finish
the sentence, the code isn’t sound yet.
Head to the exercise
The exercise is one function: return the last byte of a slice, or None if the
slice is empty, reading the byte with get_unchecked. Work out the condition
that makes the index valid before you rely on it, and say why in a // SAFETY:
comment.
Exercise
The exercise for this section is located in 01_foundations/01_unsafe