Skip to content

FFI and Justified Unsafe Boundaries

Rust can guarantee memory safety only when all participating code follows Rust’s rules. Native model runtimes, GPU drivers, tokenizers, codecs, camera/audio APIs, and C/C++ libraries cross a foreign function interface (FFI) where the compiler cannot verify pointer validity or external threading contracts.

unsafe does not disable Rust safety globally. It marks operations whose proof obligations are now the programmer’s responsibility. The engineering goal is to make that proof surface tiny, documented, tested, and hidden behind a safe API.

After this chapter, you will be able to:

  • explain what unsafe permits and what it does not;
  • list the proof obligations for raw-pointer slice construction and writes;
  • declare and call C-compatible functions with explicit ABI and status handling;
  • isolate unsafe code in a dedicated crate/private module;
  • build a safe wrapper that validates lengths, pointers, ownership, and lifetimes;
  • reason about native handles, callbacks, allocation ownership, panics, and threads;
  • test safe behavior plus raw rejection branches;
  • recognize when Send/Sync must not be implemented;
  • run the companion’s audited sample-sum FFI boundary.

This chapter depends on ownership, slices, Drop, errors, modules, and Send/Sync. It prepares for Candle/native kernels, mistral.rs integrations, ONNX Runtime, FFmpeg/media libraries, accelerator APIs, and platform audio/video input.

safe application types
│ validate sizes, ownership, state
safe wrapper API
│ tiny unsafe call + status mapping
C ABI / native library
│ raw pointers, handles, callbacks
driver / codec / runtime

Only the wrapper knows the foreign representation. Domain and harness code see owned safe Rust types.

Inside an unsafe block/function, Rust permits specific operations including:

  • dereferencing raw pointers;
  • calling unsafe functions;
  • accessing/mutating static mut;
  • implementing unsafe traits;
  • accessing union fields.

All other Rust rules still apply. unsafe does not:

  • make a dangling pointer valid;
  • make a native runtime thread-safe;
  • prevent out-of-bounds memory;
  • catch C++ exceptions;
  • guarantee ABI compatibility;
  • authorize a file/model operation;
  • make an algorithm correct.

The word means “compiler cannot verify these stated invariants,” not “anything goes.”

The companion raw function:

pub unsafe extern "C" fn mosaic_sum_samples(
samples: *const f32,
length: usize,
output: *mut f32,
) -> i32

must document # Safety:

  • when length is positive, samples points to that many initialized, aligned readable f32s;
  • the region remains valid for the call;
  • output points to one aligned writable f32;
  • regions do not violate aliasing;
  • the length fits the allocation;
  • ABI/type definitions match on both sides.

Marking the function unsafe pushes these obligations to its caller. The safe wrapper must prove them for every invocation.

In Rust 2024, unsafe operations inside an unsafe fn still require explicit unsafe blocks when the unsafe_op_in_unsafe_fn lint is enabled. This keeps the exact dangerous lines visible.

extern "C" requests the platform’s C calling convention. It does not make Rust types C-compatible. Across the boundary, prefer:

  • fixed-width integers;
  • raw pointers plus explicit lengths;
  • #[repr(C)] structs with reviewed field layouts;
  • opaque handles;
  • integer status codes plus out parameters.

Avoid passing Rust String, Vec, trait objects, references, or ordinary enums directly to C. Their layouts/ownership are Rust-specific unless a precise stable representation is established.

#[unsafe(no_mangle)] exports an exact symbol name in current Rust editions. Symbol naming is part of the native API and can collide; scope and version it carefully.

For a real external library, declarations may appear in an unsafe extern block generated by bindgen or written manually. Generated bindings are not automatically correct—header/version, defines, target ABI, and ownership docs must match the linked library.

This operation is unsafe:

let samples = unsafe {
std::slice::from_raw_parts(samples_ptr, length)
};

The compiler cannot know:

  • pointer is non-null/aligned;
  • memory covers length * size_of::<f32>();
  • multiplication/allocation fits isize::MAX rules;
  • elements are initialized;
  • memory remains alive;
  • no mutable alias violates Rust rules.

