| name | safe-over-unsafe |
| description | Use when designing safe public APIs that wrap unsafe Rust code, adding unsafe blocks to existing types, reviewing unsafe code for soundness, or creating new types backed by raw pointers, MaybeUninit, or FFI |
Safe API Design Over Unsafe Rust
Module privacy is the soundness boundary — not the unsafe keyword. The
unsafe {} block is a syntax marker; the real safety mechanism is private
fields behind a safe public API, within a module (or crate) where all code
is trusted.
When to Use
- Designing a new type backed by
MaybeUninit, raw pointers, or FFI
- Adding
unsafe blocks to existing code for performance
- Reviewing unsafe code for soundness
- Implementing
Send/Sync/Drop for types with unsafe internals
Design Checklist
Follow every step. Skipping any step is how soundness bugs happen.
1. Establish the Safety Boundary
2. Document Invariants at the Type Level
Two kinds of invariants exist — conflating them causes bugs:
- Validity invariant: Must hold at ALL times. Violation is instant UB
even if the value is never "used." (Example:
bool must be 0 or 1.)
- Safety invariant: Must hold at API boundaries. May be temporarily
violated internally. (Example:
Vec's len <= cap.)
Document both in a module-level //! or struct-level /// doc comment:
3. Enforce at Compile Time First
Every invariant should be evaluated: can this be a const assertion,
a NonZero type, or a typestate transition instead of a runtime check?
const { assert!(N > 0 && N.is_power_of_two()) };
Compile-time enforcement eliminates entire bug classes. Runtime
debug_assert! is the fallback — use assert! in public API
preconditions that guard unsafe code in the implementation.
4. Write SAFETY Comments as Proof Sketches
Every // SAFETY: comment must argue invariant preservation, not just
state a fact. Use this structure:
Bad: // SAFETY: index is in bounds
Good: // SAFETY: physical = idx & MASK, so physical < CAPACITY (mask clears // high bits). The element is initialized because idx < self.len, and our // safety invariant guarantees slots [head..head+len) are initialized. // This satisfies assume_init_ref's requirement that the value is valid T.
For unsafe fn, use # Safety doc sections documenting caller obligations.
5. Get Variance, PhantomData, and Send/Sync Right
Incorrect Send/Sync is the #1 real-world unsafe soundness bug (tokio
RUSTSEC-2025-0023, lock_api RUSTSEC-2020-0070, windows RUSTSEC-2022-0008).
| Situation | Action |
|---|
Type uses [MaybeUninit<T>; N] | Auto-derive is correct. No PhantomData needed. |
| Type uses raw pointers | Add PhantomData<T> for owned data (covariant, correct Send/Sync). |
| Need manual Send/Sync | Red flag. Bound generics: unsafe impl<T: Send + Sync> Sync for MyType<T> {} |
| Unsure about variance | If the type owns T, it should be covariant (like Vec). Use PhantomData<T>. |
Always prefer auto-derivation. Add compile-time assertions:
const _: () = {
fn assert_send<T: Send>() { fn requires_send<S: Send>() {} requires_send::<MyType<T>>(); }
fn assert_sync<T: Sync>() { fn requires_sync<S: Sync>() {} requires_sync::<MyType<T>>(); }
};
6. Handle Panic Safety
If an operation modifies state in multiple steps and any step can panic
(e.g., T::drop(), T::clone()), use the decrement-before-drop or
guard pattern:
while self.len > 0 {
self.len -= 1;
unsafe { self.buf[self.len].assume_init_drop(); }
}
Test with a deliberately-panicking Drop impl via catch_unwind.
7. Defend Against Generic Parameters
Unsafe code cannot trust safe trait implementations. T::drop() can
panic. T::clone() can panic. Ord impls can be inconsistent. Write
defensively so broken trait impls cause wrong results, never UB.
Verification Layers
All three are required. No single tool is sufficient.
| Layer | Tool | What It Catches | Required |
|---|
| Dynamic UB | cargo +nightly miri test | Aliasing, uninit, alignment, use-after-free | Every test run |
| Aliasing model | MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri test | Tree Borrows violations | CI |
| Reference model | proptest vs known-correct safe impl (e.g., VecDeque) | Logic errors, edge cases | Every test run |
| Drop correctness | DropTracker with Arc<AtomicUsize> | Double-drop, leaks | Every test run |
| Bounded proof | Kani proof harnesses | Exhaustive invariant verification | Critical code |
Risk Checklist
Before declaring unsafe code "done," verify each:
Pattern Catalog
| Pattern | When to Use | Exemplar |
|---|
| Private fields + safe API | Always | std Vec |
Crate-level #![forbid(unsafe_code)] | Multi-crate projects | Fuchsia, gossip-rs |
| Compile-time const assertions | Numeric invariants | RingBuffer power-of-2 |
MaybeUninit + init tracking | Uninitialized storage | std MaybeUninit docs |
| PhantomData for variance | Raw pointer containers | Rustonomicon Vec |
| Guard struct for panic safety | Multi-step mutations | hashbrown HashMap |
| Typestate (generic state param) | State-dependent operations | serde Serializer |
| Sealed trait | Fixed impl set | zerocopy |
unsafe trait as capability gate | External impls needed | bytemuck Pod |
| Lifetime-encoded validity | Temporal access control | crossbeam-epoch Guard |
References
- Rustonomicon: Working with Unsafe
- Ralf Jung: The Scope of Unsafe
- Unsafe Code Guidelines: Glossary
- std::mem::MaybeUninit
- Miri
- Kani Rust Verifier
- Stacked Borrows (POPL 2020)
- Tree Borrows
- Fuchsia Unsafe Guidelines
- Rust API Guidelines
- RustSec Advisory Database