The Rust Engineering Handbook / Chapter 69
Building Safe Abstractions Around Unsafe Internals
Design a small unsafe kernel whose safe public shell preserves initialization, aliasing, iteration, panic, destruction, and auto-trait invariants.
Begin with a prohibition, not a pointer: what must safe callers be unable to express?
For a fixed-capacity queue, callers must not read an empty slot, observe a value twice, retain a reference while the queue mutates that slot, make the queue drop a moved value, or cause index arithmetic to leave its allocation. If the public API makes any of those states expressible without unsafe, the abstraction is unsound even when every unsafe line looks locally plausible.
That reverses a common implementation order. Do not first optimize a data structure and then wrap safe methods around it. Choose the public operations, state the representation invariant they require, select a representation capable of preserving it, and only then identify the operations the compiler cannot prove. The unsafe code is the final, small remainder of an argument made mostly in safe Rust.
This chapter develops that argument around Ring<T, const N: usize>, a dependency-free fixed-capacity FIFO in the companion unsafe-abstractions-lab. A ring buffer is useful here because logical order and physical layout diverge after wraparound. It requires unchecked knowledge of which slots hold live T values, but it does not require callers to participate in that knowledge.
The abstraction owns the proof
An unsafe block is not a source of authority. It is a claim that facts established elsewhere justify one unchecked operation. In a safe abstraction, those facts span four layers:
- The representation can describe every permitted state.
- Constructors establish the initial invariant.
- Every safe operation preserves the invariant on success, error, and panic paths.
- The public surface does not leak a capability that bypasses the preceding three layers.
The owner of the proof is therefore the abstraction, not the individual unsafe expression. A reviewer who checks only assume_init_read() will miss a bug in push_back, Drop, get, or a public field that makes the call’s premise false.
For the lab’s ring, the representation is:
pub struct Ring<T, const N: usize> {
slots: [MaybeUninit<T>; N],
head: usize,
len: usize,
}
All fields are private. That is a soundness boundary, not stylistic tidiness. If downstream safe code could set len = N while the slots remained uninitialized, get(0) could create &T from non-values. If it could alter head, destruction could visit the wrong slots.
The representation invariant is precise enough to drive every method:
len <= N.- When
len > 0,head < N. The canonical empty state useshead == 0. - For each logical index
iin0..len, physical index(head + i) % Ncontains exactly one initialized, validTowned by the ring. - Every other slot is treated as uninitialized storage, regardless of its residual bytes.
- The mapping from logical indices to physical indices is injective.
- Every live
Tis moved out or dropped exactly once.
The zero-capacity case deserves explicit treatment. Modulo zero would panic, and no physical index exists. The safe design makes Ring<T, 0> a valid, permanently full queue: pushes return the input value, pops return None, and iteration is empty. Only non-full queues call the physical-index helper, so reaching it proves N > 0.

