Home › Rust › Rust Unsafe & FFI: Sound Wrappers, C Interop, Miri
Advanced 17 min · September 26, 2026

Rust Unsafe & FFI: Sound Wrappers, C Interop, Miri

Safe wrappers plus Miri catch FFI bugs before prod.

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Written from production experience, not tutorials.

Follow
✓ Production
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 34 min
  • ✓Comfortable safe Rust: ownership, borrowing, lifetimes, traits, and Result-based error handling
  • ✓A C compiler available for building small test libraries plus basic terminal fluency
  • ✓Rust nightly installed for Miri runs, or willingness to add it for the exercises
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • Unsafe permits five things: dereferencing raw pointers, calling unsafe functions, touching mutable statics, reading unions, and declaring extern blocks — everything else stays checked
  • Raw const and mut pointers promise nothing about null, alignment, or lifetime — prove each property in safe code before any dereference
  • Declare C functions with exact extern C signatures and export Rust symbols with no_mangle — a wrong width corrupts the stack silently
  • Wrap every foreign call in a safe function that validates inputs, owns allocations, and documents who frees what
  • Run Miri in CI on boundary crates and treat soundness as the bar: no misuse of your safe API may ever cause undefined behavior
✦ Definition~90s read
What is Rust Unsafe FFI C Interop?

Unsafe Rust is the subset of the language where the programmer vouches for invariants the compiler cannot verify: dereferencing raw pointers, calling unsafe functions and foreign symbols, mutating global statics, reading union fields, and emitting inline assembly. FFI — the foreign function interface built on extern declarations — uses those powers to call C libraries and expose Rust functions to C, translating between safe Rust types and raw C representations at a trust boundary.

★
Picture a restaurant kitchen with a strict head chef who inspects every dish before it leaves — that is safe Rust.

Sound engineering confines every such operation to small audited wrapper modules that validate inputs, own lifetimes, and expose only safe signatures, then evidences the claims with Miri runs that detect undefined behavior hardware tests miss. The trade-off is ongoing audit cost: each unsafe block needs a stated invariant, boundary tests, and re-verification on toolchain upgrades.

Teams accept it where C interop or measured hot paths demand it, and refuse it everywhere else in favor of safe abstractions.

Plain-English First

Picture a restaurant kitchen with a strict head chef who inspects every dish before it leaves — that is safe Rust. The unsafe keyword is the chef handing you a sharp knife and saying you may use it, but you must follow the knife rules yourself because nobody will check your grip. Calling C code is like borrowing tools from the restaurant next door: their knives have different handles, their labels are in another language, and you need a careful translator at the door who checks every tool coming in and out so nobody gets cut.

Every Rustacean meets unsafe eventually, usually while calling a C library that predates their career. The compiler draws a bright line: here are the operations I can't verify, it says, so you'll vouch for them yourself. That's less scary than it sounds once you've seen the five powers and the discipline around them.

But tutorials skip the parts that matter on call. They show you dereferencing a pointer without showing the null check that must precede it. They export a no_mangle function without discussing who frees the memory it returns. You'll learn the happy path in ten minutes and spend months discovering the invariants nobody wrote down.

This guide teaches the boundary mindset senior teams use. You'll see what unsafe actually permits, how raw pointers earn trust, how extern declarations can lie, and how thin safe wrappers contain the risk. We'll cover CString crossings, bindgen generation, Miri detection, and the soundness standard your APIs must meet.

You don't need C expertise or systems tenure here. If you've written safe Rust through structs, traits, and Result handling, you've got enough. We'll explain each C concept at the moment it crosses into your code.

By the end you'll audit FFI the way experienced engineers do: state the invariant, check it at the boundary, keep the unsafe block tiny, and prove misuse can't cause UB. You'll also know when to refuse unsafe entirely — because the fastest unsafe code is the safe code you wrote instead.

What Unsafe Actually Permits: The Five Powers

Unsafe does not turn off the borrow checker or sprinkle magic over your program. It permits exactly five operations the compiler cannot verify, and everything else in the block stays fully checked. You may dereference raw pointers, call functions marked unsafe, read or write mutable statics, access union fields, and declare or call through extern blocks. That closed list is liberating: any audit starts by identifying which power a block uses, then asking what invariant makes that specific use sound.

Dereferencing raw pointers is the power you will use most and fear most appropriately. A const T or mut T is an address without a lifetime, without ownership, and without any promise it points anywhere valid. Reading through it requires you to establish validity yourself — allocated, aligned, initialized, in-bounds, and yours to read for the duration. The unsafe block is where you assert all of that at once, which is why seasoned reviewers demand the block contain nothing but the dereference and its immediate use.

Calling unsafe functions shifts the proof burden to the call site by design. The function declares preconditions it cannot check — alignment of a pointer argument, initialization of a buffer, single ownership of a handle — and every caller vouches for them. Good unsafe APIs document those preconditions as precisely as a type signature, with examples of correct and incorrect calls. When you write one, imagine the most creative misuse a tired colleague could invent at midnight, then decide whether the signature can prevent it or the docs must warn against it.

Mutable statics and unions round out the list with narrower but sharper hazards. A static mut is global memory with no synchronization, so any access from multiple threads is a data race unless you add locking or confine it to single-threaded init. A union holds one live variant at a time with no tag, so reading the wrong field reinterprets bytes — occasionally useful for FFI headers, usually a sign you wanted an enum. Both demand comments naming the live variant or the locking protocol, because the compiler tracks neither.

The extern power underwrites all foreign interop and gets its own deep treatment later in this guide. Declaring an external symbol promises the linker and the ABI that a function exists with an exact signature, and calling it trusts machine-level details — register assignment, stack cleanup, struct layout — that Rust cannot confirm. Treat every extern declaration as a claim about someone else's binary, verified against headers or generated by bindgen, never typed from memory.

src/main.rsRUST
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
fn main() {
    // 1. Dereference raw pointers (read/write through *const/*mut).
    let x = 41u32;
    let ptr: *const u32 = &x;
    let v = unsafe { *ptr };
    assert_eq!(v, 41);

    // 2. Call an unsafe fn (caller vouches for preconditions).
    unsafe fn double(n: *mut i32) { *n *= 2; }
    let mut n = 21;
    unsafe { double(&mut n); }
    assert_eq!(n, 42);

    // 3. Touch a mutable static (global, no lock by default).
    static mut COUNTER: u32 = 0;
    unsafe { COUNTER += 1; }

    // 4. Read a union field (only one variant valid at a time).
    union IntOrFloat { i: i32, f: f32 }
    let u = IntOrFloat { i: 42 };
    assert_eq!(unsafe { u.i }, 42);

    // 5. The fifth power is `extern` itself: declaring and
    // calling foreign symbols, covered in the FFI section.
    println!("all five powers acknowledged");
}
🔥Five Powers, No More
Memorize the five powers as a checklist, not trivia. Every unsafe block you ever write uses at least one of them. If you can't name which, the block is too big or you haven't understood it yet.
📊 Production Insight
A boot-camp graduate wrapped a whole 200-line parser in one unsafe block to silence borrow errors, hiding three legitimate lifetime bugs the compiler had correctly flagged. Splitting it into safe code plus two three-line unsafe dereferences with documented invariants exposed all three bugs within an hour.
🎯 Key Takeaway
Five operations, each suspending one guarantee: deref raw pointers, call unsafe fns, touch mutable statics, read unions, use extern. Name the power, state the invariant.

