
Ffi Code Review
- 52 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
ffi-code-review is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- ffi-code-review
- AI & Agent Building
- AI-coding skill
Ffi Code Review by the numbers
- 52 all-time installs (skills.sh)
- Ranked #7,086 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill ffi-code-reviewAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 52 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Helps with ai & agent building tasks.
Files
FFI Code Review
Review Workflow
1. Check Cargo.toml -- Note Rust edition (2024 has breaking changes to extern blocks and unsafe attributes), build-dependencies (bindgen, cc, pkg-config), crate-type (cdylib, staticlib), and links key 2. Check build.rs -- Verify link directives (cargo:rustc-link-lib, cargo:rustc-link-search), bindgen configuration, and C source compilation 3. Check extern blocks -- Verify calling conventions, symbol declarations, and safety annotations 4. Check type layout -- Every type crossing FFI must be #[repr(C)] or a primitive FFI type 5. Check string and pointer handling -- CStr/CString usage, null checks, ownership transfers 6. Check callbacks -- extern "C" fn pointers, panic safety across FFI boundary 7. Gates -- Complete Gates below before reporting; do not skip ahead on “internal verification”
Gates
Complete in order. Do not emit findings until Gate 4 passes for each issue.
Gate 1 — Crate context (on disk) PASS when: You opened the reviewed crate’s Cargo.toml (workspace member path if applicable) and recorded edition =, plus any of links, crate-type, or build-dependencies that matter for this FFI. Blocks rationalization: Edition-specific findings (unsafe extern "C" {}, #[unsafe(no_mangle)], etc.) require this — if edition is not 2024, do not flag 2024-only requirements.
Gate 2 — Linkage and binding sources PASS when: If the crate links native code or uses bindgen/pkg-config, you opened build.rs (or the checked-in bindings entry point). If there is no build.rs, you stated that bindings are hand-written and reviewed those extern / include! sites. Artifact: At least one path you opened (e.g. build.rs, src/ffi.rs, or OUT_DIR bindings via include!).
Gate 3 — Code evidence PASS when: Every planned finding has a target [FILE:LINE] from a full function/block you read, not only diff hunks or partial snippets.
Gate 4 — Pre-report protocol PASS when: You loaded and applied the review-verification-protocol skill, including FFI-Specific Verification for repr(C), safety comments, ownership/callbacks, or bindgen-heavy code.
Output Format
Report findings as:
[FILE:LINE] ISSUE_TITLE
Severity: Critical | Major | Minor | Informational
Description of the issue and why it matters.Quick Reference
| Issue Type | Reference |
|---|---|
| C-to-Rust type mapping, repr(C) layout, enums, opaque types | references/type-mapping.md |
| Safe wrappers, ownership transfer, callbacks, build.rs, testing | references/safety-patterns.md |
Review Checklist
extern Blocks and Calling Conventions
- [ ] Foreign function declarations use
extern "C"(explicit, not bareextern) - [ ] Edition 2024:
extern "C" {}blocks written asunsafe extern "C" {} - [ ] Functions exposed to C use
extern "C" fn(not default Rust calling convention) - [ ] Calling convention matches the foreign library (
"C","system"for Win32 API) - [ ]
#[link(name = "...")]specifies the correct library name - [ ]
#[link(name = "...", kind = "static")]used when statically linking
Symbol Management
- [ ] Exported functions use
#[no_mangle]to preserve symbol names - [ ] Edition 2024:
#[no_mangle]written as#[unsafe(no_mangle)] - [ ] Edition 2024:
#[export_name = "..."]written as#[unsafe(export_name = "...")] - [ ]
#[link_name = "..."]used when Rust name differs from C symbol - [ ] Exported items are
pub(only public#[no_mangle]symbols appear in library output)
Type Layout
- [ ] Every struct/union crossing FFI has
#[repr(C)]-- Rust's default layout is undefined - [ ] Primitive types use
std::ffi/std::os::rawequivalents (c_int,c_char,c_void) - [ ] No bare
i32where C usesint-- usec_int(width varies by platform) - [ ] Quirky C types like
__be32use byte arrays ([u8; 4]), not Rust integers - [ ] Enums crossing FFI use
#[repr(C)]or#[repr(u8)]/#[repr(u32)]with explicit discriminants - [ ] C-style bitflag enums use a newtype around an integer (or
bitflagscrate), not a Rust enum - [ ]
#[non_exhaustive]on enums representing C enumerations that may gain new values
String Handling
- [ ] C strings use
CStr(borrowed) orCString(owned), never&strorString - [ ]
CString::new()result is checked for interior null bytes (returnsErron\0) - [ ]
CStringoutlives any*const c_charpointer derived from it via.as_ptr() - [ ] Incoming
*const c_charvalidated withCStr::from_ptr()insideunsafe - [ ] No assumption that C strings are valid UTF-8 -- use
to_str()which returnsResult - [ ] OS paths use
OsStr/OsStringandCStr, not&str
Ownership and Allocation
- [ ] Clear ownership contract: who allocates, who frees
- [ ] Rust-allocated memory freed by Rust (
Box::from_raw), C-allocated freed by C - [ ]
Box::into_raw/Box::from_rawpaired correctly for heap transfers - [ ]
Vec::into_raw_partsused when passing arrays to C (pointer + length + capacity) - [ ] Destructor functions exposed for every opaque Rust type given to C
- [ ] No
Droprunning on C-allocated memory (and vice versa)
Callbacks
- [ ] Callback types are
extern "C" fn(...), not closures orfn(...) - [ ] Callbacks use
std::panic::catch_unwindto prevent panics from unwinding across FFI - [ ] Callback context passed as
*mut c_voidwith safe reconstruction at call site - [ ]
Option<extern "C" fn(...)>used for nullable function pointers (niche optimization)
Bindgen and Build Scripts
- [ ] Bindgen output reviewed for correctness (auto-generated types may need adjustment)
- [ ]
-syscrate pattern used for raw bindings, separate crate for safe wrappers - [ ]
build.rsusescargo:rustc-link-libandcargo:rustc-link-searchcorrectly - [ ]
linkskey inCargo.tomlprevents duplicate linking of the same native library - [ ] Platform-specific bindings generated per-build (not checked in for a single platform)
Safety Documentation
- [ ] Every
unsafeblock has a// SAFETY:comment explaining invariants - [ ] Every public FFI wrapper function documents safety requirements
- [ ] Edition 2024:
unsafe fnbodies use explicitunsafe {}blocks around unsafe ops
Severity Calibration
Critical (Block Merge)
- Missing
#[repr(C)]on types crossing FFI boundary (undefined memory layout) - Wrong string handling:
&str/StringwhereCStr/CStringrequired - Ownership confusion: freeing C-allocated memory with Rust's allocator (or vice versa)
- Panic unwinding across FFI boundary without
catch_unwind - Using Rust enum for C bitflags (invalid discriminant = undefined behavior)
- Passing closure where
extern "C" fnpointer required
Major (Should Fix)
- Missing safety documentation on
unsafeblocks or public FFI functions - No null pointer check on incoming
*const T/*mut Tbefore dereferencing CStringdropped before its pointer is used by C (dangling pointer)- Missing
#[link(name = "...")]causing link failures on some platforms - Edition 2024:
externblock not markedunsafe extern - Edition 2024:
#[no_mangle]not wrapped in#[unsafe(...)]
Minor (Consider Fixing)
- Using
i32instead ofc_intfor Cint(correct on most platforms but not portable) - Missing
#[non_exhaustive]on enums mapping to extensible C enumerations - Verbose manual bindings where bindgen would be more maintainable
- Checked-in bindings without platform guards
Informational
- Suggestions to split raw bindings into a
-syscrate - Suggestions to add opaque wrapper types for distinct
*mut c_voidpointers - Suggestions to use
Option<NonNull<T>>for nullable pointers
Valid Patterns (Do NOT Flag)
- `unsafe extern "C" {}` in edition 2024 -- correct form for foreign declarations
- `#[unsafe(no_mangle)]` in edition 2024 -- correct form for symbol export
- `Option<extern "C" fn(...)>` for nullable callbacks -- niche optimization guaranteed
- `Option<NonNull<T>>` for nullable pointers -- zero-cost nullable pointer pattern
- *`mut c_void` for opaque C types** -- standard when internal layout is irrelevant
- Distinct empty structs wrapping `c_void` for type-safe opaque pointers -- prevents pointer confusion
- `CStr::from_bytes_with_nul_unchecked` with compile-time literal -- safe when literal is known null-terminated
- `extern "C-unwind"` for controlled unwinding -- valid per RFC 2945
- `include!(concat!(env!("OUT_DIR"), "/bindings.rs"))` in bindgen crates -- standard pattern
- `Box::into_raw` / `Box::from_raw` pairs for ownership transfer -- correct pattern when paired
Before Submitting Findings
Complete Gates 1-4 in order before reporting any issue; Gate 4 incorporates the review-verification-protocol skill.
Safety Patterns
Wrapping Unsafe FFI in Safe Rust
The goal of FFI bindings is a safe public API built on unsafe internals. The safe wrapper must enforce all invariants that the C library documents.
// Raw FFI (typically in a -sys crate)
unsafe extern "C" {
fn widget_create() -> *mut Widget;
fn widget_set_name(w: *mut Widget, name: *const c_char) -> c_int;
fn widget_destroy(w: *mut Widget);
}
// Safe wrapper
pub struct Widget {
ptr: NonNull<ffi::Widget>,
}
impl Widget {
pub fn new() -> Result<Self, Error> {
// SAFETY: widget_create returns null on failure, valid pointer otherwise
let ptr = unsafe { ffi::widget_create() };
NonNull::new(ptr).map(|p| Widget { ptr: p }).ok_or(Error::CreateFailed)
}
pub fn set_name(&mut self, name: &str) -> Result<(), Error> {
let c_name = CString::new(name)?;
// SAFETY: self.ptr is valid (maintained by construction),
// c_name is null-terminated and lives through this call
let ret = unsafe { ffi::widget_set_name(self.ptr.as_ptr(), c_name.as_ptr()) };
if ret == 0 { Ok(()) } else { Err(Error::SetNameFailed) }
}
}
impl Drop for Widget {
fn drop(&mut self) {
// SAFETY: self.ptr was allocated by widget_create
// and has not been freed (we own it)
unsafe { ffi::widget_destroy(self.ptr.as_ptr()) }
}
}Key principles for safe wrappers:
- Capture
&vs&mutaccurately -- if C mutates behind a pointer, take&mut self - Use Rust lifetimes to enforce C's lifetime requirements (e.g.,
Device<'ctx>borrowsContext) - Do not implement
Send/Syncunless the C library documents thread safety - Use
PhantomData<*const ()>to suppress auto-Send/Syncfor thread-unsafe types
Ownership Transfer Patterns
Rust-to-C (giving ownership)
// Give C a heap-allocated Rust object
#[unsafe(no_mangle)]
pub extern "C" fn create_config() -> *mut Config {
Box::into_raw(Box::new(Config::default()))
}
// C must call this to free -- never free with C's free()
#[unsafe(no_mangle)]
pub extern "C" fn destroy_config(ptr: *mut Config) {
if ptr.is_null() { return; }
// SAFETY: ptr was created by create_config via Box::into_raw
// and has not been freed yet (caller contract)
unsafe { drop(Box::from_raw(ptr)) }
}C-to-Rust (borrowing C memory)
// C owns the buffer, Rust borrows it
pub fn process_buffer(ptr: *const u8, len: usize) -> Result<(), Error> {
if ptr.is_null() { return Err(Error::NullPointer); }
// SAFETY: caller guarantees ptr is valid for len bytes
// and the memory won't be freed during this call
let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
// ... use slice ...
Ok(())
}The Golden Rule
Rust-allocated memory must be freed by Rust. C-allocated memory must be freed by C. Never mix allocators.
CString Lifetime Pitfall
The most common FFI bug: dropping a CString while C still holds a pointer to it.
// BAD -- dangling pointer! CString is dropped at semicolon
let ptr = CString::new("hello").unwrap().as_ptr(); // DANGLING
unsafe { some_c_function(ptr) }; // undefined behavior
// GOOD -- CString lives long enough
let c_str = CString::new("hello").unwrap();
let ptr = c_str.as_ptr();
unsafe { some_c_function(ptr) }; // c_str still alive
// c_str dropped here, after useCallback Safety
Preventing Panics Across FFI
A panic unwinding past an extern "C" function boundary is undefined behavior. Always catch panics in callbacks:
extern "C" fn my_callback(data: *mut c_void) -> c_int {
let result = std::panic::catch_unwind(|| {
// SAFETY: data was passed as our context pointer
let ctx = unsafe { &mut *(data as *mut MyContext) };
ctx.handle_event()
});
match result {
Ok(Ok(())) => 0,
Ok(Err(_)) => -1, // application error
Err(_) => -2, // panic caught, turned into error code
}
}Passing Context Through Callbacks
C callbacks often take a void* context parameter. Use Box::into_raw to pass Rust state:
let ctx = Box::new(MyContext::new());
let ctx_ptr = Box::into_raw(ctx) as *mut c_void;
// SAFETY: register_callback stores ctx_ptr and passes it to on_event
unsafe { ffi::register_callback(on_event, ctx_ptr) };
// Later, in cleanup:
// SAFETY: ctx_ptr was created by Box::into_raw above
unsafe { drop(Box::from_raw(ctx_ptr as *mut MyContext)) };Error Handling Across FFI
Map C error patterns (return codes, errno, out-parameters) to Result in safe wrappers:
pub fn open_file(path: &CStr) -> Result<FileHandle, Error> {
let fd = unsafe { ffi::open(path.as_ptr(), ffi::O_RDONLY) };
if fd < 0 { Err(Error::from_errno(std::io::Error::last_os_error())) }
else { Ok(FileHandle(fd)) }
}Build.rs Patterns
// build.rs -- linking, bindgen, and C compilation
fn main() {
// Link directives
println!("cargo:rustc-link-lib=ssl"); // dynamic (default)
println!("cargo:rustc-link-lib=static=mylib"); // static
println!("cargo:rustc-link-search=native=/usr/local/lib");
println!("cargo:rerun-if-changed=wrapper.h");
// Bindgen: generate Rust bindings from C headers
let bindings = bindgen::Builder::default()
.header("wrapper.h")
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate().expect("bindgen failed");
let out = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings.write_to_file(out.join("bindings.rs")).unwrap();
// cc crate: compile bundled C source
cc::Build::new().file("src/native/helper.c").compile("helper");
}Review bindgen output for: correct #[repr(C)], pointer mutability matching headers, platform-aware types (c_long not i64), distinct opaque types, and excluded internal-only C functions.
Testing FFI Code
Run tests with sanitizers to catch memory bugs invisible to the compiler:
RUSTFLAGS="-Z sanitizer=address" cargo +nightly test # use-after-free, overflow
RUSTFLAGS="-Z sanitizer=memory" cargo +nightly test # uninitialized reads
valgrind --leak-check=full ./target/debug/my_ffi_tests # leak detectionCommon Pitfalls
| Pitfall | Fix |
|---|---|
Dangling CString pointer | Bind CString to a variable before .as_ptr() |
| Double free | One side allocates, same side frees |
| Use-after-free | Ensure wrapper's Drop runs at the right time |
Missing repr(C) | Add #[repr(C)] to every type crossing FFI |
| Panic across FFI | Wrap callback bodies in catch_unwind |
| Thread-unsafe type across threads | Don't impl Send/Sync without proof |
Atomics and Shared State Across FFI
AtomicXxx vs C _Atomic ABI compatibility
Rust's AtomicU32 has the same in-memory representation as C11's _Atomic uint32_t on every platform Rust currently targets, but neither language spec formally guarantees this. The compatibility relies on (a) matching layout and alignment, and (b) the C11/C++11 memory model being compatible with Rust's (which it is, by design).
Cross-language atomic access is only well-defined when both sides treat the location as atomic. Passing &AtomicU32 as a plain *mut u32 and letting C do non-atomic stores creates a data race from Rust's perspective — undefined behavior even if the bits look right.
// BAD -- C treats it as a plain int and does *p = v;
unsafe extern "C" { fn c_set(p: *mut u32); }
let a = AtomicU32::new(0);
unsafe { c_set(a.as_ptr()) }; // races with any Rust load/store of `a`
// GOOD -- C side declares _Atomic uint32_t* and uses atomic_store_explicit
unsafe extern "C" { fn c_set_atomic(p: *mut u32, v: u32); }
let a = AtomicU32::new(0);
unsafe { c_set_atomic(a.as_ptr(), 1) }; // C guarantees atomic storeAvailability is also platform-gated: AtomicU64 is missing on thumbv6m-*, 32-bit PowerPC, and some RISC-V profiles. Code shared with C that assumes 64-bit atomicity must #[cfg(target_has_atomic = "64")]-gate or fall back to a mutex.
[FILE:LINE] FFI_ATOMIC_PASSED_AS_PLAIN_POINTER—AtomicU{8,16,32,64}::as_ptr()(or&AtomicX as *mut _) passed to a C function whose header declares the parameter as plainuintN_t*rather than_Atomic uintN_t*.[FILE:LINE] FFI_ATOMIC64_NOT_TARGET_GATED—AtomicU64/AtomicI64used across FFI without#[cfg(target_has_atomic = "64")]on a crate that lists embedded targets inCargo.tomlor CI.
Raw pointers, Send, Sync at FFI boundaries
*const T and *mut T are !Send and !Sync by default. Wrapping a C handle in a newtype and adding unsafe impl Send (and sometimes Sync) is the standard pattern — but each line is a safety boundary that must be justified against the C library's documented thread-safety contract.
The standard Send-only pattern (handle moves between threads but is never used from two threads at once):
struct Handle(*mut ffi::OpaqueT);
// SAFETY: libfoo docs §3.2 -- handles may be used from any single thread,
// just not concurrently from multiple threads.
unsafe impl Send for Handle {}
// NOTE: deliberately NOT Sync; concurrent use is documented as UB.Sync is much stronger: it claims &Handle can be shared, i.e. that two threads may call C through the same handle simultaneously. Only sound if the C library documents that handle as thread-safe (e.g. SQLite with SQLITE_THREADSAFE=1, or libcurl multi-handles under documented rules).
// BAD -- libfoo docs say "not thread-safe"; this enables races.
unsafe impl Sync for Handle {}
// BAD -- no safety comment; reviewer cannot verify the claim.
unsafe impl Send for Handle {}
unsafe impl Sync for Handle {}[FILE:LINE] FFI_UNSAFE_SYNC_ON_NON_THREADSAFE_HANDLE—unsafe impl Syncon a wrapper around a C library whose documentation does not assert per-handle thread safety.[FILE:LINE] FFI_UNSAFE_SEND_SYNC_NO_SAFETY_COMMENT—unsafe impl (Send|Sync) foran FFI wrapper with no preceding// SAFETY:comment referencing the C library's threading docs.
UnsafeCell on FFI boundaries
&T in Rust is a promise that the referent will not be mutated for the lifetime of the borrow. A C function declared extern "C" fn c_func(x: &T) that mutates through x violates that promise — undefined behavior even if no Rust code observes the mutation. The correct Rust type for "C may mutate through this borrow" is &UnsafeCell<T> (or a *mut T and an unsafe contract).
// BAD -- C writes through x, but &T promises immutability.
unsafe extern "C" {
fn c_increment(x: &u32); // C does (*x)++
}
// GOOD -- UnsafeCell signals "C may mutate"; or use *mut u32.
unsafe extern "C" {
fn c_increment(x: &UnsafeCell<u32>);
}Similarly, if a *mut T is handed to C, stored there, and later mutated by C from another thread, the Rust side must model the shared-mutable semantics — Atomic*, Mutex<T>, or UnsafeCell<T> with hand-rolled synchronization. Plain &mut T cannot escape this way without violating aliasing rules.
[FILE:LINE] FFI_SHARED_REF_C_MUTATES—extern "C" fn(..&T..)orextern "C" fn(..&mut T..)where the C implementation is documented to mutate*T(or to retain the pointer after return and mutate later).[FILE:LINE] FFI_POINTER_STORED_BY_C_NO_INTERIOR_MUT— a*mut Tis registered with C (callback context, observer list, etc.) and the C side may mutate the pointee from another thread, butTis notAtomic*,Mutex<T>, or wrapped inUnsafeCell.
Threading models — Rust threads vs C-spawned threads
When a C library calls a Rust callback from a thread the C library spawned, that thread is not a std::thread thread. It is a perfectly valid OS thread (kernel-scheduled, has a TLS slot), but Rust's std::thread::current() builds a thread handle lazily, and thread::park/unpark only work with handles that have been observed by both sides.
std::sync::Mutex, RwLock, atomics, and Condvar work across any OS thread — they are backed by OS primitives (futex / SRW / pthread mutex) that do not care which language spawned the thread.
thread_local! works on C-spawned threads (it uses platform TLS), but the per-thread destructors are only guaranteed to run when the thread exits through Rust's thread-exit chain. A C-managed thread pool that recycles workers without going through pthread_exit (or that exits the process without joining) may skip the destructors entirely.
// BAD -- relies on TLS destructor to flush a buffer in a C thread pool.
thread_local! {
static BUF: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
}
// If the C library reuses the OS thread or exits without pthread_exit,
// `BUF`'s Drop never runs and the buffered bytes are silently lost.For C-callback contexts, prefer explicit lifecycle hooks (register-on-enter, flush-on-callback-exit) over TLS destructors.
[FILE:LINE] FFI_TLS_DESTRUCTOR_ON_C_THREAD—thread_local!whoseDropperforms cleanup that must run (flush, unregister), inside a module whose entry point is a callback invoked by C-managed threads.[FILE:LINE] FFI_PARK_UNPARK_ACROSS_C_CALLBACK—thread::parkorThread::unparkused to coordinate with a thread that originated in a C library (theThreadhandle may not refer to that OS thread reliably).
Cross-references
[../../rust-code-review/references/concurrency-primitives.md]—Send/Syncbounds,Mutex/RwLocksemantics, poisoning, async-aware locking.[../../rust-code-review/references/memory-ordering.md]—Relaxed/Acquire/Release/AcqRel/SeqCstpairing rules and the publish-via-Release / observe-via-Acquire pattern.[../../rust-code-review/references/lock-free-patterns.md]— ABA, hand-rolled CAS, hazard pointers, epoch reclamation.
Review Questions
1. Is ownership transfer documented and paired (allocate/free)? 2. Do CString values outlive their derived pointers? 3. Are callbacks wrapped in catch_unwind? 4. Are Send/Sync deliberately not implemented for thread-unsafe FFI types?
Calling conventions — extern "C" vs extern "system" vs extern "Rust"
The calling convention is part of the function pointer's type. fn() -> i32, extern "C" fn() -> i32, and extern "system" fn() -> i32 are three distinct types and cannot be substituted for one another.
| Form | Meaning | When to use |
|---|---|---|
extern "C" | The C ABI as implemented by the target's C compiler | Standard FFI: POSIX, Linux, macOS, Win64. The default when you write bare extern fn. |
extern "system" | The OS-native system ABI | Win32 API entry points. On Win32 x86 this is stdcall; on x64 it equals extern "C". |
extern "Rust" | The unstable Rust ABI (compiler-chosen, changes between rustc releases) | Never across FFI. Implicit when you write fn with no extern qualifier. |
extern "C-unwind" | C ABI but unwinding through the boundary is defined (stable since Rust 1.71) | Only when both sides have agreed that panics may propagate. |
Unwinding across a non-`C-unwind` FFI boundary is undefined behavior. Wrap callback bodies in std::panic::catch_unwind and convert panics to error codes, or use std::panic::abort_unwind (stable since 1.81) — or call std::process::abort() directly — to terminate on unwind.
// BAD -- implicit extern "Rust"; cannot be called by C even if signature matches.
type Callback = fn(*mut c_void, i32);
// GOOD -- explicit C ABI, matches the C-side function pointer type.
type Callback = extern "C" fn(*mut c_void, i32);[FILE:LINE] FFI_EXTERN_FN_MISSING_ABI— function-pointer type written asfn(...)(implicitextern "Rust") where a C library expects anextern "C" fn(...). Add the explicit ABI; the types are not interchangeable.[FILE:LINE] FFI_WIN32_API_EXTERN_C_NOT_SYSTEM— Win32 API declaration usesextern "C"instead ofextern "system". Compiles on x64; crashes on i686 because ofstdcallvscdecl.[FILE:LINE] FFI_PANICKING_BODY_NO_CATCH_UNWIND—extern "C" fnbody can panic (usesunwrap, indexes,?on incompatible types) without a surroundingstd::panic::catch_unwind. Wrap the body or declare the functionextern "C-unwind".
Allocator ownership across the boundary
Whoever allocates also frees. A pointer can travel freely across FFI, but the free call must return to the original allocator. Three patterns:
- Implementation-managed — the library allocates and exposes a paired
*_freefunction. Example:ECDSA_SIG_new/ECDSA_SIG_free. The Rust wrapper calls_newto obtain a handle and_freefrom itsDrop. - Caller-managed — the caller allocates and the library writes into the buffer. Example:
snprintf(buf, n, ...). The Rust side passesVec::as_mut_ptr+ length and reads the result on return. - Mixed (library allocates, caller frees with `free`) — example: POSIX
getline()allocates the line buffer withmallocand returns it for the caller to release withfree. Only when an API explicitly documentsmalloc-family ownership transfer should the Rust caller release withlibc::free; neverBox::from_rawunless the allocation came from Rust.
// BAD -- Box::from_raw on a malloc'd pointer; allocators differ, UB.
let p: *mut u8 = unsafe { libc::malloc(64) as *mut u8 };
unsafe { drop(Box::from_raw(p)) };
// BAD -- libc::free on a Box::into_raw pointer; allocators differ, UB.
let p: *mut u8 = Box::into_raw(Box::new(0u8));
unsafe { libc::free(p as *mut _) };
// GOOD -- each pointer returns to its own allocator.
let c_ptr = unsafe { libc::malloc(64) as *mut u8 };
unsafe { libc::free(c_ptr as *mut _) };
let rust_ptr = Box::into_raw(Box::new(0u8));
unsafe { drop(Box::from_raw(rust_ptr)) };[FILE:LINE] FFI_BOX_FROM_RAW_ON_C_ALLOCATED—Box::from_rawon a pointer returned bymalloc/calloc/library-specific allocator. Use the library's own free function (orlibc::free).[FILE:LINE] FFI_LIBC_FREE_ON_BOX_POINTER—libc::freeon a pointer obtained fromBox::into_raworBox::leak. Reconstruct theBoxwithBox::from_rawand let drop run.
Callbacks across FFI — fn pointers + void* user_data
Closures cannot cross FFI directly: they are anonymous types with no stable layout, and their call convention is extern "Rust". The canonical pattern uses a free extern "C" trampoline plus a void* user-data pointer that carries Box::into_raw(Box::new(closure)).
unsafe extern "C" {
fn register_cb(cb: extern "C" fn(*mut c_void, i32), user_data: *mut c_void);
}
extern "C" fn trampoline<F: FnMut(i32)>(user_data: *mut c_void, event: i32) {
let _ = std::panic::catch_unwind(|| {
// SAFETY: user_data was Box::into_raw'd for type F at registration time.
let closure = unsafe { &mut *(user_data as *mut F) };
closure(event);
});
}
pub fn register<F: FnMut(i32) + 'static>(f: F) -> *mut c_void {
let boxed = Box::into_raw(Box::new(f)) as *mut c_void;
unsafe { register_cb(trampoline::<F>, boxed) };
boxed // caller must Box::from_raw(boxed as *mut F) on unregister
}[FILE:LINE] FFI_CLOSURE_PASSED_DIRECTLY— code attempts to pass a Rust closure where the C signature expectsextern "C" fn(...). Replace with a free-function trampoline plus aBox::into_raw(Box::new(closure))user-data pointer.[FILE:LINE] FFI_CALLBACK_NO_CATCH_UNWIND—extern "C" fntrampoline invokes a closure withoutstd::panic::catch_unwind. Panic across the C boundary is UB.[FILE:LINE] FFI_CALLBACK_USER_DATA_NEVER_RECLAIMED— registration path callsBox::into_rawbut no unregister/cleanup path callsBox::from_raw. Memory leak per registration.
Symbol naming
#[no_mangle] (or #[unsafe(no_mangle)] in edition 2024) preserves the exact source identifier as the symbol name. #[export_name = "..."] (or #[unsafe(export_name = "...")]) overrides the symbol name. On the import side, #[link_name = "..."] inside an extern block renames the imported symbol. Without any of these, Rust mangles the name and C cannot find it.
[FILE:LINE] FFI_NO_MANGLE_NEEDS_UNSAFE_2024— edition 2024 crate uses#[no_mangle]without theunsafe(...)wrapper. The bare attribute is deprecated; switch to#[unsafe(no_mangle)].[FILE:LINE] FFI_LINK_NAME_MISMATCH—#[link_name = "..."]on an extern declaration does not match the symbol the C library actually exports (typo, missing prefix, mangled C++ name). Linker either fails or silently resolves to the wrong symbol.
-sys crate split convention
Hand-written or bindgen-generated raw FFI declarations belong in a sibling *-sys crate (openssl-sys); the safe wrapper lives in the namesake crate (openssl). The split (a) lets the bindings and the wrapper evolve on independent SemVer tracks, (b) lets multiple safe wrappers share one set of raw declarations, and (c) makes the unsafe surface easy to audit. Cargo also forbids two crates linking the same native library (via the links key) from coexisting, so any nontrivial wrapper that does this in-tree forces a major bump on every bindings change.
[FILE:LINE] FFI_BINDINGS_MIXED_WITH_WRAPPER— a crate exposespub extern "C" fn/raw struct declarations alongside its safe wrapper API. Split the raw declarations into a*-syscrate.[FILE:LINE] FFI_SYS_CRATE_DUPLICATE_LINK— two different versions of the same-syscrate end up in the dep graph (both settinglinks = "foo"). Cargo refuses to build; align versions inCargo.toml.
See also: type-mapping.md for C-to-Rust primitive correspondence and #[repr(C)] layout. See ../../rust-code-review/references/unsafe-deep.md for the broader unsafe-block discipline that FFI inherits.
Type Mapping
C-to-Rust Primitive Type Table
Always use std::ffi or std::os::raw types for C interop. Never assume int is i32 on all platforms.
| C Type | Rust Type | Notes |
|---|---|---|
int | c_int | Platform-dependent width |
unsigned int | c_uint | Platform-dependent width |
char | c_char | Signed or unsigned depending on platform |
short | c_short | |
long | c_long | 32-bit on Windows, 64-bit on LP64 Unix |
long long | c_longlong | |
float | c_float / f32 | |
double | c_double / f64 | |
size_t | usize | |
ssize_t | isize | |
void | () (return) / c_void (pointer) | |
void* | *mut c_void | |
const void* | *const c_void | |
char* | *mut c_char | |
const char* | *const c_char | |
bool / _Bool | bool | Only with #[repr(C)]; C99+ |
int8_t | i8 | Fixed-width, always safe |
uint8_t | u8 | Fixed-width, always safe |
int32_t | i32 | Fixed-width, always safe |
uint64_t | u64 | Fixed-width, always safe |
__be32 | [u8; 4] | Big-endian; don't use i32 |
String Types
use std::ffi::{CStr, CString, c_char};
// Borrowing a C string (incoming from C, null-terminated)
// SAFETY: ptr is a valid null-terminated C string
let c_str: &CStr = unsafe { CStr::from_ptr(ptr) };
let rust_str: &str = c_str.to_str()?; // Fails if not UTF-8
// Creating a C string to pass to C
let c_string = CString::new("hello")?; // Err if contains \0
let ptr: *const c_char = c_string.as_ptr();
// c_string MUST outlive ptr -- dropping c_string invalidates ptrFor OS-native paths, use OsStr/OsString which handle platform encoding.
Struct Layout with repr(C)
#[repr(C)] guarantees field ordering, padding, and alignment match the C ABI.
// C definition:
// struct Point { int32_t x; int32_t y; };
#[repr(C)]
pub struct Point {
pub x: i32,
pub y: i32,
}
// C definition with padding:
// struct Mixed { char tag; int32_t value; };
// Has 3 bytes of padding between tag and value
#[repr(C)]
pub struct Mixed {
pub tag: c_char,
// 3 bytes padding inserted by repr(C) to align value
pub value: i32,
}Size and Alignment Verification
Always verify layout matches at compile time or in tests:
// Compile-time assertions
const _: () = assert!(std::mem::size_of::<Point>() == 8);
const _: () = assert!(std::mem::align_of::<Point>() == 4);
// In tests, compare against C sizeof/alignof if available
#[test]
fn layout_matches_c() {
assert_eq!(std::mem::size_of::<Mixed>(), 8); // 1 + 3pad + 4
assert_eq!(std::mem::align_of::<Mixed>(), 4);
}Enum Representation
Fieldless Enums (C-style)
// Maps to: enum Status { OK = 0, ERR = 1, BUSY = 2 };
#[repr(C)]
pub enum Status {
Ok = 0,
Err = 1,
Busy = 2,
}Use #[repr(u32)] or #[repr(i32)] when C specifies an exact underlying type.
Bitflag Enums -- Do NOT Use Rust Enums
C enums used as bitflags produce combined values that are invalid Rust enum discriminants. Use a newtype or bitflags:
// BAD -- value 3 (READ | WRITE) is undefined behavior
#[repr(C)]
enum Permission { Read = 1, Write = 2, Execute = 4 }
// GOOD -- newtype with constants
#[repr(transparent)]
pub struct Permission(pub u32);
impl Permission {
pub const READ: Self = Self(1);
pub const WRITE: Self = Self(2);
pub const EXECUTE: Self = Self(4);
}Data-Carrying Enums (Tagged Unions)
With #[repr(C)], a data-carrying enum becomes a struct with a discriminant field and a union of variant data:
#[repr(C)]
pub enum Event {
Click(i32, i32), // tag=0, data=(i32, i32)
KeyPress(c_char), // tag=1, data=c_char
}
// Equivalent C: struct { uint32_t tag; union { ... } data; }Opaque Types
When C exposes a type whose internals Rust should not access, create a distinct empty type:
// Instead of bare *mut c_void everywhere:
#[non_exhaustive]
#[repr(transparent)]
pub struct DatabaseHandle(c_void);
#[non_exhaustive]
#[repr(transparent)]
pub struct ConnectionHandle(c_void);
unsafe extern "C" {
fn db_open() -> *mut DatabaseHandle;
fn db_connect(db: *mut DatabaseHandle) -> *mut ConnectionHandle;
fn db_close(db: *mut DatabaseHandle);
}
// Now db_connect(conn) is a compile error -- types are distinctThe #[non_exhaustive] prevents construction outside the defining crate.
Nullable Pointers and Option
Rust guarantees that Option<NonNull<T>>, Option<&T>, Option<&mut T>, and Option<extern "C" fn(...)> have the same layout as a raw pointer (niche optimization). None is null.
use std::ptr::NonNull;
// Nullable pointer from C -- zero overhead
fn from_c(ptr: *mut Foo) -> Option<NonNull<Foo>> {
NonNull::new(ptr)
}
// Nullable callback
type Callback = Option<extern "C" fn(c_int) -> c_int>;
unsafe extern "C" {
// C signature: void register(int (*cb)(int));
// cb can be NULL
fn register(cb: Callback);
}Function Pointers
Function pointers across FFI must use extern "C" calling convention. Rust closures cannot cross FFI.
type CCallback = extern "C" fn(data: *mut c_void, status: c_int); // Correct
type BadCallback = fn(data: *mut c_void, status: c_int); // Wrong ABI
// Closures (Fn/FnMut/FnOnce) cannot cross FFI -- unknown size and calling conventionA function pointer's calling convention is part of its type: extern "C" fn() and fn() are different types.