Skip to content
advanced Phase 14 · Unsafe Rust and FFI

Encapsulating Unsafe with Invariants

Hide unsafe details behind safe APIs with documented preconditions.

50m
0 problems
Topic Progress 0%

Validate in the Safe Layer, Trust in the Unsafe Core

Soundness has a one-sentence definition worth memorizing: safe code can never cause undefined behavior. The moment a safe function of yours can be called with arguments that make the unsafe code underneath do something invalid, the whole crate is unsound — and no amount of unsafe at the call site protects you, because callers were told they did not need any.

The shape that keeps the promise is a two-layer split. The safe layer checks every precondition it can check and returns a Result; the unsafe core assumes the checks already happened and contains no branches, no error paths, and no doubt.

pub struct Buf { data: *mut u8, len: usize }

impl Buf {
    /// Copy `bytes` into a buffer the C side will read.
    pub fn from_bytes(bytes: &[u8]) -> Option<Buf> {
        if bytes.is_empty() { return None; }          // reject before any unsafe runs
        // allocation cannot fail here; C requires 16-byte alignment
        if bytes.as_ptr() as usize % 16 != 0 { return None; }

        let data = unsafe { ffi::alloc(bytes.len()) }; // core: one obligation, one line
        if data.is_null() { return None; }

        // SAFETY: data is 16-aligned, len bytes are writable, src is len initialized bytes
        // and the two ranges cannot overlap (fresh allocation vs. caller's slice).
        unsafe { std::ptr::copy_nonoverlapping(bytes.as_ptr(), data, bytes.len()); }
        Some(Buf { data, len: bytes.len() })
    }

    /// # Safety
    /// `f` must tolerate `self.len()` bytes and must not retain the pointer.
    pub unsafe fn read_with(&self, f: unsafe fn(*const u8, usize)) {
        // SAFETY: constructor guarantees data is non-null, aligned, and live for &'a self;
        // the caller contract above covers retention.
        f(self.data, self.len)
    }
}

Every precondition has a home, and the table is the review artifact:

Precondition Who can check it Where it gets checked
non-null, aligned, right size you safe constructor, before the first unsafe
bytes initialized you copy/validate in the safe layer
caller tolerates the buffer the caller # Safety doc on the unsafe fn
no other owner exists you, structurally private field: no Clone, no second handle
allocation outlives use you, structurally Drop + private ownership of the pointer

The two "structurally" rows matter most: the strongest invariant is one a caller cannot violate because the type system gives them no way to express it. If Buf is not Clone, holds the only pointer, and frees in Drop, then uniqueness is proven by construction rather than re-checked at every call.

Note the division of labor in read_with: conditions you can verify are verified in the safe path, while the one thing only an outsider can know — that the callback tolerates the buffer — is pushed onto an unsafe fn with a # Safety section. Blurring that line is how sound crates fail: a length check presented as if it also proved initialization, or a safe function that forwards a caller-supplied length straight into slice::from_raw_parts.

Interviewers test this by handing you an API and asking "how would you make it safe?" Answer in two moves: enumerate the preconditions, then name the layer that checks each one. The preconditions themselves are the same six the dereference checklist in Raw Pointers uses — non-null, aligned, initialized, in bounds, live, correctly aliased — which is why that topic is a hard prerequisite here. What you write down next, and how a reviewer verifies it, is the next chapter.

SAFETY Comments a Reviewer Can Check, Not Conclusions They Must Trust

An unsafe block without a written argument is a code review of a memory model. The comment convention exists so the reviewer's job is ordinary: each unsafe block carries a // SAFETY: line stating the invariant and where it comes from, such that a second person can verify it by reading the surrounding code — not by reconstructing your mental state from three months ago.

// BAD — a conclusion the reviewer must take on faith
unsafe { *p = v };

// BAD — restates the operation, adds nothing
// SAFETY: dereferencing raw pointer

// GOOD — each claim checkable at a named location
// SAFETY: p comes from Vec::as_mut_ptr in `new` (line 41); the Vec is borrowed
// exclusively by &mut self, so no other reference to this slot exists; `i` was
// bounds-checked by the caller-facing get_mut above; T = u32 is always initialized.
unsafe { *p = v };

The difference is falsifiability. The good comment names where each of the six conditions is established, and if a reviewer cannot find that location, the code is wrong or the comment is — either way you have found a bug before it shipped. This is the practical meaning of "every unsafe block needs a safety argument": the argument must be local enough to check and specific enough to refute.

Unsafe functions need the same treatment from the other side, because their callers are the ones making promises. Rust API guidelines prescribe a # Safety section on every unsafe fn, one bullet per obligation:

/// Reads a frame from the mapped file.
///
/// # Safety
/// `map` must be a live mapping of at least `offset + len` bytes — file truncation
/// after mapping invalidates this and makes the read undefined behavior.
pub unsafe fn read_frame(map: *const u8, offset: usize, len: usize) -> Vec<u8> { /* ... */ }