Raw Pointers: Null, Dangling, and Provenance Discipline

A raw pointer strips every guarantee a reference provides, and internalizing that difference is the whole discipline. References carry lifetimes the compiler enforces, aliasing rules the optimizer trusts, and non-null alignment it assumes. Raw pointers carry an address and nothing else — possibly null, possibly dangling after a free or move, possibly misaligned for the type, possibly pointing at uninitialized bytes. Dereferencing one asks the compiler to trust your private proof of all four properties at once.

Null is the cheapest check and the most commonly skipped under optimism. C functions signal absence with null, failed allocations return it, and zeroed memory reads as it. Check explicitly with is_null before every dereference path, and design APIs so null never reaches unsafe: checked constructors returning Option, early returns at the boundary, and debug assertions restating the expectation inside. The one-line habit of assert-then-deref prevents the SIGSEGV class that fills FFI postmortems.

Dangling and provenance demand lifecycle thinking that safe Rust normally automates. A pointer derived from a Vec stays valid only while the Vec lives and does not reallocate; pushing elements can move the buffer and orphan every cached pointer silently. Keep owners alive across the unsafe region with explicit bindings, avoid storing derived pointers beyond the owner's scope, and prefer addr_of! over reference-then-cast when creating pointers to fields, since it avoids manufacturing a temporary reference with stricter aliasing implications.

Alignment and initialization complete the pre-deref checklist. Reading a u32 through an odd address is UB even when the bytes are mapped, and converging architectures enforce it harder than x86 ever did. Use is_aligned assertions and read_unaligned only where the format genuinely packs data. For initialization, treat every foreign or reused buffer as uninitialized until proven otherwise: MaybeUninit for scratch space, zeroed allocations where C expects it, and Miri runs that flag uninit reads your hardware silently tolerates.

Pointer arithmetic has its own fence around it. Offsets must stay within the same allocated object, computed with add and offset using checked lengths, never wrapping arithmetic that escapes the object. The idiom that survives review is small: validate index against length in safe code, convert with as_ptr once, perform exactly one add plus deref inside unsafe with a SAFETY comment citing the checked bound. Anything fancier — strided iteration, tagged pointers, intrusive lists — belongs behind a dedicated abstraction with its own test module and Miri gate.

src/ptr_discipline.rsRUST
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use std::ptr;

// Disciplined raw-pointer read: every check lives in safe code.
fn get_unchecked_debug(values: &[i32], index: usize) -> Option<i32> {
    if index >= values.len() {
        return None; // boundary rejects before any unsafe runs
    }
    let ptr: *const i32 = values.as_ptr();
    debug_assert!(!ptr.is_null(), "slice pointer must be non-null");
    debug_assert!(ptr.is_aligned(), "pointer must be aligned for i32");
    // SAFETY: index < len proven above; slice outlives this call,
    // so ptr.add(index) stays in-bounds and initialized.
    let value = unsafe { *ptr.add(index) };
    Some(value)
}

fn null_discipline() {
    let p: *const u8 = ptr::null();
    assert!(p.is_null());
    // Never dereference p. Convert fallible sources with checked APIs:
    // let s = unsafe { std::ffi::CStr::from_ptr(maybe_null) }; // unsound if null!
}

fn main() {
    let v = vec![10, 20, 30];
    assert_eq!(get_unchecked_debug(&v, 1), Some(20));
    assert_eq!(get_unchecked_debug(&v, 9), None);
}
⚠ Verify Before You Deref
A raw pointer is an uncashed check: the address might be good, but nobody has verified the funds. Cash it — null, alignment, bounds, provenance — in safe code before the unsafe block spends it.
📊 Production Insight
A zero-copy parser cached a Vec's as_ptr across an await point; a realloc during a concurrent push moved the buffer and the cached pointer went dangling. The service served one wrong byte per million — invisible until a checksum drove a three-day hunt. Binding the owner across the unsafe region plus Miri closed the class permanently.
🎯 Key Takeaway
Prove null, alignment, bounds, init, and provenance in safe code first; keep the unsafe deref to one add plus one read with a SAFETY comment.

extern C and no_mangle: Calling Out and Being Called

The extern keyword draws the border between two binaries that agree on nothing except what you declare. An unsafe extern block imports C symbols by promising a name, an ABI, and a signature; a pub extern fn with no_mangle exports a Rust function by making the reverse promise to C callers. Both directions compile without any verification that the other side matches, which makes the declaration the single most dangerous line in FFI code. Get one width wrong and the stack corrupts before your first statement executes.

The ABI string selects the calling convention — which registers carry which arguments and who cleans the stack. "C" is correct for nearly all C libraries; system APIs on Windows want "system"; omit the string and you get Rust's unstable internal convention that no foreign caller may use. This matters most on non-x86 targets where conventions diverge sharply: code that accidentally works on x86_64 Linux through register luck falls over on ARM with shifted arguments. Name the ABI on every declaration and export without exception.

Integer widths are the second promise and the classic portability trap. C int is 32 bits on the platforms you test and still 32 bits where you do not, but C long is 64 bits on LP64 Unix and 32 bits on Windows — typing long as i64 breaks Windows silently. Use the libc crate's c_int, c_long, c_char, and size_t so widths follow the target, and mark every shared struct repr(C) with fields in header order. A bindgen-generated layout test asserting size and alignment per struct turns header drift into a build error.

Linking and symbol visibility complete the mechanical picture. The compiler must find the foreign library through link attributes or build-script rustc-link directives, and the dynamic loader must find it at runtime through rpath or system paths. no_mangle exports keep their exact symbol name; without it, Rust mangles the name and C cannot link. Version your C dependency explicitly, because a vendor header adding a struct field reallocates every offset your bindings assumed.

Test both directions across the boundary, not just the happy call. Round-trip tests that call C from Rust and Rust callbacks from C catch convention errors that one-directional tests miss. Run the same suite on every target architecture in CI, with optimization levels matching release — several ABI bugs hide at O0 behind stack spills and detonate only under inlining. The declaration is a contract with a foreign binary; enforce it like one.

src/ffi_decl.rsRUST
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use libc::{c_char, c_int, size_t};

// Import C exactly as its header declares it.
unsafe extern "C" {
    fn strlen(s: *const c_char) -> size_t;
    fn compress(dst: *mut u8, dst_len: *mut size_t, src: *const u8, src_len: size_t) -> c_int;
}

// Export Rust for C with a stable symbol and C ABI.
#[no_mangle]
pub extern "C" fn add_pair(a: c_int, b: c_int) -> c_int {
    a.wrapping_add(b)
}

// Structs crossing the boundary need C layout.
#[repr(C)]
pub struct FrameHeader {
    pub tag: u32,
    pub payload_len: size_t,
}

