We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Macros, FFI and Type-Driven Design step 25 of 28
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)returnsResultand rejects interior nuls, giving you aNulErrorthat 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()returnsResultbecause 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
mallocmust be freed by Cfree. 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_rawtwice is a double free. Nothing detects it. -
Never calling
from_rawis 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…
Stuck?
Rust reference solution
Sign in to attempt this problem and reveal the reference solution.