Systems Programming · Unsafe Code

What Makes a Safe Wrapper Around Unsafe Code Sound

A safe function that can cause undefined behaviour from any call site destroys the guarantee for the entire crate graph, and the obligations that prevent it are structural rather than local.

The central claim of a memory-safe language is conditional: safe code, composed from safe abstractions, cannot cause undefined behaviour. The condition is that every safe abstraction is sound, meaning no sequence of safe calls can violate the invariants the unsafe implementation depends on. Soundness is not a property of a function body. It is a property of the API surface, and a single unsound safe function voids the guarantee for every crate that can reach it.

This is why an unsafe fn is a lesser sin than an unsound safe one. An unsafe fn documents its preconditions and requires the caller to acknowledge them; the failure is local to a caller that ignored the documentation. A safe function that can produce undefined behaviour offers no such signal, and callers have no way to know they must be careful.

The obligations a wrapper takes on

A safe wrapper converts a precondition into a postcondition or into a state. It does this by establishing, at every entry point, the facts the unsafe code needs. The facts fall into a small number of families, and each family has a characteristic failure.

Validity. If a type has invariants (a non-null pointer that is always dereferenceable, a length that never exceeds capacity, a boolean that is only ever 0 or 1), the wrapper must maintain them across every mutation. Violating them is undefined behaviour even if no code reads the invalid field, because the compiler may assume the invariant when it lays out or optimises the type.

Initialisation. A wrapper that hands out a reference to storage must have written every byte the reference covers, including any padding the type requires to be initialised for a typed copy. Partial initialisation is the failure mode, and it is easy to introduce when a fill loop can return early.

Aliasing. A wrapper that hands out two references into the same buffer, or a reference and a raw pointer that outlives it, must ensure the aliasing model is respected. The borrow checker helps only when the references carry lifetimes it can see.

Thread safety. If a wrapper can be used from multiple threads, the synchronisation must be real. Deriving Sync for a type that contains a raw pointer is the classic mistake, because it removes the only thing preventing two threads from reaching the interior without a lock.

Panic safety. If a safe operation can panic partway through a mutation, the structure must remain valid at the point of the panic, because the panic will unwind through user code that may observe the structure. A vector that has had its length set before its elements are written leaves a window where a panic exposes uninitialised memory.

Panic safety as a state-machine problem

Unwinding makes every multi-step mutation observable at intermediate points. The discipline that handles it is to order the steps so that each intermediate state is valid, and to make the final state change a single step that cannot panic.

The canonical shape is the vector’s push. The capacity is checked first; if it must grow, the reallocation happens before the length is touched, because reallocation can panic and the structure must be unchanged if it does. The element is then written through a raw pointer to the uninitialised slot, which cannot panic, and only then is the length incremented. If the element’s constructor panics, the length was never incremented, so the structure is exactly as it was.

fn push(&mut self, value: T) {
    if self.len == self.cap {
        self.grow();          // may panic; nothing has changed yet if it does
    }
    unsafe {
        // Slot is within capacity and uninitialised; write is infallible.
        self.ptr.as_ptr().add(self.len).write(value);
    }
    self.len += 1;            // the only state change that becomes visible
}

The order is the whole argument. Writing before growing, or incrementing before writing, leaves an intermediate state in which a panic exposes a length that counts an uninitialised element. The second ordering is worse, because the unwinding code that drops the vector will run the destructor for a value that was never constructed.

A related hazard is the guard pattern. Where an operation must restore an invariant on both the success and the panic path, a local guard whose destructor performs the restore keeps the invariant true during unwinding. The guard’s destructor runs during unwind, so it must not itself panic; a panic during unwinding aborts the process.

The opposite choice is to declare the operation UnwindSafe and forbid panics. That is not available in general, because the wrapper cannot control whether a user-supplied destructor or comparison panics. The state-machine ordering is the only approach that does not depend on the user.

Encapsulation and the leak of representation

Soundness depends on the unsafe implementation’s invariants being unobservable and unmodifiable from outside. A public field defeats this directly: a caller can set the length of a vector to a value larger than the initialised region and then read uninitialised memory through safe indexing. Private fields with public accessors that validate are the mechanism.

The subtler leak is a public method that returns a reference to an internal field whose invariants are not maintained for that field alone. Returning &mut T to a slot in a structure whose invariant is “all slots before the length are initialised” gives the caller a reference they may use after the length changes, at which point the reference may point at a slot that has been logically freed.

Trait implementations are a third leak surface. Implementing a public trait for a wrapper type can expose behaviour the wrapper’s invariants did not anticipate, particularly when the trait’s methods take &self and the wrapper’s interior mutation relies on &mut. Clone on a type with a cached pointer, Default on a type whose empty state violates the invariant, and PartialEq on a type whose comparison reads padding are all routes to unsoundness that the type system does not flag.

The general rule is that the module boundary is the soundness boundary. Everything inside the module may rely on the invariant; everything outside may not, and must go through code that re-establishes it.

Send and Sync are the thread-safety interface

Send and Sync are auto traits: a type is Send if it can be moved to another thread, Sync if a shared reference to it can be used from another thread. Both are derived structurally, so a type containing a raw pointer is neither, since a raw pointer is neither. The derivation is conservative and correct by default.

The unsound wrapper appears when someone writes a manual unsafe impl Sync because the derivation is inconvenient. The impl asserts that concurrent shared access is safe, and the assertion is checked by nothing. If the type contains a Cell, two threads incrementing through &self lose updates and, worse, may tear the value if it spans a machine word. If it contains a raw pointer into a buffer that can reallocate, one thread’s reallocation frees memory another thread is reading.

The sound version of the same type uses an atomic or a lock for the interior state, and the Sync derivation then follows from the field types with no manual impl at all. When a manual impl is genuinely necessary (usually for a type that wraps a C library with its own internal locking), the comment must state which synchronisation makes the shared access safe, and that synchronisation must be visible in the code rather than asserted in prose.

Auditing a wrapper

A review of a safe wrapper reduces to four checks, in order of how often they catch something.

First, enumerate every public entry point, including trait implementations and Deref targets, and ask what invariant each one assumes on entry. A method that assumes the length is valid but is reachable from a state where it is not is a bug.

Second, for each entry point, identify the operations that can panic and confirm that no invariant is broken when they do. The check is local: the panic points are the allocation calls, the user-supplied closures, the indexing operations, and the destructors.

Third, confirm that the type’s auto-trait derivation matches its actual thread-safety. A wrapper around a C structure with no locking is not Sync, and the compiler will say so unless someone overrode it.

Fourth, confirm that no public API hands out a view whose lifetime outlives the invariant that made it valid. This is the check that catches the raw pointer stored in a field and returned by an accessor.

The failure mode that these checks miss is the invariant that is true in the current implementation but not stated anywhere, so a later refactor breaks it without changing the API. Writing the invariant down in the type’s documentation, in the form “this field is always non-null and points at len initialised elements”, is what makes the invariant survive. The same reasoning applied to a single unsafe block rather than a whole API is covered in What the Unsafe Keyword Actually Disables; the allocation-boundary version of the same problem appears in Ownership Across the FFI Boundary.

References

  1. The Rustonomicon: Working with Unsafe — Rust project
  2. The Rust Reference: Unsafe traits and Send/Sync — Rust project
  3. std::panic::UnwindSafe documentation — Rust project
  4. The Rustonomicon: Unwinding — Rust project