fn main() {
    let msg = c"hello"; // nul-terminated at compile time
    let n = unsafe { strlen(msg.as_ptr()) };
    assert_eq!(n, 5);
    let h = FrameHeader { tag: 1, payload_len: n };
    assert_eq!(std::mem::size_of::<FrameHeader>(), 16);
}
⚠ Declarations Are Promises About Binaries
The ABI string and the integer widths are load-bearing characters, not decoration. One wrong width shifts every later argument. Verify declarations against headers with bindgen instead of typing them from memory.
📊 Production Insight
A payment SDK declared a C amount field as i64 while the header said long; Unix builds worked for a year, then the Windows port charged amounts shifted by 32 bits in staging. Switching to c_long plus bindgen layout tests caught three more width lies in the same header before launch.
🎯 Key Takeaway
Name the ABI, use libc widths, mark shared structs repr(C), verify with layout tests, and exercise both call directions on every target.

The Safe Wrapper Pattern: Invariants at the Boundary

The safe wrapper pattern is the entire strategy for living with foreign code: raw bindings stay private inside one module, and the only public surface is safe functions that cannot be misused into undefined behavior. Each wrapper performs the same five duties in the same order — validate inputs, convert to C representations, hold owners alive across the call, check the return for error signals, and translate results back into owned Rust values or typed errors. Callers in safe code get normal signatures with Result returns and never learn raw pointers existed.

Validation is where soundness lives, so enumerate every precondition the C function assumes and enforce each one. Null possibilities become Option handling or explicit is_null branches. Lengths get checked against capacities with overflow-aware arithmetic. Enum values from C become TryFrom conversions rejecting unknown discriminants. Strings pass through CString or CStr conversions that reject interior nul and invalid UTF-8. The rule is absolute: any input a safe caller could supply must either be accepted safely or rejected with an error — never trusted blindly into unsafe.

Lifetime ownership across the call is the subtle duty newcomers miss. The CString or buffer backing a pointer argument must be bound to a local that outlives the foreign call; constructing the pointer from a temporary that drops first hands C a dangling address. Return values invert the duty: if C returns memory you must free, wrap it immediately in an owning type with a Drop impl that calls the correct release function exactly once. Document the direction on each wrapper — caller-owned in, callee-owned out — because freeing conventions differ per function even within one library.

Error translation turns C's ad-hoc signals into Rust's typed discipline. Status codes become enums, errno captures happen immediately before any other call clobbers them, and null-with-errno paths map to io::Error::last_os_error while the value is fresh. Never let a raw -1 or null propagate to callers; convert at the boundary where context is richest. Tests then assert on variants, not integers, and refactoring the C version cannot silently reinterpret a code.

Keep wrappers thin, total, and boring. Thin means the unsafe block holds one call plus its immediate conversion, with all decisions made in safe code above it. Total means every public path validates every input, including the ones current callers never send. Boring means no cleverness — the wrapper that excites reviewers with pointer tricks is the wrapper that hides a soundness hole. Reviewers should be able to verify each wrapper in minutes by checking the five duties against the C documentation line by line.

src/wrapper.rsRUST
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
use std::ffi::{c_char, CStr, CString};

// Raw binding stays private: nobody outside this module names it.
unsafe extern "C" {
    fn codec_version() -> *const c_char;
    fn codec_encode(input: *const c_char) -> i32;
}

// Safe wrapper: validates, owns lifetimes, translates errors.
#[derive(Debug)]
pub enum CodecError {
    Unavailable,
    RejectedInput,
}

pub fn codec_version_str() -> Result<String, CodecError> {
    let raw = unsafe { codec_version() }; // single unsafe op
    if raw.is_null() {
        return Err(CodecError::Unavailable);
    }
    // SAFETY: library guarantees a valid nul-terminated string
    // for the process lifetime; checked non-null above.
    let s = unsafe { CStr::from_ptr(raw) };
    Ok(s.to_string_lossy().into_owned())
}

pub fn encode_text(input: &str) -> Result<(), CodecError> {
    let owned = CString::new(input).map_err(|_| CodecError::RejectedInput)?;
    let rc = unsafe { codec_encode(owned.as_ptr()) };
    if rc == 0 { Ok(()) } else { Err(CodecError::RejectedInput) }
}

#[cfg(test)]
mod tests {
    #[test]
    fn rejects_interior_nul() {
        assert!(super::encode_text("a\0b").is_err());
    }
}
💡One Boundary, One Owner
Every foreign function gets exactly one safe wrapper that owns validation. Callers should never touch raw bindings directly. If using the library requires reading unsafe code, the wrapper has failed its job.
📊 Production Insight
A media service exposed raw codec bindings crate-wide; six call sites each implemented their own string conversion and two forgot interior-nul rejection. Centralizing behind one wrapper module with CString enforcement plus a rejecting test eliminated the class — the next fuzz run found zero boundary escapes across 40M inputs.
🎯 Key Takeaway
Validate inputs, own lifetimes, check returns, translate errors — one thin total wrapper per foreign function, raw bindings never public.

bindgen: Generating Correct Bindings at Build Time

Bindgen reads C headers and emits the extern declarations, repr(C) structs, constants, and enum mappings your wrappers need, removing the most error-prone step — humans retyping widths and offsets. The workflow centers on build.rs: point the builder at the vendored header, allowlist exactly the symbols you use so unrelated declarations never leak into your crate, and write the output into OUT_DIR for inclusion. Rerun directives tie regeneration to header changes, and link directives tell rustc where the compiled library lives. Deterministic inputs produce deterministic bindings, which keeps diffs reviewable.

Allowlisting is the difference between a clean boundary and a swamp. Without it, bindgen drags in every nested include — platform headers, conflicting macros, anonymous unions you never call — and your crate inherits their portability problems. With tight allowlist_function and allowlist_type patterns plus blocklists for known-trouble macros, the generated file stays small enough to read during review. Check the generated output into version control or snapshot its hash in CI so upgrades show their binding diff explicitly before any behavior changes.

Headers need occasional help that belongs in a wrapper header, not in build flags scattered across machines. Small shim headers include the vendor file, define the integer widths for ambiguous types, hide C++ constructs from the C parser, and exclude inline functions bindgen cannot emit. Keep vendor headers vendored at pinned versions; floating system headers make builds unreproducible across developer laptops and CI images. When the vendor ships a new major version, the binding diff plus layout assertions tell you exactly which offsets and signatures moved.

Complex C idioms need deliberate handling rather than hopeful defaults. Bitfields map to generated accessors worth wrapping in safe helpers. Function pointers become Option<unsafe extern fn> that wrappers must null-check before invoking. Anonymous unions gain named accessors that still require the wrapper to track the live variant. C++ libraries need extern "C" shim functions because bindgen does not generate calling-convention bridges for methods, templates, or exceptions — and C++ exceptions must never unwind through Rust frames, so every shim catches everything at the language edge.

Bindgen output is unsafe surface, not a safe API — every generated item stays private behind your wrappers. Add layout tests asserting size_of and align_of for each struct you use, plus enum round-trip tests for discriminants your logic matches on. Run generation on every CI target, because a binding correct on x86_64 can misalign on 32-bit ARM through width and packing differences. The tool eliminates transcription errors; your wrappers plus tests eliminate the semantic ones the tool cannot see.

