Rust’s unsafe keyword is often seen as a necessary evil, a way to escape the borrow checker’s iron grip when you absolutely need to do something low-level or performance-critical. But what if I told you that you can write and audit unsafe code safely, meaning with a high degree of confidence that it’s correct and won’t lead to memory unsafety?

Let’s see unsafe in action. Imagine you’re building a high-performance data structure, like a custom vector. You might want to manage the raw memory yourself.

use std::alloc::{alloc, dealloc, Layout};
use std::ptr;

struct RawVec<T> {
    ptr: *mut T,
    cap: usize,
}

impl<T> RawVec<T> {
    fn new() -> Self {
        RawVec {
            ptr: ptr::null_mut(),
            cap: 0,
        }
    }

    fn grow(&mut self) {
        let (new_cap, new_layout) = if self.cap == 0 {
            (1, Layout::array::<T>(1).unwrap())
        } else {
            let new_cap = self.cap * 2;
            let new_layout = Layout::array::<T>(new_cap).unwrap();
            (new_cap, new_layout)
        };

        // SAFETY:
        // 1. We are allocating memory for `new_cap` elements of type `T`.
        // 2. `new_layout` is valid because `new_cap` is non-zero and `T` is not zero-sized.
        // 3. The allocation must succeed. If it fails, we'd panic.
        let new_ptr = unsafe { alloc(new_layout) };
        if new_ptr.is_null() {
            panic!("allocation failed");
        }

        // SAFETY:
        // 1. `self.ptr` is a valid pointer to `self.cap` elements of type `T`.
        // 2. `self.cap` is the number of initialized elements.
        // 3. `new_ptr` is a valid pointer to memory of size `new_cap * size_of::<T>()`.
        // 4. The source and destination memory regions do not overlap.
        // 5. `T` is Copy or we are moving the values.
        if self.cap > 0 {
            unsafe {
                ptr::copy_nonoverlapping(self.ptr as *const T, new_ptr as *mut T, self.cap);
            }
        }

        // SAFETY:
        // 1. `self.ptr` is a valid pointer to `self.cap` elements of type `T`.
        // 2. `self.cap` is the number of initialized elements.
        // 3. `old_layout` is valid.
        if self.cap > 0 {
            let old_layout = Layout::array::<T>(self.cap).unwrap();
            unsafe {
                dealloc(self.ptr, old_layout);
            }
        }

        self.ptr = new_ptr as *mut T;
        self.cap = new_cap;
    }

    fn push(&mut self, elem: T) {
        if self.cap == 0 || self.cap == self.len() { // `len()` would need to be defined
            self.grow();
        }

        // SAFETY:
        // 1. `self.ptr` is a valid pointer to allocated memory.
        // 2. `self.len()` is less than `self.cap`, so there is space for a new element.
        // 3. We are writing `elem` into the next available slot.
        unsafe {
            let end = self.ptr.add(self.len());
            ptr::write(end, elem);
        }
    }

    fn len(&self) -> usize {
        // This is a simplification; a real Vec would track len separately.
        // For this example, we'll assume capacity is the current number of elements.
        self.cap
    }
}

impl<T> Drop for RawVec<T> {
    fn drop(&mut self) {
        if self.cap != 0 {
            let layout = Layout::array::<T>(self.cap).unwrap();
            unsafe {
                dealloc(self.ptr as *mut u8, layout);
            }
        }
    }
}

fn main() {
    let mut vec = RawVec::<i32>::new();
    vec.push(10);
    vec.push(20);
    println!("Capacity: {}, Pointer: {:?}", vec.cap, vec.ptr);
}

This RawVec example demonstrates manual memory management. The unsafe blocks are where we tell the compiler, "Trust me, I know what I’m doing here." This involves operations like direct pointer manipulation, calling foreign functions, and accessing or modifying memory outside the Rust’s usual safety guarantees.

The core problem unsafe Rust aims to solve is bridging the gap between Rust’s strict memory safety and the practical needs of systems programming. It allows you to implement data structures that could be implemented in C/C++ (like custom allocators, lock-free data structures, or FFI bindings) while still aiming to retain as much safety as possible within those boundaries. The unsafe keyword doesn’t disable safety checks; it merely signals that you, the programmer, are taking responsibility for upholding them in that specific block.

The mental model for unsafe Rust is that it’s a contract. The unsafe block is your promise to the compiler that you will uphold certain invariants. These invariants are typically:

  1. Valid Pointer Dereference: If you dereference a raw pointer, it must be valid for reads or writes, aligned, and point to initialized memory of the correct type.
  2. Valid Memory Access: If you’re accessing memory through a pointer, you must ensure it’s within the allocated bounds and initialized for the operation.
  3. Pointer Validity: Raw pointers must be valid for the operations performed on them (e.g., not dangling, not null when dereferenced).
  4. Type Soundness: If you cast a pointer to a different type, the underlying memory must be compatible with that type.
  5. Foreign Function Interface (FFI): When calling C functions, you must ensure you’re passing arguments and handling return values according to the C ABI.

Auditing unsafe code is less about finding compiler errors (since the compiler is deliberately letting you bypass checks) and more about rigorous manual verification. The most effective way to audit unsafe code is to use a combination of:

  • Clear Documentation: Every unsafe block must have comments explaining why it’s unsafe and how the invariants are being upheld. This is crucial for future you and your colleagues.
  • Formal Verification Tools: Tools like miri (the Rust interpreter) can detect many memory safety errors at runtime by simulating execution. Running your unsafe code with miri is non-negotiable.
  • Unit and Integration Tests: Write comprehensive tests that exercise the unsafe code under various conditions, including edge cases, invalid inputs, and concurrent access patterns if applicable.
  • Static Analysis: While Rust’s compiler is excellent, static analysis tools can sometimes catch subtle issues.
  • Code Reviews: Have experienced Rustaceans review your unsafe blocks. A fresh pair of eyes can often spot logical flaws.

The one thing most people don’t know about unsafe Rust is that the borrow checker still applies to the pointers themselves. You can’t have two mutable pointers to the same data managed by unsafe code and mutate both concurrently without explicit synchronization. The unsafe keyword exempts you from borrow checker rules, not from the fundamental concept of memory safety and avoiding data races. You are responsible for ensuring that your unsafe operations do not violate these underlying principles, even if the compiler isn’t actively enforcing them for you.

The next logical step after mastering unsafe for manual memory management is exploring how to use unsafe for Foreign Function Interface (FFI) calls, which involves interacting with code written in other languages, most commonly C.

Want structured learning?

Take the full Cargo course →