Skip to content

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

Hard Framework

Opaque handles and bounded C formatting

Fix two bugs in the supplied C interoperability code: the callback records individual events instead of running totals, and the formatter misses one truncation boundary.

pub fn accumulate(events: Vec<i32>) -> Vec<i64>
pub fn format_pairs(items: Vec<(i32, String)>) -> Vec<String>
pub fn truncated(items: Vec<(i32, String)>) -> Vec<bool>

Keep the opaque-handle callback and snprintf calls. The purpose is to understand and repair those interfaces.

Required results

accumulate returns the total after each event. For example, [5, -2, -10, 7] produces [5, 3, -7, 0]; empty input produces an empty vector. Each call starts at zero. Inputs used here have running totals that fit in i64.

For each pair (n, text), format_pairs formats n-text using a 32-byte C buffer. That allows 31 output bytes plus the terminating null byte. truncated reports whether the full output would exceed those 31 bytes.

Input pair Formatted output Truncated?
(7, "ok") "7-ok" No
(-3, "x") "-3-x" No
(1, "b" repeated 29 times) "1-" plus all 29 letters No
(1, "c" repeated 30 times) "1-" plus the first 29 letters Yes

Preserve the supplied error behavior: embedded null bytes in text, or a negative snprintf result, produce an empty string and false. These cases are errors, so false does not mean formatting succeeded.

The limit counts bytes, not Unicode characters. If truncation splits a UTF-8 character, the supplied to_string_lossy conversion replaces the incomplete sequence with a replacement character. The returned Rust string can therefore have a different byte length from the truncated C buffer.

Passing state through an opaque handle

Many C callback APIs accept a context pointer alongside a function pointer. The context identifies the state that a closure would otherwise capture.

Here, accumulate creates a Box<State>, converts it to a raw pointer, and passes it as *mut Counter. Each call through c_api::counter_push casts the handle back to State and updates it. After all calls finish, Box::from_raw reconstructs the owning box exactly once.

Both sides of this callback are implemented in Rust; the declaration and exported function use the C ABI. This exercises the calling interface without requiring a separate C callback library.

Counter is a distinct handle type whose private fields prevent ordinary construction outside its module. Its marker fields suppress Send, Sync, and Unpin. They do not validate raw pointers, enforce thread affinity at runtime, or pin the allocation automatically. The boxed State remains in place because this code leaves it allocated until the calls are finished.

Do not dereference the handle as though it contained a real Counter. It points to a State. Different handle types help avoid mixing APIs, but the unsafe implementation must still recover the correct pointee type.

A cast alone does not access memory. The subsequent reference creation is where the pointer must be aligned, live, and valid for exclusive access. Concurrent or reentrant accesses could violate that requirement. Real asynchronous callback APIs also need a way to stop callbacks before freeing their context.

Matching variadic arguments

snprintf has fixed buffer, size, and format parameters followed by .... Rust checks the fixed arguments and rejects some unsupported variadic argument types. It does not verify that each extra argument matches a conversion in the format string.

For this call, %d needs a C int and %s needs a pointer to a live, null-terminated C string. The supported target uses i32 for c_int. CString supplies the string storage; keep it alive throughout the call.

C's default argument promotions also matter. Rust requires explicit casts for types such as f32 and i8 in variadic positions: use f64 and c_int, respectively. The compiler reports E0617 for the unpromoted types. A successful compile still does not establish that the format and arguments agree.

Keep the format fixed as c"%d-%s" and pass user text as an argument. Treating user text as a format could make C consume arguments that were never supplied.

Detecting truncation

On success, snprintf returns the byte count that the full output would require, excluding its terminator. Check for a negative error result before converting that count to usize.

With this 32-byte buffer, a result below 32 fits; a result of 32 or more means truncation. To allocate enough space for a retry, you would need the reported count plus one for the terminator.

The starter compares with >, so it misses the case where the reported count equals 32. Correct that comparison and make the callback append the updated total.

Reviewing a C boundary

Check the declaration against the actual C signature and target ABI. Document who owns each allocation, when pointers remain valid, and which matching function releases them. Aliasing raw pointers is not automatically invalid, but accesses must honor Rust reference rules and the C API's own restrictions.

Plan for panic behavior too. An unwinding Rust panic that escapes an extern "C" function aborts the process. catch_unwind can handle an unwinding Rust panic before that boundary, but cannot catch an aborting panic. Permitting unwinding requires a compatible ABI and agreement on both sides.

Generated bindings still need review of these contracts. Likewise, Miri can model some foreign operations through built-in shims, but cannot generally interpret arbitrary native library code. Passing tests and Clippy is useful evidence, not a proof that an FFI boundary is sound.

References: Rust FFI guidance, variadic argument promotions, and Miri limitations.