Skip to content

The Rust Engineering Handbook

Appendix K — Unsafe Code Review Checklist

Audit unsafe Rust through explicit obligations for validity, aliasing, provenance, bounds, destruction, concurrency, and evidence.

An unsafe review is not complete when the tests are green. It is complete only when a reviewer can point from each unchecked operation to the assumption that makes it valid, the code or caller that establishes that assumption, and the evidence that would reveal a violated assumption.

Consider the boundary fixture’s slice construction:

// SAFETY: the C contract requires data to point to at least len readable,
// initialized bytes for this call; the length check establishes five bytes.
let input = unsafe { std::slice::from_raw_parts(data.as_ptr(), len) };

The comment is useful, but it is not yet a proof. It does not itself establish that the foreign caller supplied one allocation, that len fits the address space, that no writer races with the read, or that the pointer remains live for the derived reference. Review must connect those facts to the API contract and its callers.

Start with a ledger, not with a count of unsafe tokens:

Assumption Established by Required by Failure if false Validation
data covers len initialized bytes in one live allocation foreign caller contract from_raw_parts out-of-bounds or uninitialized read C smoke test, negative harness, Miri on Rust-owned analogue
memory is not mutated for the reference’s lifetime caller exclusivity and call-scoped borrow creation of &[u8] aliasing violation or data race API review, concurrency harness
len * size_of::<u8>() <= isize::MAX boundary length policy slice layout and pointer offset rules invalid slice value explicit maximum and boundary tests
the handle is the one live allocation from wireview_new opaque-token lifecycle dereference and final Box::from_raw use-after-free or double free lifecycle tests and foreign sanitizer run

If a row says only “caller promises,” find the public documentation that makes the promise visible and testable. If no layer owns an assumption, the API is unsound or underspecified.

Do not approve ledger rows independently. Liveness determines whether a pointer remains usable; aliasing determines whether its bytes may be read; panic and drop determine whether ownership is released once; auto traits determine whether a previously local proof must survive transfer or sharing. Trace every unsafe operation across the rows it depends on, because a complete-looking column can still hide a broken composed proof.

Review the boundary before the block

Inventory unsafe functions, blocks, trait implementations, external declarations, mutable statics, unions, raw allocation ownership, and unsafe attributes. Rust 2024 also requires explicit unsafe forms for external blocks and attributes such as no_mangle; that syntax exposes an obligation but does not discharge it.

For every safe entry point above unsafe internals, ask:

  1. Can safe input reach the unsafe operation without validation?
  2. Can a returned reference, iterator, guard, or handle outlive its owner?
  3. Does encapsulation prevent callers from forging states the proof excludes?
  4. Do feature flags, platforms, or generic instantiations create an unreviewed path?
  5. Is the unsafe region narrow enough that ordinary control flow remains visible?

The companion RawOwner<T> is a useful audit surface. new obtains a pointer by leaking exactly one Box<T>. get exposes only &T; get_mut requires &mut self; Drop reconstructs exactly one box. The private pointer and PhantomData<T> are not decoration: they encode ownership for variance, drop checking, and auto-trait reasoning.

A safe API surrounds a hatched unsafe kernel. Seven gates—validity, aliasing, provenance, bounds and alignment, panic and drop, Send and Sync, and tests and Miri—send obligations inward while dashed evidence arrows return outward. A ledger records assumption, owner, consequence, and validation.
Unsafe review succeeds when every unchecked operation is connected to owned assumptions and evidence without confusing tests for proof.

The figure separates proof from testing. Tests can provide evidence for selected executions; they cannot establish a universal safety theorem.

Validity and initialization gate

Name the exact type created or read at each unsafe operation. “The bytes exist” is weaker than “these bytes form a valid T.” References must be non-null, aligned, point to live storage, and satisfy aliasing requirements. bool, references, function pointers, and enums can reject bit patterns that integers accept.

