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

FFI Boundaries

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

Programming / Rustv1.0.0Updated OCT 21 20266 references8 scripts

Calling C from Rust and exposing Rust to C: declarations, layouts, strings, null handling, ownership, callbacks, panics and threads. Most real-world unsafe Rust lives here, and most FFI bugs are contract mismatches the compiler cannot see.

Contents

Boundary checklist

For every foreign function or exported function:

  • Signature matches the C header exactly (types, pointer constness, return type, variadics, calling convention). Generated by bindgen, or checked against the header.
  • Every pointer coming from C is checked for null (or the contract says it cannot be null and that is documented in # Safety).
  • Every pointer's lifetime is known: borrowed for the call, owned by the handle, or transferred.
  • Every allocation is freed by the allocator that created it.
  • No Rust panic can unwind into C, and no C++ exception or longjmp can cross a frame not declared for it.
  • Enums/bools from C are validated before becoming Rust enums/bools.
  • Thread-safety of the C library is reflected in Send/Sync of the Rust wrapper.
  • The raw bindings are private (mod sys), and the public API is a safe wrapper.

Declaring foreign functions

Edition 2024 requires unsafe extern (accepted since 1.82 in every edition). The unsafe asserts that the declarations are correct; individual items may be marked safe when calling them can never cause UB.

rust
use std::ffi::{c_char, c_int, c_void};

#[link(name = "z")]
unsafe extern "C" {
    pub safe fn zlibVersion() -> *const c_char; // no preconditions: returns a static string
    pub fn crc32(crc: std::ffi::c_ulong, buf: *const u8, len: std::ffi::c_uint) -> std::ffi::c_ulong;
}

unsafe extern "C" {
    fn qsort(
        base: *mut c_void,
        n: usize,
        size: usize,
        cmp: Option<unsafe extern "C" fn(*const c_void, *const c_void) -> c_int>,
    );
}
  • Items without a qualifier are unsafe to call. Mark safe only if every possible argument value is fine (no pointers, no preconditions on integers).
  • std::ffi::c_int, c_char, c_long, c_ulong, c_void etc. are in core::ffi since 1.64; c_long differs between Linux (64-bit) and Windows (32-bit), so never hard-code i64.
  • size_t maps to usize; ssize_t/ptrdiff_t to isize.
  • Variadic C functions (printf) can be declared with ... and called, but Rust cannot define them on stable.
  • The improper_ctypes lint warns when a declaration uses a type with no C layout (e.g. String, &str, a non-repr(C) struct); improper_ctypes_definitions does the same for exported functions.

Types that cross the boundary

Rust typeC typeNotes
#[repr(C)] structstructfield order and padding follow C rules; repr(Rust) layout is unspecified
#[repr(C)] / #[repr(u8)] fieldless enumenumonly for values Rust produces; receiving an unknown value from C is UB
integer newtype #[repr(transparent)] struct Mode(c_int)enum / flags from Cvalidate with TryFrom or keep as integer
bool_Bool / boolsame ABI as C _Bool; NOT the same as int flags: receive c_int and compare with 0
Option<&T>, Option<&mut T>, Option<NonNull<T>>, Option<Box<T>>T* (nullable)guaranteed null-pointer optimisation
Option<extern "C" fn(..)>nullable function pointera bare extern "C" fn must never be null
*const c_charconst char*convert with CStr
&[T]nonepass ptr + len separately
charuint32_tC char is c_char; Rust char must be a valid scalar value
() returnvoid
rust
use std::ffi::c_int;

#[repr(transparent)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RawStatus(c_int); // whatever C sends is a valid c_int

#[derive(Debug)]
pub enum Status { Ok, Busy, Failed(c_int) }

impl From<RawStatus> for Status {
    fn from(s: RawStatus) -> Self {
        match s.0 {
            0 => Status::Ok,
            1 => Status::Busy,
            other => Status::Failed(other),
        }
    }
}

Opaque handles

For C structs Rust never looks inside, use a zero-sized opaque type so Rust code cannot construct, move or dereference one (the pattern bindgen generates and the Rustonomicon recommends; extern type is unstable):

rust
use std::marker::{PhantomData, PhantomPinned};

#[repr(C)]
pub struct sqlite3 {
    _data: (),
    _marker: PhantomData<(*mut u8, PhantomPinned)>, // !Send, !Sync, !Unpin
}

The safe wrapper owns a NonNull<sqlite3>, frees it in Drop, and decides Send/Sync from the library's threading documentation.

Strings: CString and CStr

DirectionTypePitfall
Rust -> C, borrowedCString::new(s)? then c.as_ptr()the CString must outlive every use of the pointer (US010); interior NUL returns NulError
literalc"text" (1.77) -> &'static CStrno allocation, NUL included
C -> Rust, borrowedunsafe { CStr::from_ptr(p) }needs non-null, NUL-terminated, unchanged for the returned lifetime, which is unbounded: bind it to a real lifetime
C -> Rust, bytesCStr::from_bytes_with_nul(b)? / from_bytes_until_nul (1.69)safe, checked
to &strc.to_str()? or to_string_lossy()C strings are not guaranteed UTF-8
Rust -> C, ownership transferredCString::into_raw()C must hand it back to a Rust free function calling CString::from_raw exactly once
rust
use std::ffi::{CStr, c_char};

/// Borrow a C string for as long as `_owner` is borrowed.
///
/// # Safety
///
/// `p` must be non-null, NUL-terminated, and not modified or freed while `_owner` is borrowed.
pub unsafe fn borrow_c_str<'a, O: ?Sized>(_owner: &'a O, p: *const c_char) -> &'a CStr {
    // SAFETY: forwarded from the contract; the lifetime is tied to `_owner` instead of being unbounded.
    unsafe { CStr::from_ptr(p) }
}