A missing # Safety on an unsafe fn is not a style complaint: with no stated contract, there is no such thing as a correct caller, and the function is unusable-sound. Inside the body, #![deny(unsafe_op_in_unsafe_fn)] keeps the obligations from dissolving into an implicitly-unsafe function, and clippy::undocumented_unsafes fails the build when a new unsafe { } appears without its comment — conventions that survive deadlines only when a linter enforces them.

An incomplete comment is a reviewable defect, not a style preference: if an invariant is actually established somewhere else, the reviewer has to go find it — and if they cannot, you have a bug or a lie. Treat a SAFETY comment the way you treat an assertion: it must be possible to imagine the code change that makes it false, and the reviewer should be able to picture that change in a single step.

Two habits separate documentation that ages well from documentation that rots. Put the comment above the block, adjacent to the code it certifies, because a comment three screens away is a comment nobody updates. And treat a stale comment as a defect: the invariant that was true before a refactor and false after it is exactly how aliasing bugs enter a codebase, and it is why reviewers of unsafe code are trained to ask "where is this established?" rather than "is this right?".

That question — who promises what, and how do you enforce the promise — generalizes beyond functions. Some promises are global properties of a type, and Rust has a construct for those: the unsafe trait, covered next, before we close the phase on Common Unsoundness Patterns.

Unsafe Traits: Promising a Global Property the Compiler Cannot See

An unsafe fn puts obligations on the caller. An unsafe trait puts obligations on the implementor — a global promise about every value of the type, spanning threads, allocations, and program phases that no single method body can prove.

Send and Sync are the canonical pair, and their definitions are promises about what other code may assume. Interviewers probe them with two questions: which side of the trait is bound by the unsafe marker, and what failure mode follows if the promise is broken. The table is the answer to the second:

Trait The implementor promises Broken promise produces
Send exclusive access transfers cleanly to another thread data race when a value is moved and shared
Sync &T may be handed to another thread safely data race on any shared read/write
GlobalAlloc (unsafe) blocks obey layout, alignment, and zeroing rules allocator mismatch, corrupt heap
a custom unsafe trait Versioned every read() returns a complete record torn reads downstream

The reason raw pointers are !Send and !Sync by default is the compiler refusing to guess: it cannot know whether the address points at thread-local memory or at a buffer another thread is writing. If you do know, you assert it:

struct Handle(*mut ffi::Session);   // !Send, !Sync by default

unsafe impl Send for Handle {}      // you promise: ownership moves, no aliasing survives
unsafe impl Sync for Handle {}      // you promise: the C session is internally synchronized

// The lie:
struct Cheat(*const u8);
unsafe impl Send for Cheat {}       // compiles; two threads now race with no warning

Nothing checks the second impl. The compiler takes your word, and the reward for lying is a program that is undefined under a condition no test reliably reproduces — the exact failure class of the last topic in this phase. This is why unsafe impl deserves the same treatment as unsafe { }:

struct Session(*mut ffi::handle);   // !Send, !Sync by default

// SAFETY: the C handle is guarded by an internal mutex; ownership moves with the
// struct and no alias to the handle survives the move between threads.
unsafe impl Send for Session {}

Adjacent evidence, not a conclusion — the same falsifiable standard as any other unsafe block, plus a # Safety-style justification if the trait defines one.

A related distinction interviewers like: a trait method can be unsafe fn too, which flips the obligations — the implementor is then trusted and the caller must uphold the contract (as with Read-style raw I/O traits), whereas the unsafe trait marker constrains who may implement at all. Knowing which side of the contract you are on tells you where the validation belongs, which is the through-line of this topic.

All three chapters — validate first, document each block, assert global properties explicitly — exist to serve one goal: keep the unsafe surface small enough that a competent reviewer can certify it in an afternoon. One module, a few dozen commented blocks, a crate root that denies unsafe everywhere else. When the surface is that small, the remaining failures are subtle rather than sloppy, and they have names: aliasing violations, lifetime lies, layout assumptions. Recognizing them on sight is Common Unsoundness Patterns, where this phase ends. The FFI declarations those patterns corrupt were built in FFI with C Libraries.

Quiz

1. A crate publishes `pub unsafe fn` with no # Safety section in its docs. What is the actual consequence?

Question 1 options

2. Which precondition should be checked by your safe constructor rather than passed on as a caller obligation?

Question 2 options

3. A type wraps `*mut ffi::Session` from a C library. Under what condition is `unsafe impl Send for Handle` correct?

Question 3 options

4. Which SAFETY comment meets the bar for review?

Question 4 options

Flashcards

Question

What does soundness mean, and what single fact makes a crate unsound?

Answer

Soundness means safe code can never cause undefined behavior. A crate becomes unsound the moment one safe function can be called in a way that drives the unsafe core into an invalid state — because callers were explicitly told no unsafe was required.