A null check proves only one item on that list.

The companion raw function rejects null/zero status cases before construction, while its caller contract covers the remaining obligations.

pub fn sum_samples(samples: &[f32]) -> Result<f32, NativeError> {
if samples.is_empty() {
return Err(NativeError::EmptyInput);
}
let mut output = 0.0;
let status = unsafe {
mosaic_sum_samples(
samples.as_ptr(),
samples.len(),
&mut output,
)
};
match status {
STATUS_OK => Ok(output),
other => Err(NativeError::Rejected(other)),
}
}

Why the call is sound:

  • a non-empty slice supplies a valid aligned pointer/length;
  • the shared slice guarantees readable initialized elements;
  • the local output is writable/aligned/live;
  • the input and output are distinct;
  • neither pointer escapes;
  • the foreign function is synchronous and follows its documented contract.

The wrapper gives ordinary Rust callers no way to violate those pointer conditions.

If native code retains pointers after returning, a borrowed slice wrapper is not safe. The API must transfer/retain ownership, pin stable storage, and define release callbacks/handles.

The workspace globally forbids unsafe code for ordinary crates. mosaic-native intentionally uses a different lint policy:

  • unsafe code is denied by default in that crate;
  • only private module ffi has #[allow(unsafe_code)];
  • unsafe_op_in_unsafe_fn is denied;
  • Clippy undocumented unsafe blocks are denied;
  • the crate exports only safe sum_samples.

This creates a review boundary:

mosaic-native/src/lib.rs
└── private ffi module
├── raw C-compatible function
├── unsafe blocks with SAFETY comments
└── safe wrapper helper
public crate API
└── safe sum_samples(&[f32]) -> Result<f32, NativeError>

In production, separate crates can isolate FFmpeg, ONNX, or accelerator-specific unsafe/native dependencies and platform features.

Bindings mirror the foreign API:

C header ──bindgen/manual──▶ raw Rust declarations

They usually remain unsafe and low-level. Build a second layer:

raw declarations ──safe wrapper──▶ domain-safe Rust API

The wrapper owns:

  • handle lifetime and Drop;
  • status/error mapping;
  • length/range checks;
  • thread/callback rules;
  • string encoding;
  • resource limits;
  • panic/exception boundary policy;
  • version compatibility.

Do not expose generated binding types throughout the application. That leaks native versioning and proof obligations into product code.

Many runtimes expose:

runtime_t* runtime_create(...);
void runtime_destroy(runtime_t*);

Safe Rust wrapper:

struct Runtime {
raw: NonNull<ffi::runtime_t>,
}
impl Drop for Runtime {
fn drop(&mut self) {
unsafe { ffi::runtime_destroy(self.raw.as_ptr()) }
}
}

Proof requirements:

  • successful creation returns non-null valid handle;
  • exactly one wrapper owns each handle;
  • destroy is called exactly once;
  • destroy is allowed on the current thread;
  • no calls occur after destroy;
  • callbacks/tasks cannot outlive the handle.

Drop cannot return errors. If shutdown can fail materially, provide explicit close/shutdown that returns Result, then make Drop a best-effort safety net with restricted diagnostics.

Memory allocated by one library/runtime must usually be freed by its matching function. Do not turn a C buffer into Vec::from_raw_parts unless Rust’s allocator/layout ownership contract truly matches.

Prefer:

  • copy into Rust-owned bounded memory;
  • wrapper holding pointer/length plus foreign free function in Drop;
  • callback that returns ownership exactly once;
  • shared foreign handle with documented lifetime.

Record whether outputs are borrowed until next call, owned by caller, or owned by runtime. Native docs often use these distinctions implicitly.

C strings:

  • end with NUL;
  • cannot represent interior NUL under ordinary convention;
  • may not be UTF-8;
  • may be borrowed or owned.

Use CString for Rust-to-C strings after rejecting interior NUL. Use CStr for bounded, NUL-terminated borrowed data when validity is proven. Do not call unbounded CStr::from_ptr on untrusted memory without a documented terminator guarantee.

