Foreign Function Interface (FFI)

FFI Testing and Debugging

Bugs at an FFI boundary—such as mismatched struct field offsets, missing #[repr(C)] attributes, use-after-free across C callbacks, or off-by-one buffer lengths—can cause silent memory corruption if left untested. FFI testing and debugging combines compile-time layout assertions, automated header verification, Miri and AddressSanitizer runs, and cross-language round-trip tests.

FFI Verification Stack:
  1. Compile-Time Layout Checks  (`const { assert!(size_of / align_of / offset_of!) }`)
  2. Automated Binding Sync      (`bindgen` layout tests & `cbindgen` CI diff check)
  3. Rust Pointer/Provenance UB  (`cargo +nightly miri test` on Rust-side wrappers)
  4. Cross-Language Memory Bugs  (`-Zsanitizer=address` across Rust + C compiled code)

1. Compile-Time Layout and Offset Verification

Whenever you write a #[repr(C)] struct by hand to match a C header definition, add a const _: () = { ... }; block that asserts the struct's exact size_of, align_of, and every field's offset_of! at compile time. When bindgen generates bindings from a C header, it automatically emits unit tests verifying these exact layout metrics.

use core::ffi::{c_char, c_int};
use std::mem::{align_of, offset_of, size_of};

/// Matches C `struct c_packet_meta { int32_t id; uint8_t tag[4]; int64_t timestamp; };`
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CPacketMeta {
    pub id: c_int,
    pub tag: [c_char; 4],
    pub timestamp: i64,
}

// Compile-time layout verification: fails compilation immediately if layout drifts!
const _: () = {
    assert!(size_of::<CPacketMeta>() == 16);
    assert!(align_of::<CPacketMeta>() == 8);
    assert!(offset_of!(CPacketMeta, id) == 0);
    assert!(offset_of!(CPacketMeta, tag) == 4);
    assert!(offset_of!(CPacketMeta, timestamp) == 8);
};

fn main() {
    let meta = CPacketMeta {
        id: 101,
        tag: [b'R' as c_char, b'U' as c_char, b'S' as c_char, b'T' as c_char],
        timestamp: 1_700_000_000,
    };
    println!("Verified CPacketMeta layout (16B): id={}, ts={}", meta.id, meta.timestamp);
}
Verified CPacketMeta layout (16B): id=101, ts=1700000000

2. Testing FFI Entrypoints Under Miri for Edge-Case Pointers

Even when the real C caller is an external project, you can test your exported pub unsafe extern "C" fn entrypoints directly inside #[test] functions and run them under cargo +nightly miri test to verify:

  • Passing NULL pointers (std::ptr::null(), std::ptr::null_mut()) returns clean error codes without dereferencing null.
  • Passing (NULL, 0) empty buffers does not trigger slice::from_raw_parts(NULL, 0) Undefined Behavior.
  • Creating, mutating, and destroying opaque handles (Box::into_raw / Box::from_raw) leaves zero leaked allocations and violates no Stacked/Tree Borrows aliasing rules.
#[repr(C)]
pub struct CounterHandle {
    value: u64,
}

#[unsafe(no_mangle)]
pub extern "C" fn counter_new(initial: u64) -> *mut CounterHandle {
    Box::into_raw(Box::new(CounterHandle { value: initial }))
}

#[unsafe(no_mangle)]
pub unsafe extern "C" fn counter_add(ptr: *mut CounterHandle, delta: u64) -> i32 {
    if ptr.is_null() {
        return -1;
    }
    // SAFETY: Caller guarantees `ptr` is valid and unaliased.
    unsafe {
        (*ptr).value = (*ptr).value.saturating_add(delta);
    }
    0
}

#[unsafe(no_mangle)]
pub unsafe extern "C" fn counter_free(ptr: *mut CounterHandle) {
    if !ptr.is_null() {
        unsafe {
            drop(Box::from_raw(ptr));
        }
    }
}

fn main() {
    // 1. Test null-pointer defense
    assert_eq!(unsafe { counter_add(std::ptr::null_mut(), 10) }, -1);

    // 2. Test full create -> mutate -> free lifecycle (Miri verifies zero memory leaks!)
    let h = counter_new(50);
    assert_eq!(unsafe { counter_add(h, 25) }, 0);
    unsafe {
        assert_eq!((*h).value, 75);
        counter_free(h);
        // Also verify freeing NULL is a safe no-op (matching C `free(NULL)` convention):
        counter_free(std::ptr::null_mut());
    }
    println!("FFI lifecycle and null-pointer edge cases verified");
}
FFI lifecycle and null-pointer edge cases verified

3. Debugging Mixed Rust + C Stacks with AddressSanitizer and LLDB

Tool How to Run on Mixed Rust + C Code What It Catches
AddressSanitizer (ASan) Compile C code in cc::Build with .flag("-fsanitize=address") and run Rust with RUSTFLAGS="-Zsanitizer=address" Heap buffer overflows, use-after-free, and mismatched malloc/Box deallocations across the Rust/C boundary.
rust-lldb / gdb rust-lldb target/debug/deps/ffi_test-* Sets breakpoints seamlessly in both Rust functions (b rust_adler32) and C functions (b c_caller), walking unified stack frames via bt.
nm -gU / objdump -T nm -gU target/release/libmy_ffi.dylib Lists all exported dynamic symbols to confirm #[unsafe(no_mangle)] symbols are present and internal Rust symbols stay private.