Skip to content

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

Medium Framework

C strings and ownership across FFI

Implement string conversion and an owned-handle API for calling C from Rust. Fix the dangling pointer in the starter, validate incoming bytes, and ensure every handle created by lifecycle is released.

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>

Required behavior

  • c_byte_len creates a C string and calls the supplied strlen. Return its byte count, excluding the terminator, or None if s contains any zero byte. Empty text gives Some(0).
  • decode validates a complete byte buffer using the rules below, then returns an owned UTF-8 string without the terminator.
  • make_handle transfers ownership of a newly created CString through into_raw. Return null if the input contains any zero byte.
  • handle_len returns zero for null; otherwise call strlen on a valid live handle. It does not consume or free the handle.
  • free_handle does nothing for null. Otherwise reclaim the original pointer with CString::from_raw and drop the resulting owner once.
  • Keep the supplied lifecycle: create, measure and free each handle in input order. Preserve duplicates. Empty input returns an empty vector; an item containing a zero byte produces a null handle and a zero length.

decode checks errors in this order:

Buffer Result
Contains no zero byte, including an empty buffer Err("missing_terminator")
Its first zero byte is not its last byte Err("interior_nul")
Has exactly one final zero, but preceding bytes are not valid UTF-8 Err("not_utf8")
Has exactly one final zero and valid UTF-8 before it Ok(decoded_text)

For example, [0] decodes to an empty string, [255, 0] is not_utf8, and [255, 0, 0] is interior_nul. The harness displays errors with an err: prefix; your function returns the error name without that prefix.

Bytes, text and terminators

Rust's String and &str contain valid UTF-8 and have a byte length. They may contain zero bytes. A C string ends at its first zero byte and does not by itself specify an encoding. strlen counts bytes before that zero. Thus "héllo" occupies six UTF-8 bytes even though it has five Unicode scalar values.

CString owns C-compatible bytes and ensures one final zero with none before it. CString::new adds the terminator and rejects any zero already in the input, including a trailing one. &CStr borrows a C string. CStr::from_bytes_with_nul validates a buffer's terminator placement; .to_str() separately validates UTF-8. Do not use lossy decoding here, because the required result for invalid UTF-8 is an error.

Keep the owner alive during a borrowed call

The starter saves the pointer from a temporary CString in one statement and uses it in the next. The owner has already been dropped, so calling strlen through that pointer is undefined behavior. A dangling-pointer lint can flag this pattern, but lint coverage is not a lifetime proof.

Give the owner a name and keep it alive until the call returns. This complete example uses a borrowed pointer without transferring ownership:

use std::ffi::{CString, c_char};

unsafe extern "C" {
    fn strlen(s: *const c_char) -> usize;
}

fn main() -> Result<(), std::ffi::NulError> {
    let owned = CString::new("héllo")?;
    // SAFETY: owned supplies live, immutable, nul-terminated bytes for this call.
    let length = unsafe { strlen(owned.as_ptr()) };
    assert_eq!(length, 6);
    Ok(())
}

A temporary can remain alive through a call in the same statement. The requirement is that the owner outlive every use of its pointer, not that every temporary is invalid. A named owner usually makes that reasoning easier. If C retains the pointer after returning, a local owner that is about to be dropped is insufficient.

Transfer and reclaim ownership deliberately

into_raw keeps the allocation alive after the CString value is consumed. The caller must eventually return the original pointer for reclamation. Do not pass an offset pointer, an already-freed pointer or a foreign allocation to CString::from_raw. The position of the terminating zero must still match the original string length when reclaiming it.

This exercise treats handles as read-only until freed. Do not remove or move the terminator, access a freed handle, or access the bytes while they are being freed. Repeated reads are allowed while the handle remains valid; reclamation happens exactly once.

Use the deallocation API specified by the allocator or library that created an object. In particular, do not use C's free for these CString handles. Allocator compatibility is an API contract, not something to infer from both pointers referring to heap memory.

Declaring a C-facing API

Rust String, &str and slices have no directly supported C ABI in these signatures. Use an agreed pointer/length representation or a terminated string, and document ownership. A #[repr(C)] aggregate still needs appropriate field types; a function pointer needs the correct calling convention. Neither an attribute nor a raw pointer makes an interface safe on its own.

handle_len and free_handle are unsafe because callers must establish pointer validity and ownership conditions that the functions cannot check. Their # Safety documentation states those obligations. Compiler and Clippy checks catch some mistakes, but cannot prove that foreign callers obey the contract or that allocations are eventually freed.

Submissions must compile, pass the tests and pass clippy -D warnings.