Paths are platform-native. Converting every Path to UTF-8 C strings can corrupt valid paths. Follow the target library/platform’s exact path API.

Do not unwind a Rust panic across a C ABI that does not permit it. Callback wrappers should:

  • catch panics if required by the ABI;
  • convert to a status/record;
  • never expose borrowed Rust data past its lifetime;
  • synchronize according to callback thread;
  • handle callback-after-shutdown prevention;
  • keep function pointer and context pointer paired.

Conversely, C++ exceptions must not cross a C ABI into Rust. Use a C wrapper that catches and maps them.

Callback registration often creates reference cycles or use-after-free risk. Model it as explicit registration ownership plus deregistration/drain before drop.

A raw pointer wrapper does not automatically inherit the foreign library’s thread guarantees.

Questions:

  • can the handle move between threads?
  • can shared calls overlap?
  • must create/use/destroy occur on one thread?
  • does the library use thread-local state?
  • are callbacks invoked concurrently?
  • are error objects thread-local?

If thread-confined, keep the wrapper non-Send and run it on one owning worker. Other tasks send bounded commands. This is often safer than a mutex and avoids unsafe auto-trait implementations.

An unsafe impl Send/Sync is itself a proof claim that all safe methods remain sound under those thread operations. Require native documentation, tests, and review.

Native AI/media calls also require semantic memory layout:

  • dtype;
  • dimensions and shape;
  • strides/contiguity;
  • channel order;
  • color space;
  • sample format/rate/channels;
  • device/host residency;
  • alignment;
  • ownership and synchronization.

A pointer to enough bytes can still represent the wrong tensor. Wrap these facts in validated types before calling FFI. Check multiplication overflow when computing byte lengths.

GPU operations may be asynchronous: the call can return before the device stops reading host/device memory. The wrapper must tie buffer lifetime to an event/stream completion, not merely the function return.

Native behavior depends on:

  • header/binding version;
  • linked runtime version;
  • compile flags and enabled codecs/operators;
  • target CPU/GPU architecture;
  • driver version;
  • dynamic library resolution path;
  • model/kernel format version.

At startup, query and record runtime versions where possible. Reject incompatible ABI versions before work. Containerize/package exact shared libraries or verify them cryptographically.

“Cargo.lock is pinned” does not pin system FFmpeg or a GPU driver.

Run:

Terminal window
cd rust/rust-ai-engineering
cargo run -q -p mosaic-native --example sum_samples
cargo test -p mosaic-native
cargo clippy -p mosaic-native --all-targets -- -D warnings

Expected output:

sample sum: 0.75

The implementation is crates/mosaic-native/src/lib.rs. It includes:

  • one C-compatible raw operation;
  • a complete # Safety contract;
  • null checks before dereference/write;
  • explicit unsafe blocks with local proof comments;
  • a safe slice-based wrapper;
  • typed empty/status errors;
  • tests for successful/empty safe calls;
  • a raw-boundary test proving null branches return before dereference.

This function is intentionally simple so the proof is inspectable. The pattern, not the arithmetic, transfers to media and model integrations.

Failure investigation: intermittent crash in native inference

Section titled “Failure investigation: intermittent crash in native inference”

Symptom: rare segmentation fault under concurrent requests.

Investigation:

  1. capture runtime/model/driver/build identity;
  2. determine whether the handle is documented thread-safe;
  3. reproduce with a serialized single-owner worker;
  4. enable native sanitizers where supported;
  5. inspect pointer lifetime across async/device completion;
  6. verify shape/length multiplication and alignment;
  7. minimize to raw-wrapper contract test;
  8. audit unsafe Send/Sync implementations.

Do not classify a process-level native crash as a retryable provider error. Isolate it with worker processes if the threat/reliability model requires fault containment.

Failure investigation: output corrupt only for some images

Section titled “Failure investigation: output corrupt only for some images”

Likely causes: incorrect stride, channel order, orientation, color space, or buffer lifetime—not necessarily pointer length.

