Trust is earned, not given

A different perspective

2024-10-08 · Projects

Rust, part 11: unsafe, FFI, and the boundary to C

Part 11from the Rust series · 15 parts in all

unsafe is not "turn off the checks" and it is not a mode you enter. It is a marker on five specific capabilities, and everything else in the language keeps being checked. Part 11 is what it unlocks, and the discipline that keeps it from spreading.

Five things, and only five

  1. Dereference a raw pointer (*const T / *mut T).
  2. Call an unsafe fn, including a foreign function.
  3. Access or modify a static mut.
  4. Implement an unsafe trait (Send, Sync).
  5. Access a field of a union.

Inside that block the compiler stops proving those five things and starts trusting you. Note what it does not stop doing: borrows are still checked, types still match, and a &mut you made is still exclusive. That gap is where the bugs live — an unsafe block that creates two aliasing mutable references is undefined behaviour the compiler will not catch, and that is the entire reason the discipline below matters.

Calling C, and being called

use std::ffi::{c_char, CStr};

extern "C" {
    fn strlen(s: *const c_char) -> usize;
}

/// Length of a NUL-terminated C string.
///
/// # Safety
/// `ptr` must point to a NUL-terminated string that stays valid and unmodified
/// for the duration of this call. The caller must own it.
pub unsafe fn c_len(ptr: *const c_char) -> usize {
    // SAFETY: the caller has promised the pointer is a valid C string. Reading
    // it here does not only exercise that promise; it is the only unsafe act.
    unsafe { strlen(ptr) }
}

// The safe wrapper the rest of the program should actually use:
pub fn len_of(s: &CStr) -> usize {
    // SAFETY: CStr guarantees NUL termination and a valid pointer.
    unsafe { c_len(s.as_ptr()) }
}

Three conventions worth adopting verbatim. Every public unsafe fn carries a # Safety doc section saying what the caller must guarantee. Every unsafe block carries a // SAFETY: comment saying why the guarantee holds, and clippy's undocumented_unsafe_blocks lint enforces it. And the unsafe function is private: the safe wrapper is the API, so the invariant has one place to hold.

Making the boundary small

Next: the crate that made that boundary mostly unnecessary for the most common task in the ecosystem — moving data between representations.