Writing Safe Rust Bindings for C Libraries: Lessons from libasdcp
Building safe Rust wrappers around C/C++ libraries is one of those tasks that sounds straightforward until you’re three days deep in lifetime annotations and opaque pointer types. Here’s what I learned wrapping libasdcp — the reference implementation for reading and writing SMPTE AS-DCP (MXF) files used in digital cinema.
The Challenge
libasdcp is a C++ library with a C-style API surface. It handles:
- Reading/writing encrypted MXF containers
- JPEG2000 frame extraction
- PCM audio essence parsing
- Cryptographic operations for DCP content
The API uses raw pointers extensively, has manual memory management, and relies on return codes for error handling. Classic FFI territory.
Step 1: Raw Bindings with bindgen
Start with bindgen to generate raw FFI bindings:
// build.rs
fn main() {
println!("cargo:rustc-link-lib=asdcp");
println!("cargo:rustc-link-lib=kumu");
let bindings = bindgen::Builder::default()
.header("wrapper.h")
.allowlist_function("ASDCP_.*")
.allowlist_type("ASDCP_.*")
.generate()
.expect("Unable to generate bindings");
bindings
.write_to_file(PathBuf::from(env::var("OUT_DIR").unwrap()).join("bindings.rs"))
.expect("Couldn't write bindings");
}
This gives you a bindings.rs full of unsafe extern functions. It compiles, but it’s not safe Rust — it’s C with Rust syntax.
Step 2: Safe Wrappers
The real work is building a safe API on top. Key patterns:
RAII for Resource Management
pub struct MxfReader {
inner: *mut ffi::ASDCP_MXFReader,
}
impl MxfReader {
pub fn open(path: &Path) -> Result<Self> {
let c_path = CString::new(path.to_str().ok_or(Error::InvalidPath)?)?;
let reader = unsafe { ffi::ASDCP_MXFReader_new() };
let result = unsafe { ffi::ASDCP_MXFReader_OpenRead(reader, c_path.as_ptr()) };
if result != ffi::ASDCP_SUCCESS {
unsafe { ffi::ASDCP_MXFReader_delete(reader) };
return Err(Error::OpenFailed(result));
}
Ok(Self { inner: reader })
}
}
impl Drop for MxfReader {
fn drop(&mut self) {
unsafe { ffi::ASDCP_MXFReader_delete(self.inner) };
}
}
Representing Errors as Types
Convert return codes to proper Rust errors early:
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("failed to open MXF file: code {0}")]
OpenFailed(i32),
#[error("frame read error at index {0}")]
FrameReadError(usize),
#[error("invalid path encoding")]
InvalidPath,
}
Buffer Safety
The trickiest part: libasdcp fills caller-provided buffers. You need to ensure the buffer outlives the read operation and is properly sized:
pub fn read_frame(&self, index: usize) -> Result<Vec<u8>> {
let frame_size = self.frame_size()?;
let mut buffer = vec![0u8; frame_size];
let result = unsafe {
ffi::ASDCP_MXFReader_ReadFrame(
self.inner,
index as u32,
buffer.as_mut_ptr(),
frame_size,
)
};
if result != ffi::ASDCP_SUCCESS {
return Err(Error::FrameReadError(index));
}
Ok(buffer)
}
Lessons Learned
-
Don’t try to be too clever with lifetimes. Sometimes owning data (via
Vec<u8>) is simpler and safer than trying to express complex borrow relationships across the FFI boundary. -
Test with ASAN and Miri. Memory bugs in FFI code are subtle. Run your test suite under AddressSanitizer and use Miri for the pure-Rust portions.
-
Document the safety invariants. Every
unsafeblock should have a comment explaining why it’s sound. Future you will thank present you. -
Integration tests over unit tests. For FFI wrappers, testing with real files catches issues that mock-based unit tests miss entirely.
-
Pin your C library version. API changes in the underlying C library can silently break your
unsafeassumptions. Lock it down and upgrade deliberately.
The result is a crate that lets the rest of our Rust codebase work with DCP files through a completely safe API, while the FFI complexity stays contained in one well-tested module.