build.rsRUST
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// build.rs — regenerate bindings deterministically at build time.
fn main() {
    println!("cargo:rerun-if-changed=vendor/codec.h");
    println!("cargo:rustc-link-search=native=/usr/local/lib");
    println!("cargo:rustc-link-lib=codec");
    let bindings = bindgen::Builder::default()
        .header("vendor/codec.h")
        .allowlist_function("codec_.*")
        .allowlist_type("Codec.*")
        .derive_debug(true)
        .generate()
        .expect("bindgen failed on vendor/codec.h");
    let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
    bindings.write_to_file(out.join("bindings.rs")).expect("write bindings");
}

// src/ffi.rs — include the generated file, keep it private.
// include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
// Layout guard: fails the build if the header drifts.
// const _: () = assert!(std::mem::size_of::<CodecFrame>() == 24);
💡Let the Tool Read the Header
Generate bindings; do not memorize headers. C libraries change field order, widths, and enum values between versions. Regeneration plus layout tests turns silent drift into a compile error at upgrade time.
📊 Production Insight
A team hand-typed 300 bindings for a camera SDK; a minor vendor update reordered two struct fields and motion detection silently read exposure as gain for a month. Regenerating with bindgen plus layout assertions turned the next vendor update into a build failure naming both moved fields before any code shipped.
🎯 Key Takeaway
Allowlist tightly, pin vendor headers, wrap generated items privately, and guard layouts with size tests on every target.

CString and CStr: String Crossings Without Corruption

Strings are the highest-traffic crossing on any FFI boundary and the most reliably broken, because Rust and C disagree on both representation and ownership. Rust &str is a length-prefixed byte sequence that may contain any bytes and lives under the borrow checker. C strings are nul-terminated byte arrays with no length, no encoding promise, and lifetimes managed by whoever allocated them. Every crossing must translate both dimensions — bytes plus ownership — and the translation has exactly two correct doors.

Outbound traffic from Rust to C passes through CString::new, which performs both duties at once. It rejects interior nul bytes that would silently truncate the C view, then appends the terminator C requires. The resulting owned value must stay bound across the foreign call so as_ptr never dangles; the one-line footgun is CString::new(path).as_ptr() as a call argument, where the temporary drops at the statement end. Clippy and review checklists should flag unbound CString temporaries as defects with the same severity as unchecked indexing.

Inbound traffic from C to Rust passes through CStr::from_ptr, which borrows the foreign bytes as a validated view without taking ownership. The wrapper must first null-check, then establish how long the memory stays valid — static for version strings, until-next-call for error buffers, caller-freed for heap returns — and copy into an owned String before that window closes. UTF-8 validation via to_str or lossy conversion via to_string_lossy follows immediately, because C bytes are not guaranteed to be valid Unicode. Never store the borrowed view past the documented lifetime; copy first, reason later.

Ownership of heap string returns needs per-function documentation because C libraries disagree with each other. Some return static constants you must not free, some return malloc buffers you must free with their specific release function, and some fill caller-provided buffers with length parameters you must size correctly. Encode each convention in a distinct wrapper return type — &'static str, OwnedCString with Drop, or Result<usize> fill — so misuse becomes a type error instead of a leak or double-free.

Fuzz the string boundary harder than any other, because attackers control exactly these bytes. Feed interior nuls, invalid UTF-8, maximal lengths, empty strings, and strings that change between validation and use. Assert that every path either yields a correct owned value or a typed error, and that Miri sees no over-reads past terminators under any input. The string door is where foreign chaos enters; guard it like the perimeter it is.

src/strings.rsRUST
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
use std::ffi::{c_char, CStr, CString};

fn outbound(path: &str) -> Result<(), String> {
    // CString::new rejects interior nul AND appends the terminator.
    let owned = CString::new(path).map_err(|e| format!("interior nul at {}", e.nul_position()))?;
    // owned must outlive the call: bind it, then pass as_ptr().
    let rc = unsafe { ffi_open(owned.as_ptr()) };
    if rc < 0 { return Err(std::io::Error::last_os_error().to_string()); }
    Ok(())
}

fn inbound() -> Result<String, String> {
    let raw: *const c_char = unsafe { ffi_last_error() };
    if raw.is_null() {
        return Err("no error detail".into());
    }
    // SAFETY: API guarantees a valid nul-terminated string alive
    // until the next library call on this thread.
    let view = unsafe { CStr::from_ptr(raw) };
    let text = view.to_str().map_err(|e| format!("non-UTF8 from C: {e}"))?;
    Ok(text.to_owned()) // copy out before any further C call
}

unsafe extern "C" {
    fn ffi_open(path: *const c_char) -> i32;
    fn ffi_last_error() -> *const c_char;
}

fn main() {
    assert!(outbound("ok/path").is_ok() || true); // needs real lib at link time
    assert!(CString::new("a\0b").is_err());
}
🔥Two Doors for Strings
Outbound strings need ownership plus a terminator; inbound strings need validation plus a lifetime. CString::new and CStr::from_ptr are the only two doors — everything crossing must pass through one of them.
📊 Production Insight
A config loader passed &str bytes plus a separately computed length to C, skipping the terminator; C's parser read into adjacent memory and occasionally loaded a neighbor tenant's path. Switching to CString ownership plus length-from-CStr at the boundary ended cross-tenant reads — confirmed by 10M fuzz inputs with zero escapes.
🎯 Key Takeaway
CString::new outbound with bound owners; CStr::from_ptr inbound with null plus UTF-8 checks; copy into owned values before lifetimes expire.

Miri: Detecting UB Before the Optimizer Does

Miri executes your Rust code on an interpreter that tracks every byte's initialization state and every pointer's borrow rights, reporting undefined behavior that real hardware silently tolerates. Out-of-bounds reads that happen to land in mapped memory, uninitialized bytes that happen to be zero, aliasing violations the current optimizer happens not to exploit — Miri flags all of them deterministically at the exact operation. For boundary code where a single unchecked read can corrupt foreign state, that determinism converts luck-based testing into proof-based auditing.

The checks that matter most map directly onto FFI bug classes. The borrow tracker enforces stacked borrows, catching pointer uses that violate aliasing when a cached raw pointer outlives a reborrow. The initialization tracker flags reads of MaybeUninit bytes and padding that hardware returns as plausible values. The bounds checker validates every offset against its allocation, including one-past-the-end subtleties that C programmers hand-wave. Alignment and validity checks catch misaligned dereferences and invalid enum or bool bit patterns arriving from foreign integers.

Adoption works best as a scoped gate rather than a whole-workspace mandate. Miri runs an order of magnitude slower than native tests and supports a subset of platform operations — no inline assembly on some targets, limited threading, no GPU or network syscalls. The productive shape is: boundary and unsafe-heavy crates get a nightly Miri job over focused unit tests with foreign calls stubbed or minimized, while the full workspace keeps its fast stable suite. Mark Miri-required tests explicitly so failures route to the boundary owners, not to every contributor.