Add controlled fixtures with non-square dimensions, padding/stride, known color patches, rotations, and odd sizes. Validate semantic layout before FFI and compare with a trusted decoder.

Split by operation/handle and expose narrow safe abstractions.

Comments must explain why every unsafe precondition holds.

Alignment, length, initialization, lifetime, aliasing, and thread rules remain.

Foreign pointers converted to Rust owners incorrectly

Section titled “Foreign pointers converted to Rust owners incorrectly”

Use the matching allocator/free contract.

Respect thread confinement with an owner worker.

Catch/map at the language boundary.

Translate into stable safe domain types at the adapter.

Security:

  • treat model/media files as hostile binary input;
  • bound lengths/dimensions before allocation and multiplication;
  • isolate native decoders/runtimes in processes when memory corruption risk warrants it;
  • verify library/model artifacts and versions;
  • restrict dynamic library search paths;
  • audit build scripts and generated bindings;
  • fuzz raw parsers/safe wrappers with sanitizers.

Performance:

  • avoid copies only after proving lifetime/layout;
  • crossing FFI itself is usually cheap;
  • batch calls when native overhead is measurable;
  • retain device/model handles under bounded lifecycle;
  • account for synchronization and device completion;
  • zero-copy can retain huge buffers or require pinning;
  • benchmark end-to-end with transfer, conversion, and queueing.

Safety proof comes before zero-copy optimization.

  1. What operations require unsafe?
  2. Why is a raw pointer plus null check insufficient?
  3. What does extern "C" guarantee?
  4. Why should generated bindings remain behind a wrapper?
  1. Add a safe mean function with checked empty input and no duplicate unsafe proof.
  2. Wrap an opaque create/destroy handle with NonNull and Drop.
  3. Add checked (rows, columns, stride) image view construction.
  4. Map a native error code into retryable/permanent typed variants.
  5. Add a callback wrapper that prevents panic from crossing the ABI.

Design safe wrappers for:

  • local model session;
  • tokenizer vocabulary buffer;
  • decoded image frame;
  • audio resampler;
  • GPU asynchronous output;
  • FFmpeg packet/frame lifetime.

For each, document ownership, thread rules, buffer layout, error mapping, version identity, and shutdown.

Use native test tooling to cover:

  • null pointers;
  • zero/maximum lengths;
  • overflowed shape multiplication;
  • misreported stride;
  • callback after shutdown;
  • concurrent access to thread-confined handle;
  • wrong allocator/free pair;
  • panic/exception boundary.

Never intentionally dereference invalid pointers in the normal test process. Use rejection branches, sanitizers, fuzzing, and process isolation.

Factor raw invocation so sum and mean share one safe validated call result. Do not copy/paste unsafe blocks with diverging proof comments.

For a handle, constructor returns Result<Runtime, Error> only after non-null/version validation. Keep raw field private. If close can fail, provide explicit close(self).

Image view validation uses checked multiplication/addition and ensures each row fits the supplied byte slice. Capture pixel format as an enum/newtype.

Callbacks need a heap-owned context with a precise deregistration/drain protocol. A simplistic raw pointer to a stack closure is unsound.

  • Does an unsafe block make surrounding safe references unrestricted?
  • Who owns the proof for an unsafe function call?
  • Why can a valid pointer still encode a wrong tensor?
  • Can Drop report a native shutdown error?
  • What should happen when a native handle is thread-confined?
  • Does Cargo pin a system shared library?
  • When is process isolation part of the safe architecture?
  • Why can GPU async work outlive the FFI call?
  • Unsafe code is isolated to the smallest reviewed module/crate.
  • Every unsafe operation has an exact proof comment.
  • Raw functions document complete caller obligations.
  • Safe callers cannot violate pointer/lifetime/layout preconditions.
  • Native ownership/free/thread rules are explicit.
  • Panics/exceptions cannot cross unsupported ABIs.
  • Build/runtime/model/driver identity is recorded.
  • I ran the safe example, raw rejection tests, and strict Clippy gate.