Skip to content

← Macros, FFI and Type-Driven Design step 27 of 28

Hard Framework

Opaque pointers, variadics, and the FFI hazard catalogue

The two patterns behind essentially every real C binding — opaque handles and variadic functions — plus the consolidated list of every way FFI can invoke undefined behaviour.

pub fn accumulate(events: Vec<i32>) -> Vec<i64>
pub fn format_pairs(items: Vec<(i32, String)>) -> Vec<String>
pub fn truncated(items: Vec<(i32, String)>) -> Vec<bool>

Part (a): context through an opaque handle

Item 18.26 left a gap. A C callback is a bare function pointer with no environment, so how does a callback know which accumulator to add to, which connection to write on, which parser state to update?

The answer, in every C library ever written: an extra void* parameter that the library carries around and hands back to you untouched. qsort_r, pthread_create, sqlite3_exec, every GTK signal handler, every libcurl callback.

Here it is with real exported symbols:

#[unsafe(no_mangle)]
pub unsafe extern "C" fn counter_push(ctx: *mut Counter, value: c_int) { ... }

accumulate boxes a State, leaks the box into a raw pointer, calls the exported function once per event through the C ABI (declared in a nested module, as 18.24 requires), and reclaims the box at the end.

Modelling the handle

Counter is the type the C side sees. It is deliberately impossible to do anything with:

#[repr(C)]
pub struct Counter {
    _data: (),
    _marker: PhantomData<(*mut u8, PhantomPinned)>,
}

This is the Nomicon’s idiom, and every piece earns its place:

  • _data: () is private, so the type cannot be constructed outside its module.
  • PhantomData<*mut u8> makes it neither Send nor Sync, because a C handle usually has thread affinity you cannot see.
  • PhantomPinned makes it !Unpin, because the C side may have stored the address and moving the object would invalidate it.

Do not use an empty enum for this. It is the obvious idea and the Nomicon explicitly warns against it: uninhabited types are UB to hold references to, so &Opaque is instantly unsound even if you never touch it.

Give distinct C handles distinct Rust types. If a library has Parser* and Lexer*, make Parser and Lexer two opaque structs rather than aliasing both to c_void. Then mixing them up is a compile error rather than a segfault.

The three things nothing checks

  • The context must outlive every callback invocation. Reclaim the box while a callback might still fire and you have a use-after-free. There is no lifetime on a *mut c_void.
  • Casting back to the wrong type is UB with no diagnostic. ctx.cast::<State>() is an assertion; the compiler takes your word for it.
  • Reclaiming twice is a double free, same as 18.25.

Clippy helps with exactly one of these: from_raw_with_void_ptr fires if you call Box::from_raw on a *mut c_void directly, because that reconstructs a Box<c_void> and drops the wrong type. Cast to the real type first.

Part (b): variadics

unsafe extern "C" {
    fn snprintf(buf: *mut c_char, size: usize, fmt: *const c_char, ...) -> c_int;
}

... in a declaration is stable and has been for years. Defining a variadic Rust function is not — that is the c_variadic feature and it is E0658 on 1.95. You can call C’s variadics; you cannot write your own for C to call.

Zero type checking, and default argument promotion

Variadic arguments are not type-checked. At all. Passing a &str where %s expects a *const c_char compiles cleanly and hands C a fat pointer it will interpret as an address — undefined behaviour, usually a crash, sometimes worse.

There are also default argument promotions, inherited from C: arguments in the variadic part are promoted before being passed. float becomes double; integer types narrower than int become int. So pass f64 not f32, and c_int not i8, or the callee reads the wrong number of bytes off the stack.

And never, ever pass user input as the format string. snprintf(buf, n, user_input) with a %s in user_input reads an address that was never pushed. This is a class of vulnerability with its own CVE category.

Variadics are the sharpest example of a boundary the type system cannot police. Everything else in this track had some check — improper_ctypes, the unsafe keyword, a lint. Here there is nothing.

The truncation idiom

snprintf writes at most size bytes including the nul, and returns the number of characters that would have been written had the buffer been large enough, excluding the nul.

That return value is the whole trick. It is not “how much I wrote” — it is “how much I needed”. So:

returned < size   ->  the output fits
returned >= size  ->  it was truncated, and `returned` tells you the size to allocate

With a 32-byte buffer, formatting %d-%s with 7 and "ok" produces "7-ok" and returns 4. Formatting with a 40-character string returns 42, and the buffer holds the first 31 characters plus a nul. The comparison is >=, not >, because the nul terminator eats one of the 32 bytes.

Note also c"%d-%s" — a C string literal, stable since Rust 1.77. It is a &CStr with the nul already there, so no allocation and no CString::new. Clippy’s manual_c_str_literals will push you towards it if you write CStr::from_bytes_with_nul(b"%d-%s\0") instead.

The FFI hazard catalogue

Consolidated, because this is what practitioners actually use during review:

  1. A declaration that does not match the real signature. Silent, total, undetectable (18.22).
  2. Dereferencing a dangling or misaligned pointer — including a pointer derived from a temporary (18.25).
  3. Violating aliasing: handing C two mutable pointers to the same object.
  4. A foreign exception crossing a non--unwind boundary, or catch_unwind catching one.
  5. An empty enum used as an opaque type.
  6. Freeing Rust memory with C free, or vice versa (18.25).
  7. Data races on static mut.
  8. Transmuting function pointers across calling conventions.
  9. Calling a null function pointer — which Option<fn> exists to prevent (18.26).
  10. Invalid values: a bool that is not 0 or 1, an out-of-range enum discriminant, a &str that is not UTF-8, a char that is not a scalar value.

On unwinding specifically: since Rust 1.81 a panic escaping an extern "C" function aborts rather than being UB. That was a deliberate safety-over-convenience choice; before 1.81 it was undefined. extern "C-unwind" opts into permitting unwinding, which is what lets a Rust panic propagate through C++ frames that run destructors. Wrapping every exported entry point in std::panic::catch_unwind is standard practice that beginners never think to do.

Related lints worth knowing about, none of them a safety net: clashing_extern_declarations (only catches contradictions within one crate), unsupported_calling_conventions, uses_power_alignment (AIX), ffi_unwind_calls.

And the big one: Miri cannot see across FFI at all. The tool that catches undefined behaviour in unsafe Rust simply stops at the boundary. The usual safety net is absent precisely where the risk is highest.

::: question If all of this is so dangerous, why does anyone hand-write bindings?

Mostly, they do not. bindgen reads C headers and generates the Rust declarations; cbindgen reads Rust and generates a C header. Between them they cover the majority of real binding work, and both need cargo and a build script, which is why they cannot appear in this course.

But that does not make this chapter theoretical — it changes what the chapter is for. The generated code is exactly what you have been writing here: unsafe extern "C" blocks, #[repr(C)] structs, c_int aliases, raw pointers. When bindgen produces something that segfaults — because a header used a macro it could not expand, or a struct had a bitfield, or a callback needed a lifetime nobody could express — the job is to read the generated declarations and find the one that lies.

That is the actual job: reading and auditing generated code. Which is why everything in this article is stated as something to check rather than something to write. :::

Your job

counter_push currently records the raw events instead of the running total, and format_one‘s truncation test is off by one. Fix both.

Loading visualization…