Skip to content
← All Rust articles

Rust explainer

Safety invariants and privacy: unsafe contaminates the module

About 10 min to read. Allow extra time to work through examples.

The soundness of an unsafe block can depend on safe code elsewhere. Private fields limit which code can break an unsafe abstraction's invariants, so the review covers the code that can reach those private details, usually one module.

This article uses Rust. You can make it your practice mode to browse related content across the site.

When you write unsafe code, you have to decide how much of the program to review to be confident it is sound. This article argues that reviewing the unsafe blocks alone is not enough, and explains why the usual review unit is the module that holds the unsafe code.

Two terms first. Code is sound if no use of it from safe code can cause undefined behavior (UB). An invariant is a fact that some code promises to keep true at all times, such as "the length is never larger than the capacity". Unsafe code often relies on invariants that ordinary safe code establishes.

Safety is non-local

This function comes from the Rustonomicon. It reads an element of a byte slice without the usual bounds check:

fn index(idx: usize, arr: &[u8]) -> Option<u8> {
    if idx < arr.len() {
        // SAFETY: `idx` was just checked against the length.
        unsafe { Some(*arr.get_unchecked(idx)) }
    } else {
        None
    }
}

get_unchecked requires its caller to pass an index smaller than the slice length. The if provides that guarantee, so the function is sound: no argument can make it read outside the slice.

