Skip to content

← The Expert Edge: Idiom, Review and Capstones step 12 of 14

Hard End-to-End

Designing safe wrappers around unsafe FFI

Given a raw sys layer over C’s strtoll, memcmp and qsort, build a safe API on top of it — and delete the two half-finished functions someone left behind.

pub fn parse_int(s: &str) -> Result<i64, String>
pub fn bytes_equal(a: &[u8], b: &[u8]) -> bool
pub fn sorted(v: Vec<i32>) -> Vec<i32>

Anyone can call C from Rust. Confining unsafe behind an API that cannot be misused is the actual skill, and it is what separates someone who can call C from someone who can ship a binding.

The criterion, stated exactly

A pub safe function must be safe for every possible input, not merely for correct ones.

Not “for inputs a reasonable caller would pass”. Not “for inputs documented as valid”. Every input, including hostile, absurd and accidental ones. If there is any argument at all for which your function causes undefined behaviour, the function must be unsafe fn — or, better, must be redesigned so the bad input is unrepresentable or returns an error.

The architecture

The sys module is given and is a literal transcription of the C header. Nothing clever lives there, so there is nothing there to get wrong. That separation is the standard shape of every real binding crate (libc, openssl-sys, libgit2-sys): a mechanical -sys layer, and a hand-written safe layer above it that does all the thinking.

Your layer’s job is to establish, before each call, exactly the preconditions the C function has — and to make the cases where it cannot into Result or Option, not into panics or undefined behaviour.

The three wrappers

parse_int must handle:

  • empty or whitespace-only inputErr("empty input")
  • an interior nul byteErr("interior nul byte"). A C string ends at the first nul, so "12\u{0}34" cannot be handed over intact. This is the single most common real-world FFI bug in string handling, and the answer is to refuse, not to truncate.
  • no digits at allErr("no digits")
  • trailing junkErr("trailing junk after N bytes"), where N is how many bytes strtoll actually consumed.
  • a value outside i64Err("out of range")

The trailing-junk case is what the endptr is for: strtoll writes back a pointer to the first character it did not consume, and comparing it with the start tells you how far it got. That is C’s universal idiom for “parse a prefix”, and reading it correctly is most of this function.

::: question Why does the reference solution check the range with Rust’s own parse::<i64>() instead of asking C? Because C reports overflow through errno, and using errno correctly is harder than it looks.

strtoll sets errno to ERANGE on overflow — but it does not clear it on success, so you have to zero it before the call to be able to trust it after. Rust’s std has no portable API for writing errno, the symbol that holds it is spelled differently on every platform (__errno_location, __error, _errno), and std’s own code touches it too.

So the range check happens where it can be done right, in Rust. That is not a cop-out: it is the ordinary judgement call of binding design. When the C contract’s error channel is awkward or ambiguous, do the check on the Rust side and document that you did. The alternative — a wrapper that looks like it handles overflow and actually reads a stale global — is worse than no wrapper at all.

It is also the honest answer to “why is FFI slow to write”. Not the calls; the contracts. :::

bytes_equal must compare lengths first — memcmp takes one length and reading past the end of either buffer is undefined — and must special-case the empty slice. An empty Rust slice’s pointer is dangling but aligned, which is perfectly legal in Rust and not something you may hand to C, even with a count of zero.

sorted needs an unsafe extern "C" fn comparator. Two rules there: the comparator must be total (a comparator that says a < b and b < a makes qsort read out of bounds — that is a documented UB in glibc), and it must never panic, because unwinding out of an extern "C" function is undefined behaviour. Do not put an unwrap, an assert, or an indexing operation in a callback C will invoke.

The two leftovers

pub unsafe fn parse_int_unchecked(p: *const c_char) -> i64
pub fn bytes_equal_raw(a: *const u8, b: *const u8, n: usize) -> bool

Both fail the lint gate, for different reasons, and the right fix for both is deletion.

bytes_equal_raw is a safe pub fn that dereferences raw-pointer arguments — clippy::not_unsafe_ptr_arg_deref, deny-by-default. There is no input validation possible, so it is unsound as written.

parse_int_unchecked at least admits it is unsafe, but has no # Safety section — clippy::missing_safety_doc — so it imposes an obligation on callers without saying what the obligation is.

Marking a wrapper unsafe purely to silence a lint is the failure mode this item exists to prevent. It compiles, the lint goes quiet, and you have moved the entire burden onto every caller forever. unsafe fn is the right answer only when the precondition genuinely cannot be checked and the function is genuinely needed. Neither is true here: parse_int(&str) does everything parse_int_unchecked does, safely, and the goal of the exercise is that no pub unsafe fn remains at all.

Three things a real binding also does

  • RAII. If the C library hands back something that must be freed, wrap it in a type with a Drop impl, so it cannot leak and cannot be freed twice. Drop must be idempotent and must not panic — a panic during unwinding aborts the process.
  • repr(transparent) newtype handles. A struct Fd(c_int) has the same ABI as a bare c_int but is a different Rust type, so you cannot pass a file descriptor where a socket was wanted.
  • Error codes become Result. C says “returns -1 and sets errno”; your layer says Result<T, Error> at the boundary and never again.

One thing the community disagrees about

clippy::multiple_unsafe_ops_per_block (pedantic, off by default) wants one unsafe operation per block, each with its own // SAFETY: comment, on the grounds that a comment covering three operations usually only justifies one of them. Others find that unreadable and prefer one block per logical step with a comment that covers it. Both positions are defensible; what is not defensible is an unsafe block with no comment at all.

Note also that missing_safety_doc only fires on pub unsafe fn. A private unsafe fn escapes it entirely — which is a lint limitation, not a licence.