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.
Learning objectives
Section titled “Learning objectives”After this chapter, you will be able to:
- explain what
unsafepermits 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/Syncmust not be implemented; - run the companion’s audited sample-sum FFI boundary.
Dependencies and downstream use
Section titled “Dependencies and downstream use”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 / runtimeOnly the wrapper knows the foreign representation. Domain and harness code see owned safe Rust types.
What unsafe allows
Section titled “What unsafe allows”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.”
Unsafe functions state caller obligations
Section titled “Unsafe functions state caller obligations”The companion raw function:
pub unsafe extern "C" fn mosaic_sum_samples( samples: *const f32, length: usize, output: *mut f32,) -> i32must document # Safety:
- when length is positive,
samplespoints to that many initialized, aligned readablef32s; - the region remains valid for the call;
outputpoints to one aligned writablef32;- 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.
C ABI and symbol boundaries
Section titled “C ABI and symbol boundaries”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.
Raw pointers carry no lifetime or length
Section titled “Raw pointers carry no lifetime or length”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::MAXrules; - 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.
Build a safe wrapper
Section titled “Build a safe wrapper”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.
Isolate unsafe by default
Section titled “Isolate unsafe by default”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
ffihas#[allow(unsafe_code)]; unsafe_op_in_unsafe_fnis 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 helperpublic 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.
Generated bindings versus safe wrappers
Section titled “Generated bindings versus safe wrappers”Bindings mirror the foreign API:
C header ──bindgen/manual──▶ raw Rust declarationsThey usually remain unsafe and low-level. Build a second layer:
raw declarations ──safe wrapper──▶ domain-safe Rust APIThe 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.
Opaque handles and Drop
Section titled “Opaque handles and Drop”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.
Ownership across allocators
Section titled “Ownership across allocators”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.
Strings and paths at FFI
Section titled “Strings and paths at FFI”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.
Callbacks and panic boundaries
Section titled “Callbacks and panic boundaries”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.
Send and Sync for native handles
Section titled “Send and Sync for native handles”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.
Tensor and media layout contracts
Section titled “Tensor and media layout contracts”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.
Version and build identity
Section titled “Version and build identity”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.
Runnable companion implementation
Section titled “Runnable companion implementation”Run:
cd rust/rust-ai-engineeringcargo run -q -p mosaic-native --example sum_samplescargo test -p mosaic-nativecargo clippy -p mosaic-native --all-targets -- -D warningsExpected output:
sample sum: 0.75The implementation is crates/mosaic-native/src/lib.rs. It includes:
- one C-compatible raw operation;
- a complete
# Safetycontract; - 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:
- capture runtime/model/driver/build identity;
- determine whether the handle is documented thread-safe;
- reproduce with a serialized single-owner worker;
- enable native sanitizers where supported;
- inspect pointer lifetime across async/device completion;
- verify shape/length multiplication and alignment;
- minimize to raw-wrapper contract test;
- audit unsafe
Send/Syncimplementations.
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.
Common failure modes
Section titled “Common failure modes”One giant unsafe module
Section titled “One giant unsafe module”Split by operation/handle and expose narrow safe abstractions.
Safety comments restating the code
Section titled “Safety comments restating the code”Comments must explain why every unsafe precondition holds.
Null check treated as full proof
Section titled “Null check treated as full proof”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.
Unsafe Send/Sync to satisfy an executor
Section titled “Unsafe Send/Sync to satisfy an executor”Respect thread confinement with an owner worker.
Panic or exception crossing ABI
Section titled “Panic or exception crossing ABI”Catch/map at the language boundary.
Native types in domain APIs
Section titled “Native types in domain APIs”Translate into stable safe domain types at the adapter.
Security and performance implications
Section titled “Security and performance implications”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.
Exercises
Section titled “Exercises”Recall
Section titled “Recall”- What operations require unsafe?
- Why is a raw pointer plus null check insufficient?
- What does
extern "C"guarantee? - Why should generated bindings remain behind a wrapper?
Implementation
Section titled “Implementation”- Add a safe mean function with checked empty input and no duplicate unsafe proof.
- Wrap an opaque create/destroy handle with
NonNullandDrop. - Add checked
(rows, columns, stride)image view construction. - Map a native error code into retryable/permanent typed variants.
- Add a callback wrapper that prevents panic from crossing the ABI.
Design
Section titled “Design”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.
Failure lab
Section titled “Failure lab”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.
Solution guidance
Section titled “Solution guidance”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.
Check your understanding
Section titled “Check your understanding”- 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
Dropreport 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?
Completion checklist
Section titled “Completion checklist”- 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.