Rust Is Memory Safe. Somebody Still Does the Knife Work.
Every guarantee you have been sold about Rust is real. It is also propped up on raw pointer arithmetic sitting in the standard library, held together by comments that nothing verifies. Here is what is actually back there.
Somebody hands you a codebase. It is in Rust, and they tell you this like it settles something. It's memory safe, they say, the way a restaurant tells you the fish is fresh.
Fine. Let's go look in the walk-in.
Because here's the thing nobody puts on the menu: the safest collection type in the entire language — Vec — pushes elements by doing raw pointer arithmetic inside an unsafe block, and the only thing standing behind that operation is a comment a human typed. Not a proof. A comment. And it works, and it is genuinely one of the best pieces of engineering in modern software, and both of those facts are true at the same time.
That's not a scandal. That's just what a kitchen looks like when you walk past the dining room and push through the doors.
Five Elements, Six Reads, Zero Warnings
Before Rust makes sense, you need to see what it replaced. Five elements allocated. Six read.
#include <stdio.h>
#include <stdint.h>
int main(void) {
uint32_t array[5] = {0, 0, 0, 0, 0};
for (int index = 0; index < 6; index++) {
printf("Index %d: %u\n", index, array[index]);
}
return 0;
}
Compile it with every warning GCC has to offer. Every single one.
$ gcc -Wall -Wextra -Wpedantic -O0 -o overflow overflow.c
$ echo $?
0
Nothing. Not a warning, not a note, not a raised eyebrow. The plate goes out.
That is the entire problem with C in one exit code. The language watched you read past the end of an array and said nothing, because the standard says it doesn't have to. There is nobody at the pass. There has never been anybody at the pass.
Index 0: 0
Index 1: 0
Index 2: 0
Index 3: 0
Index 4: 0
Index 5: 32766
That last number is whatever happened to be lying on the counter next to the array. It is not stable across machines. It is not stable across compilers. On your laptop it might be zero and the whole thing looks fine, which is the part that should worry you, because the dish that looks fine is the one that goes out for six months before anyone gets sick. On your machine it's zero, the tests are green, everybody goes home. That's not luck holding. That's undefined behaviour being polite to you, and it will stop.
Yes, you can bring in an inspector:
$ gcc -Wall -fsanitize=address -g -O0 -o overflow_asan overflow.c
$ ./overflow_asan
=================================================================
==499==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7fe427000034
READ of size 4 at 0x7fe427000034 thread T0
#0 0x560812fb0466 in main overflow.c:7
AddressSanitizer is excellent. It is also a health inspector who only shows up when you call and schedule the visit, who only sees the dishes you happen to cook that day, in a build you remembered to configure. Everything on the paths your tests never walk goes out unchecked, forever.
Fifteen years around C teaches you a particular posture. You watch your back on every line, because nothing else is watching it for you. You get permanently wary, and you start calling that wariness experience. It works, sort of, for one careful person on a good day. It does not survive a team, a deadline, or a Friday.
Rust's move is not cleverness. It's staffing the pass.
The Same Program, in a Kitchen That Has a Pass
Before the analogy, here is the payoff, so you know what we're building toward. Same loop. Same off-by-one. Rust, compiled with rustc 1.75:
fn main() {
let array: [u32; 5] = [0, 0, 0, 0, 0];
for index in 0..6 {
println!("Index {}: {}", index, array[index]);
}
}
Index 0: 0
Index 1: 0
Index 2: 0
Index 3: 0
Index 4: 0
thread 'main' panicked at oob.rs:4:41:
index out of bounds: the len is 5 but the index is 5
It stopped. It said the length was 5 and the index was 5 and it gave you the file and the line. Nobody got served a number scraped off the stack.
And when the mistake is visible at compile time, it never even runs:
fn main() {
let array: [u32; 5] = [0, 0, 0, 0, 0];
println!("{}", array[5]);
}
error: this operation will panic at runtime
--> oob2.rs:3:20
|
3 | println!("{}", array[5]);
| ^^^^^^^^ index out of bounds: the length is 5 but the index is 5
|
= note: `#[deny(unconditional_panic)]` on by default
Same bug, three outcomes. C hands it to the guest. Rust catches it at the pass — either as it goes out, or before it's even plated.
Now the part that explains why one language is built like that and the other isn't.
How a Kitchen Actually Works, Because the Rest of This Depends on It
If you have never worked in one, everything I am about to say about Rust will sound like decoration. It isn't. The structure of a professional kitchen and the structure of a memory-safe language are the same solution to the same problem, and once you see it you cannot unsee it.
So, quickly, how the thing runs.
A kitchen is stations, not people. Grill, sauté, garde manger, pastry. Each station owns its equipment — its board, its pans, its portion of the walk-in.
You do not wander over and grab someone else's mise because you ran out of shallots. You ask, or you go cut your own. That sounds like politeness. It isn't. If two cooks are both reaching into the same hotel pan during service, neither one knows how much is left, and somebody is going to hit the bottom of it mid-ticket and lose the plate.
One station owns a thing at a time. That is ownership, and Rust's version is the same rule for the same reason: if two parts of your program both think they own a buffer, one of them will free it while the other is still reading.
Mise en place is the prep shift. Before service, usually hours before anyone sits down, you break down proteins, portion sauces, dice the aromatics, label everything, and stage it in reach. It is unglamorous and nobody sees it. The entire point is that at 8pm when the rail is stacked four tickets deep, you are executing, not deciding. Every decision you can make cold, at 6am, is a decision you are not making badly at 8pm in the middle of a rush. Rust's borrow checker is the prep shift. It does its thinking cold, at compile time, so that runtime is pure execution.
The pass is where plates get checked. Between the line and the dining room there is a counter, and somebody senior stands at it — the expediter, often the chef. Nothing leaves without going past that person. Wrong temp, wrong plate, missing garnish, thumbprint in the sauce, it goes back. The expediter is not there to be liked. They are there because the alternative is the guest finding the mistake, and by then it is a refund, a bad review, and a table you have lost forever. The compiler is the pass.
The walk-in and the knife station are where the danger lives. Somebody has to break down a whole animal. Somebody has to run the slicer. That work is genuinely dangerous, it cannot be eliminated because the food does not portion itself, and the way a competent kitchen handles it is not by pretending the knives aren't there. It is by confining that work to specific people, at a specific station, with specific procedure, so that the eighteen-year-old on garde manger never touches a bandsaw. That is unsafe.
And the rule underneath all of it: when in doubt, throw it out. Not sure how long that stock has been in the walk-in? Bin it. It costs you money and it costs you nothing else. Because the alternative — serving it and finding out — is a category of mistake you do not recover from. Waste is survivable. Poison is not.
Hold onto that last one. It is going to explain a design decision in the Rust standard library that looks bizarre until you see it as a kitchen rule.
Here is the whole mapping in one place. Every row is load-bearing later:
| Kitchen | Rust |
|---|---|
| One station owns a thing at a time | Ownership |
| Asking to borrow, versus just taking | &T and &mut T |
| Everyone can taste, only one can stir | Many immutable borrows, xor one mutable |
| Mise en place, done cold at 6am | Compile-time checks, erased before codegen |
| The pass, and the expediter standing at it | The borrow checker |
| The knife station and the bandsaw | unsafe blocks |
| The prep tag on the container | // SAFETY: comments |
| When in doubt, throw it out | mem::forget is safe — leaking beats double-free |
| Nobody at the pass at all | C |
Print it out. Tape it above your desk. It will do more for your understanding of Rust than most of the tutorials will.
One Station Owns the Pan
Start with the rule everything else is built on, because if you only learn one thing about Rust memory safety, it is this one.
Every value has exactly one owner. When the owner goes out of scope, the value is dropped and its memory is freed — once, automatically, with no free() call anywhere in your code. Hand the value to somebody else and you have not made a copy. You have handed over the pan.
fn main() {
let sauce = String::from("beurre blanc");
let plated = sauce;
println!("{}", sauce);
}
That does not compile, and the reason is the whole language:
error[E0382]: borrow of moved value: `sauce`
--> move.rs:4:20
|
2 | let sauce = String::from("beurre blanc");
| ----- move occurs because `sauce` has type `String`, which does not implement the `Copy` trait
3 | let plated = sauce;
| ----- value moved here
4 | println!("{}", sauce);
| ^^^^^ value borrowed here after move
|
help: consider cloning the value if the performance cost is acceptable
sauce moved to plated. There is now exactly one owner, and it is not sauce anymore. Reaching for it is reaching for a pan that is no longer on your station.
In C, both names would still point at the same memory, both would still work, and both would eventually go out of scope and try to free it. That is a double-free, and it is one of the most reliably exploitable bugs in the language. Rust doesn't detect double-frees. It makes them unrepresentable, because there is never a second owner to do the second free.
Notice the last line of that error, too. It offers you clone() — the escape hatch, named explicitly, with the cost stated: if the performance cost is acceptable. You want two copies of the sauce, you make two batches. Nobody is stopping you. The compiler just refuses to let you pretend one batch is two.
Two Cooks, One Pan
The rule that stations don't share mise is worth seeing in code, because it's the one people fight hardest before they understand it.
let mut sauce = String::from("beurre blanc");
let taste_one = &sauce; // garde manger tastes it
let taste_two = &sauce; // sous tastes it
println!("{taste_one} {taste_two}"); // fine. tasting doesn't change the sauce.
let stir = &mut sauce; // now somebody wants to actually work on it
stir.push_str(", mounted");
Any number of people can taste. Exactly one can hold the spoon. And the moment somebody has the spoon, nobody else gets to taste, because what they'd be tasting is a sauce mid-change — half-mounted, half-not, depending on when they got there.
Try to do both at once and the expediter stops the plate. Here is the whole program, and then the actual compiler output — not a paraphrase:
fn main() {
let mut sauce = String::from("beurre blanc");
let taste = &sauce;
let stir = &mut sauce;
stir.push_str(", mounted");
println!("{}", taste);
}
error[E0502]: cannot borrow `sauce` as mutable because it is also borrowed as immutable
--> borrow.rs:5:16
|
4 | let taste = &sauce;
| ------ immutable borrow occurs here
5 | let stir = &mut sauce;
| ^^^^^^^^^^ mutable borrow occurs here
...
8 | println!("{}", taste);
| ----- immutable borrow later used here
Read what it did there. It didn't just say no. It showed you where you started tasting, where you grabbed the spoon, and where the tasting was still expected to be valid — three lines, in order, with the conflict between them. Rust's error messages are the single most underrated thing about learning the language. The compiler is not being obstructive. It is training you.
That is the entire data-race prevention story. Not a runtime lock, not a mutex you remembered to take, not a convention in a style guide somebody will violate at 4pm on a Friday. A structural rule enforced cold, before service, by something that does not care that you're in a hurry.
The reason this beats "just be careful" is the same reason a kitchen has the rule rather than trusting everyone to be considerate: discipline scales badly under pressure, and pressure is exactly when it matters. C's model is "just be careful." It works fine right up until the deadline, the new hire, or the 3am incident.
Prep Is Where the Work Actually Happens
Here is the whole mapping in one place, so you can stop holding it in your head:
| In the kitchen | In Rust | What goes wrong without it |
|---|---|---|
| One station owns the hotel pan | Ownership — one binding owns a value | Two cooks empty the same pan; two owners free the same allocation |
| Passing a plate vs. letting someone taste | &mut T (one) vs. &T (many) |
Two people stirring the same pot in different directions; a data race |
| Mise en place, done cold at 6am | Borrow checking at compile time | Deciding at 8pm with a burn on your arm; deciding at runtime, in production |
| The expediter at the pass | The compiler refusing to build | The guest finds the mistake instead of you |
| Knife station, one place, trained staff | unsafe blocks, confined and marked |
Everyone has a bandsaw; every line of C is a place to slip |
| The prep tag on the container | // SAFETY: comment |
Nobody knows if it was done right, and nothing checks |
| When in doubt, throw it out | mem::forget is safe; leaking is legal |
You serve it and find out |
| The health inspector you scheduled | AddressSanitizer, fuzzing | Only sees the dishes you cooked that day |
Now the part that trips people up.
Every kitchen that runs well runs on mise en place. You break down the proteins, portion the sauces, and stage the line at six in the morning, cold, with nobody watching, so that at eight o'clock when the tickets are stacked four deep you are executing and not thinking.
Ownership, borrowing, and lifetimes are prep. All three are resolved during type checking and erased before code generation. There is no lifetime data in the compiled binary, no check on dereference, no runtime trace of 'a anywhere.
So when someone tells you Rust must be slower because the compiler enforces more rules, they have it exactly backwards. The rules cost compile time. Service is free.
Compare the garbage-collected languages, which solve the same safety problem by keeping somebody on payroll during service to walk the line and clear abandoned stations. It works. I am not knocking it. But you are paying a collector, a heap that collector manages, and pause behaviour you spend your afternoons tuning instead of eliminating. Rust proves liveness up front and emits the free call directly. Nobody is walking the line, because the line was set up correctly before anyone sat down.
Somebody Did the Knife Work So You Didn't Have To
Now the walk-in. Vec::push is close to the most-called method in the language, and here is its entire body, from library/alloc/src/vec/mod.rs in rust-lang/rust, MIT/Apache-2.0, fetched from master:
pub fn push(&mut self, value: T) {
let _ = self.push_mut(value);
}
It delegates. So follow it one more step, into push_mut, which is where the actual work happens:
pub fn push_mut(&mut self, value: T) -> &mut T {
// Inform codegen that the length does not change across grow_one().
let len = self.len;
// This will panic or abort if we would allocate > isize::MAX bytes
// or if the length increment would overflow for zero-sized types.
if len == self.buf.capacity() {
self.buf.grow_one();
}
unsafe {
let end = self.as_mut_ptr().add(len);
ptr::write(end, value);
self.len = len + 1;
// SAFETY: We just wrote a value to the pointer that will live the lifetime of the reference.
&mut *end
}
}
Pointer arithmetic. A raw write. A manual length update. A raw pointer turned into a reference by hand. This is C's memory model, in the standard library, underneath the collection you were told was safe.
And it should be. Somebody has to break down the protein. The question was never whether the knife comes out — it was who holds it, where they stand, and whether anybody wrote down what they did.
That // SAFETY: line is a prep tag. Nothing verifies it. No tool checks it. It is a cook's word that the work was done right, and the entire edifice of Rust's safety guarantee is standing on people who take that seriously and reviewers who read it.
I find this reassuring rather than alarming, and the reason is boring: it's confined. In C, that knife is on every station, in every drawer, and every line of your codebase is a place where somebody can slip. In Rust it's one station, marked, staffed by people who know the drill, and everyone else works with what comes off it and never touches the blade.
The Bounds Check You Can Delete Safely
Lifetimes are free. Bounds checks are not. That's the one place safe Rust actually pays at runtime, and it's where most of the performance I recover from other people's code is hiding.
Picture two cooks portioning the same hotel pan of confit.
The first one works off the ticket. Every single portion, he walks to the rail, reads the count, goes back to the pan, checks whether there's anything left in it, then plates one. Then he does the whole thing again for the next portion. He is never wrong — he checks every time, so he can't overrun the pan — and he is burning half his motion on verification he already did ninety seconds ago.
The second one counts the pan out once at the top. Lays every portion on the board in a row, then works down the row. He never checks whether there's anything left, because he laid them out himself and the row is the answer. When the row ends, he's done.
That's an indexed loop versus an iterator. for i in 0..slice.len() { slice[i] } re-verifies the bound on every single access — the compiler has to insert a check, because from where it's standing, i is just some number and this is just some slice, and nothing structurally connects them. for x in slice.iter() walks the row. The bound isn't checked because the bound is built into the shape of the thing, and the compiler can see that.
So the move is not unsafe. Anyone who reaches for unsafe to win a benchmark is the line cook who stops washing his hands because it saves eight seconds. Congratulations, you shaved 3% off a hot loop and bought yourself a use-after-free that shows up in six months on somebody else's hardware. That's not optimisation, that's just gambling with a longer settlement period. The move is iterators, and the standard library shows you exactly why. From library/core/src/slice/iter/macros.rs in the same repository:
#[inline]
fn next(&mut self) -> Option<$elem> {
// intentionally not using the helpers because this is
// one of the most mono'd things in the library.
let ptr = self.ptr;
let end_or_len = self.end_or_len;
and the advance:
// SAFETY: by type invariant, the `end_or_len` field is always
// non-null for a non-ZST pointee. (This transmute ensures we
// get `!nonnull` metadata on the load of the field.)
if ptr == crate::intrinsics::transmute::<$ptr, NonNull<T>>(end_or_len) {
return None;
}
// SAFETY: since it's not empty, per the check above, moving
// forward one keeps us inside the slice, and this is valid.
self.ptr = ptr.add(1);
ptr.add(1). Walking a raw pointer, no per-element bounds check, one comparison against an end pointer per step. The knife work is already done — inside the abstraction, by the standard library, reviewed to death.
Which is why for x in slice.iter() beats for i in 0..slice.len() { slice[i] } and will keep beating it. The indexed version begs for a bounds check on every access. The iterator proves the bound structurally and the check evaporates. Converting indexed loops to iterator chains is the first thing I do on an optimisation pass and usually the biggest single win, and it costs nothing — you are not going around the safety, you are using the thing that already contains it.
While you're in there, read the second line of that function again: "one of the most mono'd things in the library." Somebody deliberately wrote uglier code to control monomorphisation cost and left a note explaining why, so the next well-meaning contributor doesn't tidy it up and regress the entire library. That note is worth more than the optimisation. Undocumented cleverness is just a landmine you left for a colleague.
Three Doors, and the One You Pick Says What You Know
The thing I've come to appreciate most about Rust's slice API is that it doesn't pretend everyone at the pass has the same job.
Same operation, three doors. First one returns an Option, from library/core/src/slice/mod.rs:
pub const fn get<I>(&self, index: I) -> Option<&I::Output>
where
I: [const] SliceIndex<Self>,
{
index.get(self)
}
You can't touch the result without acknowledging it might not be there. The type system puts the empty case in your hands and refuses to let you walk past it. This is the door for somebody's first week, and there is nothing condescending about it — it is the door I use most, because most of the time I do not actually know the index is in range and I should stop pretending I do.
Second door is slice[i]. Panics if you're wrong. You're asserting you know the invariant, and if you don't, the program stops immediately, loudly, at the exact line where you were wrong. That is a plate hitting the floor in the kitchen instead of on the guest's table.
Third door is the knife station:
pub const unsafe fn get_unchecked<I>(&self, index: I) -> &I::Output
where
I: [const] SliceIndex<Self>,
{
// SAFETY: the caller must uphold most of the safety requirements for `get_unchecked`;
// the slice is dereferenceable because `self` is a safe reference.
// The returned pointer is safe because impls of `SliceIndex` have to guarantee that it is.
unsafe { &*index.get_unchecked(self) }
}
No check. C's behaviour, on request, requiring the unsafe keyword at every single call site so that anybody grepping the codebase finds all of them in one pass.
Look at that SAFETY comment though. It splits the obligation — the caller upholds most of the requirements, and the parts the library can prove locally get named separately. That is what a real prep tag looks like. When I audit someone's Rust, unsafe blocks are where the entire budget goes, and a comment that specific takes ten minutes. // SAFETY: trust me takes an afternoon, and it ends in a conversation where somebody has to explain to me why this is fine, and "it's always worked" is not an answer. It has always worked so far. Those are different sentences.
Waste Is Not Poison
Here's my favourite thing in the whole standard library, and it's a function with no unsafe on it at all. From library/core/src/mem/mod.rs:
pub const fn forget<T>(t: T) {
let _ = ManuallyDrop::new(t);
}
mem::forget eats a value and never runs its destructor. Any safe Rust program can call it. That memory is gone for the life of the process. Leaking is safe, officially, on purpose.
The reasoning sits a few lines up, in the docs for ManuallyDrop:
/// if a panic were introduced between construction of `ManuallyDrop` and building the
/// string (which cannot happen in the code as shown), it would result in a leak and not a
/// double free. In other words, `ManuallyDrop` errs on the side of leaking instead of
/// erring on the side of (double-)dropping.
Read that as a kitchen rule and it's the oldest one there is. A leak is a hotel pan of something good going bad in the walk-in — wasteful, embarrassing, costs you money. A double-free is serving the same plate twice. When you're not sure, you throw it out. Every time. You never, ever send it back out.
Rust made that call deliberately and then defined its safety guarantee so that wasting food never counts as a violation.
Which means the honest list of what safe Rust still lets you do is longer than the marketing suggests:
- Leaks.
mem::forgetis safe.Rccycles never drop. - Deadlocks. The borrow checker stops two threads racing on memory. It has nothing whatsoever to say about two threads each holding the lock the other one wants.
- Integer overflow. Debug panics. Release wraps, silently. An overflowed length that then indexes is still bounds-checked, so it stays memory safe and stays completely wrong.
- Every logic bug you will ever write. An auth check that returns the wrong boolean compiles beautifully.
Memory safety buys you a bug class. It does not buy you correctness. A security review that stops at "well, it's Rust" never made it out of the dining room. You looked at the tablecloths and called it a health inspection.
The Knife Station Still Cuts People
CrabbyAvif is Google's Rust AVIF decoder, shipping in Android right now. From src/internal_utils/mod.rs in webmproject/CrabbyAvif, Apache-2.0, commit d3b6e4a:
fn slice_impl(&self) -> &[T] {
// SAFETY: We only construct this with `ptr` which is valid at least as long as this struct
// is alive, and ro/mut borrows of the whole struct to access the inner slice, which makes
// our access appropriately exclusive.
unsafe { &(*self.ptr) }
}
Same shape as the standard library code, different team, real product. In the current tree, 25 of 62 Rust files under src/ contain unsafe, which is entirely normal for a codec doing FFI and buffer work.
It produced CVE-2025-48530. High severity, CVSS 8.1, memory safety, inside the unsafe code. Caught before public release, rendered non-exploitable by Scudo — Android's hardened allocator — and patched in the August 2025 update.
Good. That is the system working. Somebody at a well-run station made a mistake, and the layers behind them caught the plate before it reached anybody. Nobody gets to be so good they stop needing the second check, and any engineer who tells you otherwise has either never shipped anything or has and doesn't know what happened next. Keep the allocator hardening. Keep the fuzzing. Keep the review. Especially on the codebases where the compiler is doing most of the work for you, because that's exactly where people get comfortable.
What I Tell People Who Ask What to Learn First
Learn Rust. Then go back and learn C.
Not because C is finished — it's underneath everything, including Rust, and it will outlive all of us. But if you learn C first you spend years building that same wariness and calling it skill, and then you have to unlearn it. Learn Rust first and the borrow checker will explain ownership to you a hundred times, in a bad mood, until you actually understand what the invariants are. After that you can read C and the rules stop being folklore passed down by people who got burned. You go back knowing why Rust was built, which is a much better order than finding out through a 3am page and a core dump.
Three boundaries, and all three are visible in the source if you go look.
The compiler enforces ownership, borrowing, and lifetimes, and erases all of it before code generation — safety that costs you nothing during service.
Underneath, unsafe blocks in the standard library do the allocator work, and their correctness rests on comments no tool will ever check.
Past that, unsafe in your own code is exactly as dangerous as C, and CVE-2025-48530 is what that looks like in a codebase built specifically to avoid it.
Safe Rust cannot cause undefined behaviour provided the unsafe code beneath it is sound. That qualifier isn't fine print somebody buried. It is the entire claim, and it's a far better one than "nothing dangerous happens here," because it's true and it tells you where to look.
You can't get rid of the knife. You can decide who holds it, where they stand, and whether they write down what they did. That's not a compromise. That's just how a professional kitchen has always worked.