JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Unsafe Rust, the Soundness Contract, and FFI

Safe Rust kept the borrow checker between you and the abyss. Now we open the trapdoor on purpose: what unsafe actually turns off, the contract you must honor so the rest of your program stays sound, and how to call into C across the FFI boundary without quietly breaking everything.

What unsafe actually turns off

Through the first four guides of this rung you watched the borrow checker reject correct-looking code, learned lifetimes, and saw how aliasing XOR mutability keeps shared and mutable references from ever overlapping. It is tempting to imagine that unsafe throws all of that away. It does not. The first honest thing to know about unsafe Rust is how little it actually disables. Borrow checking, lifetime checking, type checking, move checking — every one of them stays fully on inside an unsafe block. The compiler does not relax into C.

What unsafe does is unlock exactly five extra powers, and nothing more: you may dereference a raw pointer (`*const T` or `*mut T`), call a function marked unsafe (including any FFI function), access or modify a mutable static, implement an unsafe trait, and access the fields of a union. That is the whole list. Notice the shape of it — these are precisely the operations whose correctness the compiler cannot prove on its own. An ordinary `&T` is guaranteed by the type system to point at a live, valid `T`; a `*const T` carries no such promise, so reading through it is something only you can vouch for. The unsafe keyword is you raising your hand and saying "I have checked this; trust me."

The soundness contract

Here is the idea everything else hangs on. Rust's safety promise is not "safe code is checked and unsafe code is on its own." It is a deal between the two: safe code may never cause undefined behavior, no matter how it is used. A function is called sound if there is no possible safe input — no argument, no sequence of calls — that drives it into UB. When you write unsafe, your job is to keep that promise on safe code's behalf. The unsafe block is where you do the work the compiler cannot; the soundness contract is the standard that work must meet.

The sharp consequence is that a single unsafe block can be wrong in a way that makes faraway, perfectly innocent safe code corrupt memory. Suppose you write a fast `split_at_mut` that hands out two `&mut` slices into one array using raw pointers. If your index math is off by one so the two slices overlap, you have just minted two mutable references to the same byte — a violation of aliasing XOR mutability. Your unsafe block compiles and may even run fine today, but you have broken the invariant the optimizer relies on, and some safe caller, somewhere, will eventually hit the UB the optimizer was allowed to assume away. This is why we say unsafe code is not where bugs happen — it is where bugs are caused and safe code is where they appear.

Because the blast radius reaches into safe code, the discipline that actually works is encapsulation. You wrap the unsafe deeds inside a module or type, expose only a safe API, and then prove — by hand, in your head and in comments — that no safe use of that API can reach UB. This is exactly how the standard library is built: `Vec`, `RefCell`, `Mutex`, `Arc` are all thin safe shells around unsafe guts, each one a small soundness proof you are trusting. The goal is never to eliminate unsafe; it is to shrink it to a few audited lines and make the boundary sound.

Invariants you must keep, and Miri

Honoring the contract means knowing the specific invariants that raw operations must not violate. A few are constant companions. A reference must always point to a properly aligned, non-null, initialized value of its type for as long as it is live; a `bool` must hold 0 or 1 and nothing else; an enum must hold a valid discriminant. Dereferencing a dangling or misaligned pointer is instant UB even if the program seems to work. And the deepest one, carried over from the previous guide: while a `&mut T` exists, nothing else may read or write that memory, and while a `&T` exists, nobody may write it — even through a raw pointer, even in unsafe.

That last rule is subtle enough that the language community built an operational model to pin it down precisely: Stacked Borrows (and its successor Tree Borrows), the aliasing discipline enforced by Miri. You can picture it as a stack of permissions attached to each location: creating a reference pushes a tag, using it must find that tag still on top, and using a pointer out of turn pops everything above it and invalidates those borrows. The details are research-grade, but the practical payoff is enormous: Miri is an interpreter that runs your tests and screams the moment an unsafe block touches memory it was not entitled to — catching exactly the silent corruption that compiles cleanly and passes at `-O0` but detonates under optimization.

Crossing into C: the FFI boundary

The most common reason to write unsafe is to talk to the enormous world of existing C: libc, OpenSSL, SQLite, the operating system itself. This is FFI, the foreign function interface, and it is unsafe for a precise reason — the C function makes no promises the Rust compiler can read, so calling it is one of the five powers unsafe unlocks. To call C you declare its signature in an `extern "C"` block. The `"C"` chooses the C ABI: the platform calling convention you met back in the assembly rung, which fixes how arguments land in registers like rdi and rsi, how the return comes back in rax, and how the stack is arranged. Names must match the C symbol exactly, because all the linker sees is a symbol — there is no type checking across the boundary at all.

The other half of FFI is making your data look the way C expects. A normal Rust struct has no guaranteed field order — the compiler may reorder fields to pack them tighter. The moment you hand a struct to C you must annotate it `#[repr(C)]`, which pins the fields in declaration order with C's alignment and padding rules so both languages agree on the byte layout. Strings are the classic trap: a Rust `&str` is a pointer plus a length and is not null-terminated, while C expects a `char *s` ending in a 0 byte. You bridge that with `CString` to send and `CStr` to receive. The sketch below shows the whole shape of one safe call into C.

// declare the C function: size_t strlen(const char *s);
extern "C" {
    fn strlen(s: *const c_char) -> usize;
}

// a SAFE wrapper that upholds the contract for every caller
pub fn c_strlen(text: &str) -> Result<usize, NulError> {
    let owned = CString::new(text)?;     // copy + append a trailing 0 byte
    let p: *const c_char = owned.as_ptr();
    // SAFETY: p points to a valid, NUL-terminated buffer that
    // `owned` keeps alive for the whole duration of this call.
    let n = unsafe { strlen(p) };
    Ok(n)                                 // `owned` dropped here, after the call
}
The unsafe call is two lines; the safety work is everything around it. The SAFETY comment records exactly why the contract holds — and why `owned` must outlive the call.

Ownership across the boundary, and Drop

The trickiest FFI bugs are not about types — they are about ownership crossing a border where the borrow checker cannot follow. C has no notion of who owns a pointer, so every C API encodes its own rule in prose: does this function return a buffer you must free(), or one it still owns? If you call a C library's open and it hands back a handle, you typically owe it a matching c_close, exactly the malloc/free pairing you learned in the foundations rung — except now the compiler cannot see the obligation. Forget the close and you leak a C resource that Rust's ownership system never knew existed.

This is where the rung comes full circle. The fix is to put that foreign obligation back under Rust's RAII by giving the handle a Rust owner and implementing the Drop trait for it. You wrap the raw C handle in a struct, hand out only safe methods, and write a `drop` that calls the C cleanup function exactly once. Now the foreign resource is released deterministically at the closing brace, on every path including a panic unwind — the very same guarantee you saw RAII give in C++, here expressed as Drop. The unsafe call to the C destructor lives in one audited place, and every safe user of your wrapper gets correct cleanup for free.

Two honest cautions about Drop at the boundary. First, panics must not be allowed to unwind across an FFI boundary into C — that is undefined behavior, so any Rust function you expose to C with `extern "C"` should catch or abort on panic rather than let it escape. Second, Drop runs on a normal end of life; if a value is leaked (with std::mem::forget, or by a reference cycle of Rc) its drop never fires, so Drop is the right tool for cleanup but not a guarantee against a determined leak. Naming and dating it plainly: this whole FFI machinery has been part of Rust since 1.0 in 2015, and it is how Rust earns the right to call itself a systems language — it can speak C's ABI fluently while keeping the safety contract intact right up to the edge.