Slices and buffers

rust
use std::ffi::c_int;

unsafe extern "C" {
    /// Fills `out` with up to `cap` bytes and returns how many were written, or -1 on error.
    fn read_frame(out: *mut u8, cap: usize) -> c_int;
}

pub fn next_frame(cap: usize) -> Option<Vec<u8>> {
    let mut buf: Vec<u8> = Vec::with_capacity(cap);
    // SAFETY: buf has capacity `cap`; read_frame writes at most `cap` bytes into it.
    let n = unsafe { read_frame(buf.as_mut_ptr(), cap) };
    let n = usize::try_from(n).ok()?; // negative -> error
    assert!(n <= cap, "C library reported more bytes than capacity");
    // SAFETY: read_frame initialised the first n (<= cap) bytes.
    unsafe { buf.set_len(n) };
    Some(buf)
}

Receiving (ptr, len) from C: special-case len == 0 (C often passes NULL), check ptr non-null otherwise, then slice::from_raw_parts (see undefined-behavior-catalog.md).

Ownership: who frees what

Created byFreed byMechanism
Rust Box<T>RustBox::into_raw -> C -> exported foo_free(p) -> Box::from_raw
Rust CStringRustinto_raw -> C -> foo_string_free(p) -> CString::from_raw
Rust Vec<T>RustManuallyDrop + (ptr, len, cap) -> C -> Vec::from_raw_parts(ptr, len, cap)
C mallocC freelibc::free(p.cast()), or the library's own free function
C library objectthe libraryits destroy function in the wrapper's Drop

Never mix: freeing malloc memory via Box/Vec drop, or a Rust allocation with free(), is UB (even if the global allocator happens to be malloc today; a custom #[global_allocator] breaks it).

Exporting Rust to C

rust
use std::ffi::{CStr, c_char, c_int};
use std::panic::{AssertUnwindSafe, catch_unwind};

#[derive(Default)]
pub struct Counter {
    total: u64,
}

/// Creates a counter. Free it with `counter_free`.
#[unsafe(no_mangle)]
pub extern "C" fn counter_new() -> *mut Counter {
    Box::into_raw(Box::new(Counter::default()))
}

/// Adds the length of `word` to the counter. Returns 0 on success, -1 on bad input or panic.
///
/// # Safety
///
/// `c` must be a live pointer from `counter_new`, not used concurrently; `word` must be null or a
/// NUL-terminated string valid for the duration of the call.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn counter_add(c: *mut Counter, word: *const c_char) -> c_int {
    if c.is_null() || word.is_null() {
        return -1;
    }
    // SAFETY: c is non-null and, per the contract, a live, exclusively used Counter.
    let counter = unsafe { &mut *c };
    // SAFETY: word is non-null and NUL-terminated per the contract.
    let word = unsafe { CStr::from_ptr(word) };
    catch_unwind(AssertUnwindSafe(|| {
        counter.total += word.to_bytes().len() as u64;
        0
    }))
    .unwrap_or(-1)
}

/// Frees a counter. Null is ignored.
///
/// # Safety
///
/// `c` must be null or a pointer from `counter_new` that has not been freed.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn counter_free(c: *mut Counter) {
    if !c.is_null() {
        // SAFETY: c came from Box::into_raw in counter_new and is freed only once (contract).
        drop(unsafe { Box::from_raw(c) });
    }
}
  • Exported functions taking pointers should be unsafe extern "C" fn (callable unsafely from Rust too); a safe pub extern "C" fn that dereferences its pointer argument is unsound for Rust callers (US023).
  • #[unsafe(no_mangle)] is required in edition 2024 (US020): two crates exporting the same symbol is UB at link time.
  • Build with crate-type = ["cdylib"] (or staticlib) and generate the header with cbindgen.

Callbacks and user data

C callbacks take a function pointer plus a void* user-data pointer. Use a generic trampoline, and carry panics back across the C frames instead of unwinding through them:

