Skip to content
rusthacks.dev
Systems

Safe Rust Bindings for a C Library a Worked Example

A good binding is two crates. The sys crate mirrors the C header exactly, and the safe crate hides the pointers behind types that cannot be misused.

19 min read
Summarize in AI
Flat illustration for the post “Safe Rust Bindings for a C Library a Worked Example”, composed around ffi.

Raw bindings from bindgen compile and call C correctly, and are almost unusable. Every function is unsafe, every string is a *const c_char, every handle must be freed by hand, and every rule about who owns what lives in a C comment that the compiler never reads. A safe binding turns those comments into types. When it is done, a Rust program can use the C library with no unsafe of its own, and the mistakes that C makes easy, a use after free, a leak, a double free, a call from the wrong thread, become compile errors.

This guide wraps the small C key-value store from the previous guide (calling C from Rust) in a safe crate, one decision at a time. Each decision is the answer to a rule in the C header, and the compiler's refusals at the end prove the rules are enforced. It was built and tested on Rust 1.99 (edition 2024) with cc 1.11.1, and every output block is real.

What the C header promised

Wrapping a library starts with reading its contract, because a safe API is only as good as your understanding of the rules it encodes. The store's header makes these promises:

  1. kv_open returns a handle or NULL, and the handle must be freed exactly once with kv_close.
  2. kv_set copies the key and value, so the caller keeps ownership of what it passed. It returns 0 or -1.
  3. kv_get returns a pointer owned by the store, valid until the next kv_set of that key or kv_close, or NULL if absent.
  4. kv_foreach calls a function pointer with an opaque void *.
  5. The header says nothing about threads, so none can be assumed.

Each of them maps to a Rust feature. Promise 1 becomes ownership and Drop. Promise 2 becomes &str arguments converted to temporary CString values. Promise 3 becomes a lifetime. Promise 4 becomes a generic closure parameter. Promise 5 becomes the absence of Send and Sync.

Layer one: the private sys module

The raw declarations live in their own module and are not public. Nothing outside the crate sees an unsafe function.

src/sys.rsrust
//! Raw declarations of the C API. Nothing outside this crate sees them.
#![allow(non_camel_case_types)]
use std::ffi::{c_char, c_int, c_void};

#[repr(C)]
pub struct kv_t {
    _private: [u8; 0],
}

pub type kv_visit_fn = Option<unsafe extern "C" fn(*const c_char, *const c_char, *mut c_void)>;

unsafe extern "C" {
    pub fn kv_open(name: *const c_char) -> *mut kv_t;
    pub fn kv_set(store: *mut kv_t, key: *const c_char, value: *const c_char) -> c_int;
    pub fn kv_get(store: *const kv_t, key: *const c_char) -> *const c_char;
    pub fn kv_len(store: *const kv_t) -> usize;
    pub fn kv_foreach(store: *const kv_t, visit: kv_visit_fn, user_data: *mut c_void);
    pub fn kv_close(store: *mut kv_t);
}

You can write this by hand for a small library or generate it with bindgen, as in the previous guide. Either way, it should be dull and mechanical. All the thinking happens one layer up. In a published binding, this layer would be a separate -sys crate with links = "kv" in its manifest, but a private module keeps the example in one file.

Layer two: the safe API

src/lib.rsrust
//! A safe interface to the C `kv` store.

mod sys;

use std::ffi::{c_char, c_void, CStr, CString, NulError};
use std::ptr::NonNull;

/// Everything that can go wrong, as a normal Rust error.
#[derive(Debug, PartialEq, Eq)]
pub enum Error {
    /// A key, value or name contained a NUL byte, which a C string cannot hold.
    InteriorNul { position: usize },
    /// The C library reported failure, in practice that it ran out of memory.
    Failed,
    /// The library returned text that is not valid UTF-8.
    NotUtf8,
}

impl From<NulError> for Error {
    fn from(e: NulError) -> Self {
        Error::InteriorNul { position: e.nul_position() }
    }
}