Audit initialization as a state machine:

  • Which bytes are uninitialized, initialized but not yet a valid T, and valid?
  • Can a panic expose or drop a partially initialized value?
  • Does MaybeUninit<T> remain in place until validity is established?
  • Does every initialized element get dropped exactly once?
  • Does ManuallyDrop<T> move the drop obligation somewhere explicit?

Reject this repair:

let data = [0_u8; 4];
let flag = unsafe { std::mem::transmute::<[u8; 4], bool>(data) };

It does not merely have an awkward size; it attempts to replace a validity proof with reinterpretation. Prefer typed construction and checked conversions. Size equality, when present, would still be insufficient.

Pointer gate: provenance, range, and alignment

Review a pointer for a particular operation, not as “valid” in the abstract. Record:

  • the allocation or exposed-address operation from which it originates;
  • whether the access is read, write, or creation of a reference;
  • byte range and arithmetic overflow checks;
  • required alignment, including zero-length slice rules;
  • liveness for the entire access and any derived reference;
  • whether the range stays within one allocation;
  • which aliases exist during the operation.

Pointer-to-integer round trips and provenance remain version-sensitive territory. Use stable strict-provenance APIs where they express the intended operation, and do not turn a current aliasing model such as Stacked Borrows or Tree Borrows into a language guarantee. A safety argument must survive model evolution by relying on documented operations and conservative ownership.

Bounds checks must dominate the unsafe access on every control-flow path. Check multiplication before forming byte sizes. Prefer checked_mul, Layout, slices, and typed pointer operations that retain structure. Alignment is independent of bounds: an address can lie inside an allocation and still be misaligned for T. Use unaligned operations only when the representation genuinely permits them and the code never creates a misaligned reference.

Aliasing and reference-creation gate

Raw pointers postpone checks; creating &T or &mut T asserts their requirements immediately. Ask when the reference begins, when it last participates in access, and whether callbacks, destructors, signals, or other threads can touch the storage during that interval.

A common compiling mistake is to create a mutable reference “for convenience” while retaining a usable raw pointer elsewhere. Converting back to a pointer does not erase the reference’s aliasing claim. Keep raw access raw until the shortest point where a reference is genuinely justified, and return a reference only when an owner in the safe API controls its lifetime.

For containers, prove that logical indices map to distinct initialized slots. For iterators, prove that yielded references remain valid after iterator state advances. For self-referential or pinned structures, prove address stability and projection separately from ownership of the pointer handle.

Panic, drop, and partial-state gate

Trace every early return, panic, and destructor. Unsafe code may not assume “this call cannot panic” merely because it currently contains no explicit panic!; indexing, allocation, formatting, user callbacks, trait methods, and destructors can panic.

At each mutation, classify the invariant:

  • always valid: any panic may observe and drop the state;
  • guarded: a drop guard restores or completes the transition;
  • temporarily inaccessible: encapsulation prevents safe observation until commit;
  • abort-only: the design relies on process abort and documents that deployment constraint.

For manual ownership, count acquisition and release paths. Box::from_raw, Vec::from_raw_parts, allocator deallocation, file descriptors, and foreign release functions each require provenance from their matching constructor and exactly-once release. A destructor must not read moved-out or uninitialized fields. If a destructor can call user code, include reentrancy and panic in the proof.

Auto traits and concurrency gate

Treat unsafe impl Send and unsafe impl Sync as public claims about every safe operation, generic parameter, and destructor. RawOwner<T> uses conditional implementations:

unsafe impl<T: Send> Send for RawOwner<T> {}
unsafe impl<T: Sync> Sync for RawOwner<T> {}

The bounds are different because moving unique ownership between threads requires T: Send, while shared access exposes &T and therefore requires T: Sync. Review interior mutability, thread-affine release, callbacks, raw aliases, and any method that manufactures shared access. A marker such as PhantomData<T> can change auto-trait and drop-check behavior; removing it is a semantic change.

