Here is the question every learner asks once they start writing unsafe code, and the answer that surprises them.
How much of my program do I have to check?
The whole module. Not the block. Not the function. The module.
Safety is non-local
The soundness of an unsafe block depends on invariants established by safe code elsewhere. The Nomicon’s example is exact, and it is worth typing out because the size of it is the point:
fn index(idx: usize, arr: &[u8]) -> Option<u8> {
if idx < arr.len() {
// SAFETY: `idx` was just checked against the length.
unsafe { Some(*arr.get_unchecked(idx)) }
} else {
None
}
}
That is sound. Now change one character — < to <= — in the safe part:
if idx <= arr.len() {
The unsafe block is byte-for-byte identical. It now reads out of bounds.
Nothing in the unsafe block changed. The proof changed, and the proof lived in safe code. So:
An unsafe block does not have a bounded blast radius, and “I reviewed the unsafe code” is not a soundness argument.
💡If safe code can invalidate an unsafe block's reasoning, in what sense is safe Rust still "safe"? click to reveal
Safe code cannot cause undefined behaviour on its own. It can only cause it by breaking an invariant that some unsafe code in the same module was relying on.
That is a real and useful guarantee, and it is the reason the audit unit is finite. A program with no unsafe anywhere cannot have undefined behaviour, full stop — the compiler proved it. A program with one unsafe block in one module has undefined behaviour only if that module’s safe code is wrong.
What it is not is a guarantee that each function can be reviewed in isolation. The unit of “this is proven correct” is the module boundary, not the line. Once you internalise that, a lot of otherwise-mysterious std design decisions — why Vec‘s fields are private, why String has no pub bytes, why so many types have no public constructor — stop looking like paranoia.
The Vec example
Vec::push is full of unsafe code, and it trusts three things: ptr points at an allocation, that allocation has room for cap elements, and the first len of them are initialised.
Now add a method to the same module:
impl<T> MyVec<T> {
fn make_room(&mut self) {
self.cap += 1; // not one unsafe operation in sight
}
}
That function contains no unsafe keyword, performs no pointer arithmetic, dereferences nothing. It is as safe-looking as code gets. And it makes the next push write past the end of the allocation.
The bug is not in push. The bug is in make_room. But push is where the memory corruption happens, and push is the code a reviewer will stare at.
Privacy is the resolution
Here is what stops this being hopeless.
cap is a private field. make_room is not pub. Therefore only code inside this module can break the invariant. Everything outside — the rest of your crate, every downstream user, every macro — interacts with MyVec only through its public API, which was written to preserve the invariant.
Unsafe code has to trust some safe code. Privacy is what stops it having to trust all safe code in the universe.
That is the whole design. The module is the unit of encapsulation in Rust, and it is therefore the unit of soundness auditing. Make the boundary small and the audit is small.
💡Adding a pub to a struct field is normally an API-compatibility question. When is it a soundness question?
click to reveal
Whenever any unsafe code in the module relies on that field’s value.
Make MyVec‘s cap public and you have not merely widened the API — you have moved the soundness boundary from “this module” to “the entire program”. Any safe code anywhere can now write v.cap = 9999, and the next push corrupts the heap. You have converted a private invariant into a global obligation that nobody has been told about.
The same reasoning applies to several things that do not look like field access:
-
#[derive]d traits are part of the attack surface.#[derive(Clone)]on a refcounted handle copies the pointer without bumping the count (item 17.18).#[derive(Default)]can construct a state your invariant forbids. - Public constructors, including tuple-struct constructors, let a caller pick the field values directly.
-
A
pub fnreturning&mutto an internal field is a hole with extra steps — the caller can write anything.
The habit worth building: when you add pub to anything inside a module containing unsafe, ask “what would this let a hostile caller do to my invariants?” Usually the answer is nothing. When it is not, you have just found a soundness bug during code review, which is the cheapest possible place to find one.
Practical consequences, stated plainly
-
The audit unit is the module, not the block. When reviewing unsafe code, read every function in the module — especially the ones with no
unsafein them. -
Keep modules containing
unsafesmall. The cost of a soundness audit scales with the module, so the module is the thing to keep small. This is why std puts each unsafe abstraction in its own file. -
Adding
pubto a field, a constructor or a#[derive]is a soundness change, not just an API change. -
Write down the invariant. A module-level comment saying “INVARIANT:
len <= cap, and slots0..lenare initialised” is what makes the audit mechanical rather than archaeological. Every function in the module either preserves it or is a bug. -
In a single-file program there are no module boundaries by default. Everything is in the crate root, so “the module” is “the whole file”. If you want the encapsulation, create it explicitly with
mod inner { ... }and export a narrow surface.
That last point is a real limitation of this track’s format. The problems here are single files, so the privacy machinery that makes this principle work is mostly absent — you have to supply the discipline yourself. In a real crate, use the module system; it is doing load-bearing safety work, not just organisation.
Lints that enforce parts of this
Three are on by default and each catches a specific way of leaking an invariant across the module boundary.
clippy::not_unsafe_ptr_arg_deref (correctness/deny) fires when a safe pub fn dereferences a raw-pointer argument. This is, literally, an unsoundness detector: the function promises “give me any pointer” and then trusts it. Either check it, or make the function unsafe and document the contract.
clippy::mut_from_ref (correctness/deny) fires on any function taking a shared reference and returning &mut. Call it twice and you have two live &mut with no unsafe at the call site.
clippy::missing_safety_doc (style/warn, fatal under -D warnings) requires a # Safety section on every pub unsafe fn. That section is the contract you are exporting past the module boundary.
And clippy::undocumented_unsafe_blocks, enabled throughout this track, is where you record which invariant each block is relying on — which is exactly the information the module audit needs.
Why this converts writing unsafe code into designing abstractions
Once you accept that the module is the unit, the design questions answer themselves:
- What should be private? Everything the invariant depends on.
- Where should the checks live? At the public boundary, so the private code can assume them.
- How big should this module be? As small as the invariant allows.
- What goes in the safety comment? Which specific invariant, established by which specific code, this block is relying on.
That is the shape of every sound abstraction in the standard library, and it is the shape of everything you build in items 17.16 through 17.24: a narrow public surface that checks, a private interior that trusts, and a written-down invariant connecting them.