How to write safe wrappers around unsafe code

Wrap unsafe Rust code in a safe function that validates inputs to prevent undefined behavior.

The Bridge Over Dangerous Ground

You're integrating a C library into your Rust project. The C side hands you a raw pointer to a buffer. You need to read the data. If you pass that pointer around your Rust code, every function signature turns into a minefield of unsafe blocks. The compiler stops protecting you. You need a way to take that dangerous raw pointer, check it once, and then hand safe values to the rest of your program. That's the safe wrapper pattern.

Concept: The Guardrail

Think of unsafe as a construction zone. Inside, the ground is unstable, and heavy machinery moves without warning. You can't let pedestrians wander in there. A safe wrapper is the sidewalk and guardrail built around that zone. The construction happens behind the fence. The public API is the smooth path where people walk without risk.

The wrapper checks the inputs, ensures the invariants hold, and then performs the dangerous operation. The caller never sees the danger. They just get the result. The wrapper enforces a promise: "If you call this function with valid arguments, undefined behavior cannot happen." The compiler trusts the wrapper because the wrapper proved it.

Minimal Example

Here is the simplest form. A function takes a raw pointer, validates it, and returns a safe value.

/// Reads a value from a raw pointer, returning None if null.
///
/// This function is safe to call from anywhere. It handles the risk
/// of null pointers internally.
pub fn safe_read(ptr: *const i32) -> Option<i32> {
    // Validate the pointer before entering unsafe.
    // The safe API guarantees we never dereference null.
    if ptr.is_null() {
        return None;
    }

    unsafe {
        // SAFETY:
        // 1. ptr is not null, checked by the is_null guard above.
        // 2. The caller guarantees the pointer points to valid memory.
        //    This is a trust boundary; the wrapper assumes the caller
        //    obtained the pointer from a trusted source.
        Some(*ptr)
    }
}

The function signature is safe. No unsafe keyword. Callers can use this from any context. The unsafe block is hidden inside. The validation happens before the block. The dereference happens only after the check passes.

Walkthrough

When you call safe_read, the compiler treats it like any other function. No unsafe keyword required at the call site. The function runs. It checks is_null. If the pointer is null, it returns None immediately. The unsafe block never executes. If the pointer is valid, execution enters the block. The dereference happens. The value comes back wrapped in Some.

The caller gets an Option. The danger is contained. The wrapper enforces the invariant: "We only dereference valid pointers." The compiler trusts the wrapper because the wrapper proved it. If you try to dereference a raw pointer without unsafe, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe). This is the compiler forcing you to acknowledge the risk. The wrapper acknowledges the risk, manages it, and hides it.

Realistic Example

Real code often involves more than a single value. You might need to read a string, or convert a buffer into a slice. Here is a wrapper that reads a null-terminated string from C and converts it to a Rust String.

use std::ffi::CStr;
use std::os::raw::c_char;

/// Reads a null-terminated string from a C pointer.
///
/// Returns the string content if valid, or an error if the pointer
/// is null or contains invalid UTF-8.
pub fn read_c_string(ptr: *const c_char) -> Result<String, &'static str> {
    // Check null first. This is a safe check.
    if ptr.is_null() {
        return Err("Pointer is null");
    }

    unsafe {
        // SAFETY:
        // 1. ptr is non-null, verified above.
        // 2. CStr::from_ptr requires the pointer to point to a
        //    valid null-terminated string. We assume the caller
        //    guarantees this based on the C API contract.
        let c_str = CStr::from_ptr(ptr);

        // Convert to Rust string. This checks UTF-8 validity.
        // If the C string has bad UTF-8, this returns an error.
        c_str.to_str()
            .map(|s| s.to_string())
            .map_err(|_| "Invalid UTF-8 in C string")
    }
}

This wrapper handles multiple failure modes. It checks for null. It uses CStr::from_ptr to interpret the bytes. It checks UTF-8 validity. The unsafe block is limited to the pointer interpretation. The UTF-8 check is safe code. The result is a Result. The caller handles errors without touching raw pointers.

Pitfalls

Safe wrappers are powerful, but they have traps.

Leaking unsafe in the signature. If you write pub unsafe fn read(...), you haven't written a safe wrapper. You've just pushed the burden to the caller. The wrapper must be safe. The unsafe stays inside. If the signature requires unsafe, the wrapper failed.

Trusting the caller too much. A wrapper validates what it can. If the pointer comes from C, you can check if it's null. You can check if it's aligned. You cannot check if the memory is still allocated. You cannot check if another thread is writing to it. Those checks require cooperation from the source. The wrapper establishes a trust boundary. The safe code trusts the wrapper. The wrapper trusts the source. If the source lies, the wrapper fails. Document the trust.

Panic in unsafe. If you panic inside an unsafe block while holding an invariant violation, you might leak resources or corrupt state. Be careful with panics. Prefer returning Result or Option from the wrapper. Let the caller decide how to handle errors.