impl std::fmt::Display for Error {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Error::InteriorNul { position } => write!(f, "text contains a NUL byte at position {position}"),
            Error::Failed => f.write_str("the kv library reported an error"),
            Error::NotUtf8 => f.write_str("the kv library returned text that is not UTF-8"),
        }
    }
}

impl std::error::Error for Error {}

/// An owned handle to a store. The C store is freed when this value is dropped.
///
/// Holding a `NonNull` makes the type `!Send` and `!Sync` automatically. The C library
/// does not document thread safety, so we do not claim it.
pub struct Kv {
    raw: NonNull<sys::kv_t>,
}

impl Kv {
    pub fn open(name: &str) -> Result<Kv, Error> {
        let name = CString::new(name)?;
        // SAFETY: `name` is a valid NUL-terminated string that outlives the call.
        let raw = unsafe { sys::kv_open(name.as_ptr()) };
        NonNull::new(raw).map(|raw| Kv { raw }).ok_or(Error::Failed)
    }

    pub fn set(&mut self, key: &str, value: &str) -> Result<(), Error> {
        let (key, value) = (CString::new(key)?, CString::new(value)?);
        // SAFETY: `self.raw` is live (we own it), both strings are valid for the call,
        // and `kv_set` copies them. `&mut self` rules out a concurrent reader.
        let rc = unsafe { sys::kv_set(self.raw.as_ptr(), key.as_ptr(), value.as_ptr()) };
        if rc == 0 { Ok(()) } else { Err(Error::Failed) }
    }

    /// The value is borrowed straight from the C store, with no copy. The `&self` borrow
    /// lasts as long as the returned `&str`, so `set` (which needs `&mut self`) cannot run
    /// while it is in use. That is exactly the validity rule the C header documents.
    pub fn get(&self, key: &str) -> Result<Option<&str>, Error> {
        let key = CString::new(key)?;
        // SAFETY: `self.raw` is live; `key` is valid for the call.
        let ptr = unsafe { sys::kv_get(self.raw.as_ptr(), key.as_ptr()) };
        if ptr.is_null() {
            return Ok(None);
        }
        // SAFETY: the C library returns a NUL-terminated string owned by the store, valid
        // until the next `kv_set` of this key or `kv_close`. Both need `&mut self` or
        // ownership, which the returned lifetime (tied to `&self`) forbids.
        let text = unsafe { CStr::from_ptr(ptr) };
        text.to_str().map(Some).map_err(|_| Error::NotUtf8)
    }

    pub fn len(&self) -> usize {
        // SAFETY: `self.raw` is live.
        unsafe { sys::kv_len(self.raw.as_ptr()) }
    }

    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }

    /// Snapshot of every entry, copied into owned strings so it can outlive later `set` calls.
    ///
    /// ```
    /// use safekv::Kv;
    ///
    /// # fn main() -> Result<(), safekv::Error> {
    /// let mut kv = Kv::open("doc")?;
    /// kv.set("a", "1")?;
    /// kv.set("b", "2")?;
    /// let snapshot = kv.entries();
    /// kv.set("a", "changed")?; // fine: the snapshot owns its data
    /// assert_eq!(snapshot, [("a".to_string(), "1".to_string()), ("b".to_string(), "2".to_string())]);
    /// # Ok(())
    /// # }
    /// ```
    pub fn entries(&self) -> Vec<(String, String)> {
        let mut out = Vec::with_capacity(self.len());
        self.for_each(|k, v| out.push((k.to_owned(), v.to_owned())));
        out
    }

    /// Visit every entry in insertion order. The closure may capture and mutate local state.
    pub fn for_each<F: FnMut(&str, &str)>(&self, mut f: F) {
        unsafe extern "C" fn trampoline<F: FnMut(&str, &str)>(k: *const c_char, v: *const c_char, user: *mut c_void) {
            // SAFETY: `user` is the `&mut F` created below and is live for the whole call.
            let f = unsafe { &mut *user.cast::<F>() };
            let (k, v) = unsafe { (CStr::from_ptr(k), CStr::from_ptr(v)) };
            // A panic must not unwind into C. Dropping it here loses the panic, so record it
            // instead in a real wrapper; this version simply stops propagating.
            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&k.to_string_lossy(), &v.to_string_lossy())));
        }
        // SAFETY: `self.raw` is live and `f` outlives the call, which is the only time C uses the pointer.
        unsafe { sys::kv_foreach(self.raw.as_ptr(), Some(trampoline::<F>), (&mut f as *mut F).cast()) };
    }
}

