← Back to Blog

Wrapping Legacy C Libraries in Rust for Safe Infrastructure Tooling



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 CPacket vs &mut CPacket: Raw pointers mark the FFI boundary. Wrapping the raw pointer inside Packet hides unsafe operations from consumers.
  • CStr::from_ptr: Converts null-terminated C strings into Rust string slices. Lifetime elision ties the &str lifetime to &self, preventing employ-after-free bugs.
  • Drop implementation: When Packet goes out of scope, Rust invokes drop to execute free_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.