rust
use std::any::Any;
use std::ffi::{c_int, c_void};
use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};

type Visit = unsafe extern "C" fn(user: *mut c_void, value: c_int);

unsafe extern "C" {
    /// Calls `cb(user, v)` synchronously for every stored value; does not keep `user`.
    fn store_for_each(cb: Visit, user: *mut c_void);
}

struct Ctx<F> {
    f: F,
    panic: Option<Box<dyn Any + Send>>,
}

unsafe extern "C" fn trampoline<F: FnMut(i32)>(user: *mut c_void, value: c_int) {
    // SAFETY: `user` is the &mut Ctx<F> passed by for_each, alive and unaliased during the call.
    let ctx = unsafe { &mut *user.cast::<Ctx<F>>() };
    if ctx.panic.is_some() {
        return; // a previous call panicked: skip the rest
    }
    if let Err(payload) = catch_unwind(AssertUnwindSafe(|| (ctx.f)(value))) {
        ctx.panic = Some(payload);
    }
}

pub fn for_each<F: FnMut(i32)>(f: F) {
    let mut ctx = Ctx { f, panic: None };
    // SAFETY: trampoline::<F> matches `Visit` and receives a pointer to `ctx`, which outlives the
    // synchronous call; the library does not retain `user` (documented above).
    unsafe { store_for_each(trampoline::<F>, (&raw mut ctx).cast()) };
    if let Some(payload) = ctx.panic {
        resume_unwind(payload); // re-raise on the Rust side, after C has returned
    }
}

If the C library stores the callback (asynchronous callbacks), the user data must be heap-allocated (Box::into_raw), outlive every possible call, and be freed by an unregister function; the closure must then be Send + 'static if the library calls from other threads.

Panics and unwinding

ABIPanic escaping a Rust function with this ABIForeign exception entering Rust through this ABI
extern "C"aborts the process (since 1.81; was UB before)UB
extern "C-unwind" (1.71)unwinds into the caller (C++ can catch it as a foreign exception)allowed: unwinds through Rust frames, running destructors
extern "Rust" (default)unwinds normallyn/a
  • Wrap exported bodies in catch_unwind and translate to an error code; AssertUnwindSafe is needed for &mut captures, and it is your claim that a half-finished update is acceptable after a panic.
  • Declare imported functions extern "C-unwind" when they may throw C++ exceptions (or call Rust callbacks that are allowed to unwind).
  • longjmp over Rust frames (libpng/libjpeg error handling, Lua errors) is only acceptable across frames with no destructors to run; wrap such libraries in a small C shim that converts the jump into an error return.
  • With panic = "abort" in the profile nothing unwinds, but a panic still kills the host process: library crates loaded into other programs should catch and convert anyway.

Threads, errno and global state

  • Read the C library's threading statement and encode it: "thread-safe" -> Send + Sync; "one thread at a time" -> Send only; "only the creating thread" -> neither (the default for raw pointers).
  • errno is thread-local: read it immediately with std::io::Error::last_os_error() before any other call that may change it.
  • std::env::set_var races with C code calling getenv on other threads (UB in glibc); in edition 2024 it is unsafe (US021). Set environment before spawning threads or pass configuration explicitly.
  • Signal handlers may only call async-signal-safe functions; most of std (allocation, locking, printing) is not.
  • fork() in a multi-threaded process: only async-signal-safe calls until exec (CommandExt::pre_exec is unsafe for exactly this reason).

Tools

ToolUse
bindgengenerate Rust declarations from C headers (in build.rs or ahead of time); keeps signatures in sync
cbindgengenerate C headers from exported Rust functions/types
cxxsafe C++ <-> Rust bridge with checked types for C++ (strings, vectors, unique_ptr)
libc, windows-sys / windowsmaintained platform bindings; do not hand-write stat or ioctl structs
cccompile C shims in build.rs
-sys crate conventionfoo-sys holds raw bindings and linking; foo holds the safe wrapper

Testing FFI code

  • Miri cannot run most foreign functions; structure code so the pointer logic on the Rust side can be tested with Rust stand-ins (a trait over the C API, or #[cfg(miri)] fakes).
  • Run the real integration tests under AddressSanitizer (-Zsanitizer=address); for mixed C/Rust also compile the C side with -fsanitize=address, or use Valgrind on a debug build.
  • Fuzz the safe wrapper's public API with cargo-fuzz (it uses ASan by default).
  • Assert in tests that every *_new is paired with *_free (leak checks in ASan/LeakSanitizer).

Commands: tooling-and-testing.md.

More in Rust

rust-borrow-checker-error-explainer

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

rust-cargo-workspace-designer

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

rust-error-handling-thiserror-anyhow-designer

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

rust-lifetime-annotation-advisor

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

rust-tokio-async-service-builder

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

rust-trait-design-reviewer

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