impl Drop for Kv {
    fn drop(&mut self) {
        // SAFETY: we own the handle and this is the only place it is freed.
        unsafe { sys::kv_close(self.raw.as_ptr()) };
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn set_get_and_overwrite() {
        let mut kv = Kv::open("t").unwrap();
        assert!(kv.is_empty());
        kv.set("host", "a.test").unwrap();
        kv.set("port", "80").unwrap();
        kv.set("host", "b.test").unwrap();
        assert_eq!(kv.len(), 2);
        assert_eq!(kv.get("host").unwrap(), Some("b.test"));
        assert_eq!(kv.get("absent").unwrap(), None);
    }

    #[test]
    fn interior_nul_is_an_error_not_a_crash() {
        let mut kv = Kv::open("t").unwrap();
        assert_eq!(kv.set("ba\0d", "x"), Err(Error::InteriorNul { position: 2 }));
        assert!(matches!(Kv::open("na\0me"), Err(Error::InteriorNul { .. })));
    }

    #[test]
    fn for_each_can_capture_state() {
        let mut kv = Kv::open("t").unwrap();
        for (k, v) in [("a", "1"), ("b", "22")] {
            kv.set(k, v).unwrap();
        }
        let mut seen = Vec::new();
        kv.for_each(|k, v| seen.push(format!("{k}={v}")));
        assert_eq!(seen, ["a=1", "b=22"]);
    }

    #[test]
    fn entries_snapshot_survives_a_later_set() {
        let mut kv = Kv::open("t").unwrap();
        kv.set("k", "old").unwrap();
        let snapshot = kv.entries();
        kv.set("k", "new").unwrap();
        assert_eq!(snapshot, [("k".to_string(), "old".to_string())]);
        assert_eq!(kv.get("k").unwrap(), Some("new"));
    }

    #[test]
    fn many_open_and_close_cycles_do_not_crash() {
        for i in 0..2_000 {
            let mut kv = Kv::open("cycle").unwrap();
            kv.set(&format!("key{i}"), "value").unwrap();
        }
    }

    #[test]
    fn unicode_round_trips() {
        let mut kv = Kv::open("t").unwrap();
        kv.set("city", "東京").unwrap();
        assert_eq!(kv.get("city").unwrap(), Some("東京"));
    }
}

Read the file against the five promises.

Ownership: Kv owns the handle

Kv holds a NonNull<kv_t>, a pointer that the type guarantees is not null. Kv::open checks the C return value once, converting a NULL into Err(Error::Failed). From then on every method can assume the handle is valid, and the check cannot be forgotten, because it lives in the only constructor. The fields are private, so no one can build a Kv around a bad pointer.

The Drop impl calls kv_close. That single line eliminates the leak, because the handle is freed when the value goes out of scope on every path, including early returns and panics. It eliminates the double free, because Rust drops a value once and the move rules stop anyone from using it afterward. And it eliminates the use after free, as the third compile error below shows. Of the three bugs C programmers fear most, ownership types remove them entirely.

Strings: errors instead of crashes

The C side wants NUL-terminated strings, and Rust strings can contain NUL bytes. Every method that takes text converts it through CString::new, which returns an error for an embedded NUL. The wrapper does not unwrap that error; it turns it into Error::InteriorNul { position } through a From impl so the ? operator does the conversion. The demo exercises it:

Text
error: text contains a NUL byte at position 3

A caller who passes user input with a stray NUL gets a typed error they can handle, rather than a truncated key, which is what C would have silently stored.

Lifetimes: encoding the pointer validity rule

This is the most important design decision in the file, and it is worth slowing down for. The header says kv_get's result is valid until the next kv_set of that key or kv_close. There are two ways to wrap it.

The cautious way copies the text into an owned String before returning, at the cost of an allocation on every read. It is always safe, and the right choice if reads are rare or the values small. The zero-copy way, used here, returns &str borrowed directly from the C store, and lets the borrow checker enforce the validity rule. The signature fn get(&self, key: &str) -> Result<Option<&str>, Error> has an elided lifetime that ties the returned &str to the borrow of self. While that reference is alive, self is borrowed, and set needs &mut self, so the compiler refuses to compile a program that would invalidate the pointer. The safe interface is a precise transcription of the C comment.

Here is a program that violates the rule, and what the compiler said:

examples/bad1.rsrust
use safekv::Kv;

fn main() {
    let mut kv = Kv::open("s").unwrap();
    kv.set("host", "a.test").unwrap();
    let host = kv.get("host").unwrap().unwrap(); // borrowed from the C store
    kv.set("host", "b.test").unwrap();           // C frees the old value here
    println!("{host}");
}
Text
error[E0502]: cannot borrow `kv` as mutable because it is also borrowed as immutable
 --> examples/bad1.rs:7:5
  |
6 |     let host = kv.get("host").unwrap().unwrap(); // borrowed from the C store
  |                -- immutable borrow occurs here
7 |     kv.set("host", "b.test").unwrap();           // C frees the old value here
  |     ^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
8 |     println!("{host}");
  |                ---- immutable borrow later used here

In C the same sequence compiles, runs, and reads freed memory. In Rust it does not compile, and the message points at the exact line where the C library would free the string. The cost of the guarantee is that code which holds a value across a set must copy it first (.to_string()), which is exactly what the C comment told callers to do.

Mutability: &self and &mut self

The split between get, len and for_each taking &self, and set taking &mut self, is the other half of the lifetime rule. It also happens to match the semantics: reading never changes the store, writing does. In C every function takes the same plain pointer, so the distinction is invisible; the wrapper adds it, and with it the ability to have many readers or one writer, checked at compile time.

Callbacks: a generic trampoline

kv_foreach takes a bare C function pointer and a void *. The wrapper's for_each accepts any FnMut(&str, &str), so the caller can capture variables. Inside is the trampoline pattern: a nested generic extern "C" function that casts the void * back to the closure and calls it. A panic inside the closure must not unwind into C, so the call sits in catch_unwind. The comment in the code is honest about the limit: the panic is swallowed rather than re-raised in the caller, and a production wrapper would record it and resume it after kv_foreach returns. The trampoline is unsafe code that no user of the crate has to read or write.

Using it

With the unsafe code behind the interface, the demo program has none:

examples/demo.rsrust
use safekv::Kv;

fn main() -> Result<(), safekv::Error> {
    let mut kv = Kv::open("settings")?;
    kv.set("host", "example.test")?;
    kv.set("port", "8080")?;

    println!("host = {:?}", kv.get("host")?);
    println!("nope = {:?}", kv.get("nope")?);

    let mut total = 0;
    kv.for_each(|key, value| {
        total += key.len() + value.len();
        println!("  {key}: {value}");
    });
    println!("{} entries, {} bytes of text", kv.len(), total);

    match kv.set("bad\0key", "x") {
        Err(e) => println!("error: {e}"),
        Ok(()) => unreachable!(),
    }
    Ok(())
}
Text
host = Some("example.test")
nope = None
  host: example.test
  port: 8080
2 entries, 24 bytes of text
error: text contains a NUL byte at position 3

The ? operators carry errors up through main, absent keys are None rather than a null pointer to be checked, the closure updates total as a normal local, and the handle is freed when kv goes out of scope, with no call to remember. This is the point of the exercise: the demo reads like a program in any other safe language.

Threads: the decision to leave Send off

The compiler decided something on the crate's behalf. NonNull<T> is neither Send nor Sync, so Kv is neither, automatically. The header was silent about thread safety, so silence is the correct default: a library that does not promise to be thread-safe should be assumed not to be. The effect is that moving a Kv into another thread fails to compile:

Text
error[E0277]: `NonNull<safekv::sys::kv_t>` cannot be sent between threads safely
  --> examples/bad2.rs:5:24
   |
 5 |       std::thread::spawn(move || {
   |       ------------------ ^------
   |       |                  |
   |  _____|__________________within this `{closure@examples/bad2.rs:5:24: 5:31}`
   | |     |
   | |     required by a bound introduced by this call
 6 | |         println!("{}", kv.len());
 7 | |     })
   | |_____^ `NonNull<safekv::sys::kv_t>` cannot be sent between threads safely
   |
   = help: within `{closure@examples/bad2.rs:5:24: 5:31}`, the trait `Send` is not implemented for `NonNull<safekv::sys::kv_t>`
note: required because it appears within the type `Kv`

If you read the library's source or documentation and learn that a handle may be used from one thread at a time but moved between threads, you can opt in deliberately with unsafe impl Send for Kv {} and a comment citing the evidence. If the library is fully thread-safe internally, you can add unsafe impl Sync too. Both are claims about code you did not write, so make them only with documentation or source in hand. If you want sharing without evidence, wrap the handle in a Mutex<Kv> in your application, and the mutex serializes access, which is safe for any library that tolerates use from different threads one at a time.

Use after drop

Since Kv owns the handle, destroying it takes the value away. The compiler does the bookkeeping that C leaves to the programmer:

Text
error[E0382]: borrow of moved value: `kv`
 --> examples/bad3.rs:6:20
  |
4 |     let kv = Kv::open("s").unwrap();
  |         -- move occurs because `kv` has type `Kv`, which does not implement the `Copy` trait
5 |     drop(kv);
  |          -- value moved here
6 |     println!("{}", kv.len());
  |                    ^^ value borrowed here after move

Two lines of C do this without complaint and crash later. Here the second line is rejected, and the message names the move. Because Kv is not Copy or Clone, there cannot be two owners, and the handle is freed exactly once.

Offering both the borrowed and the owned view

The entries method added during testing shows a pattern worth adopting wherever a wrapper hands out pointers the C library owns. Keep the zero-copy accessor, get, for callers who read and move on, and add an owned snapshot, entries, for callers who need data to outlive the next mutation. The snapshot is built on the same for_each machinery, copying each key and value into a String, so it adds no new unsafe code. The doctest demonstrates the difference: after taking a snapshot, a later set is allowed, because the snapshot borrows nothing from kv. The borrow checker's refusal in the first error message turns into a documented escape hatch.

Resist the urge to make the safe type clever. An iterator that yields borrowed pairs straight from the C list would be faster, and the C library gives no guarantee about the list's stability while you hold a pointer into it, so writing one means reading the C source to find out. The callback-based for_each is the access path the header documents, so build on that, and offer an iterator only if profiling shows the snapshot's allocations matter.

Mapping C error conventions

This C library signals failure with a single -1, so Error::Failed is enough. Real libraries are less kind, and the mapping is where wrappers earn their keep. A library that returns negative errno values becomes std::io::Error::from_raw_os_error(-rc), which gives callers kind() and a readable message for free. A library with its own error codes becomes a #[non_exhaustive] enum with one variant per documented code, and an Unknown(i32) for codes added later, so a new library version does not turn into a panic in your match. A library with a thread-local "last error" function, like OpenSSL's, needs the message fetched immediately after the failing call, on the same thread, before any other call clobbers it; put the failing call and the fetch in one private helper so nobody can separate them. In every case the principle matches the rest of the guide: the C caller had to know the convention, and the Rust caller should not.

Putting a global initializer behind a constructor

Many C libraries need lib_init() before the first call and lib_cleanup() at exit. The first is easy to forget and the second is dangerous to call twice. Wrap the init in a static INIT: std::sync::Once that Kv::open runs through INIT.call_once(...), so the first handle created initializes the library and later ones do nothing, from any thread. Skip the cleanup call unless the library demands it; process exit reclaims memory, and a cleanup that races with a handle still in use on another thread is a crash that only appears at shutdown, in production, once a month. If cleanup is mandatory, tie it to a reference-counted private guard that every handle holds, so it runs when the last handle drops and not before.

Test the wrapper, and what tests cannot see

The six unit tests and one documentation test in the file cover the behaviors that map to the header's promises. Set and overwrite checks that the second set of host replaces the first. A key containing a NUL byte becomes an error and not a crash. A closure captures local state. Non-ASCII text round-trips. The entries snapshot survives a later set, which is the copy-versus-borrow decision made visible. And two thousand open and close cycles run without a crash, a cheap smoke test for the Drop path. The doctest on entries doubles as the documentation example and as a test that the documented usage compiles. They all passed:

Text
running 6 tests
test tests::entries_snapshot_survives_a_later_set ... ok
test tests::interior_nul_is_an_error_not_a_crash ... ok
test tests::for_each_can_capture_state ... ok
test tests::set_get_and_overwrite ... ok
test tests::unicode_round_trips ... ok
test tests::many_open_and_close_cycles_do_not_crash ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Doc-tests safekv
test src/lib.rs - Kv::entries (line 89) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Tests have limits at this boundary. Miri cannot execute C code, so it will not check the foreign side. A use after free inside the C library, or a leak, needs a sanitizer: build the C file with -fsanitize=address and run the tests under it, or use valgrind on Linux. Run those on CI for the wrapper crate, since a wrapper that is sound on paper still depends on the C library behaving as documented. Include tests for the awkward inputs: empty strings, a very long value, keys that differ only in case, and the repeated open and close of many stores to expose leaks.

Conventions that make a binding crate pleasant

A few habits distinguish a binding you would depend on from one you would fork. Name the crate foo and its raw layer foo-sys, and pin the native library's version in the -sys crate's build script. Implement Debug for Kv without printing the pointer, or print the name only. Provide Error with Display and std::error::Error, as here, so it composes with anyhow and thiserror. Document every public method with a # Errors section. Offer a Result-returning API rather than panics; panicking on out-of-memory is the choice of the standard library, not of a binding. And if the C library has global state, such as an initialization call, wrap it in a std::sync::Once inside Kv::open so the user cannot forget it.

When not to write a binding

If the library has a maintained Rust binding, use it and spend the time elsewhere; check crates.io for a -sys crate before writing declarations. If you call one function once, an unsafe block with a SAFETY comment at the call site is simpler than a crate. If the C library is so small that porting it is an afternoon's work, a Rust port removes the foreign code, the build step and the sanitizer runs. And if the library is C++, plain bindgen struggles with templates and exceptions, so reach for cxx. Everywhere else, the process in this guide, read the contract, own the handle, borrow the pointer, convert the errors, leave out the auto traits you cannot justify, is the path to a binding where the compiler does the reviewing. For the complementary direction, see calling Rust from C with cbindgen, and for the language rules underneath, writing a safe wrapper around unsafe code.

A final review before publishing

Walk the public surface once with a hostile caller in mind. Can any safe sequence of calls reach a use after free, a double free or a data race? The compile errors above argue no for the three most common, and a reviewer should try to find a fourth. Is every unsafe block short, commented and local to one function? Does every public function either return a Result or cannot fail? Are the Send and Sync decisions written down in the type's documentation, with the evidence? If you can answer yes to all four, the crate is ready for someone else to build on.

Taggedffi
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.