RustArena

Step 6 of 6

When (not) to use unsafe at work

Here's the honest picture: most Rust developers write little or no unsafe, and many application teams have none in their own code at all. The unsafe code your programs rely on lives in std, Tokio, hashbrown and similar crates, where specialists write it, review it and test it with Miri and fuzzers. You get the speed without the risk.

When unsafe is (rarely) right

A checklist before writing unsafe

  1. Is there a safe API? Most "I need unsafe" moments have one:
You want to... Safe tool
two &mut into one slice split_at_mut, chunks_mut, get_disjoint_mut
swap or move out of a &mut std::mem::swap, mem::take, mem::replace
mutate through a shared reference Cell, RefCell, Mutex, atomics
reinterpret bytes as numbers u32::from_le_bytes, f32::from_bits, or the bytemuck / zerocopy crates
skip a bounds check iterators, chunks_exact, an assert! before the loop
a global counter an atomic in a static
  1. Is there a well-tested crate that already wraps it?
  2. Did you measure that it's worth it?
  3. Can you keep it tiny? One small module, a safe public API, assert!s that make it sound.
  4. Is every block documented? A // SAFETY: comment per block, a # Safety section per unsafe fn.
  5. Is it tested? Unit tests run under Miri in CI, and a second reviewer who knows unsafe Rust.

Tooling that keeps unsafe in check

Put #![forbid(unsafe_code)] at the top of your crate (in main.rs or lib.rs) and the compiler rejects any unsafe in it, permanently:

#![forbid(unsafe_code)]

fn main() {
    let x = 5;
    let p = &raw const x;
    println!("{}", unsafe { *p });
}
error: usage of an `unsafe` block

Many teams set it on every crate except one small, well-reviewed module. Also useful: clippy's undocumented_unsafe_blocks lint, the unsafe_op_in_unsafe_fn lint (on by default since edition 2024), and cargo geiger, which counts the unsafe code in your dependencies.

The safe versions are usually shorter

use std::mem;

struct Player {
    name: String,
    inventory: Vec<String>,
}

fn main() {
    let mut a = 1;
    let mut b = 2;
    mem::swap(&mut a, &mut b);
    println!("{a} {b}");

    let mut p = Player { name: "Ferris".to_string(), inventory: vec!["map".to_string()] };
    let old = mem::take(&mut p.inventory); // moves the Vec out, leaves an empty one
    println!("{} had {:?}, now {:?}", p.name, old, p.inventory);

    let bytes = [0x2a, 0x00, 0x00, 0x00];
    println!("{}", u32::from_le_bytes(bytes));
}
2 1
Ferris had ["map"], now []
42

mem::take replaces a value with its Default and hands you the old one, which is how you "move out of a &mut" without ptr::read. mem::replace(&mut x, new) does the same with a value you choose.

Your task

A teammate wrote rotate_left_by_one with ptr::read, ptr::copy and ptr::write. It works, but every reader now has to verify the pointer math. Rewrite it with no unsafe, using the slice method rotate_left. Keep the empty-slice case working (rotate_left(1) panics on an empty slice).

Challenge 2

take_name moves the name out of a Player and leaves an empty name behind, using ptr::read and ptr::write. That's correct only because the two lines are in exactly this order with nothing in between that could panic; one careless edit and the String gets freed twice. Rewrite it with std::mem::take and no unsafe.

Challenge 3

read_u32_le reads the first four bytes of a buffer as a little-endian u32 by casting the byte pointer to *const u32. That's two bugs: it never checks the buffer has four bytes, and a u32 must be read from an address divisible by 4, which a byte slice doesn't guarantee (debug builds abort with misaligned pointer dereference). Rewrite it safely: return None if there are fewer than 4 bytes, otherwise Some(u32::from_le_bytes(...)).

Previous: Undefined behavior and Miri