The wrapper is a contract. If the contract lies, the program explodes. Write the truth.

The Trust Boundary

A safe wrapper cannot validate everything. When you receive data from outside Rust, you are crossing a trust boundary. The wrapper can verify structure. It can check lengths. It can check alignment. It cannot verify intent.

Consider a pointer to a buffer. You can check that the pointer is non-null. You can check that the length is within bounds. You cannot check that the buffer contains the data you expect. You cannot check that the buffer won't be freed while you hold it. Those guarantees come from the protocol or the API contract.

The # Safety section in the doc comment tells the caller what they must guarantee. This is not optional. It is the specification of the trust boundary. If the caller violates the safety requirements, undefined behavior occurs. The wrapper cannot save them. The wrapper only protects against misuse of the API, not misuse of the underlying system.

Encoding Invariants

Sometimes you can move checks from runtime to compile time. std::ptr::NonNull is a type that cannot be null. If you wrap your pointer in NonNull, the compiler forces you to prove non-nullness at construction. Later uses don't need to check.

use std::ptr::NonNull;

/// A wrapper that guarantees a non-null pointer.
struct SafePtr {
    // NonNull encodes the invariant in the type.
    // The compiler prevents null values here.
    ptr: NonNull<i32>,
}

impl SafePtr {
    /// Creates a SafePtr from a raw pointer.
    /// Returns None if the pointer is null.
    pub fn new(ptr: *const i32) -> Option<Self> {
        // NonNull::new checks nullness and returns Option.
        // This moves the check to construction time.
        NonNull::new(ptr as *mut i32).map(|n| SafePtr { ptr: n })
    }

    /// Reads the value. No null check needed.
    pub fn read(&self) -> i32 {
        unsafe {
            // SAFETY:
            // 1. self.ptr is NonNull, so it is never null.
            // 2. The caller guarantees the pointer is valid.
            *self.ptr.as_ptr()
        }
    }
}

Using NonNull reduces runtime cost. The null check happens once at construction. Subsequent reads are free. Use NonNull when the non-null invariant is permanent. This is a common pattern in the standard library. Vec uses NonNull internally. Rc uses NonNull. Encode what you can.

Testing Unsafe Wrappers

Unit tests help, but they miss undefined behavior. A test might pass even if the code has UB, because UB often looks correct until it doesn't. You need tools that detect UB.

Use Miri. Miri is an interpreter that runs your code and detects undefined behavior. Run cargo miri test. If Miri finds a problem, you have a bug even if the tests pass. Miri catches out-of-bounds access, use-after-free, alignment errors, and invalid memory access. Treat Miri failures as compiler errors. They are bugs waiting to happen.

Convention: Run Miri on any crate that contains unsafe code. Add Miri to your CI pipeline. If Miri fails, the build fails. This catches subtle bugs that unit tests miss.

Common Patterns

You'll see safe wrappers in many places. The standard library is full of them. String::from_raw_parts is a safe wrapper around unsafe logic. Vec::from_raw_buffer does the same. These functions take ownership of raw memory and turn it into a safe collection. They validate the pointer, the capacity, and the length. They assume the memory is initialized. The pattern is consistent: validate, assume trust, wrap.

Another pattern is the "Safe Iterator". You have a raw pointer to a linked list. You write an iterator struct that holds the pointer. The next method advances the pointer safely. The iterator checks for null and returns None. The caller gets a safe iteration over an unsafe structure. The iterator encapsulates the traversal logic. The caller just loops.

Performance Considerations

Safe wrappers add checks. Checking null costs a branch. Checking alignment costs a bitwise operation. In tight loops, these checks add up. If profiling shows the wrapper is the bottleneck, you have options.

You can use debug_assert for checks that are only needed during development. This catches bugs during development without slowing down production. You can use NonNull to eliminate null checks entirely. You can split the API into a checked version and an unchecked version. The unchecked version takes NonNull and assumes validity. The checked version does the work. This gives callers a choice. Safety by default, speed when needed.

Convention: Use debug_assert! for invariants that are expensive to check but critical for correctness. This is the standard trade-off in performance-sensitive code. The checks run in debug builds. They vanish in release builds. You get safety during development and speed in production.

Decision: When to Use This

Use safe wrappers when you need to expose functionality to the rest of your Rust codebase without spreading unsafe annotations. Use safe wrappers when you can validate inputs at runtime, like checking null pointers or buffer lengths. Use safe wrappers when you are building a library and want to guarantee that misuse of the API cannot cause undefined behavior. Reach for raw unsafe blocks only when you are writing the wrapper itself or when performance profiling proves that the validation overhead is unacceptable. Reach for extern "C" functions when you are implementing the FFI boundary and must match the calling convention of the foreign code. Pick std::ptr::NonNull when you want to encode the non-null invariant in the type system rather than checking it at runtime.

Don't leak unsafe. The caller deserves safety.

Where to go next