We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← The Expert Edge: Idiom, Review and Capstones step 12 of 14
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
pubsafe 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 input →
Err("empty input") -
an interior nul byte →
Err("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 all →
Err("no digits") -
trailing junk →
Err("trailing junk after N bytes"), whereNis how many bytesstrtollactually consumed. -
a value outside
i64→Err("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
Dropimpl, so it cannot leak and cannot be freed twice.Dropmust be idempotent and must not panic — a panic during unwinding aborts the process. -
repr(transparent)newtype handles. Astruct Fd(c_int)has the same ABI as a barec_intbut 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 saysResult<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.
Stuck?
Rust reference solution
Sign in to attempt this problem and reveal the reference solution.