Concurrency evidence should include the synchronization relation, not only a stress loop. State which lock, atomic ordering, channel handoff, or exclusive owner makes each access legal. Miri can detect some data races, but it explores executions rather than proving all schedules; specialist tools and simpler designs remain necessary.

Evidence campaign

Use layers, each with a named blind spot:

  1. cargo check and Clippy enforce types and configured lints, including unsafe_op_in_unsafe_fn.
  2. Unit and property tests cover success, boundaries, empty inputs, maximum sizes, repeated operations, and destructor counts.
  3. Compile-fail tests preserve rejected safe misuse.
  4. Miri interprets selected Rust tests and can detect classes including use-after-free, invalid uninitialized reads, alignment errors, invalid basic values, and some aliasing/data-race violations.
  5. Sanitizers exercise native code and foreign calls that Miri may not support.
  6. Fuzzing expands malformed-input and state-transition coverage.
  7. Manual proof review and independent unsafe specialists examine assumptions tools cannot quantify.

For the companion fixture:

cd examples/rust-engineering-handbook/part-11/boundary-contracts-lab
cargo test --locked
cargo clippy --locked --all-targets -- -D warnings
cargo +nightly miri test

The Miri command is conditional on an installed nightly Miri component. Record the exact nightly and flags. FFI calls, platform APIs, alternative schedules, and unspecified layout can remain outside its coverage. A clean run means “no detected violation in these executions,” not “sound.”

Documentation gate

Every unsafe function needs a # Safety section that assigns caller obligations. Every unsafe trait must state implementor obligations. Every unsafe block needs a nearby // SAFETY: explanation tied to checked facts, not a restatement of the operation. Internal safety documentation should also record:

  • invariant and representation;
  • constructor and mutation preservation;
  • panic and drop behavior;
  • auto-trait reasoning;
  • platform, toolchain, and layout assumptions;
  • validation commands and residual risks;
  • the reviewer required when the unsafe surface changes.

Keep the comment close to code, but keep the full safety case in a durable artifact when it spans functions. Documentation drift is a correctness defect: a changed precondition must change code, public docs, tests, and the ledger together.

Audit exercise

Review board. Audit RawOwner<T> and wireview_parse in boundary-contracts-lab.

  • Context: a library proposes to reuse the owner in a multi-threaded parser and to permit a zero-length foreign buffer.
  • Constraints: preserve the safe Rust API, C11 compatibility, and exactly-once release; do not assume Miri can execute the C client.
  • Deliverable: completed obligation ledger, unsafe inventory, panic/drop trace, Send/Sync disposition, test additions, and approve/reject decision with residual risk.
  • Evaluation: every assumption has an owner; zero-length pointer rules are explicit; callback and concurrent access are addressed; tests are distinguished from proof.
  • Allowed assumptions: current stable Rust 1.97 semantics and the checked-in header are the review snapshot.

Unsafe review card

  • Is each unsafe operation necessary, and is the region minimal?
  • Is every created value initialized and valid for its exact type?
  • Are provenance, one-allocation range, size arithmetic, bounds, and alignment established?
  • Are reference lifetimes and alias exclusions explicit?
  • Can panic, early return, reentrancy, or drop expose partial state?
  • Is every resource released exactly once by its matching owner?
  • Are manual Send/Sync claims conditional on the right properties?
  • Does concurrency evidence name synchronization and thread affinity?
  • Do tests, Miri, sanitizers, and fuzzing each have a stated coverage job and blind spot?
  • Do # Safety, safety comments, and the safety case agree with code?
  • Has an independent unsafe specialist reviewed the residual risk?

The durable rule is compact: unsafe review is an obligation-assignment exercise. If a reviewer cannot say who establishes a fact, where it is preserved, and how failure is sought, the proof is incomplete.

Sources and version notes

Core examples target Rust 2024 and stable Rust. The manuscript snapshot is Rust 1.97.0. Pointer aliasing and provenance explanations continue to evolve; rely on current official contracts, label experimental models, and re-run the evidence campaign on the publication toolchain.