Rust Unsafe & FFI: Sound Wrappers, C Interop, Miri
Safe wrappers plus Miri catch FFI bugs before prod.
20+ years shipping production backend systems. Written from production experience, not tutorials.
- ✓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
- 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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
One Unchecked FFI Index Corrupted Audio on 40,000 Devices for 11 Weeks
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.- 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.
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.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.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.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.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.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.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.| File | Command / Code | Purpose |
|---|---|---|
| src | fn main() { | What Unsafe Actually Permits |
| src | use std::ptr; | Raw Pointers |
| src | use libc::{c_char, c_int, size_t}; | extern C and no_mangle |
| src | use std::ffi::{c_char, CStr, CString}; | The Safe Wrapper Pattern |
| build.rs | fn main() { | bindgen |
| src | use std::ffi::{c_char, CStr, CString}; | CString and CStr |
| .github | rustup +nightly component add miri | Miri |
| src | use std::marker::PhantomData; | Soundness |
| src | fn justified_hot_path(buf: &[f32], cursor: usize) -> f32 { | When Unsafe Is Justified |
| src | pub extern "C" fn boundary_add(a: i32, b: i32) -> i32 { | Auditing, Documenting, and Containing Unsafe Long-Term |
Key takeaways
Common mistakes to avoid
7 patternsSprinkling small unsafe blocks everywhere without stated invariants
Dereferencing raw pointers without null, alignment, and provenance checks
Using static mut for shared state between Rust and C
Declaring extern fn with the wrong ABI or mismatched integer widths
Passing Rust &str bytes directly as C strings without nul termination
Building slices from raw parts with unchecked length arithmetic
Skipping Miri because tests pass on real hardware
Interview Questions on This Topic
What operations require unsafe and why does each exist?
Frequently Asked Questions
20+ years shipping production backend systems. Written from production experience, not tutorials.
That's Unsafe. Mark it forged?
17 min read · try the examples if you haven't