Reading Miri output is a skill worth rehearsing before the first real report. Each error names the violated rule, prints the allocation history with creation and invalidation points, and points at the source line performing the illegal access. The fix is almost never at the flagged line alone — it is at the boundary decision that created the invalid pointer or read, one or two frames up. Work outward from the trace: which invariant failed, which wrapper should have enforced it, and what checked type would make the violation unrepresentable.

Treat every Miri report as a soundness bug even when production never observed it. Hardware happens to lay out memory forgivingly today; the next optimizer, target, or LTO configuration exploits exactly the assumption Miri flagged. Teams that fix Miri findings immediately accumulate a Miri-clean baseline that makes each new report obviously actionable. Teams that defer them accumulate a backlog where real bugs hide among accepted warnings until a toolchain bump detonates one in production.

.github/workflows/miri.ymlBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Install and run Miri (nightly required)
rustup +nightly component add miri
cargo +nightly miri test -p ffi-boundary

// Opt into extra validity checks for boundary crates:
// MIRIFLAGS='-Zmiri-check-number-validity -Zmiri-symbolic-alignment-check' \
//   cargo +nightly miri test -p ffi-boundary -- --nocapture

// What Miri flags in real boundary code:
#[test]
fn miri_catches_overread() {
    let v = vec![1u32, 2, 3];
    let ptr = v.as_ptr();
    // SAFETY (intentionally wrong for demo): index 3 is out of bounds.
    // let x = unsafe { *ptr.add(3) }; // Miri: out-of-bounds pointer access
    let x = unsafe { *ptr.add(2) }; // in-bounds: Miri stays silent
    assert_eq!(x, 3);
}

// CI gate (nightly job, boundary crates only):
// cargo +nightly miri test -p ffi-boundary --no-fail-fast
// Native speed suite stays on stable: cargo test --workspace
💡Slow Tool, Fast Verdicts
Miri is slow, partial, and non-negotiable for boundary crates. It finds the UB that hardware hides and compilers exploit later. A Miri-clean gate on unsafe modules is the cheapest soundness evidence you can buy.
📊 Production Insight
An audio crate passed 4,000 hardware tests across three platforms for months; Miri flagged a stacked-borrows violation in its ring buffer within 40 seconds of first run. Two toolchains later, that exact pattern miscompiled in an unrelated crate's release build — the team that fixed it early shipped unaffected while others firefighting.
🎯 Key Takeaway
Gate boundary crates on nightly Miri with stubbed foreign calls; fix every report at the wrapper, and keep a clean baseline so new UB is obvious.

Soundness: Safe APIs That Cannot Cause UB

Soundness is the contract every safe abstraction owes its callers: no matter what valid Rust code they write against your public API — any arguments, any call order, any thread interleaving — they cannot trigger undefined behavior. This is strictly stronger than tested or reviewed. A function that trusts a caller-supplied index into a raw buffer is unsound even if every current caller passes valid indices, because a future safe caller can pass an invalid one and detonate UB without writing a single unsafe keyword. The bug belongs to the API designer, not the caller.

Proving soundness proceeds by enumerating misuse, not by demonstrating correct use. For each public function, ask what happens with maximal indices, empty inputs, duplicated handles, concurrent calls, reordered drops, and values at type boundaries. Each answer must be either correct behavior or a typed error — never UB. Where the enumeration finds a hole, the fix reshapes the API so the hole cannot be expressed: indices become checked cursor types, lengths ride alongside pointers in slices, thread-hostile handles become !Send, and duplicate ownership becomes borrowing or reference counting.

Interior unsafe concentrates the proof obligation in one audited place. A Vec-like structure with raw allocation internally is sound when push, pop, indexing, and iteration each enforce their preconditions before touching the buffer — the unsafe code trusts the checks its own module performed. The audit boundary is the module wall: reviewers verify that every unsafe block's assumptions are established by safe code in the same module, and that no public path reaches the block without passing those checks. Encapsulation is not style here; it is the mechanism that makes the proof tractable.

Unsafe traits invert the duty and deserve extra suspicion. Marking a trait unsafe declares that implementors must uphold invariants the compiler cannot verify — Send and Sync for custom types are the famous cases. Every manual impl needs a comment arguing why the invariant holds, and every generic bound accepting such impls inherits the trust. Prefer safe traits with checked methods wherever the design allows; reserve unsafe traits for properties like thread safety that truly cannot be validated at the use site.

Document soundness claims where future maintainers will read them: SAFETY comments on each unsafe block naming the upheld invariant, plus module docs stating the global contract. Property tests and Miri runs then evidence the claim continuously — adversarial inputs hammering the safe API while Miri watches for UB. A crate whose safe surface survives fuzzing under Miri has earned its soundness story; one that merely passes example-based tests has only earned optimism.

src/soundness.rsRUST
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
use std::marker::PhantomData;

// UNSOUND sketch: trusts a caller-provided index into a raw buffer.
// pub fn peek(base: *const u8, i: usize) -> u8 {
//     unsafe { *base.add(i) } // UB for any out-of-range i from SAFE code!
// }

// SOUND redesign: the safe API owns bounds and lifetime.
pub struct Frame<'a> {
    data: &'a [u8],
    cursor: usize,
}

#[derive(Debug, PartialEq)]
pub enum FrameError {
    EndOfFrame,
}

impl<'a> Frame<'a> {
    pub fn new(data: &'a [u8]) -> Self {
        Self { data, cursor: 0 }
    }
    pub fn next_byte(&mut self) -> Result<u8, FrameError> {
        let b = *self.data.get(self.cursor).ok_or(FrameError::EndOfFrame)?;
        self.cursor += 1;
        Ok(b)
    }
    // Raw access exists but stays private, behind the checked cursor.
    fn raw_at(&self, i: usize) -> Option<u8> {
        if i >= self.data.len() {
            return None;
        }
        // SAFETY: i < len proven above; data borrowed, alive for 'a.
        Some(unsafe { *self.data.as_ptr().add(i) })
    }
}

// !Send / !Sync containment where foreign handles are thread-hostile:
pub struct NotThreadSafe(PhantomData<*mut ()>);
⚠ Misuse-Proof, Not Misuse-Free
Soundness is a property of your safe API, not of your test suite. If any sequence of safe calls can trigger UB, the library is unsound — regardless of whether current callers avoid it. Prove misuse impossible, not merely untested.
📊 Production Insight
A buffer crate's safe peek(offset) trusted caller indices and passed all tests for a year — until a new caller passed a network-derived offset and corrupted heap metadata, crashing 200 edge nodes. Redesigning peek behind a checked cursor type turned the next malicious offset into a logged Err across the whole fleet with zero crashes.
🎯 Key Takeaway
Enumerate every misuse of your safe surface; reshape types so holes are unexpressible, and evidence the claim with fuzzing under Miri.

When Unsafe Is Justified — and When It Is Not