The figure is a review map. “Safe shell” does not mean that safe methods are automatically correct; it means callers are entitled to rely on them without supplying extra proof. Every arrow into the kernel must carry established premises, and every return path must restore the ledger.
Constructors are proof roots
new starts with no live values:
pub const fn new() -> Self {
Self {
slots: [const { MaybeUninit::uninit() }; N],
head: 0,
len: 0,
}
}
This is valid for every T and N because MaybeUninit<T> may contain uninitialized storage. It does not pretend those slots contain T. head = 0 and len = 0 establish the canonical empty state without indexing or arithmetic.
Contrast that with a constructor that accepts head, len, and raw storage separately. Validation could check numeric bounds, but it could not safely inspect whether an arbitrary slot contains a live T; reading it to find out already assumes validity. A constructor for a state-rich unsafe representation should accept inputs whose safety-relevant properties can be established before typed access. When that is impossible, keep the constructor private or make it unsafe with caller obligations that are genuinely enforceable at the call site.
Serialization is not a privileged constructor. Deserializing raw fields into this type would expose the invariant to corrupt or hostile input and would attempt to serialize unspecified storage. Serialize the logical sequence, then rebuild through safe insertion. This costs a traversal but keeps representation changes and platform layout out of the format.
An operation ledger is better than intuition
The central audit artifact is a preservation table. It names the facts before and after each mutation, including ownership on failure.
| Operation | Required state | Transition | Preserved fact |
|---|---|---|---|
new |
none | empty storage, head = len = 0 |
no live slots |
push_back(v) |
len < N |
write at logical len, then increment len |
new slot becomes live only after write |
full push_back(v) |
len == N |
return Err(v) unchanged |
ownership remains with caller |
pop_front() |
len > 0 |
remove old head from live range, then read it | moved slot cannot be visited again |
empty pop_front() |
len == 0 |
return None unchanged |
no storage access |
get(i) |
i < len |
map logical index and borrow slot | shared borrow prevents mutation |
iter() |
shared borrow of ring | advance only a logical cursor | yielded references cannot outlive ring borrow |
drop |
invariant holds | repeatedly pop and destroy | each remaining live value exactly once |
Order is part of the proof. push_back writes a T before increasing len. If writing cannot panic—and MaybeUninit::write merely stores the supplied value—then len never claims an uninitialized slot. pop_front removes the slot from the represented live range before assume_init_read transfers ownership. Once transferred, Drop will not find it again.
The implementation follows that order:
pub fn push_back(&mut self, value: T) -> Result<(), T> {
if self.is_full() {
return Err(value);
}
let index = self.physical(self.len);
self.slots[index].write(value);
self.len += 1;
Ok(())
}
pub fn pop_front(&mut self) -> Option<T> {
if self.is_empty() {
return None;
}
let index = self.head;
self.len -= 1;
self.head = if self.len == 0 { 0 } else { (self.head + 1) % N };
Some(unsafe { self.slots[index].assume_init_read() })
}
The unsafe read’s safety comment must connect to the transition, not repeat the method name: the old head was live; state was changed first so no remaining operation owns that slot; assume_init_read transfers the one T to the caller.
An alternative is to read first and then update indices. It may work while assume_init_read cannot panic, but it leaves the representation temporarily claiming ownership of a moved value. That makes maintenance fragile: adding instrumentation, a guard, or a callback between the steps could introduce unwinding across a false invariant. Prefer mutation sequences whose intermediate states either satisfy the invariant or are isolated behind a guard with a defined cleanup action.
Borrowing turns temporal claims into types
The ring’s get does not expose a raw pointer. It returns Option<&T> tied to &self:
pub fn get(&self, logical: usize) -> Option<&T> {
if logical >= self.len {
return None;
}
let index = self.physical(logical);
Some(unsafe { self.slots[index].assume_init_ref() })
}
The bounds check proves the slot is in the logical live set. The representation invariant proves the slot contains a valid T. The borrow of self supplies the lifetime and prevents a safe caller from invoking push_back, pop_front, or any future &mut self method while the reference is live. These are separate premises.
Returning &'a T from a raw-pointer-taking function and letting a caller choose 'a would manufacture the lifetime rather than derive it. Returning &T from &mut self is sound, but unnecessarily exclusive if observation is all the method needs. Returning a pointer can be useful for FFI or specialized traversal, but it is an escape hatch: its documentation must define invalidation, provenance, allowed arithmetic, aliasing, and whether the pointed-to value may be moved.
A mutable accessor requires more care but not necessarily more unsafe code. get_mut(&mut self, i) -> Option<&mut T> can use assume_init_mut after the same live-range check. The exclusive receiver prevents two safe calls from creating overlapping mutable references. An API that accepts two indices at once must reject equality before making both references and must prove the mapped physical indices are distinct. “The logical indices differ” is only enough because injectivity is part of the ring invariant.
Iterator safety is an abstraction proof in miniature
Iterators combine state, borrowing, and repeated reference creation. The lab’s iterator stores a shared ring reference and a logical cursor:
pub struct Iter<'a, T, const N: usize> {
ring: &'a Ring<T, N>,
logical: usize,
}
impl<'a, T, const N: usize> Iterator for Iter<'a, T, N> {
type Item = &'a T;
fn next(&mut self) -> Option<Self::Item> {
let item = self.ring.get(self.logical)?;
self.logical += 1;
Some(item)
}
}
This deliberately reuses the safe get method. The iterator itself contains no unsafe block. Its &Ring borrow freezes structural mutation for the iterator’s lifetime, so wraparound cannot change beneath it. The logical cursor stays in 0..=len; get handles the terminal value. ExactSizeIterator is justified because size_hint computes len - logical and neither value can change incompatibly during the shared borrow.
A pointer-based iterator may remove repeated modulo arithmetic, but it expands the proof. A wrapped ring has two physical segments. The iterator must keep pointers inside their respective allocation ranges, create references only for live elements, switch segments exactly once, implement size_hint without underflow, and remain correct for zero-sized T. That optimization should follow measurement. If adopted, keep the simple iterator as a behavioral oracle and document why its exact semantics match the optimized one.
Mutable iteration is harder because Iterator::next returns an item whose lifetime is not limited to the &mut self call. Several yielded &mut T may coexist. Soundness requires each physical slot be yielded at most once and the iterator retain exclusive access to the whole ring for 'a. Slice iterators already prove these properties; decomposing the initialized live region into one or two disjoint mutable slices is often safer than hand-rolling pointer stepping.
Panic paths and destruction share one invariant
Safe abstraction design must account for panics even when the current core operations appear infallible. Three boundaries matter:
- User code can panic while consuming a returned value or inside higher-order methods.
T::dropcan panic, despite this being poor library behavior.- Future maintenance can insert allocations, formatting, tracing, or callbacks into a transition.
The ring’s primitive push performs no user callback between writing and publishing len. Primitive pop restores representation state before returning ownership. This gives them a strong local panic story. A method such as retain, however, would invoke user code while rearranging values. It needs a guard that records which values remain owned, which were moved, and what cleanup is owed if the predicate panics. Reusing len as both public state and an in-progress cleanup counter without documenting intermediate states invites double drops.
Drop is intentionally boring:
fn drop(&mut self) {
while let Some(value) = self.pop_front() {
drop(value);
}
}
It delegates ownership transitions to the already-audited safe primitive. If one element’s destructor panics, Rust will stop this loop during unwinding and the remaining elements may not be dropped. That is a leak/resource-release concern, not permission to access invalid memory: the ring state already excludes the popped element. Trying to catch destructor panics inside a generic container introduces double-panic and process-abort complexity and is rarely an appropriate default. Document resource consequences and keep memory safety independent of destructor cooperation.
Auto traits are part of the public safety surface
Whether a container is Send or Sync determines where safe callers may move or share it. The best outcome is for structural auto-trait derivation to express the correct rule. Ring<T, N> stores MaybeUninit<T>, integers, and no hidden thread-affine state, so it follows T: a ring of a sendable value can be sent, and a ring of a sync value can be shared.
The lab includes a positive compile-time assertion for Ring<u64, 8>. A negative property is equally important: Ring<Rc<_>, N> must not become Send. That rejected program belongs in an audit even if a unit test cannot encode non-implementation on stable Rust.
Raw pointers often make structural auto-trait behavior more conservative. Do not answer by reflexively adding unsafe impl<T: Send> Send. First ask what the pointer refers to, who owns it, whether moving the wrapper changes address-sensitive assumptions, whether another thread can mutate the allocation, and whether callbacks or deallocation are thread-affine. Chapter 71 develops the composed proof. Here the design rule is simpler: every manual unsafe trait implementation is another unsafe kernel, even if it contains no unsafe expression.
Marker fields can express ownership and variance relationships to the compiler, but PhantomData<T> is not a comment. Its exact form can affect drop checking, variance, and auto traits. Choose it from the relationship the wrapper really has, then test the consequences. Do not use a marker solely to force a desired Send result.
Tests challenge the argument; they do not create it
The fixture tests behavior that is easy to get wrong:
- wraparound preserves FIFO order;
- a full push returns ownership of the rejected value;
- capacity zero never indexes or takes a remainder by zero;
- iteration follows logical rather than physical order;
- popped and remaining values are each dropped once;
- ordinary auto-trait expectations compile.
Drop probes turn ownership claims into counts. Model-based tests can compare long operation sequences with VecDeque. Property tests can vary capacity and command sequences. Miri can explore undefined behavior in exercised paths; sanitizers can find some memory errors; fuzzing can stress transition combinations. None can enumerate all T, all interleavings with future methods, or every compiler-permitted execution. Passing Miri is evidence against bugs in tested executions, not a proof that an unsafe abstraction is sound.
The proof remains deductive: the constructor establishes the invariant; each method preserves it; unsafe operations receive all documented premises; encapsulation prevents unreviewed mutation. Tests search for mistakes in that proof and protect it against regression.
Escape hatches must be honest
Some users need integration points. Prefer capability-shaped escape hatches over raw representation exposure:
as_slices()can return the two initialized shared segments.make_contiguous()can normalize order before returning one slice, at an explicit movement cost.try_pushcan return the input on capacity failure.- consuming conversion can transfer logical values into another owner.
Exposing slots, head, and len, even behind a safe “advanced” feature, destroys the abstraction. A raw-parts API can be legitimate, but it should be consuming where possible and pair decomposition with an unsafe reconstruction function. Its safety documentation must state allocation ownership, initialization map, index relations, capacity, aliasing, and exactly who drops each value. “Caller must pass valid parts” is not an actionable contract.
Avoid Deref to a convenient representation when the logical value is not actually that representation. A wrapped ring is not one contiguous slice. Returning a temporary normalized slice could allocate or move unexpectedly; claiming a stable contiguous view could be false. Name the operation and its cost.
Document the kernel as an audit record
Every unsafe site should record:
- the exact unchecked operation;
- the library or language preconditions it relies on;
- which constructor or prior check established each premise;
- which fields encode the relevant state;
- why aliasing and lifetime rules are satisfied;
- how success, error, panic, and destruction preserve ownership;
- which tests or tools challenge the claim;
- what remains an assumption rather than runtime-checked fact.
Comments should be local enough to review with the operation but derived from a representation-level safety section. When an invariant changes, search should find every unsafe site and preservation table row affected by it. Treat an increase in unsafe sites, a new public constructor, a manual auto-trait implementation, or a new callback during mutation as a safety-significant design change requiring re-audit.
Choose the safest representation that meets the constraint
The ring is not the only valid implementation of a bounded FIFO. A senior design review should compare it with alternatives before accepting its unsafe burden.
VecDeque<T> is the default when heap allocation and a runtime capacity are acceptable. It already implements wraparound, iteration, growth, and destruction behind a mature standard-library API. Reserving capacity can bound reallocations during a phase, while an application-level admission check can enforce a maximum length. Reimplementing it merely to own the code usually produces more risk than control.
An array of Option<T> can express vacancy in safe Rust. Each slot always contains a valid enum, so insertion and removal use replace or take. This can be an excellent implementation when capacities are small and the extra tag/layout cost is measured to be acceptable. Do not assume Option<T> adds one byte: niche optimization may make it the same size for some T, but that is type-specific and should not be an API premise. The safe representation also simplifies panic and drop reasoning enough that a modest space cost may win overall.
A pair of initialized slices is useful when data arrives in batches. Rather than expose an element-at-a-time ring, keep separate read and write regions and advance committed lengths. This can reduce modulo arithmetic and enable vectored I/O, but it makes reservation and commit protocols part of the public design. A reservation must not let safe code read uncommitted slots, and cancellation must return the reservation without publishing partial initialization.
An index-based slab or generational arena is preferable when consumers retain handles rather than FIFO order. It replaces reference invalidation with explicit handle validity, often at the cost of generation counters and indirect access. An intrusive list may remove separate node allocation, but it adds pinning, link consistency, removal, and ownership obligations. Choose it only when stable node address and embedding are measured requirements.
The comparison is not “safe is slow, unsafe is fast.” It is a total engineering-cost comparison:
| Representation | Main advantage | Main cost | Proof surface |
|---|---|---|---|
VecDeque<T> |
mature, general, safe | allocator and possible growth | application capacity policy |
[Option<T>; N] ring |
safe vacancy tracking | possible tag/space and branch cost | index/state correctness |
[MaybeUninit<T>; N] ring |
exact storage, no allocation | unsafe initialization/drop core | validity, ownership, indexing |
| reservation/commit buffer | batch and I/O efficiency | cancellation protocol | initialized versus committed ranges |
| intrusive structure | stable embedded nodes | pinning and unlink complexity | address, ownership, aliasing, drop |
Benchmark representative workloads, including full-queue rejection, wraparound, destructor-heavy values, and cache behavior. Include compile time, review time, tool coverage, and the cost of future feature requests. A five-percent microbenchmark win can be a loss if it creates a bespoke unsafe container that only one maintainer can audit.
Safe APIs can still weaken the design
Soundness is the minimum bar. A method can be memory-safe yet undermine the abstraction’s operational contract.
An infallible push_back that silently overwrites the oldest value may be sound, but it changes data-loss policy. If overwrite is required, name it and return the displaced value so the caller can observe loss. A push_back that allocates a larger backing store breaks the fixed-memory promise. A clone-based peek-and-remove avoids move reasoning but silently adds a T: Clone constraint and may duplicate expensive or security-sensitive resources.
A method that invokes a user callback while holding &mut self can allow reentrancy indirectly through global state or callbacks into surrounding components, even though Rust prevents a second direct borrow of the ring. Specify whether callbacks occur before or after commitment, what state observers see, and what happens on panic. Often the safest shape is to remove a value, restore the invariant, and then invoke user code with owned data.
Leaking physical layout through an as_raw_parts method creates compatibility pressure. Users may persist head, depend on the two-segment split, or reconstruct references after mutation. Once such an escape hatch is public, changing the internal representation can become a breaking change even if the logical FIFO API is unchanged. Prefer semantic views and consuming conversions.
Convenience traits also deserve review. Index cannot report bounds failure except by panic and may encourage callers to assume random access is the primary operation. Deref can expose methods whose contracts the wrapper did not intend to adopt. Clone duplicates every value and may turn a uniquely owned queue into surprising work. Implement traits because their laws fit, not because they are easy to derive.
Operational consequences of an unsafe container
A fixed-capacity ring makes memory use predictable: storage is N * size_of::<T>() plus metadata and alignment. For a large N local variable, that can overflow a thread’s stack before any method runs; boxing the ring changes allocation and address behavior. Zero-sized types make byte capacity zero while logical capacity remains N, so throughput and index logic must not infer element count from byte size.
Latency depends on the payload. Moving a T out is a bytewise move in the language model and may be optimized, but dropping a rejected or consumed value can execute arbitrary destructor work. Full-queue policy therefore belongs in capacity planning. Returning Err(value) lets the producer decide whether to retry, shed, persist, or drop; silently dropping inside the queue hides latency and resource-release effects.
Observability should remain outside the invariant-critical interval when possible. Increment counters after state is consistent. Avoid formatting T under mutation merely for tracing because Debug is user code and can allocate or panic. Expose logical metrics—length, capacity, full rejections, high-water mark—rather than raw slots. If a metrics callback is needed, copy primitive measurements and invoke it after releasing the mutable borrow.
Security review should consider stale bytes. Moving or dropping a value ends its Rust lifetime but does not guarantee the storage is physically zeroed. A later process dump or vulnerability may reveal residual secrets. Zeroization is a separate requirement with compiler-optimization and destructor considerations; it should be implemented through a purpose-built, reviewed policy rather than assumed from MaybeUninit. Conversely, eagerly zeroing every vacant slot may create unacceptable latency and still not cover copies made elsewhere.
Maintenance policy is part of containment. Assign an owner for the invariant document, require safety-significant review for representation changes, and keep the fixture runnable on the declared MSRV. Record why each unsafe site exists so a later standard-library API can replace it. Track compiler and language changes, but do not churn correct unsafe code merely to follow a new explanatory model. Re-audit when documented preconditions change or when new methods alter aliasing, panic, trait, or destruction behavior.
A deliberate red-team pass
Before accepting the abstraction, try to falsify the ring’s proof:
- Set
N = 0and follow every path that contains% Nor an index. - Fill, pop, wrap, fill again, and verify the physical mapping remains injective.
- Keep a shared element reference and attempt a structural mutation; safe code must be rejected.
- Make
Tnon-Sendand attempt to move the ring to another thread. - Make
T::dropobservable, then pop some values and drop the ring. - Add a panic point to a proposed bulk operation at every transition.
- Consider a zero-sized
T, a highly alignedT, and a type with a destructor. - Ask whether a public trait implementation exposes representation-dependent behavior.
- Search for every place that modifies
headorlen; each is part of the unsafe proof even if written in safe Rust.
This pass often finds the important flaw outside an unsafe block. That is exactly the purpose of treating soundness as an abstraction property. It also gives future maintainers concrete questions instead of inherited confidence.
Audit exercise: defend a ring buffer
Implement a fixed-capacity ring buffer or audit the lab version. Submit six artifacts:
- A representation invariant covering zero capacity, initialized slots, logical-to-physical mapping, and exactly-once destruction.
- An operation table for construction, push, pop, shared and mutable access, iteration, and drop, including failure and panic paths.
- One safety record for every unsafe expression, mapping each precondition to an established fact.
- Rejected safe-call examples showing that references cannot coexist with structural mutation and non-
Sendelements do not cross threads. - Tests for wraparound, full/empty boundaries, zero capacity, drop counts, iterator exhaustion, and comparison against a safe reference model.
- A residual-risk note covering destructor panic, untested executions, platform-independent assumptions, and any proposed escape hatch.
Then propose an optimization—pointer iteration, bulk insertion, or contiguous normalization. State the measured cost it targets and list the new proof obligations before implementing it. If the obligation list is larger than the expected benefit, retain the simpler design.
Review questions
- Can safe code create a state outside the representation invariant?
- Does every constructor establish every invariant, including edge capacities?
- Are state transitions ordered so unwinding cannot expose false ownership?
- Does each reference derive its lifetime from an owner borrow?
- Can an iterator yield the same mutable location twice?
- Does
Dropvisit exactly the remaining live set? - Do auto traits follow real ownership and synchronization facts?
- Are raw-parts APIs narrow, unsafe where necessary, and explicit about transfer?
- Does each unsafe comment cite established premises rather than confidence?
- Do tests challenge transitions without being presented as proof?
The safe-shell pattern is not “hide unsafe in a module.” It is an architectural proof: private state makes the invariant enforceable, safe operations preserve it, borrows encode temporal restrictions, and a small kernel performs only the unchecked acts left over. Representation reinterpretation can still undermine that proof if equal sizes are mistaken for equal values. That is the next boundary.
Sources and version notes
Core examples target Rust 2024 and were reviewed against Rust 1.97.1, with Rust 1.85.0 as the fixture MSRV. The primary references are the standard-library documentation for MaybeUninit, Iterator, and marker traits Send and Sync; the Rust Reference on behavior considered undefined; and the Rustonomicon chapters on safe abstractions and Send and Sync. Aliasing models continue to evolve; the chapter relies on documented reference and library preconditions rather than treating one executable model as the final language specification.
Continue reading
Full table of contents