Safety Invariants in Low-Level Code
In Rust, safety invariants are divided into two distinct layers:
- Language Validity Invariants: Rules enforced by the Rust abstract machine (such as no null or misaligned references, no aliasing
&mut T, no uninitialized reads of inhabited types). Violating a validity invariant at any instant—even inside a private helper function—is immediate Undefined Behavior. - Library / Domain Safety Invariants: Invariants maintained by a module's encapsulation boundary (for example,
Vec<T>maintaininglen <= cap, orStringmaintaining valid UTF-8 inside itsVec<u8>). A method inside the module may temporarily adjustptr,len, andcapstep by step, provided the invariant is restored before returning to safe callers—even if a user closure panics mid-operation!
+-------------------------------------------------------------------+
| Safe Module Boundary (`pub fn`) |
| Callers cannot violate module invariants using safe methods |
| |
| +-----------------------------------------------------------+ |
| | Private Implementation & Fields | |
| | - Fields (`ptr`, `len`, `cap`) are PRIVATE (not `pub`!) | |
| | - `#![deny(unsafe_op_in_unsafe_fn)]` enforced (Rust 2024)| |
| | - Exception-Safety Guard restores invariants on panic! | |
| +-----------------------------------------------------------+ |
+-------------------------------------------------------------------+
Why Module Encapsulation Is Part of the unsafe Trust Boundary
If a struct RawVec has a pub len: usize field and its Drop implementation frees self.len elements using unsafe pointer math, then the entire module is unsound—because completely safe external code can write v.len = 999999; and trigger a memory-corrupting out-of-bounds drop!
Rule: Any field whose value is trusted by an unsafe block must be private to the module, and every function in that module (safe or unsafe) must preserve that field's invariant.
Panic Safety (Exception Safety) in Low-Level Code
A classic vulnerability in low-level Rust code occurs when an unsafe method temporarily puts a data structure into an inconsistent state, invokes a user-provided closure or Clone::clone() implementation, and that user code panics and unwinds the stack before the invariant is restored!
Always set the data structure to a conservatively valid state (such as self.len = 0) before running potentially panicking user code, or use a Drop guard to restore the invariant on unwind:
#![deny(unsafe_op_in_unsafe_fn)]
use std::mem::MaybeUninit;
use std::ptr;
/// A fixed-capacity 4-element buffer that tracks initialized elements via `len`.
///
/// Safety Invariant: `self.data[0..self.len]` are always initialized `T` values,
/// and `self.len <= 4`.
pub struct InlineQuad<T> {
data: [MaybeUninit<T>; 4],
len: usize, // Private! Safe callers cannot corrupt `len`.
}
impl<T> InlineQuad<T> {
pub const fn new() -> Self {
Self {
data: [const { MaybeUninit::uninit() }; 4],
len: 0,
}
}
pub fn push(&mut self, value: T) -> Result<(), T> {
if self.len >= 4 {
return Err(value);
}
self.data[self.len].write(value);
self.len += 1;
Ok(())
}
pub fn as_slice(&self) -> &[T] {
let ptr = self.data.as_ptr().cast::<T>();
// SAFETY: By our struct's safety invariant, `0..self.len` are initialized `T`s.
unsafe { std::slice::from_raw_parts(ptr, self.len) }
}
/// Applies a fallible/panicking transformation to each element in place.
/// Demonstrates panic safety: if `f` panics mid-loop, no double-free or
/// uninitialized read can ever occur when `InlineQuad` is dropped during unwind!
pub fn clear_with<F: FnMut(T)>(&mut self, mut f: F) {
// Set `self.len = 0` FIRST (or decrement via a Drop guard) so that if `f(item)`
// panics during unwinding, `InlineQuad::drop` will not double-free elements!
let old_len = self.len;
self.len = 0;
for i in 0..old_len {
// SAFETY: `i < old_len`, so `self.data[i]` was initialized; we take ownership once.
let item = unsafe { self.data[i].assume_init_read() };
f(item);
}
}
}
impl<T> Drop for InlineQuad<T> {
fn drop(&mut self) {
let ptr = self.data.as_mut_ptr().cast::<T>();
for i in 0..self.len {
// SAFETY: `0..self.len` are initialized and dropped once here.
unsafe {
ptr::drop_in_place(ptr.add(i));
}
}
}
}
fn main() {
let mut quad = InlineQuad::new();
quad.push(String::from("alpha")).unwrap();
quad.push(String::from("beta")).unwrap();
println!("Slice before clear: {:?}", quad.as_slice());
let mut drained = Vec::new();
quad.clear_with(|s| drained.push(s.to_uppercase()));
println!("Drained panic-safely: {drained:?}, remaining len={}", quad.as_slice().len());
}
Slice before clear: ["alpha", "beta"]
Drained panic-safely: ["ALPHA", "BETA"], remaining len=0
Summary Checklist for Auditing Low-Level Rust Code
| Invariant Check | Question to Verify |
|---|---|
| Encapsulation | Are all fields trusted by unsafe blocks private to the module? |
| Validity | Are all pointers non-null, aligned, in-bounds, and derived with valid provenance before creating &T / &mut T? |
| Aliasing | Could a callback or reentrant call create a second &T or &mut T while an exclusive &mut T is alive? |
| Panic / Unwind Safety | If a user closure, Clone::clone, Ord::cmp, or Drop::drop panics mid-loop, does the struct remain in a valid state for its own Drop? |
| Rust 2024 Explicitness | Is unsafe_op_in_unsafe_fn enabled so every unsafe operation inside an unsafe fn has its own granular unsafe { ... } block and // SAFETY: justification? |