Prompt and Applicable Context
A Rust crate fails to compile after moving from the 2021 to the 2024 Edition because its existing extern block is no longer accepted. Explain why unsafe extern is required, how to audit the ABI declaration, and how to wrap the unsafe boundary in a verifiable safe API.
Rust 2024 requires foreign blocks to use the unsafe keyword. Rust cannot prove the signatures, calling convention, global variables, or pointer contracts supplied by an external library, so the declaration author must own those assumptions.
What the Interviewer Evaluates
The interviewer is looking for a distinction between an unsafe declaration and an API that exposes unsafe at every call site. You should cover ABI, integer widths, layout, nullable pointers, ownership, thread constraints, initialization, and a small auditable FFI module.
Clarifying Questions
Confirm the crate's Edition, target platforms, foreign ABI, and header version. Ask whether functions return owned resources, which pointers may be null, who frees them, whether callbacks cross threads, and whether dynamic library versions can drift. A syntax-only fix is incomplete without these answers.
30-Second Answer Framework
“Rust 2024 marks the extern declaration itself as unsafe because the compiler cannot verify the foreign ABI contract. I would write unsafe extern, audit calling conventions, layout, integer widths, pointer validity, destruction functions, and thread rules, and mark a function safe only when its preconditions are proven by the wrapper. I would keep unprovable conditions unsafe, then validate the migration with cross-target builds and ABI regressions.”
Step-by-Step Deep Dive
Step 1: State the responsibility of unsafe extern
unsafe extern says that a declaration can enable undefined behavior and that the declaration author is responsible for its contract. It does not validate the C implementation or check arguments for callers. The 2024 Edition makes that responsibility visible in source.
Step 2: Audit ABI and layout
Check calling conventions such as extern "C", struct layout, enum representation, alignment, integer widths, and return-value rules. Headers, generated bindings, and the linked library must describe the same versioned contract; a local successful run is not cross-target evidence.
Step 3: Separate safe and unsafe foreign functions
Functions in a foreign block are unsafe by default. A function may be declared safe when its public preconditions have been proved; callers then need no unsafe block, but the declaration author still owns that proof. Do not mark an unknown function safe merely to reduce unsafe syntax.
unsafe extern "C" {
safe fn library_version() -> u32;
fn library_parse(ptr: *const u8, len: usize) -> i32;
}Step 4: Wrap pointers, ownership, and destruction
Before turning a raw pointer into a reference, validate non-nullness, alignment, length, and lifetime. Resources returned by a foreign library normally must be destroyed by its matching free function; Rust's default destructor or another allocator must not be used across that boundary.
Step 5: Check thread and callback constraints
Determine whether handles can cross threads, whether callbacks run on library-owned threads, whether callbacks can re-enter, and whether destruction waits for callbacks. If those properties cannot be statically guaranteed, constrain the wrapper's threading model and provide a shutdown barrier.
Step 6: Put safety preconditions in the wrapper
A safe function should accept Rust types that express its constraints, such as slices, enums, or owned handles, rather than making every caller pass a raw pointer and length. Centralize checks, keep the unsafe operation to a few lines, and document and test every precondition.
Step 7: Validate with migration tools and multiple targets
Run the Edition migration checks and cargo fix --edition, then manually review the generated extern changes. CI should cover host and target platforms, debug and release builds, static and dynamic linking, and ABI regressions against the real library version.
Step 8: Handle version drift and rollback
If headers, bindings, and the dynamic library disagree, pin versions or regenerate bindings instead of hiding the mismatch with casts. Roll out in stages, preserve a rollback path, and monitor load failures, error-code changes, and resource leaks.
High-Quality Sample Answer
I would separate declaration review from call-site encapsulation. First, change each foreign block to unsafe extern "C" and compare the ABI, layout, nullability, ownership, and free functions with the exact header and library version. Mark only functions with proven public preconditions as safe. Then place raw pointers behind a Rust handle and slice-based FFI module that checks lengths, initialization, threading, and callback shutdown, and always frees resources with the library's function. Finally, use cargo fix --edition for mechanical changes, review the diff, and run ABI, error-path, concurrent-shutdown, and dynamic-library compatibility tests on multiple targets. This keeps unsafe visible and auditable without forcing every caller to reproduce the foreign library's hidden contract.
Common Mistakes
Adding unsafe to extern and stopping there
That fixes syntax only. Incorrect signatures, layouts, or destruction protocols can still cause undefined behavior, so contract review and runtime regressions remain necessary.
Marking every foreign function safe
safe is a guarantee to callers, not a compiler hint. Use it only when the wrapper and types continually enforce the preconditions; unknown or globally stateful functions should remain unsafe.
Freeing a C resource with Rust's Box
Cross-allocator destruction can corrupt the heap. The creating library must provide the free function, and the wrapper's Drop implementation should call it with the correct shutdown order.
Follow-Up Questions and Responses
What if the C header does not document struct layout?
Treat layout as an unverified contract. Prefer official bindings or opaque handles. If a struct must cross the boundary, pin compiler, platform, and library versions and verify size, alignment, and end-to-end behavior instead of guessing fields.
What if a library upgrade changes behavior after a safe declaration?
Re-audit the safe guarantee for every version upgrade. Pin library and binding versions and add compatibility tests for error codes, threading, and resource semantics. If the same preconditions no longer hold, remove safe and handle the condition explicitly in the wrapper.
How should a Rust API handle callbacks that can run on any thread?
Do not pass an arbitrary Rust closure with shared mutable state directly to C. Use a thread-safe channel or controlled executor, define callback lifetime and a shutdown barrier, and expose a safe interface only when Send, synchronization, and lifetime requirements are enforced.