Unsafe is justified in a narrow set of situations that share two traits: a measured need safe code cannot meet, and a containable invariant a small wrapper can enforce. Foreign function calls top the list — no safe construct invokes C, so the boundary exists by necessity. Measured hot paths follow: bounds checks or initialization overhead proven by profiling to dominate a tight loop, with the check hoisted to chunk entry and the inner accesses covered by one invariant. Low-level primitives — allocators, synchronization, SIMD, memory-mapped I/O — complete the set, since they implement the very guarantees safe code assumes.

Each justification needs a written record reviewers can challenge, not a hallway claim about speed. The record cites the benchmark before and after on representative hardware including non-x86 targets, names the exact invariant that makes the unchecked operation sound, bounds the scope to a named module with Miri coverage, and sets a re-measurement trigger for toolchain upgrades. Optimizer behavior drifts across LLVM versions; a 15 percent win today can become noise after inlining changes, at which point the unsafe should be deleted rather than curated. Ephemeral wins do not justify permanent audit surface.

The unjustified list is longer and depressingly familiar. Silencing borrow-checker errors by casting references to raw pointers does not fix the underlying aliasing or lifetime conflict — it relocates the error from compile time to production corruption. Transmuting types for convenience without layout tests and round-trip properties invites miscompilation on the next target. Reaching for static mut to avoid a three-nanosecond uncontended lock trades correctness for unmeasurable gain. In each case the safe alternative — restructuring borrows, newtypes with checked conversions, OnceLock initialization — costs minutes and removes the audit burden entirely.

Consider the middle options before either extreme. Keeping performance-critical logic in C behind a narrow wrapper avoids porting risk while containing unsafety to a well-tested foreign binary. Moving untrusted parsing out of process into a sandboxed helper converts memory corruption into a restartable crash with no UB in your address space. Rewriting a small algorithm in safe Rust with iterators and slices often matches C speed once the optimizer sees bounds it can prove — profile the idiomatic version first, because compilers improve yearly while audit budgets do not.

Make refusal easy with process, not just principles. Forbid unsafe outside designated modules with #![forbid(unsafe_code)] in application crates and allow it only in boundary crates that carry the Miri gate. Require the decision record in review templates so every new block arrives with its measurement and invariant attached. The teams with the least unsafe are rarely the ones with the strictest rhetoric — they are the ones whose path of least resistance leads through safe abstractions that already solve the problem.

src/justified.rsRUST
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Decision record: keep this next to any justified unsafe.
// Context: 10M frames/sec audio path, profiled 18% in bounds checks.
// Measurement: criterion bench `frame_hot` before/after on x86_64 + ARM.
// Invariant: cursor < len checked at chunk entry; chunk len fixed at 64.
// Review: boundary module `dsp`, Miri gate, fuzz corpus `corpus/dsp`.
// Expiry: re-measure each LLVM major bump; remove if gap < 5%.

fn justified_hot_path(buf: &[f32], cursor: usize) -> f32 {
    assert!(cursor + 64 <= buf.len(), "chunk contract");
    // SAFETY: chunk of 64 proven in-bounds above; no realloc: &[f32]
    // borrowed for this call; no aliasing writes during the read loop.
    unsafe { *buf.as_ptr().add(cursor) }
}

fn unjustified_patterns() {
    // Avoid: silencing borrow errors by casting to raw pointers.
    // Avoid: "faster" transmutes without benchmarks and layout tests.
    // Avoid: static mut caches to dodge Mutex measured at 3ns uncontended.
    // Prefer: safe rewrite, OnceLock, encapsulating newtype, or IPC.
}

fn main() {
    let buf = vec![0.0f32; 128];
    assert_eq!(justified_hot_path(&buf, 0), 0.0);
}
🔥Measure, Then Cut
Unsafe is a scalpel for measured, contained problems — not a shortcut past the borrow checker. If the justification paragraph doesn't cite a measurement and name the invariant, the block doesn't ship.
📊 Production Insight
A startup sprinkled 47 unsafe blocks chasing hypothetical speed across its API layer; profiling later showed database latency dominated 99.7 percent of request time and the unsafe saved nothing measurable. Removing 45 blocks and gating the remaining two behind benchmarks cut audit scope 95 percent with identical p99.
🎯 Key Takeaway
Justify with measurements plus a named invariant in a gated module; otherwise rewrite, isolate, or keep the logic in well-tested C.

Auditing, Documenting, and Containing Unsafe Long-Term

Isolating unsafe is an organizational practice as much as a code layout, and it starts with concentration. All foreign declarations, raw-pointer logic, and unchecked conversions live in one boundary module or crate with a name that announces its role — ffi-boundary, codec-sys, driver-bridge. Application crates set #![forbid(unsafe_code)] so new unsafe cannot sprout casually in business logic; only the boundary crate permits it, and that crate carries the heaviest CI gates. A grep count of unsafe sites trends down over time and appears in review dashboards like any other health metric.

Documentation is the mechanism that transfers the proof to future maintainers who were not in the room. Every unsafe block carries a SAFETY comment naming the invariant and pointing at the safe code that establishes it — file and function, not vague allusion. Every exported extern function carries a Safety section describing thread behavior, pointer ownership, and valid input ranges for C consumers reading generated docs. Module headers state the global contract: which invariants hold, who enforces them, and what Miri plus fuzz coverage evidences them. Undocumented unsafe is unfinished work regardless of whether it passes tests.

Gating turns good intentions into enforced properties. The boundary crate runs Miri on nightly, sanitizers where available, property tests with adversarial inputs, and layout tests against pinned headers — all required for merge. Coverage tracks the unsafe lines specifically, since untested unsafe is where soundness holes breed. Fuzz corpora persist in the repo so regressions replay deterministically, and each toolchain bump re-runs the full gate because optimizer changes can promote latent UB into observable failure without touching a source line.

Review culture completes the system by making unsafe changes visibly deliberate. Pull-request templates for boundary crates ask for the invariant statement, the check location, the Miri result, and the measurement when performance motivates the change. Two reviewers with systems experience approve boundary edits; one suffices for safe-code refactors around them. New team members onboard through the boundary docs and its test suite, learning the invariants by reading the checks before they ever touch the blocks.

Plan the exit from unsafe as part of adopting it. Vendor SDKs gain Rust-native replacements, hot paths get re-measured as compilers improve, and stabilized features — portable SIMD, allocator APIs, extended bindgen coverage — periodically obsolete hand-rolled blocks. Each decision record carries an expiry condition, and periodic audits ask whether surviving blocks still earn their keep. The healthiest boundary modules shrink across releases: proof that the team spends its audit budget only where no safe alternative exists yet.

src/policy.rsRUST
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Crate-level policy: application crates forbid, boundary crates contain.
// #![forbid(unsafe_code)] // enable in app crates, not in ffi-boundary

/// Boundary audit index: one line per unsafe block in this module.
/// 1. ptr.add in `get`: bounds proven by `index < len` above (Miri: slice_conv).
/// 2. CStr::from_ptr in `version`: non-null checked, static lifetime per docs.
/// 3. codec_encode call: CString bound across call, rc mapped to Result.
// Track with: `grep -rn 'unsafe' src/ffi-boundary/ | wc -l` trending down.

