Wrapping Legacy C Libraries in Rust for Safe Infrastructure Tooling

As infrastructure teams adopt Rust, they encounter a common challenge: critical business logic or protocol parsers remain locked in legacy C libraries. Rewriting everything from scratch is rarely practical. Instead, you can wrap unsafe C code in safe Rust abstractions using Rust's Foreign Function Interface (FFI).
The Problem: Managing Unsafe C Pointers in Rust
Suppose you have a C library that parses custom network packets. Its header (libparser.h) looks like this:
typedef struct Packet {
int id;
char* payload;
} Packet;
// Allocates and parses a packet. Returns NULL on failure.
Packet parse_packet(const uint8_t data, size_t len);
// Must be called to prevent memory leaks.
void free_packet(Packet* pkt);
Calling these functions directly forces users to manage memory manually, missing the safety benefits of Rust.
The Safe Rust Wrapper Solution
You can use the Drop trait to clean up memory automatically, and lifetimes to prevent payload access after deallocation.
use std::ffi::CStr;
use std::os::raw::{c_char, c_int, c_uchar};
// Bindings typically generated by bindgen
#[repr(C)]
struct CPacket {
id: c_int,
payload: *mut c_char,
}
extern "C" {
fn parse_packet(data: const c_uchar, len: usize) -> mut CPacket;
fn free_packet(pkt: *mut CPacket);
}
// Our Safe Abstraction
pub struct Packet {
inner: *mut CPacket,
}
impl Packet {
pub fn parse(data: &[u8]) -> Result<Self, &'static str> {
// SAFETY: We pass a valid pointer and length.
// We check the return value for NULL.
let ptr = unsafe { parse_packet(data.as_ptr(), data.len()) };
if ptr.is_null() {
Err("Failed to parse packet")
} else {
Ok(Packet { inner: ptr })
}
}
pub fn id(&self) -> i32 {
// SAFETY: inner is guaranteed to be non-null and valid.
unsafe { (*self.inner).id }
}
pub fn payload(&self) -> &str {
// SAFETY: C strings must be null-terminated. We tie the lifetime
// of the returned &str to &self, ensuring it doesn't outlive the struct.
unsafe {
let c_str = CStr::from_ptr((*self.inner).payload);
c_str.to_str().unwrap_or("Invalid UTF-8")
}
}
}
// Implement Drop to automatically free the C memory
impl Drop for Packet {
fn drop(&mut self) {
// SAFETY: The pointer is valid and this is only called once.
unsafe {
free_packet(self.inner);
}
}
}
Key Safety Guarantees
#[repr(C)]: Instructs Rust to align with C's memory layout. Without it, Rust might reorder fields and break binary compatibility.*mut CPacketvs&mut CPacket: Raw pointers mark the FFI boundary. Wrapping the raw pointer insidePackethides unsafe operations from consumers.CStr::from_ptr: Converts null-terminated C strings into Rust string slices. Lifetime elision ties the&strlifetime to&self, preventing employ-after-free bugs.Dropimplementation: WhenPacketgoes out of scope, Rust invokesdropto executefree_packet. This prevents memory leaks on the Rust side.
Wrapping unsafe C interfaces offers an incremental path to modernize infrastructure. You preserve proven C logic while building safe interfaces in Rust.