Programming Languages · Memory and Ownership
Unsafe Abstraction Boundaries and What Soundness Actually Costs
The contract an unsafe block transfers from compiler to programmer, the invariants a safe wrapper must maintain, and how those invariants fail in practice.
Every memory-safe language has a place where the checking stops. The interesting design question is where that place is and what the language says about it. A garbage-collected language puts it inside the runtime, out of reach. C puts it everywhere, so the boundary does not exist. Rust puts it at a keyword, and the keyword carries a specific obligation: the code inside may do things the type system forbids, and in exchange the programmer must guarantee that the resulting behaviour, seen from outside the block, is indistinguishable from something the type system would have allowed.
That obligation is what soundness means in this context, and it is a property of the abstraction rather than of the block.
What the block actually disables
Inside an unsafe block the compiler still checks types, arities, moves, and trait bounds. What it stops checking is a specific list: that a raw pointer is non-null and aligned and points at a live object of the right type; that a dereference does not alias a live reference in a forbidden way; that a call to a function marked unsafe satisfies that function’s documented preconditions; and that a value written through a pointer is not later read as a different type in violation of validity rules.
The keyword is a permission, not an assertion of correctness. A block that contains only unsafe { } and nothing else compiles, and proves nothing. The compiler trusts the block; it does not verify it. That asymmetry is deliberate: verifying arbitrary pointer manipulation is the halting-problem-shaped problem that the safe language exists to avoid, so the escape is total and the responsibility is total.
Because the permission is local and the responsibility is not, the boundary has to be placed carefully. A unsafe block inside a function that also exposes safe operations is a boundary the caller cannot see, and the caller’s safety depends on invariants the block must maintain. The convention that follows is that unsafe should be confined to the smallest region that needs it, and that any function whose correctness depends on invariants beyond its own arguments should itself be marked unsafe so that callers inherit the obligation.
The invariant is the interface
A safe wrapper over unsafe internals is sound when every sequence of safe operations on it preserves the invariant that the unsafe code relies on. The invariant is usually a relation between fields that the type system cannot express: a length field that must equal the number of initialized elements, a pointer that must point into a buffer owned by the same struct, a capacity that must be a power of two, a state machine whose transitions must follow a fixed order.
Vec<T> is the canonical example. Its invariant is that the first len elements of the buffer are initialized, that len <= cap, and that the buffer was allocated by the global allocator with the layout for T. The type system cannot state any of that, so the implementation maintains it, and every safe method preserves it:
pub struct Vec2<T> {
ptr: *mut T,
len: usize,
cap: usize,
}
impl<T> Vec2<T> {
pub fn push(&mut self, value: T) {
if self.len == self.cap {
self.grow(); // reallocates, preserving initialization
}
// SAFETY: len < cap after grow, so ptr.add(len) is in bounds and
// the slot is uninitialized, which is the only state a write may target.
unsafe { std::ptr::write(self.ptr.add(self.len), value) };
self.len += 1;
}
}
The SAFETY comment is not decoration. It is the place where the obligation is discharged in writing, and its absence is a signal that the obligation was never made explicit. The block above is also a complete example of the rule that the invariant must be re-established after a failure: if grow panics before the buffer is replaced, len must not have been advanced, or the next method will read an element that was never written. The comment must justify the operation from invariants that hold at that point, not from the intent of the surrounding code.
The interesting failure mode is an invariant that holds for the type’s own methods but not for code that can observe the fields. A struct with a public field cannot maintain an invariant over that field, because any caller can break it. This is why safe wrappers keep their fields private and expose accessors, and why pub(crate) visibility is a real design decision rather than a formality.
How soundness fails in practice
Three failure patterns account for most unsound safe APIs.
The first is an invariant that a safe method can violate without the unsafe code noticing. A split_at_mut that returns two mutable slices from one buffer is sound only if the index is within bounds; the implementation checks the index, and the check is the entire proof. An off-by-one in the check makes two overlapping &mut exist, which is undefined behaviour even if no test observes it. The classic case in this family is a length field updated before the elements are initialized, so a panic between the two leaves the Vec claiming initialized elements that are not.
The second is an invariant that depends on a caller-supplied type. A safe generic function that assumes T has no destructor, or that T cannot panic, or that T’s Clone does not re-enter, is unsound for some T. This is where trait bounds become safety-critical: T: Copy is a promise the compiler enforces, while “T’s destructor does nothing” is a promise it cannot, and a wrapper relying on the latter is unsound for any caller who implements Drop. The usual repair is to let an auto trait carry the condition: Send and Sync are derived from a type’s fields, so a wrapper that is only safe to share under conditions the compiler cannot see holds a field that is itself not Sync (a raw pointer, for instance), and the marker disappears from the wrapper without any declaration.
The third is an invariant that depends on a lifetime the type system did not record. A wrapper that stores a raw pointer derived from a reference and hands it back later must ensure the referent is still alive; if the struct does not carry a lifetime parameter tying it to the referent, nothing stops the referent from being dropped first. Adding the lifetime parameter is the fix, and the fact that it changes the type’s signature is the cost.
The RustBelt work formalizes why these are the failure modes: it gives a semantic model in which each type is interpreted as a set of values satisfying a protocol, and a library is sound when its safe interface preserves the protocol even though its internals manipulate the representation directly. The model’s value is that it makes “the invariant” a mathematical object rather than a comment.
One invariant deserves separate treatment because it is violated by code that looks correct: uniqueness of a mutable reference. A &mut T promises that no other reference to the same object exists for its lifetime, and that promise is what lets the optimizer cache a field in a register across a loop. A wrapper that produces two &mut to overlapping regions from a shared buffer, or that reborrows a &mut while an earlier reborrow is still live, breaks the promise even if the writes happen to be ordered so that no race occurs. The observable failure is not a wrong value at the point of the double borrow; it is a wrong value somewhere later, after the optimizer has used the promise it was given. This is why the invariant must be stated in terms of what the safe interface permits rather than in terms of what a particular test run produced.
Costs and the alternatives
The cost of the boundary is that correctness becomes a proof obligation on a human. That is not a small cost, and it is why the amount of unsafe code in a dependency is a meaningful signal even though it is a poor one: a hundred lines of well-reviewed unsafe code can be sounder than ten lines of careless code.
The alternative designs each move the boundary rather than removing it. A language with a tracing collector moves it inside the runtime, which is unsafe code written by someone else and audited once. A language that requires machine-checked proofs of its low-level libraries can in principle discharge the obligation once for each abstraction and check the result mechanically, at the cost of proof burden falling on whoever writes the abstraction. C moves the boundary nowhere and provides sanitizers as a partial substitute: address and undefined-behaviour sanitizers instrument the generated code to detect some violations at runtime, which converts an undefined behaviour into a diagnostic at the cost of a large slowdown and incomplete coverage.
The practical position is that unsafe code should be rare, small, and concentrated in abstractions with narrow interfaces, so that the proof obligation is small enough to be discharged by review. A wrapper whose invariant is a single relation between two fields is auditable; a wrapper whose invariant spans the whole program’s allocation discipline is not.
The boundary is also where the other mechanisms in this cluster become negotiable. Borrow checking stops at the block, interior mutability is built on the one type the compiler exempts, and destructors must be assumed not to panic for any unsafe code that writes before it initializes. Each of those is a place where a sound wrapper is the only thing standing between the type system’s guarantees and undefined behaviour.
References
- The Rustonomicon: Meet Safe and Unsafe — The Rust Project
- The Rust Reference: Block expressions — The Rust Project
- Jung, Jourdan, Krebbers, Dreyer, RustBelt: Securing the Foundations of the Rust Programming Language — POPL 2018