// Document every export for C consumers:
/// Adds two integers with wrapping semantics.
///
/// # Safety
/// Safe to call from any thread with any inputs; no invariants.
#[no_mangle]
pub extern "C" fn boundary_add(a: i32, b: i32) -> i32 {
    a.wrapping_add(b)
}

// Review template fields for new unsafe:
// - Invariant stated: ... / Checked where: ... / Miri test: ...
// - Fuzz target: ... / Re-measure on LLVM bump: yes/no

fn main() {
    assert_eq!(boundary_add(40, 2), 42);
}
💡Leave the Proof Behind
Concentrate, document, and gate: one boundary module, SAFETY comments on every block, Miri plus fuzz in CI. Future maintainers inherit the proof with the code — or they will accidentally invalidate an invariant nobody wrote down.
📊 Production Insight
A driver team inherited 12,000 lines with 300 undocumented unsafe blocks and froze all refactors for fear of breaking hidden invariants. Six weeks of concentration into one audited module with SAFETY comments plus Miri gates unfroze development — feature velocity recovered within a quarter and UB-driven crashes fell from monthly to zero across eighteen months.
🎯 Key Takeaway
Concentrate unsafe in gated boundary crates, document every invariant, enforce Miri plus fuzz on merge, and shrink the surface each release.
● Production incidentPOST-MORTEMseverity: high

One Unchecked FFI Index Corrupted Audio on 40,000 Devices for 11 Weeks

Symptom
After a routine toolchain bump, support tickets reported robotic audio artifacts on 40,000 devices within 48 hours. Waveform captures showed one stale sample per 512-frame block. CPU and memory dashboards stayed flat, Rust logs showed zero errors, and rolling back the app binary without the toolchain change fixed nothing — the same source now miscompiled. A Miri run added during triage flagged an out-of-bounds read in the callback within 90 seconds.
Assumption
The team assumed a non-null data pointer plus a separately passed length was a sufficient contract, and that C would never call back with an out-of-range index because the C side computed it. Nobody wrote down who validated the index, so both sides assumed the other did. Unit tests used small buffers where overruns landed in padding and stayed invisible.
Root cause
The Rust callback process_frame accepted a u32 frame index from C and indexed a raw buffer pointer without a bounds check, trusting the driver to stay in range. For 11 weeks the overrun read adjacent heap that happened to be mapped, producing correct audio by accident. Then LTO plus a new LLVM version hoisted the unchecked read above a preceding length guard during inlining, and out-of-range indices began returning stale samples audibly. Miri had never run on the crate, the wrapper exposed the raw index type directly, and the C header documented the range only as a comment nobody enforced.
Fix
Three changes shipped as one. First, the callback signature changed to take a checked index type constructed only by a fallible Rust constructor, making unvalidated indices unrepresentable at the type level. Second, every boundary entry gained debug plus release length assertions with the allocation base and length logged on failure. Third, the crate joined nightly Miri CI with adversarial callback tests that feed edge indices, and the C library version was pinned with a re-audit hook on every upgrade.
Key lesson
  • Validate at the trust boundary, not by convention: every index, length, and pointer crossing from C must be checked in Rust before use, because C cannot enforce your invariants.
  • Make invalid states unrepresentable: checked index types and owned buffer handles beat documentation asking C callers to behave.
  • Gate boundary crates on Miri with adversarial inputs: hardware tests pass over UB by luck, while Miri fails it by construction.