Now add one character to the safe condition, turning < into <=:

    if idx <= arr.len() {

The unsafe block is unchanged, and the program still compiles. But a call such as index(3, &[1, 2, 3]) now passes index 3 to get_unchecked, which is undefined behavior. A debug build may catch this with the standard library's optional precondition checks and abort, but nothing guarantees that, and an optimized build is not required to detect it.

The unsafe block did not change; the justification for it did, and that justification was in safe code. Reviewing only the lines inside unsafe { ... } therefore does not show that the program is sound.

Check your understanding If safe code can invalidate an unsafe block's reasoning, in what sense is safe Rust still "safe"? Show answer

Safe code cannot cause undefined behavior by itself. It can contribute to UB only through unsafe code: either the unsafe code is wrong, or it relies on an invariant that some safe code it trusted failed to keep.

This is still a strong guarantee. If a program has no unsafe of its own, then any UB it has must come from unsafe code it depends on (the standard library or other crates) or from a bug in the compiler. If a crate has one unsafe block, a reviewer has to look at that block plus every piece of code that the block's justification depends on.

What the guarantee does not give you is the ability to review each function on its own. That is why many standard library types keep their fields private. Vec<T> does not expose its pointer, capacity or length as public fields, and String does not give safe code direct access to its byte buffer, because safe code that could write those values could break the invariants their unsafe code relies on.

A Vec-like example

Consider a simplified Vec, called MyVec, with three private fields: ptr (the start of a heap allocation), cap (how many elements that allocation can hold) and len (how many elements are currently stored). Its push method uses unsafe code to write the new element at position len, and it relies on three facts: ptr points to an allocation with room for cap elements, the first len slots are initialized, and len <= cap. When len == cap, push grows the allocation first.

Now add this method to the same module:

impl<T> MyVec<T> {
    fn make_room(&mut self) {
        self.cap += 1; // no unsafe operation here
    }
}

make_room contains no unsafe keyword, no pointer arithmetic and no dereference. It still breaks the invariant: cap now claims one more slot than the allocation has. Once len reaches the real allocation size, push sees len < cap, skips growing, and writes past the end of the allocation.

The bug is in make_room, but the memory corruption happens in push. A reviewer who reads only unsafe blocks will look at push and find nothing wrong with it.

Privacy limits what you must review

Rust's privacy rules keep this manageable. A private field or method can be used only inside the module that defines it and that module's descendants (its child modules, their children, and so on). Code anywhere else must go through the public items.

So if cap is a private field and make_room is not pub, only code in that module (and its child modules) can change cap directly. Code elsewhere in the crate, and code in other crates, can use MyVec only through its public methods. If those methods preserve the invariant, nothing outside can break it.

Unsafe code has to trust some safe code. Privacy lets it trust only the code that can see the private details.

This is why the module is the usual unit for a soundness review. If you widen visibility with pub(crate) or pub(super), the code that can break the invariant grows to match, and so does the review.

Check your understanding Adding pub to a struct field is normally an API-compatibility question. When is it a soundness question? Show answer

When unsafe code relies on that field's value.

If MyVec is a public type and you make cap public, any code that can name the type can write v.cap = 9999 in safe code, and the next push can write past the allocation. The invariant that only the module had to keep is now a requirement on every user of the type, and nothing tells them about it. That makes the public API unsound.

Some other changes widen access in less obvious ways:

  • Derived traits add public operations. #[derive(Clone)] on a reference-counted handle copies the pointer without increasing the count (see Implementing a reference-counted pointer). #[derive(Default)] compiles only when every field type implements Default, and when it does, it builds a value from each field's default, which may not satisfy your invariant.
  • Constructors that set fields directly. A struct literal, or a tuple struct's constructor, can be used outside the module only if all the fields are visible there. Making the fields public lets callers choose any values.
  • A pub fn that returns &mut to an internal field lets the caller write any value to that field, which is the same as making it public.

When you add pub to anything in a module that contains unsafe code, ask what the new access would let a caller do to the invariants. Often the answer is nothing. When it is not, you have found a soundness bug during review, before any code relies on it.

Practical consequences

  1. Review the module, not only the unsafe blocks. Read every function that can touch the state the unsafe code relies on, including functions with no unsafe in them.
  2. Keep modules with unsafe code small. The review covers everything that can see the private state, so a smaller module means less to check. A common design is a small private module that holds the unsafe abstraction and exposes a narrow public API.
  3. Treat new access as a possible soundness change. Making a field public, adding a public constructor or deriving a trait can let callers break an invariant.
  4. Write the invariant down. A comment such as "INVARIANT: len <= cap, and slots 0..len are initialized" gives the reviewer a checklist: every function in the module must keep it true.
  5. A single-file program is one module unless you add more. Everything you write at the top level of the file is in the crate root, so every function in the file can see every private field. To get the protection described here, put the abstraction in its own module with mod inner { ... } and make only the safe API pub.

The exercises in this track are single files, so the module boundary exists only if you create it. In a larger crate, use the module system for the same reason: it controls which code can break your invariants.

Clippy lints that help

Clippy enables these three by default:

  • clippy::not_unsafe_ptr_arg_deref (correctness group, an error by default) reports a public function that is not marked unsafe but may dereference a raw-pointer argument. Such a function promises to accept any pointer, then trusts it. Either validate what you can, or make the function unsafe and document what callers must guarantee.
  • clippy::mut_from_ref (correctness group, an error by default) reports a function that takes only shared references but returns a mutable one. Calling such a function twice can give safe code two live &mut to the same value.
  • clippy::missing_safety_doc (style group, a warning by default, so an error under -D warnings) requires a # Safety section in the documentation of a public unsafe fn. That section states the conditions callers must meet, which is the part of your contract that crosses the module boundary.

A fourth lint is off by default. clippy::undocumented_unsafe_blocks is in the restriction group, and some exercises in this track enable it. It requires a // SAFETY: comment on each unsafe block, which is where you record the invariant the block relies on and which code keeps it true.

Designing the abstraction

Treating the module as the review unit leads to a few design rules:

  • What should be private? Every field and helper that the invariant depends on.
  • Where should checks go? In the public functions, so the private code can rely on them.
  • How big should the module be? No bigger than the code that needs to see the private state.
  • What goes in a safety comment? The specific invariant the unsafe block relies on, and which code establishes it.

The data-structure exercises in this track, such as Build a vector with allocation, push and pop and Build a safe mutable-slice split, follow this shape: a narrow public API that performs checks, a private interior that relies on them, and a written invariant that connects the two.

step 20 of 24

Unsafe and Soundness

Up next Keep a vector valid when its drain is forgotten