Writing Safe Rust Bindings for C Libraries: Lessons from libasdcp

· 3 min read
rust ffi cinema

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

  1. 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.

  2. 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.

  3. Document the safety invariants. Every unsafe block should have a comment explaining why it’s sound. Future you will thank present you.

  4. Integration tests over unit tests. For FFI wrappers, testing with real files catches issues that mock-based unit tests miss entirely.

  5. Pin your C library version. API changes in the underlying C library can silently break your unsafe assumptions. 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.