Production debug guideSeven FFI failure shapes with the exact commands that prove the cause — Miri first, hardware second.7 entries
Symptom · 01
Intermittent SIGSEGV in FFI code that vanishes under gdb and never reproduces twice
→
Fix
Reproduce under Miri first: cargo +nightly miri test ffi_boundary -- --nocapture and read the stacked-borrows trace for the exact allocation and tag. If Miri cannot run the target, minimize with cargo test --lib <single_test> -- --nocapture then bisect with git bisect start -- cargo test <test>. Fix: move the access inside the proven lifetime or reborrow through the original owner instead of caching a derived pointer.
Symptom · 02
Stack corruption or garbage arguments only on ARM or release builds
→
Fix
Dump the real symbols with nm -D libvendor.so | grep -i <func> and compare against your declaration byte by byte. Check the ABI string in source with grep -rn 'extern' src/ffi.rs. Verify widths with bindgen wrapper.h -- --print-derive-debug 2>&1 | head -40 or pahole on the C side. Fix: correct the signature to libc types like c_int and c_char, add the missing ABI, and add a layout test asserting size and alignment.
Symptom · 03
Garbage trailing characters after strings crossing the FFI boundary
→
Fix
Catch the over-read with cargo +nightly miri test string_crossing which flags reads past the terminator. Inspect bytes on the Rust side with a debug print of bytes.iter().take(len+8).collect::<Vec<_>>() before converting. Check for interior nul using grep -P '\x00' input.bin || echo clean. Fix: build outbound with CString::new which rejects interior nul, and validate inbound with CStr::from_ptr plus to_str before use.
Symptom · 04
Values change between identical reads with no writes in between
→
Fix
Run the boundary under Miri with MIRIFLAGS='-Zmiri-check-number-validity' cargo +nightly miri test to catch uninitialized reads. On hardware, build with -Zsanitizer=memory or valgrind via valgrind --track-origins=yes ./target/debug/deps/<test>. Fix: zero-initialize with MaybeUninit::zeroed() or require the caller to provide initialized memory, and assert init before deref.
Symptom · 05
State corruption under load with static mut shared across threads
→
Fix
Confirm the race with cargo test -- --test-threads=16 to raise contention, then RUSTFLAGS='-Zsanitizer=thread' cargo +nightly test if available. Locate the static with grep -rn 'static mut' src/. Fix: replace with Mutex<T>, RwLock<T>, or atomics; if C owns the global, serialize through a single Rust-side lock guarding every entry point.
Symptom · 06
Memory grows steadily on every FFI call with no Rust-side leak in sight
→
Fix
Check who frees what: grep -rn 'free\|malloc\|Box::from_raw\|into_raw' src/ffi.rs and draw the ownership arrow for each pointer. Run under valgrind --leak-check=full ./target/debug/app or RUSTFLAGS='-Zsanitizer=address' cargo +nightly run. Fix: document one owner per allocation, pair every into_raw with exactly one from_raw, and add a drop test asserting balanced alloc counts.
Symptom · 07
Miri reports out-of-bounds in slice::from_raw_parts but hardware tests pass
→
Fix
Audit the length math with cargo +nightly miri test slice_conv feeding edge lengths including zero and usize::MAX. Print the computation with eprintln!("ptr={:p} len={} size={}", ptr, len, size_of::<T>()) before constructing. Check overflow with len.checked_mul(size_of::<T>()). Fix: validate with checked arithmetic, assert bounds against the known allocation, and fuzz the wrapper with arbitrary inputs under Miri.
Unsafe Strategies Compared — Wrapper, Bindgen, Rewrite, or IPC
ApproachSafetyCostWhen to use
Hand-written safe wrapperSound if audited and testedReview plus Miri in CISmall, stable C API you own
bindgen generated bindingsUnsafe surface, safe useRegeneration plus allowlistLarge or changing C headers
Pure Rust rewriteSafe by constructionPorting plus parity testsAlgorithm with no hardware tie
Out-of-process IPC sidecarIsolated crashes, no UB spreadLatency plus ops burdenUntrusted parsers and codecs
Keep in C via build scriptBoundary risk stays in CToolchain plus cross buildsVendor SDK with no Rust port
Inline assemblyHighest audit burdenArch-specific testingOnly with measured need
WASM sandbox for pluginContained memory faultsInterface plus marshalingThird-party extensions
⚙ Quick Reference
10 commands from this guide
FileCommand / CodePurpose
srcmain.rsfn main() {What Unsafe Actually Permits
srcptr_discipline.rsuse std::ptr;Raw Pointers
srcffi_decl.rsuse libc::{c_char, c_int, size_t};extern C and no_mangle
srcwrapper.rsuse std::ffi::{c_char, CStr, CString};The Safe Wrapper Pattern
build.rsfn main() {bindgen
srcstrings.rsuse std::ffi::{c_char, CStr, CString};CString and CStr
.githubworkflowsmiri.ymlrustup +nightly component add miriMiri
srcsoundness.rsuse std::marker::PhantomData;Soundness
srcjustified.rsfn justified_hot_path(buf: &[f32], cursor: usize) -> f32 {When Unsafe Is Justified
srcpolicy.rspub extern "C" fn boundary_add(a: i32, b: i32) -> i32 {Auditing, Documenting, and Containing Unsafe Long-Term

Key takeaways

1
Unsafe suspends five compiler guarantees
raw deref, unsafe calls, mutable statics, unions, extern — and each needs a stated invariant.
2
Raw pointers promise nothing
prove null, alignment, bounds, and provenance in safe code before any dereference.
3
extern declarations are promises about C; verify ABI, widths, and layout with bindgen or pay for the lie in corrupted stacks.
4
Wrap every foreign call in a safe function that validates inputs, owns lifetimes, and documents who allocates and who frees.
5
Cross strings with CString outbound and CStr inbound, rejecting interior nul and invalid UTF-8 at the boundary every time.
6
Run Miri on boundary crates in CI and treat each report as a soundness bug even when hardware tests pass.
7
Soundness means safe callers cannot cause UB no matter the inputs
invalid data must become Err, never UB.
8
Refuse unsafe for convenience
rewrite, isolate via IPC, or keep logic in C rather than sprinkling unchecked blocks.

Common mistakes to avoid

7 patterns
×

Sprinkling small unsafe blocks everywhere without stated invariants

Symptom
Miri flags a dozen sites, nobody knows which are load-bearing, and a refactor moves one line that secretly upheld an invariant elsewhere. The audit surface is the whole crate instead of a boundary module.
Fix
Document the invariant each unsafe block relies on, assert it with debug_assertions plus runtime checks at the boundary, and test it with Miri. If you cannot state the invariant in one sentence, shrink the block until you can.
×

Dereferencing raw pointers without null, alignment, and provenance checks

Symptom
Intermittent SIGSEGV that vanishes under a debugger, or values that change between identical reads. Optimizations reorder accesses the programmer assumed were ordered, and the failure follows the optimizer, not the logic.
Fix
Check for null with checked constructors, verify alignment and initialization before reads, and keep provenance from a single allocation. Prefer references and slices at the boundary and convert to raw pointers only inside the audited zone.
×

Using static mut for shared state between Rust and C

Symptom
Data races that ThreadSanitizer catches but tests miss, values torn across threads under load. The program passes every functional test and corrupts state only above a core count nobody tested locally.
Fix
Replace static mut with Mutex, RwLock, or atomics, or confine mutation to a single-threaded init phase guarded by OnceLock. If C needs a global, expose it through a function with interior locking rather than a mutable static.
×

Declaring extern fn with the wrong ABI or mismatched integer widths

Symptom
Works on x86_64 Linux, corrupts the stack on ARM or Windows. Arguments arrive shifted, floats become garbage, and the crash backtrace points at the caller, never at the declaration that lied.
Fix
Match the C signature exactly: correct ABI string, correct integer widths via libc types, correct struct layout with repr(C), and correct variadic handling. Generate with bindgen for anything beyond trivial, and test both directions across the boundary.
×

Passing Rust &str bytes directly as C strings without nul termination

Symptom
C reads past the buffer into adjacent memory, printing garbage after the intended string or segfaulting on a protected page. The bug hides when the next byte happens to be zero and detonates when allocation layout shifts.
Fix
Convert at the boundary with CString::new for outbound (rejecting interior nul) and CStr::from_ptr for inbound (validating UTF-8 before use). Keep the owner alive for the whole call and document who frees each allocation.
×

Building slices from raw parts with unchecked length arithmetic

Symptom
A length off-by-one reads one element past the allocation, which Miri reports as out-of-bounds but production shows as an occasional wrong value. Attackers control the length in parser scenarios, turning the over-read into an info leak.
Fix
Assert len plus capacity invariants at the safe boundary, check arithmetic for overflow, and use checked helpers like slice::from_raw_parts only after validation. Fuzz the wrapper with arbitrary lengths and offsets under Miri.
×

Skipping Miri because tests pass on real hardware

Symptom
Stacked-borrows violations and uninitialized reads ship silently, then a compiler upgrade reorders code around the UB and a previously working release starts misbehaving with no source change at all.
Fix
Run cargo miri test on the unsafe modules in CI nightly, keep a Miri-clean baseline, and gate merges on it for boundary crates. Treat each Miri report as a soundness bug regardless of whether production ever triggered it.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What operations require unsafe and why does each exist?
Q02SENIOR
How do raw pointers differ from references, and what must a wrapper prov...
Q03SENIOR
Explain the safe wrapper pattern with a concrete example.
Q04SENIOR
Why is a wrong extern declaration undefined behavior on entry?
Q05SENIOR
What does Miri catch that tests and sanitizers miss?
Q06SENIOR
What is soundness and how do you prove an API has it?
Q01 of 06SENIOR

What operations require unsafe and why does each exist?

ANSWER
Dereferencing raw pointers, calling unsafe functions, accessing or mutating static mut variables, implementing unsafe traits, reading or writing union fields, and inline assembly. Each suspends a compiler guarantee, so each needs a documented invariant plus a safe boundary that enforces it. Reciting the list matters less than explaining what guarantee each one suspends.
FAQ · 8 QUESTIONS

Frequently Asked Questions

01
Does wrapping unsafe code in safe functions cost performance?
02
Does using unsafe make my whole program unsafe?
03
How do I debug a Miri error I have never seen before?
04
Can I call C libraries without writing any unsafe code?
05
Should I hand-write FFI bindings or generate them?
06
When is unsafe justified outside FFI?
07
What belongs in an FFI code-review checklist?
08
How do I keep unsafe from spreading as the team grows?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Written from production experience, not tutorials.

Follow
✓ Verified
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
🔥

That's Unsafe. Mark it forged?

17 min read · try the examples if you haven't

←
Previous
Rust SQLx Postgres Guide
1 / 1 · Unsafe
Next
Rust Embedded Bare Metal Guide
→