Skip to content

← Macros, FFI and Type-Driven Design step 25 of 28

Medium Framework

C strings, ownership across the boundary, and FFI-safe types

Strings across the boundary, and the one place where the compiler now catches the classic bug for you.

pub fn c_byte_len(s: &str) -> Option<usize>
pub fn decode(bytes: Vec<u8>) -> Result<String, String>
pub fn make_handle(s: &str) -> *mut c_char
pub unsafe fn handle_len(p: *const c_char) -> usize
pub unsafe fn free_handle(p: *mut c_char)
pub fn lifecycle(items: Vec<String>) -> Vec<usize>

Two incompatible ideas of “string”

Rust String / &str C string
length stored alongside the data not stored — walk to the first nul
encoding guaranteed UTF-8 none. Bytes.
interior nul perfectly legal terminates the string
empty length 0 one byte, 0

Every FFI string bug lives in that table. "héllo" is five characters, six bytes, and C reports 6 — because strlen counts bytes and é is two of them in UTF-8. Not a bug, not a rounding error: C has no concept of a character.

CString and CStr

  • CString — owned, heap-allocated, guaranteed nul-terminated, guaranteed no interior nul. CString::new(s) returns Result and rejects interior nuls, giving you a NulError that tells you the offending position and hands back your bytes.

    That rejection is a feature. Handing "a\0b" to a C API silently truncates it to "a" — which is how a filename check gets bypassed. Rust refuses instead.

  • CStr — borrowed, &CStr, a view over existing nul-terminated bytes. CStr::from_bytes_with_nul(&bytes) validates the shape; to_str() returns Result because the bytes may not be UTF-8; to_string_lossy() substitutes U+FFFD instead of failing.

decode distinguishes three failures deliberately: no terminator at all, an interior nul (a nul that is not the last byte), and not UTF-8. In real code those want different handling — the first two are protocol errors and the third is often recoverable with to_string_lossy.

Incidentally, if you write CStr::from_bytes_with_nul(b"hello\0") with a literal, clippy’s manual_c_str_literals tells you to write c"hello" instead. It fires on the literal shape only, not on CString::new, and not on a runtime slice like the one here.

The lint that teaches for free

Here is the single most common FFI bug ever written:

let ptr = CString::new(s).unwrap().as_ptr();
unsafe { strlen(ptr) }

The CString is a temporary. It is dropped at the end of the statement — at the semicolon — and the heap buffer is freed. ptr now points at freed memory, and strlen reads it. Sometimes it works. Sometimes it returns garbage. Under load, it segfaults.

The starter contains exactly this, and rustc catches it:

error: this creates a dangling pointer because temporary `CString`
       is dropped at end of statement

dangling_pointers_from_temporaries is warn-by-default, which under clippy -D warnings means your submission fails until you fix it. This lint is relatively recent, and it retroactively defused a bug that used to be a rite of passage.

The fix is to give the CString a name, so it lives until the end of the enclosing scope:

let owned = CString::new(s).ok()?;
let n = unsafe { strlen(owned.as_ptr()) };   // `owned` is still alive here

The general rule: a pointer derived from a temporary is dead on arrival. Bind the owner first, always.

Ownership across the boundary

The second half is an owned-handle API — make_handle / handle_len / free_handle — which is the shape of essentially every C library that hands you something you must give back.

CString::new(s)?.into_raw()   // Rust gives up ownership; caller must reclaim
CString::from_raw(p)          // reclaim it; drop frees it

into_raw leaks unless something later calls from_raw. That is the point: the memory has to outlive the function, so the compiler’s usual guarantees are deliberately suspended and a human takes over.

Three rules, all of which the compiler is now unable to help with:

  • Memory allocated by Rust must be freed by Rust; memory from C malloc must be freed by C free. Mixing them is undefined behaviour even though both are “the heap” — Rust’s allocator and libc’s allocator keep separate bookkeeping, and handing a block to the wrong one corrupts both.
  • from_raw twice is a double free. Nothing detects it.
  • Never calling from_raw is a leak. Safe, in Rust’s technical sense, and still wrong.

Which is why a C-facing Rust library always exports a matching *_free for every *_new: the caller has no other way to return the memory to the allocator that produced it.

FFI-safe types

You cannot send a &str to C. Not “should not” — the compiler stops you.

&str and &[T] are fat pointers: two machine words, a data pointer and a length. C has no such type. rustc’s improper_ctypes (declarations) and improper_ctypes_definitions (definitions) are warn-by-default and therefore hard failures under this gate, which means the harness enforces FFI type discipline for you automatically.

FFI-safe not FFI-safe
integer and float primitives &str, &[T] (fat pointers)
#[repr(C)] struct / enum / union tuples, including (i32, i32)
#[repr(transparent)] newtypes Vec<T>, String
raw pointers Box<dyn Trait> (fat pointer)
function pointers generic Option<T>

Option<T> is on the wrong side of that table in general — but not always, and the exception is precise and important enough to be the subject of item 18.26.

::: question handle_len and free_handle are pub unsafe fn. Why can’t they just be safe functions that take a pointer?

Because clippy will not let them be, and clippy is right.

not_unsafe_ptr_arg_deref fires on a public safe function that takes a raw pointer and dereferences it. The reasoning: a safe function is one any caller may call with any arguments without risking undefined behaviour. A function taking *const c_char and reading from it cannot honour that — pass it 0x1 and it reads address 1.

Marking it unsafe fn moves the obligation to the caller, where it belongs. And then missing_safety_doc requires a # Safety section in the doc comment saying exactly what the caller must guarantee, which is why both of these carry one.

The pair of lints together encode a real API design rule: either the function can genuinely take care of itself and is safe, or it has a precondition, in which case it must be unsafe and the precondition must be written down. A safe function with an undocumented landmine is the thing neither lint will tolerate. :::

Your job

Fix the dangling pointer in c_byte_len, implement decode‘s three error cases, and build the handle lifecycle. lifecycle is written and must round-trip without leaking.

Loading visualization…