Question

Where does each kind of precondition get checked?

Answer

Checkable locally (non-null, alignment, length vs capacity, initialized bytes) → validated in the safe layer, returning Result. Depend on the caller (callback tolerance, retention) → # Safety section on the unsafe fn. Enforced by the type's shape (uniqueness, lifetime) → private fields, no Clone, Drop frees — the strongest kind because callers cannot violate what they cannot express.

Question

What makes a SAFETY comment acceptable in review?

Answer

It must be falsifiable: it names each of the six validity conditions (non-null, aligned, initialized, in bounds, live, correctly aliased) and the specific line or structure that establishes it, adjacent to the block. A conclusion ('this is fine'), a restatement ('dereferencing a pointer'), or a stale claim from before a refactor all fail review.

Question

unsafe trait versus unsafe fn — whose obligations change?

Answer

An unsafe trait binds the implementor: it promises a global property (Send = values may move across threads, Sync = &T may be shared across threads, GlobalAlloc = valid layout/alignment). An unsafe fn binds the caller: it must uphold the documented preconditions. A trait method declared unsafe fn flips the usual direction — implementor is trusted, caller promises.

Question

How do you keep a crate's unsafe surface auditable?

Answer

Concentrate it: every unsafe block in one module, #![deny(unsafe_code)] at the crate root with #[allow(unsafe_code)] on that module, SAFETY comments and # Safety docs enforced by clippy::undocumented_unsafes, and a safe public API so downstream code never writes unsafe. The goal is a surface a reviewer can certify in an afternoon.

Revision Notes

Key Takeaways

  • 1. Soundness = safe code cannot cause UB; a safe function that can drive an unsound state is a crate-level defect, not a local one
  • 2. Split every precondition by who can check it: locally verifiable conditions in the safe layer, outside-world conditions in the # Safety contract, structural facts enforced by the type's shape
  • 3. SAFETY comments must be falsifiable — name the invariant and the line that establishes it; a comment a reviewer cannot refute is a conclusion, not an argument
  • 4. unsafe trait binds the implementor to a global property while unsafe fn binds the caller; raw pointers are !Send/!Sync until you assert the synchronization story
  • 5. The end goal is a small, concentrated unsafe surface: one module, deny-by-default at the root, safe facade on top — auditable in a single sitting

Interview Tips

  • • "How would you make this API safe?" — two moves, in order: enumerate the preconditions (non-null, aligned, initialized, in bounds, live, correctly aliased), then name the layer that checks each one — safe constructor, # Safety doc, or the type's structure
  • • "What does soundness mean?" — safe code cannot cause undefined behavior; give the example of a safe fn forwarding an unchecked length into slice::from_raw_parts and say the whole crate is then unsound
  • • "How do you review unsafe code?" — read every SAFETY comment as a claim and ask where it is established; if the answer is not a line of code, keep reading — then check that the unsafe surface is one module you can actually finish reading
  • • "Send/Sync — who promises what?" — the implementor promises: Send = exclusive ownership transfers cleanly, Sync = &T may be shared; they are independent, raw pointers are neither by default, and an unsafe impl is your name on the guarantee

Cheat Sheet

Encapsulating Unsafe with Invariants — Cheat Sheet

Soundness definition: safe code can never cause undefined behavior. A safe fn that can reach an invalid unsafe state makes the whole crate unsound.

Two-layer rule: safe layer validates everything checkable → Result; unsafe core assumes the checks, has no error paths, and carries a // SAFETY: comment per block.

Precondition homes:

Precondition Home
non-null / aligned / sized / initialized safe constructor, before the first unsafe
caller-specific obligations # Safety section on the unsafe fn
uniqueness / no second owner structural: private field, no Clone, Drop frees
aliasing at the write site // SAFETY: line naming where it is established

Comment grades:

// SAFETY: this is fine                     — conclusion, reject in review
// SAFETY: dereferencing a raw pointer      — restates the code, reject
// SAFETY: p from Vec::as_mut_ptr (line 41);
// &mut self excludes other refs; i checked
// by the safe get_mut above; u32 always init — checkable, accept

Enforcement: clippy::undocumented_unsafes (no comment → lint), #![deny(unsafe_op_in_unsafe_fn)], #![deny(unsafe_code)] at the crate root with #[allow(unsafe_code)] on the one module that earns it.

Unsafe trait vs unsafe fn: unsafe trait = implementor promises a global property (Send/Sync: values/&T may cross threads; raw pointers are !Send/!Sync because the compiler cannot know what they alias). unsafe fn = caller promises preconditions. unsafe impl gets an adjacent justification comment like any other unsafe block.

Review question that matters: not "is this right?" but "where is that invariant established?" — if the answer is not a line of code, keep reading.

Continue Learning

Before You Start

Related Reading