Skip to content
v0.1.1latest release34 skills2 categories4 sub-categories79 tested scripts157 reference docsContact us

Unsafe Superpowers and Safety Contracts

Reference for rust-unsafe-block-auditor · 303 lines

Programming / Rustv1.0.0Updated OCT 21 20266 references8 scripts

What unsafe actually unlocks, what "sound" means, and how to write the contracts (# Safety docs and // SAFETY: comments) that make an unsafe audit possible.

Contents

The five superpowers

Inside an unsafe block (or, before edition 2024 by default, anywhere in an unsafe fn body) you may:

#SuperpowerExampleWhat you now have to prove
1Dereference a raw pointer*ptr, &*ptr, &mut *ptr, (*p).fieldnon-null, aligned, dereferenceable, points to a valid initialised value, aliasing rules for the reference you create
2Call an unsafe fn (including foreign fns and intrinsics)v.get_unchecked(i), libc::write(..), String::from_utf8_uncheckedevery item in the callee's # Safety section
3Access or modify a mutable staticCOUNTER += 1 on static mut COUNTERno data race, no overlapping references (use &raw instead)
4Implement an unsafe traitunsafe impl Send for Handle {}every invariant the trait's # Safety section requires of implementors
5Read a field of a unionu.as_f32the bytes are a valid value of the field's type

Edition 2024 adds unsafe attributes (#[unsafe(no_mangle)], #[unsafe(export_name = "..")], #[unsafe(link_section = "..")]) and unsafe extern blocks: these are not new powers but places where the author asserts something the compiler cannot check (no symbol collision; foreign signatures are correct). Inline assembly (asm!) is also only allowed inside unsafe.

What unsafe does not change

  • The borrow checker still runs on references. unsafe does not turn it off; it only allows the five operations above. Code that compiles only "because it is in unsafe" is usually using raw pointers to hide a borrow conflict, which is exactly where aliasing UB lives.
  • Types still mean what they mean. A &mut T created in unsafe code must be unique for its whole lifetime, a bool must be 0 or 1, a &T must point to a valid T. The compiler optimises safe code around your unsafe block using those facts.
  • UB is not "implementation-defined". The optimiser assumes it never happens; the effects can appear far from the bug, only in release, only on one target, or only after an unrelated compiler upgrade.

Safety vs soundness

  • Safe code cannot cause UB, whatever it does, provided all unsafe code it calls is sound.
  • Sound unsafe code / API: no sequence of safe calls can make it cause UB. Soundness is a property of the interface, quantified over all safe callers, not just the ones that exist today.
  • Unsound: there is some safe program that triggers UB through it. That is a bug even if no current caller does it, and it is treated as a security issue (RustSec advisories are filed for unsound crates).
rust
// UNSOUND: a safe function that trusts its caller.
pub fn first_byte(p: *const u8) -> u8 {
    unsafe { *p } // safe callers can pass null or a dangling pointer
}

// SOUND alternative 1: make the caller prove it.
/// # Safety
///
/// `p` must be non-null and valid for a 1-byte read.
pub unsafe fn first_byte_raw(p: *const u8) -> u8 {
    // SAFETY: guaranteed by the caller per `# Safety`.
    unsafe { *p }
}

// SOUND alternative 2: take a type that already carries the proof.
pub fn first_byte_of(bytes: &[u8]) -> Option<u8> {
    bytes.first().copied()
}

The audit question for every safe pub fn containing unsafe: "Which argument values or call sequences would break the SAFETY comment?" If any exist and are reachable from safe code, the fn is unsound.

Two kinds of unsafe keyword

PositionMeaningWho proves
unsafe fn f()"calling me has preconditions"the caller (documented in # Safety)
unsafe trait T"implementing me has invariants other unsafe code relies on"the implementor
unsafe { .. }"I have checked the preconditions of the ops in here"the author of the block
unsafe impl T for X"I have checked the trait's invariants for X"the author of the impl
unsafe extern "C" { .. }"these foreign signatures are correct"the author of the block
#[unsafe(attr)]"this attribute's requirement (e.g. unique symbol) holds"the author

unsafe fn and unsafe trait declare obligations; blocks and impls discharge them. An audit therefore checks two things: every declaration documents its obligations precisely, and every discharge cites them.

Writing # Safety sections

Rules (clippy missing_safety_doc checks the section exists on public items):

  1. One bullet per precondition, each independently checkable by a caller.
  2. State requirements on values (ptr non-null, aligned for T, valid for reads of len * size_of::<T>() bytes), on lifetimes ("the memory must not be mutated while the returned slice is alive"), on threads ("must be called from the thread that created handle"), and on ownership ("ptr must have been returned by Buffer::into_raw and not freed").
  3. Say what happens to ownership: does the function take ownership, borrow, or copy?
  4. Do not write "the pointer must be valid": define valid (null? aligned? initialised? how many bytes?).
rust
/// Builds a `&[T]` from a C array.
///
/// # Safety
///
/// - `data` must be non-null and aligned for `T`, even when `len == 0`.
/// - `data` must point to `len` consecutive, initialised values of `T` inside one allocation.
/// - The memory must not be written through any other pointer for the lifetime `'a`.
/// - `len * size_of::<T>()` must not exceed `isize::MAX`.
pub unsafe fn slice_from_c<'a, T>(data: *const T, len: usize) -> &'a [T] {
    // SAFETY: each requirement of `slice::from_raw_parts` is a bullet of our own contract.
    unsafe { std::slice::from_raw_parts(data, len) }
}

Note the unbounded 'a: returning a reference with a caller-chosen lifetime is part of the contract and must be stated ("for the lifetime 'a").

Writing // SAFETY: comments

A SAFETY comment is a proof sketch, not a restatement of the code.

BadGood
// SAFETY: this is safe// SAFETY: idx < self.len (checked on line 42) and items[..len] are initialised (struct invariant)
// SAFETY: ptr is valid// SAFETY: ptr came from Box::into_raw in new() and is only freed in Drop, which cannot run while &self exists
// SAFETY: needed for performance// SAFETY: chunks_exact(4) yields exactly 4 bytes, so the read is in bounds; read_unaligned has no alignment requirement

Conventions:

  • Directly above the block or the statement containing it (let x = unsafe { .. };); clippy's undocumented_unsafe_blocks looks there. Also above every unsafe impl.
  • One comment per block; if a block needs several paragraphs, it probably does too much: split it.
  • Reference the invariant by name (// Invariant: ... on the struct field) and the check that establishes it.
  • For FFI, cite the C library's documentation ("libfoo docs: foo_get returns a pointer owned by the handle, valid until foo_free").
  • clippy::unnecessary_safety_comment flags SAFETY comments on safe code; keep them honest.

unsafe_op_in_unsafe_fn

Before edition 2024 the whole body of an unsafe fn was an implicit unsafe block, so unsafe operations inside it needed no SAFETY comment and were easy to miss. The unsafe_op_in_unsafe_fn lint (stable since 1.52, warn-by-default in edition 2024) requires an explicit block:

rust
#![deny(unsafe_op_in_unsafe_fn)]

/// # Safety
///
/// `p` must be valid for reads and writes of one `u32` and not aliased.
pub unsafe fn double_in_place(p: *mut u32) {
    // SAFETY: valid and unaliased per this function's contract.
    let v = unsafe { p.read() };
    let doubled = v.wrapping_mul(2); // safe code stays outside the block
    // SAFETY: same pointer, same contract; no reference was created in between.
    unsafe { p.write(doubled) };
}

Set it in every crate that has unsafe fn, regardless of edition: #![deny(unsafe_op_in_unsafe_fn)] or [lints.rust] unsafe_op_in_unsafe_fn = "deny" in Cargo.toml.

Encapsulation: the module is the safety boundary

Unsafe code may rely on invariants that safe code in the same module could break, because privacy stops code in other modules from doing so. This means:

  • The unit of audit is the module (privacy boundary), not the unsafe block. A change to a safe line in the module (e.g. self.len += 1 without a write) can make an unrelated unsafe block unsound.
  • Fields that carry an invariant must be private; constructors and every &mut self method must preserve it.
  • Never expose &mut to an invariant-carrying field (pub fn len_mut(&mut self) -> &mut usize breaks the Vec-like invariant from safe code).
  • Document the invariant on the field so every editor sees it.
rust
mod ascii {
    /// A string that is guaranteed to contain only ASCII bytes.
    pub struct AsciiStr {
        bytes: Vec<u8>, // Invariant: every byte is < 0x80 (so it is also valid UTF-8).
    }

    impl AsciiStr {
        pub fn new(s: &str) -> Option<Self> {
            s.is_ascii().then(|| Self { bytes: s.as_bytes().to_vec() })
        }

        pub fn push(&mut self, c: u8) -> bool {
            if c.is_ascii() {
                self.bytes.push(c);
                true
            } else {
                false
            }
        }

        pub fn as_str(&self) -> &str {
            // SAFETY: by the field invariant every byte is ASCII, and ASCII is valid UTF-8.
            unsafe { std::str::from_utf8_unchecked(&self.bytes) }
        }
    }
}

Adding pub fn bytes_mut(&mut self) -> &mut Vec<u8> to this module would make as_str unsound without touching any unsafe line. Audits must read the whole module.

Unsafe traits

Declare a trait unsafe when other unsafe code relies on implementors being correct, and a wrong safe impl would cause UB (examples: Send, Sync, GlobalAlloc, bytemuck::Pod). If a buggy impl can only cause wrong results or panics, keep the trait safe.

rust
/// Types whose every bit pattern is a valid value and that contain no padding.
///
/// # Safety
///
/// Implementors must be `#[repr(C)]` or `#[repr(transparent)]`, have no padding bytes, contain only
/// fields that are themselves `PlainBytes`, and accept every bit pattern as a valid value.
pub unsafe trait PlainBytes: Copy + 'static {}

// SAFETY: u32 has no padding and every 32-bit pattern is a valid u32.
unsafe impl PlainBytes for u32 {}

pub fn read_plain<T: PlainBytes>(bytes: &[u8]) -> Option<T> {
    if bytes.len() < size_of::<T>() {
        return None;
    }
    // SAFETY: length checked; read_unaligned needs no alignment; T: PlainBytes accepts any bits.
    Some(unsafe { bytes.as_ptr().cast::<T>().read_unaligned() })
}

In real code prefer bytemuck::Pod / zerocopy::FromBytes derives, which verify the layout rules at compile time instead of trusting the impl. (size_of in the prelude needs Rust 1.80+; otherwise std::mem::size_of.)

unsafe impl Send/Sync is the most common unsafe trait impl and the most common source of unsoundness: see undefined-behavior-catalog.md.

Designing a minimal unsafe surface

  1. Put every unsafe operation for one invariant in one small module with private fields.
  2. Expose only safe functions whose preconditions are checked (return Option/Result) or encoded in types (NonNull, &[T], newtypes with checked constructors).
  3. Offer an unsafe fn *_unchecked variant only when profiling shows the check matters, and implement the safe version as check + call unchecked.
  4. Keep raw pointers internal; convert to references at the narrowest point.
  5. Prefer well-audited crates for common needs: bytemuck/zerocopy (casting), memchr, smallvec, arrayvec, crossbeam, parking_lot, once_cell (or std OnceLock/LazyLock), pin-project-lite, libc/windows-sys, bindgen/cxx.

Crate-level policy

rust
// Crates with no unsafe at all (most application and library crates):
#![forbid(unsafe_code)]

// Crates that need unsafe:
#![deny(unsafe_op_in_unsafe_fn)]
#![warn(
    clippy::undocumented_unsafe_blocks,
    clippy::missing_safety_doc,
    clippy::multiple_unsafe_ops_per_block,
)]

Or in Cargo.toml (Rust 1.74+ [lints] table; workspaces can share [workspace.lints]):

toml
[lints.rust]
unsafe_op_in_unsafe_fn = "deny"

[lints.clippy]
undocumented_unsafe_blocks = "warn"
missing_safety_doc = "warn"
multiple_unsafe_ops_per_block = "warn"

forbid cannot be overridden by an inner #[allow]; deny can, which lets a single audited module opt in with #[allow(unsafe_code)] while the rest of the crate stays unsafe-free.

Audit checklist per item

For each unsafe block / fn / impl:

  • Is there a safe API that does this? (If yes, stop and replace.)
  • List each unsafe operation and its preconditions (std docs # Safety sections are authoritative).
  • For each precondition: where is it established? Is that check reachable on every path?
  • Can safe code in this module or a caller break the invariant (pub fields, &mut getters, mem::swap, mem::forget, panics in the middle of an update, re-entrancy through callbacks/Drop)?
  • Panic safety: if a closure/user code panics halfway, is the data structure still valid when unwinding runs Drop? (Set len before calling user code, or use a guard that fixes up on drop.)
  • Thread safety: are Send/Sync derived correctly for any type holding raw pointers or UnsafeCell?
  • Lifetimes: does any returned reference have an unbounded or caller-chosen lifetime?
  • Is there a Miri test that exercises this path (including edge cases len 0, len max, empty, full)?
More in Rust

rust-borrow-checker-error-explainer

Explain and fix Rust borrow-checker, ownership, move and lifetime compiler errors: E0382 use of moved value, E0499 cannot borrow as mutable more than once, E0502 also borrowed as immutable, E0505, E0506, E0503, E0507 cannot move out of index/Rc/reference, E0515 return reference to local, E0597 does not live long enough, E0716 temporary value dropped while borrowed, E0373 closure may outlive (spawn needs move), E0521 borrowed data escapes, E0384, E0596, E0594, E0381, E0713, 'lifetime may not live long enough', RefCell 'already borrowed: BorrowMutError' panics. Ranks fixes from restructuring (split borrows, entry API, split_at_mut, get_disjoint_mut, mem::take, Option::take, indices, thread::scope) down to clone and Rc<RefCell>/Arc<Mutex>; never unsafe. Covers NLL limits (get-or-insert), iterator invalidation, closure captures, self-referential structs. Ships std-only Rust triage tools.

rust-cargo-workspace-designer

Design, review and fix Cargo workspaces: when to split a crate (compile time, boundaries, publishing, proc-macros), crates/* vs apps/ + crates/ layouts, virtual manifest vs root package, members/exclude/default-members, resolver 2 vs 3 (MSRV-aware, edition 2024), [workspace.package] and [workspace.dependencies] inheritance, [workspace.lints], root-only profiles (lto, codegen-units), additive features with dep: and weak ?/ syntax, feature unification pitfalls, internal path + version deps, publish = false, Cargo.lock and MSRV policy, xtask, CI with --locked, nextest, rust-cache, sccache, cargo-hack/deny/machete/udeps/semver-checks, and cargo build --timings. Ships a workspace auditor and crate-graph tool.

rust-error-handling-thiserror-anyhow-designer

Design and review Rust error handling with thiserror 2 and anyhow 1: library vs application split, typed error enums with #[from], #[source], #[error(transparent)] and #[non_exhaustive], ErrorKind patterns, context via .context()/.with_context(), bail!/ensure!, source() chains, {:#} and {:?} reports, RUST_BACKTRACE/RUST_LIB_BACKTRACE, Box<dyn Error + Send + Sync>, main -> anyhow::Result or ExitCode, unwrap/expect/panic policy, Option to Result, collecting iterators of Results, axum IntoResponse mapping, retryable errors, tracing, async Send bounds, testing with matches! and downcast_ref, no_std core::error::Error, and snafu, eyre/color-eyre, miette, error-stack.

rust-lifetime-annotation-advisor

Decide when Rust lifetime annotations are needed and which ones: the three elision rules, structs holding references (struct Parser<'a>), owned vs borrowed (String, &str, Cow<'a, str>, Arc<str>), one vs several lifetimes, 'a: 'b bounds, T: 'static misconceptions, Box<dyn Trait + 'a> default object lifetimes, impl Trait + '_ and edition 2024 capture rules with use<..>, variance and PhantomData, HRTB for<'a> Fn(&'a T), GATs and lending iterators, async fn and tokio::spawn 'static, self-referential structs (ouroboros, self_cell, indices, arenas), Rc/Arc to escape lifetime soup. Ships std-only Rust checkers for redundant, missing, unused and over-restrictive lifetimes.

rust-tokio-async-service-builder

Build and review production async Rust services on Tokio 1.x and axum 0.8: runtime choice (#[tokio::main] multi_thread vs current_thread, Builder), axum routing with /{id} paths, State, extractors, IntoResponse error types, axum::serve with_graceful_shutdown, tower/tower-http middleware (TraceLayer, TimeoutLayer, CorsLayer, ConcurrencyLimitLayer, RequestBodyLimitLayer), Arc<AppState>, std vs tokio Mutex, JoinSet, TaskTracker, CancellationToken, select! cancel safety, bounded mpsc, oneshot, broadcast, watch, actors, Semaphore, timeouts, retries, spawn_blocking, sqlx PgPool, reqwest Client reuse, tracing, tests with start_paused and oneshot, Dockerfile. Fixes 'future cannot be sent between threads safely', E0373, Rc or MutexGuard held across await, 'Cannot start a runtime from within a runtime', 'Path segments must not start with :'. Ships std-only Rust scanners and a compiling axum 0.8 scaffold.

rust-trait-design-reviewer

Review and design Rust traits and generic APIs: generics vs impl Trait vs dyn Trait (static vs dynamic dispatch), dyn compatibility / object safety (generic methods, Self returns, where Self: Sized, error[E0038] not dyn compatible / cannot be made into an object), associated types vs generic parameters, blanket impls, coherence and orphan rule (E0117, E0119, E0210), newtype, extension, sealed and marker traits, supertraits and trait upcasting (1.86), async fn in traits (1.75) and the Send bound problem (trait_variant, async-trait), RPITIT, GATs, From/Into/TryFrom/AsRef/Borrow, std trait checklist (Debug, Clone, PartialEq, Hash, Default, Display, Error, Send/Sync, serde feature), #[must_use], #[non_exhaustive], semver hazards, Deref abuse, builders, typestate, PhantomData, Box<dyn Error + Send + Sync>, Fn traits, mockall.