Skip to content

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

Medium Framework

Edition 2024: unsafe extern blocks and safe declarations

A pre-2024 extern block that no longer compiles, and four functions to classify.

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

All four bodies are written. The extern block is not.

The migration

Every tutorial written before 2024 — and most code generated by an LLM today — declares foreign functions like this:

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

In edition 2024 that is a hard error. The block itself must be unsafe:

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

The keyword is not decoration and it is not about the caller. It says: the author of this declaration is taking responsibility for the signature being correct.

That is a genuinely different claim from the one unsafe { } at a call site makes. A caller writing unsafe { sqrt(x) } is asserting “I have satisfied whatever sqrt requires”. The person writing the declaration is asserting something the caller cannot possibly check: that sqrt really does take one double and return one double, in the C calling convention, with that exact symbol name. Get it wrong and every call is undefined behaviour, no matter how carefully each caller behaves (18.22).

Before edition 2024 there was no place to write that assertion down. Now there is. rustc’s missing_unsafe_on_extern lint automates the migration — but note what it does and does not do: it adds unsafe mechanically without verifying a single signature. The hard part stays manual.

safe declarations: unsafety is per function, not per category

Inside an unsafe extern block, each item may be marked:

unsafe extern "C" {
    safe fn sqrt(x: c_double) -> c_double;     // callable with NO unsafe block
    unsafe fn strlen(s: *const c_char) -> usize;   // needs one
    fn memcmp(...) -> c_int;                   // unmarked = unsafe
}

A safe fn in an extern block is callable from safe Rust with no unsafe block at all. Statics can be marked safe too.

This is the cleanest possible statement of a point that FFI teaching usually fudges: unsafety is a property of a particular function, not of the boundary.

  • sqrt(x) for any f64 — including negative, including NaN, including infinity — returns a f64. There is no input that causes memory unsafety. There is no precondition. It is a pure function of its argument. Marking it unsafe and forcing every caller to write a block would be pure ceremony.
  • strlen(p) reads memory forward from p until it finds a zero byte. If p is null, dangling, or points at a buffer with no nul in it, that is a read of unmapped memory. The caller genuinely must promise something.

The dividing line is almost always “does it take a pointer”. Not always — but if you are unsure, that heuristic is right far more often than it is wrong.

How this problem enforces the split

The four function bodies are already written, and they are written in the shape that only compiles for a correct classification:

  • root calls sqrt(x) outside any unsafe block. Mark sqrt as unsafe and you get E0133.
  • magnitude calls abs(v) outside any block. Same.
  • byte_len and compare wrap their calls in unsafe { }. Mark those safe and rustc’s warn-by-default unused_unsafe fires — which under clippy -D warnings fails your submission.

There is exactly one classification that satisfies both directions. You cannot mark everything unsafe “to be safe”, and you cannot mark everything safe to avoid blocks.

::: question abs is marked safe here. But 18.22 said abs(INT_MIN) is undefined behaviour in C. Isn’t that a lie?

Yes, and it is the honest kind of lie worth pointing at.

abs(INT_MIN) is genuinely UB by the C standard. Marking abs as safe asserts that no input can cause unsoundness, and strictly that assertion is false for one value out of four billion. In practice every real implementation returns INT_MIN unchanged (the two’s-complement negation of the most negative value is itself), so the observable consequence is a wrong number rather than memory corruption — which is why the ecosystem, including the libc crate, treats it as safe.

The lesson is the important part: marking something safe that is not is a soundness bug the compiler cannot catch. safe is a claim you make, exactly like unsafe extern is. Nothing verifies it, ever. If you are wrong, safe Rust can now invoke undefined behaviour with no unsafe anywhere in the call chain, which is precisely the situation Rust’s whole safety story is built to prevent.

Be conservative. If a function takes a pointer, it is not safe. If its C documentation contains the word “undefined”, think hard. :::

memcmp and determinism

memcmp returns “an integer greater than, equal to, or less than zero” — the sign is specified, the magnitude is not. Different libc implementations return different numbers for the same comparison. compare therefore normalises with .signum(), and falls back to comparing lengths when the common prefix is equal, so the result is a deterministic -1, 0 or 1.

That normalisation is not test-harness bureaucracy. Depending on the magnitude of a memcmp result is a real portability bug that survives testing on one platform.

Your job

Migrate the block and classify the four declarations.