Course outline

Rust / Advanced / Lesson 16

Unsafe invariants and FFI boundaries

Explain the obligations that unsafe code transfers to the programmer, prefer safe abstractions, and review foreign calls as explicit ownership and ABI contracts.

About 5 min readContent reviewed
On this page

Lesson goals

  • Distinguish unsafe operations from unchecked application policy.
  • State validity, alignment, aliasing, and lifetime requirements.
  • Describe edition 2024 unsafe extern blocks without executing foreign code.

Unsafe transfers proof obligations

unsafe permits specific operations that the compiler cannot fully validate, including raw-pointer dereference and calls to unsafe functions. It does not disable ordinary type checking, excuse undefined behavior, or make invalid memory safe. A safe abstraction around unsafe internals must remain sound for every caller permitted by its public safe API. State the invariant before writing the unsafe block and keep the trusted implementation small. A Safety comment should explain why the preconditions hold here, not merely repeat that the operation is unsafe. Logic errors and resource limits still require separate validation even when memory obligations are satisfied.

Use a proven safe primitive where possible

Simultaneously modifying two regions does not require hand-built raw pointers when a safe library API already proves non-overlap. split_at_mut returns disjoint mutable slices after checking its index. The first program updates one element on each side and prints the original array through a later shared borrow. The exclusive slice borrows end after their final uses. The standard library may use unsafe internally, but its safe interface carries an established contract. Your application should depend on that contract rather than copy a low-level implementation without understanding all its requirements.

rust Disjoint mutation through a safe interface
fn main() {
    let mut values = [1u32, 2, 3, 4];
    let (left, right) = values.split_at_mut(2);
    left[0] += 10;
    right[0] += 20;
    println!("{:?}", values);
}
Expected output
[11, 2, 23, 4]

The split proves the regions do not overlap. Only the first element of each region changes.

Validate foreign representations before crossing

C interfaces often expect a nul-terminated byte sequence rather than a Rust str. CString owns validated bytes without interior nul characters and adds the terminator; CStr borrows an appropriate C-style representation. Neither guarantees UTF-8 for every foreign byte sequence. The second safe example constructs a CString and inspects its lengths without calling external code. An as_ptr result would remain borrowed from this owner and must not be freed or retained beyond the documented lifetime by foreign code. Returning a pointer from a temporary CString would immediately lose that owner.

rust A validated C-style string without foreign execution
use std::ffi::CString;
fn main() {
    let name = CString::new("lab").expect("literal has no interior nul");
    println!("{} {}", name.as_bytes().len(), name.as_bytes_with_nul().len());
    println!("{}", CString::new(b"ab".to_vec()).is_err());
}
Expected output
3 4
true

lab has three content bytes and four including its terminator. The interior-nul input is rejected.

An external declaration is an assertion about another system

Edition 2024 requires unsafe extern blocks for external declarations, reflecting the author responsibility for correct signatures. The C ABI does not make Rust String, Vec, or ordinary Rust enum layouts compatible with a C struct. Use documented C-compatible types and repr(C) where appropriate, while separately handling padding, initialization, pointer ownership, and error conventions. A declaration can compile yet still be wrong for the actual linked library. The dependency snippet below is intentionally non-runnable because no vendor implementation or safety contract is supplied, and no return value is fabricated.

rust Declaration or intentionally rejected example
unsafe extern "C" {
    fn vendor_count(data: *const u8, length: usize) -> i32;
}

A dependency declaration only; validate the exact vendor header, target ABI, pointer validity, retention, and error rules before any call.

Review the complete unsafe boundary

For raw access, prove allocation bounds, alignment, initialized valid values, aliasing compatibility, and lifetime through the final access. For FFI, also specify who allocates and frees, whether callbacks cross threads, and whether exceptions or panics can cross the selected ABI boundary. Do not unwind through an ABI that forbids it. Use safe fallible wrappers to validate ordinary inputs before the narrow unsafe call. Miri and sanitizers can provide evidence on supported paths, but cannot certify an arbitrary foreign library. These lessons execute safe alternatives only; designing a sound custom unsafe abstraction requires a separate focused review.

Common errors and fixes

Unsafe is described as disabling all compiler checks.

Why it happens
Specific permissions were confused with unrestricted execution.
How to fix it
List the exact operation and its safety preconditions.
Prevent it next time
Keep unsafe blocks narrow and justified.

A CString pointer escapes after its owner is dropped.

Why it happens
Borrowed pointer lifetime was ignored at the FFI boundary.
How to fix it
Retain the owner for the full documented foreign use.
Prevent it next time
Document retention and deallocation responsibilities.

A Rust struct is assumed ABI-compatible without review.

Why it happens
Source-level similarity was mistaken for layout compatibility.
How to fix it
Use verified representations and matching foreign declarations.
Prevent it next time
Verify target ABI and library headers together.

Check your understanding

1. Why is split_at_mut preferable to constructing two mutable slices from raw pointers for the first program? Name the proof it supplies and one condition still checked by callers.

Show worked solution

The safe API establishes disjoint mutable regions and handles the internal pointer and aliasing obligations. The caller still must choose a valid split index or accept its documented panic behavior, and later indexing must stay within each returned region. Here index two splits four elements into two pairs, so accessing element zero on each side is valid.

2. A C function stores a provided string pointer for later callbacks. Explain why a temporary CString argument is insufficient and what the wrapper must specify.

Show worked solution

The temporary owner may be dropped immediately after the call while C still retains its pointer. The wrapper must keep owned storage alive until the library confirms it will never use the pointer again, including callback and shutdown paths. It must also specify thread access and the matching release protocol. Cloning a raw pointer provides no additional lifetime.

Key takeaways

  • Unsafe requires proofs, not weaker safety standards.
  • Prefer safe primitives with explicit contracts.
  • FFI combines ABI, ownership, lifetime, and failure obligations.

Official references