R Rust by Evidence PDF

Chapter 15 — Unsafe Rust: A Proof Boundary

Goal: Use unsafe as a narrow place where humans establish invariants, then expose an ordinary safe API whose guarantees are checkable.

This function tries to skip bounds checking:

fn first(bytes: &[u8]) -> u8 {
    unsafe { *bytes.get_unchecked(0) }
}

fn main() {
    println!("{}", first(&[]));
}

Prediction

What is the correct diagnosis?

A. The program safely returns zero.
B. Rust inserts a bounds check despite get_unchecked.
C. Calling the safe function can cause undefined behavior, so the abstraction is unsound.
D. Unsafe code is allowed to panic but cannot cause undefined behavior.

RevealC

There may be no reliable error message. Undefined behavior means the language places no requirements on what follows: a crash, an invented value, apparent success, or miscompilation are all possible. Documentation for get_unchecked supplies the evidence: the index must be in bounds, even if the resulting reference is not used.

The dangerous part is not merely the unsafe block. The public function is safe to call with any &[u8], yet its implementation requires a nonempty slice. It therefore promises more than it proves.

The simplest fix uses the safe standard operation:

fn first(bytes: &[u8]) -> Option<u8> {
    bytes.first().copied()
}

That is the preferred solution: no unsafe proof is needed, and absence is represented in the type. If an API requires a value and empty input is a caller error, another safe option is:

fn first_required(bytes: &[u8]) -> u8 {
    bytes[0]
}

This panics on empty input, but panic is defined behavior. It is not memory unsafety.

When measurement proves bounds checking matters in a larger operation, a sound wrapper can validate once before unchecked access:

fn first_required_fast(bytes: &[u8]) -> u8 {
    assert!(!bytes.is_empty(), "expected at least one byte");
    // SAFETY: the assertion proves index 0 is in bounds.
    unsafe { *bytes.get_unchecked(0) }
}

The comment states the exact invariant, not “this seems safe.” This version is mostly pedagogical: optimization commonly removes redundant checks, so benchmark before keeping it.

Unsafe Rust permits five categories of operation that safe Rust rejects:

  1. Dereference a raw pointer.
  2. Call an unsafe function or method.
  3. Access or modify a mutable static.
  4. Implement an unsafe trait.
  5. Access fields of a union.

unsafe does not turn off the borrow checker for ordinary references, and it does not legalize undefined behavior. It says, “the unchecked obligations of these specific operations have been proved by the programmer.”

Working model: an unsafe block is a small proof obligation surrounded by compiler-checked code.

Precise model: safe Rust's guarantees are conditional on every unsafe implementation being sound. Safe code cannot by itself cause undefined behavior, but safe code may reach unsound unsafe code through a safe wrapper. Sound unsafe code must handle every input and call pattern its safe API permits, including adversarial ones.

Safe Rust prevents data races, dangling references, invalid reference aliasing, and many other sources of undefined behavior. It does not prevent memory leaks, deadlocks, integer overflow policy mistakes, logical races, panics, or incorrect business results. “Memory safe” is powerful, not synonymous with “bug free.”

Keep unsafe regions narrow, but optimize for a narrow reasoning boundary, not the lowest line count. Put validation in safe code, isolate pointer manipulation, document invariants, and test boundary cases. For foreign-function interfaces, check the other language's contract: nullability, alignment, ownership, lengths, thread rules, and whether callbacks may outlive their data.

A tradeoff alternative to writing unsafe internals is to change representation or API so safe operations express the invariant. For example, accept &[u8; 1] when exactly one byte is structurally required, or return Option. Stronger types often delete the proof burden entirely.

Mini challenge

Is this safe wrapper sound?

fn byte_at(bytes: &[u8], index: usize) -> Option<u8> {
    if index < bytes.len() {
        // SAFETY: checked immediately above; `bytes` is unchanged.
        Some(unsafe { *bytes.get_unchecked(index) })
    } else {
        None
    }
}

A. No, every public function containing unsafe must itself be unsafe.
B. No, raw access always invalidates the slice.
C. Yes, the bounds check establishes the documented precondition.
D. Yes, but only for ASCII bytes.

Answer: C. The wrapper accepts all slices and indices, checks the unchecked operation's requirement, and returns None otherwise. It can remain a safe function. The simpler bytes.get(index).copied() should still be preferred unless evidence justifies the unsafe implementation: identical meaning, less proof to maintain.


Part III's unifying question is not “which advanced feature should I use?” It is “where is the guarantee enforced?” Iterators encode delayed steps in types; smart pointers divide ownership from mutation; lifetimes expose reference relationships; futures expose a polling protocol; unsafe code marks obligations the compiler cannot verify. Choose the smallest mechanism that states the real relationship, and let evidence—not ceremony—justify anything more.