Skip to content

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

Medium Framework

Edition 2024: unsafe extern blocks and safe declarations

Migrate a pre-2024 extern block to Rust edition 2024 and decide which of its four C functions may be declared safe.

The starter code declares four functions from the C standard library and wraps each one in a Rust function:

pub fn root(x: c_double) -> c_double            // calls sqrt
pub fn magnitude(v: c_int) -> c_int             // calls abs
pub fn byte_len(bytes: &[u8]) -> usize          // calls strlen
pub fn compare(a: &[u8], b: &[u8]) -> c_int     // calls memcmp

The four wrapper bodies are already written. Your task is to rewrite the extern block that declares the C functions. Leave the wrappers as they are.

Background: extern blocks

An extern "C" block declares functions that are defined outside Rust, here in the platform's C library. The "C" names the calling convention: the rules for how arguments and return values are passed between caller and callee. The compiler does not see the C code, so it has to trust that each declared name, parameter list and return type matches the real function.

The edition 2024 migration

Code written for editions 2015, 2018 and 2021 usually declares foreign functions like this:

extern "C" {
    fn sqrt(x: c_double) -> c_double;
}

Edition 2024 rejects that block with the error extern blocks must be unsafe. The block must be written as:

unsafe extern "C" {
    fn sqrt(x: c_double) -> c_double;
}

(unsafe extern is also accepted in earlier editions from Rust 1.82 onward; edition 2024 makes it mandatory.)

The unsafe on the block is a promise made by whoever writes the declaration: that every signature in it matches the foreign definition. That is a different promise from the one an unsafe { ... } block at a call site makes. A caller writing unsafe { strlen(p) } promises that it has met strlen's preconditions, such as passing a valid pointer. A caller cannot check whether the declaration itself is correct. If the declaration says sqrt takes an f64 but the real symbol takes something else, calls through that declaration are undefined behavior however careful each caller is.

The missing_unsafe_on_extern lint, part of the rust_2024_compatibility group that cargo fix --edition applies, can add the keyword for you. It only inserts unsafe; it does not check any signature. Checking the signatures is still your job.

safe and unsafe items inside the block

Inside an unsafe extern block, each function or static can be marked individually:

unsafe extern "C" {
    safe fn sqrt(x: c_double) -> c_double;          // callable without an unsafe block
    unsafe fn strlen(s: *const c_char) -> usize;    // calls need an unsafe block
    fn memcmp(a: *const c_void, b: *const c_void, n: usize) -> c_int; // unmarked: same as unsafe
}

A safe fn can be called from safe Rust with no unsafe block. An unmarked item is treated as unsafe, so calling it outside an unsafe block is error E0133. Statics in the block can be marked safe or unsafe in the same way.

Marking a foreign function safe is a claim that no argument values the Rust types allow can cause undefined behavior. The compiler does not check that claim. If it is false, safe Rust code can trigger undefined behavior without writing unsafe anywhere, which is exactly what Rust's safety rules are meant to prevent. So whether an individual function may be safe depends on its own contract, not on the fact that it is a foreign function.

Taking a raw pointer is a common reason a function cannot be safe, because the caller must promise the pointer is valid. It is not the only reason: a function can also have undefined behavior for some plain integer inputs, or depend on global state. Read each function's C documentation.

The four functions

  • sqrt takes and returns a double. Every f64 value, including negative numbers, infinities and NaN, is a valid argument: a negative input returns NaN rather than causing undefined behavior. (Some C libraries also record a domain error in errno, which is a defined side effect.) It reads no memory through pointers, so it can be declared safe.
  • abs takes and returns an int. The C standard says that if the result cannot be represented, the behavior is undefined. That happens for exactly one input, INT_MIN (c_int::MIN in Rust), because its positive counterpart is one larger than INT_MAX. Since one allowed input is undefined behavior, abs must not be declared safe.
  • strlen reads bytes starting at the pointer until it finds a zero byte (the nul terminator). If the pointer is null or dangling, or no zero byte follows inside the allocation, it reads memory it must not read. The caller has to guarantee a valid, nul-terminated string, so it is not safe.
  • memcmp reads n bytes from each of two pointers. The caller must guarantee both are valid for n bytes, so it is not safe.

How the wrappers check your classification

The wrappers are written so that only one classification compiles cleanly:

  • root calls sqrt(x) outside any unsafe block. If sqrt is unmarked or unsafe, compilation fails with E0133.
  • magnitude, byte_len and compare call their functions inside unsafe { ... }. If one of those functions is marked safe, the block contains no unsafe operation and rustc emits the warn-by-default unused_unsafe lint. The Rust grader's Clippy check treats warnings as errors, so the submission fails.

So sqrt must be safe, and the other three must be unsafe fn or unmarked (the two spellings mean the same thing). Writing unsafe fn explicitly makes the decision visible to readers.

What the wrappers return

The harness calls one wrapper per test case:

  • root(x) returns the C library's sqrt(x) for an f64 input.
  • magnitude(v) returns the absolute value of v. For c_int::MIN, which abs cannot handle, it does not call abs and returns c_int::MIN unchanged, matching Rust's i32::wrapping_abs. That check is what makes calling abs inside the unsafe block sound.
  • byte_len(bytes) copies the bytes, appends a zero byte and calls strlen, so it returns the number of bytes before the first zero byte in bytes (all of them if there is none). For example, [104, 105, 0, 120] gives 2.
  • compare(a, b) compares the two byte sequences lexicographically as unsigned bytes and returns exactly -1, 0 or 1. memcmp compares the common prefix. If that prefix is equal, the shorter sequence is less, and two equal sequences, including two empty ones, give 0.

memcmp only promises the sign of its result: "greater than, equal to, or less than zero". The magnitude can differ between C libraries, so compare reduces a nonzero result with .signum(). Code that depends on the exact value memcmp returns can pass tests on one platform and fail on another.

When the common prefix is empty, compare calls memcmp with n equal to 0 and pointers from empty slices, which are non-null but dangling. Rust's core library documents that it assumes memcmp with n == 0 is defined for any pointers, and the comment in the code relies on that.