← All posts

What the Borrow Checker Can't See: Checking Unsafe Rust with Miri

All measurements were taken on the same machine. Every terminal block is reproduced verbatim from a single recorded session. The test environment is listed at the end of the article.

Rust can catch memory errors before a program ever runs. The borrow checker tracks reference lifetimes, prevents simultaneous mutable access to the same data, and disallows using a value after it has been moved.

But those guarantees do not cover everything that can be written in Rust.

As soon as a raw pointer and unsafe enter the program, part of the compiler's job shifts to the developer. The code must still obey Rust's memory rules; it is simply up to a human to prove that it does.

This is where a dangerous illusion appears: if the program compiles, the tests pass, and the data reads correctly, then the unsafe code must be sound.

Let us test that claim in practice.

We will implement our own Vec<T> on top of the system allocator, hide one plausible bug inside it, and write a test that executes the faulty path directly. The code will compile. Every native test will pass. Even the assumption that caused the bug will be confirmed by a separate check. The important excerpts appear throughout the article; the complete source files are collected in the appendix at the end.

Then we will run the same test suite under Miri.

The error missed by both the borrow checker and ordinary tests will be found in 2.18 seconds.

Where the borrow checker's guarantees end

The borrow checker works with Rust references:

&T
&mut T

These references have lifetimes. The compiler knows which object they are tied to and checks that the object lives long enough.

Low-level code, however, often works with raw pointers:

*const T
*mut T

A raw pointer carries no lifetime that the compiler can verify. The compiler cannot determine whether the memory at that address still exists, whether it is correctly aligned, whether the value has been initialized, or whether creating a reference from the pointer is permitted.

The mere presence of a raw pointer is not an error. Raw pointers can be stored, passed around, and compared in safe code. The dangerous operations—dereferencing, pointer arithmetic, and creating references—require unsafe.

An unsafe block does not mean “turn safety off.” It means something else:

I assert that the safety requirements are satisfied, even though the compiler cannot prove that on its own.

If that assertion is wrong, the program has undefined behavior, even if it continues to behave exactly as the author expected.

To see the difference, let us write a small collection whose correctness depends almost entirely on such assertions.

A custom Vec<T> backed by a raw pointer

The minimal state of a dynamic array consists of a pointer, a length, and a capacity:

pub struct MyVec<T> {
    ptr: *mut T,
    cap: usize,
    len: usize,
    /// We own values of `T` and drop them; tell the drop checker.
    _own: PhantomData<T>,
}

ptr points to the beginning of the allocated buffer.

cap indicates how many values of type T fit in the allocated memory.

len indicates how many slots have already been initialized.

PhantomData<T> adds no fields to the structure's physical representation. It tells the compiler that MyVec<T> logically owns values of type T, even though it stores them through a raw pointer. Among other things, this affects lifetime analysis and drop semantics.

The structure must maintain several invariants:

  • len never exceeds cap;
  • slots 0..len contain valid values of type T;
  • slots len..cap are still uninitialized;
  • when capacity is nonzero, ptr belongs to a live allocation of the required size;
  • every constructed T is dropped exactly once;
  • the buffer is deallocated with the same Layout that was used to allocate it.

The standard Vec<T> maintains these conditions inside the library. In our own implementation, they are our responsibility.

When the buffer fills up, we double its capacity:

fn grow(&mut self) {
    let new_cap = if self.cap == 0 {
        4
    } else {
        self.cap.checked_mul(2).expect("capacity overflow")
    };
    let new_layout = Self::layout_for(new_cap);
    let new_ptr = if self.cap == 0 {
        unsafe { alloc::alloc(new_layout) }
    } else {
        let old_layout = Self::layout_for(self.cap);
        unsafe { alloc::realloc(self.ptr.cast::<u8>(), old_layout, new_layout.size()) }
    };
    if new_ptr.is_null() {
        alloc::handle_alloc_error(new_layout);
    }
    self.ptr = new_ptr.cast::<T>();
    self.cap = new_cap;
}

Layout describes a memory block's size and alignment. The first growth uses alloc; later growth operations use realloc.

The new capacity is computed with checked_mul. If cap * 2 does not fit in a usize, the program stops instead of wrapping to a small value and allocating a buffer that is too short.

For our experiment, however, another line matters more:

self.ptr = new_ptr.cast::<T>();

After realloc, the structure stores the pointer returned by the allocator.

The remaining methods look familiar. push calls write for an uninitialized slot:

self.ptr.add(self.len).write(value);

pop decreases the length and takes the value with read. get checks the index and creates a reference. Deref constructs a slice from the pointer and length. In Drop, the elements are destroyed one by one, after which the buffer itself is deallocated.

The result is a plausible educational implementation of Vec<T>: not production-ready, but complete enough that the bug does not look artificially planted.

A bug that looks reasonable

The error is in shrink_to_fit.

The method reduces the buffer's capacity to its current length, returning the unused tail to the allocator:

pub fn shrink_to_fit(&mut self) {
    if self.cap == self.len {
        return;
    }
    // ... (len == 0: dealloc, back to the empty state)
    let old_layout = Self::layout_for(self.cap);
    let new_layout = Self::layout_for(self.len);
    // Shrinking never moves the block: realloc only trims the tail,
    // so the data keeps living at the same address.
    let trimmed =
        unsafe { alloc::realloc(self.ptr.cast::<u8>(), old_layout, new_layout.size()) };
    if trimmed.is_null() {
        alloc::handle_alloc_error(new_layout);
    }
    self.cap = self.len;
}

The code does not look careless.

The no-op case is handled. The old and new Layout values are calculated. The returned pointer is checked for null.

There is even a nearby comment explaining the decision:

When shrinking, realloc only trims the unused tail. The block does not move, so the old pointer remains valid.

But comparing this method with grow reveals one difference.

grow contains this line:

self.ptr = new_ptr.cast::<T>();

After the realloc call in shrink_to_fit, however, the trimmed pointer is never stored anywhere.

This is not an accidental typo. The omission is backed by reasoning: if the address does not change, why update the field with the same value?

The problem is that a numeric address and a valid pointer are not the same thing in Rust's memory model.

We will return to that point. First, let us test the author's reasoning against a real allocator.

glibc confirms the false assumption

This machine uses glibc. When shrinking an ordinary chunk, glibc can indeed leave the block in place and merely change its size.

A separate test checks exactly that:

let before = v.as_ptr() as usize;
v.shrink_to_fit();
let after = v.as_ptr() as usize;
ensure!(before == after, "glibc shrinks this block in place");

Before shrink_to_fit, the pointer is converted to an integer. After the call, the test reads v.as_ptr() again, which in our faulty implementation still comes from self.ptr. The two integers are then compared.

They are equal.

On this machine, the assumption in the comment is empirically confirmed: the block remained at the same address.

That is what makes errors like this especially dangerous. The false rule does not merely look plausible—it is reinforced by the system's observable behavior.

Every test passes

The test suite checks several aspects of the collection:

  • a 64-element LIFO cycle with four buffer growth operations;
  • storing strings that own heap-allocated data;
  • bounds checking in get;
  • constructing a slice through Deref;
  • the exact number of destructor calls;
  • 1,500 push operations and 750 pop operations with a checksum;
  • reading elements after shrink_to_fit;
  • preservation of the numeric address when the block is shrunk.

We run the ordinary tests:

$ cargo test
     Running tests/myvec.rs (target/debug/deps/myvec-32101f8012a1a7df)

running 8 tests
test deref_exposes_a_slice ... ok
test get_checks_bounds ... ok
test push_pop_roundtrip ... ok
test drops_run_exactly_once ... ok
test shrink_then_read ... ok
test shrink_keeps_the_address_natively ... ok
test strings_survive_growth ... ok
test stress_mixed_ops ... ok

test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

All eight tests pass. Two checks are especially important.

shrink_then_read reduces the buffer from 128 slots to three, then reads the remaining data. The read succeeds, and the values are correct.

shrink_keeps_the_address_natively confirms that the numeric address did not change.

The code contains a use-after-free, yet it passes not only the collection's general tests but also a test that directly executes the faulty path.

Why the borrow checker stays silent

The borrow checker does not analyze the logical history of self.ptr.

To it, this is simply a field of type:

*mut T

It does not know which allocation is behind the pointer, when that allocation was created, or whether the latest realloc ended its lifetime.

The operation that creates a reference in get is inside unsafe:

Some(unsafe { &*self.ptr.add(index) })

With this block, the author asserts several facts at once:

  • self.ptr is not null;
  • it is correctly aligned for T;
  • the pointer belongs to a live allocation;
  • index lies within that allocation;
  • the corresponding element is initialized;
  • creating a shared reference does not violate the aliasing rules.

The compiler can check the expression's type, but not whether these assertions are true.

unsafe does not reduce the program's obligation to obey Rust's rules. It merely removes automatic checking from certain operations.

Successful compilation therefore means only one thing here: the compiler trusted the author's promise.

Why the tests stay silent too

Ordinary tests observe the program's behavior in one concrete execution.

They see that:

  • the address is the same before and after shrinking;
  • the old bytes are still present at that address;
  • reading returns the correct values;
  • destructors run the correct number of times;
  • the program does not crash.

From the processor's point of view, the old pointer is a number. After realloc, that number still points to the region containing the expected data.

The tests therefore report honestly that the observed result is correct.

But the soundness of unsafe code depends on more than which bytes happen to be stored at an address. It also matters whether the program has the right to access those bytes through a particular pointer.

An ordinary machine does not track that right.

We need a different executor.

Miri and Rust's abstract machine

Miri is an interpreter for Rust programs from the compiler project. It does not run finished machine code; it executes MIR.

MIR—Mid-level Intermediate Representation—is the program's internal representation after type analysis and before instructions for a specific processor are generated. At this level, memory operations, branches, value moves, and function calls are already explicit.

Miri executes this representation on Rust's abstract machine.

In that model, every allocation has its own identity and lifetime. The interpreter tracks which bytes are initialized, which values are stored in memory, and which allocation a pointer is associated with.

That last property is called provenance—the origin of a pointer.

In this model, a pointer consists of more than a numeric address. It also carries a connection to a specific allocation and the authority to access it.

If an old allocation's lifetime ends, its pointer does not become valid for a new allocation merely because the allocator reused the same address.

That is precisely the information the native tests lost.

Miri is installed together with the nightly toolchain:

$ rustup toolchain install nightly --component miri

On its first run, Miri also builds a sysroot—the standard library that it will execute through its own interpreter.

Now we run the same suite under Miri:

$ cargo +nightly miri test
running 7 tests
test deref_exposes_a_slice ... ok
test drops_run_exactly_once ... ok
test get_checks_bounds ... ok
test push_pop_roundtrip ... ok
test shrink_then_read ... error: Undefined Behavior: in-bounds pointer arithmetic failed: alloc66408 has been freed, so this pointer is dangling
   --> src/lib.rs:84:25
    |
 84 |         Some(unsafe { &*self.ptr.add(index) })
    |                         ^^^^^^^^^^^^^^^^^^^ Undefined Behavior occurred here
    |
    = help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
    = help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information
help: alloc66408 was allocated here:
   --> src/lib.rs:124:22
    |
124 |             unsafe { alloc::realloc(self.ptr.cast::<u8>(), old_layout, new_layout.size()) }
    |                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: alloc66408 was deallocated here:
   --> src/lib.rs:104:22
    |
104 |             unsafe { alloc::realloc(self.ptr.cast::<u8>(), old_layout, new_layout.size()) };
    |                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    = note: this is on thread `shrink_then_rea`
    = note: stack backtrace:
            0: myvec::MyVec::<u64>::get
                at src/lib.rs:84:25: 84:44
            1: shrink_then_read
                at tests/myvec.rs:103:9: 103:17
            2: shrink_then_read::{closure#0}
                at tests/myvec.rs:88:26: 88:36

note: some details are omitted, run with `MIRIFLAGS=-Zmiri-backtrace=full` for a verbose backtrace

error: aborting due to 1 previous error

error: test failed, to rerun pass `--test myvec`

The first run, including crate compilation, took 2.77 seconds. Despite being an interpreter, Miri is reasonably fast in developer-experience terms.

Miri reconstructs the allocation's history

The diagnostic does more than point to the line where the program tried to use the invalid pointer.

Miri shows the complete history of allocation alloc66408.

It was created here:

alloc66408 was allocated here

The referenced line is in grow, where the buffer is expanded through realloc.

Miri then reports:

alloc66408 was deallocated here

This is the realloc call inside shrink_to_fit.

Finally, the program used a pointer associated with that allocation in get:

self.ptr.add(index)

From the abstract machine's point of view, every successful realloc ends the lifetime of the old allocation and returns a pointer to a new one.

The system allocator may place the new block at the same address. But that does not mean the old pointer automatically gains permission to access the new allocation.

The trimmed variable contains the valid pointer. The self.ptr field still contains a pointer whose provenance belongs to the now-dead alloc66408.

That is why Miri considers it a dangling pointer.

The error occurs before dereferencing

The diagnostic is phrased in an unusual way:

in-bounds pointer arithmetic failed

One might expect an error about reading freed memory. But the program does not even reach the read.

The violation already occurs here:

self.ptr.add(index)

add is not arbitrary integer addition. It computes the address of an element within the same allocation as the source pointer.

If the allocation no longer exists, then the notion of “the element at index index within it” no longer exists either. Pointer arithmetic on a dangling pointer is therefore itself undefined behavior—even before the result is dereferenced.

On a real processor, the operation will still become an addition of an address and an offset. Rust's memory model, however, imposes stricter rules that the optimizer is allowed to rely on.

The address is the same, but the pointer is different

The glibc test proved:

before == after

The numeric addresses really are equal.

The program, however, drew a conclusion that was too strong: because the address is the same, the old pointer must still be valid.

In Rust's memory model, a pointer is not merely an address. It also has a relationship to an allocation.

Conceptually, the situation can be represented like this:

old pointer = address 0x1000 + authority for allocation A
new pointer = address 0x1000 + authority for allocation B

The numeric part is identical. The provenance is different.

After realloc, allocation A has ended its lifetime. Only the pointer to B returned by the function is valid.

Native execution generally does not store this kind of metadata alongside every pointer. As a result, the access may successfully read the old bytes and create the impression of a completely correct program.

Undefined behavior does not mean that the program must immediately crash or return garbage. It means that the code has violated rules the compiler is entitled to assume are followed.

The access may work reliably today. After a change in compiler version, optimization level, or allocator, the result is no longer guaranteed.

Miri only sees executed paths

Miri did not statically prove every possible state of MyVec<T>.

It ran the tests and reached one specific sequence:

grow → shrink_to_fit → get

The shrink_then_read test is what led the interpreter down the faulty path.

Without that test, Miri would not have executed a read after shrinking the buffer and would not have found the bug.

This is an important limitation: Miri checks memory operations only along paths that are actually executed.

It therefore does not replace a good test suite. On the contrary, its effectiveness depends directly on how thoroughly the tests cover the unsafe logic.

A useful mental model is that tests choose where to point the spotlight, while Miri reveals rule violations inside the illuminated area.

The fix is one missing line

The diagnostic points directly to the solution.

After realloc, we must store the returned pointer, just as grow already does:

         if trimmed.is_null() {
             alloc::handle_alloc_error(new_layout);
         }
+        self.ptr = trimmed.cast::<T>();
         self.cap = self.len;

Now self.ptr receives the provenance of the new live allocation.

The comment claiming that shrinking never moves the block must be replaced as well. A correct rule must not depend on the behavior of a particular allocator:

After realloc, only the returned pointer is valid, even if its numeric address is identical to the old one.

The native tests still pass after the fix. glibc still preserves the address. But this is now merely an observation about the allocator's implementation, not the basis for the code's safety.

We run the suite under Miri again:

$ cargo +nightly miri test
running 7 tests
test deref_exposes_a_slice ... ok
test drops_run_exactly_once ... ok
test get_checks_bounds ... ok
test push_pop_roundtrip ... ok
test shrink_then_read ... ok
test stress_mixed_ops ... ok
test strings_survive_growth ... ok

test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.50s

All seven tests available to Miri pass.

Why Miri runs seven tests instead of eight

The native suite runs eight tests. Under Miri, it runs seven.

The numeric-address stability check is disabled:

#[cfg(not(miri))]

This is intentional.

The shrink_keeps_the_address_natively test checks the behavior of one specific allocator implementation—glibc on this machine. Rust does not promise that shrinking a block will preserve its address.

Miri uses its own memory and allocator model. In that model, the old object always ends its lifetime after realloc, and the new one receives a distinct identity. Requiring the addresses to match in such a model would be meaningless.

The two test environments therefore have different jurisdictions:

  • the native test checks glibc's observable behavior;
  • Miri checks compliance with Rust's memory rules.

Address equality itself is not an error. The error was using it as proof that the old pointer remained valid.

The cost of a stricter model

Miri tracks memory state and interprets every operation, so it is much slower than native execution.

On the fixed crate, the medians of three warm runs were:

CommandWall time
cargo test — 8 native tests0.05 s
cargo +nightly miri test — 7 tests3.08 s

In this suite, Miri was approximately 62 times slower.

The native 0.05-second result is close to the measurement granularity, so the ratio varied from 62× to 76× between sessions. It is more accurate to describe the slowdown as tens of times rather than as one exact constant multiplier.

There are also one-time costs:

  • 20.9 seconds to prepare the sysroot on the first run;
  • compiling the project for Miri in a clean checkout;
  • requiring a nightly toolchain.

Miri therefore does not replace an ordinary cargo test run after every change.

It has a different role in the workflow: a dedicated check for code containing unsafe. It can be run locally before submitting changes and as a separate CI job.

Even when the native suite takes tens of seconds, a Miri run often remains within a few minutes. For a check capable of finding a use-after-free inside an entirely green test suite, that is a reasonable cost.

What exactly the borrow checker cannot see

The borrow checker did not miss an error in an area it was responsible for checking.

It proved the correctness of the safe code and stopped where the author used a raw pointer inside unsafe. From that point onward, the compiler relied on invariants asserted by the programmer.

The ordinary tests did not return a false result either. They checked the values and saw the expected bytes.

Miri added the missing layer: it executed the same tests in a model that tracks allocation identity and pointer provenance.

That is why the same code produced three different but compatible results:

  • the compiler confirmed type correctness;
  • the native tests confirmed the observed result;
  • Miri found a violation of the memory rules.

The error was not in the bytes or in the numeric address. It was in the unproven right to access those bytes through the old pointer.

A bug like this can appear to work for years, until a change in the environment turns hidden undefined behavior into a visible failure.

In our experiment, Miri needed 2.18 seconds so that we would not have to wait for that moment.

Appendix: Full Source Files

lib.rs — the Vec with the planted bug
//! A hand-rolled growable array: `Layout`, `alloc`, `realloc`, `Drop`.
//!
//! The smallest Vec that still feels real: generic over `T`, doubles its
//! buffer through the global allocator, drops every element exactly once
//! and hands out slices via `Deref`. Zero-sized types are rejected up
//! front — they deserve their own chapter, not silently wrong math.

use std::alloc::{self, Layout};
use std::marker::PhantomData;
use std::ops::{Deref, DerefMut};
use std::ptr::NonNull;
use std::{ptr, slice};

/// A growable array over a raw heap buffer: `ptr` + `cap` + `len`.
///
/// Invariants: `len <= cap`; slots `0..len` are initialized; when
/// `cap > 0` the buffer holds exactly `cap` slots of `T`.
pub struct MyVec<T> {
    ptr: *mut T,
    cap: usize,
    len: usize,
    /// We own values of `T` and drop them; tell the drop checker.
    _own: PhantomData<T>,
}

impl<T> MyVec<T> {
    /// An empty vector; nothing is allocated until the first push.
    pub fn new() -> Self {
        assert!(
            size_of::<T>() != 0,
            "zero-sized types are out of scope here"
        );
        Self {
            ptr: NonNull::dangling().as_ptr(),
            cap: 0,
            len: 0,
            _own: PhantomData,
        }
    }

    /// Number of initialized elements.
    pub fn len(&self) -> usize {
        self.len
    }

    /// True when there are no elements.
    pub fn is_empty(&self) -> bool {
        self.len == 0
    }

    /// Number of slots the buffer holds without growing.
    pub fn capacity(&self) -> usize {
        self.cap
    }

    /// Raw pointer to the buffer (for address inspection in tests).
    pub fn as_ptr(&self) -> *const T {
        self.ptr
    }

    /// Append a value, growing the buffer when it is full.
    pub fn push(&mut self, value: T) {
        if self.len == self.cap {
            self.grow();
        }
        unsafe { self.ptr.add(self.len).write(value) };
        self.len += 1;
    }

    /// Remove and return the last element, if any.
    pub fn pop(&mut self) -> Option<T> {
        if self.len == 0 {
            return None;
        }
        self.len -= 1;
        Some(unsafe { self.ptr.add(self.len).read() })
    }

    /// Borrow the element at `index`, if it is within bounds.
    pub fn get(&self, index: usize) -> Option<&T> {
        if index >= self.len {
            return None;
        }
        Some(unsafe { &*self.ptr.add(index) })
    }

    /// Hand the unused tail of the buffer back to the allocator,
    /// so that `capacity() == len()`.
    pub fn shrink_to_fit(&mut self) {
        if self.cap == self.len {
            return;
        }
        if self.len == 0 {
            unsafe { alloc::dealloc(self.ptr.cast::<u8>(), Self::layout_for(self.cap)) };
            self.ptr = NonNull::dangling().as_ptr();
            self.cap = 0;
            return;
        }
        let old_layout = Self::layout_for(self.cap);
        let new_layout = Self::layout_for(self.len);
        // Shrinking never moves the block: realloc only trims the tail,
        // so the data keeps living at the same address.
        let trimmed =
            unsafe { alloc::realloc(self.ptr.cast::<u8>(), old_layout, new_layout.size()) };
        if trimmed.is_null() {
            alloc::handle_alloc_error(new_layout);
        }
        self.cap = self.len;
    }

    /// Double the buffer (four slots on the first call) and adopt the
    /// new pointer returned by the allocator.
    fn grow(&mut self) {
        let new_cap = if self.cap == 0 {
            4
        } else {
            self.cap.checked_mul(2).expect("capacity overflow")
        };
        let new_layout = Self::layout_for(new_cap);
        let new_ptr = if self.cap == 0 {
            unsafe { alloc::alloc(new_layout) }
        } else {
            let old_layout = Self::layout_for(self.cap);
            unsafe { alloc::realloc(self.ptr.cast::<u8>(), old_layout, new_layout.size()) }
        };
        if new_ptr.is_null() {
            alloc::handle_alloc_error(new_layout);
        }
        self.ptr = new_ptr.cast::<T>();
        self.cap = new_cap;
    }

    /// Layout for a buffer of `cap` elements, or a loud death.
    fn layout_for(cap: usize) -> Layout {
        Layout::array::<T>(cap).expect("capacity overflows the address space")
    }
}

impl<T> Deref for MyVec<T> {
    type Target = [T];

    fn deref(&self) -> &[T] {
        unsafe { slice::from_raw_parts(self.ptr, self.len) }
    }
}

impl<T> DerefMut for MyVec<T> {
    fn deref_mut(&mut self) -> &mut [T] {
        unsafe { slice::from_raw_parts_mut(self.ptr, self.len) }
    }
}

impl<T> Drop for MyVec<T> {
    /// Drop the initialized elements, then free the buffer — once each.
    fn drop(&mut self) {
        if self.cap == 0 {
            return;
        }
        unsafe {
            ptr::drop_in_place(ptr::slice_from_raw_parts_mut(self.ptr, self.len));
            alloc::dealloc(self.ptr.cast::<u8>(), Self::layout_for(self.cap));
        }
    }
}

impl<T> Default for MyVec<T> {
    fn default() -> Self {
        Self::new()
    }
}
myvec.rs — the native test suite (all green)
//! The suite that stays green natively — and is the whole point under Miri.

use std::sync::atomic::{AtomicUsize, Ordering};

use anyhow::{Result, ensure};
use myvec::MyVec;

#[test]
fn push_pop_roundtrip() -> Result<()> {
    let mut v = MyVec::new();
    for i in 0..64_u64 {
        v.push(i * 3);
    }
    ensure!(v.len() == 64, "expected 64 elements after 64 pushes");
    ensure!(v.capacity() == 64, "doubling from 4 should land on 64");
    for i in (0..64_u64).rev() {
        ensure!(
            v.pop() == Some(i * 3),
            "pop must return elements in LIFO order"
        );
    }
    ensure!(v.pop().is_none(), "pop on an empty vec must be None");
    Ok(())
}

#[test]
fn strings_survive_growth() -> Result<()> {
    let mut v = MyVec::new();
    for i in 0..10 {
        v.push(format!("item-{i:02}"));
    }
    ensure!(v.len() == 10, "ten strings were pushed");
    ensure!(
        v.get(7).map(String::as_str) == Some("item-07"),
        "growth must not corrupt heap-owning elements"
    );
    Ok(())
}

#[test]
fn get_checks_bounds() -> Result<()> {
    let mut v = MyVec::new();
    v.push(41_i32);
    ensure!(v.get(0) == Some(&41), "index 0 is initialized");
    ensure!(v.get(1).is_none(), "index 1 is past len and must be None");
    Ok(())
}

#[test]
fn deref_exposes_a_slice() -> Result<()> {
    let mut v = MyVec::new();
    for i in 1..=5_i64 {
        v.push(i);
    }
    let sum: i64 = v.iter().sum();
    ensure!(sum == 15, "slice iteration must see all five elements");
    ensure!(v.first() == Some(&1), "slice methods come through Deref");
    Ok(())
}

#[test]
fn drops_run_exactly_once() -> Result<()> {
    static DROPS: AtomicUsize = AtomicUsize::new(0);

    struct Counted(#[allow(dead_code)] u8);

    impl Drop for Counted {
        fn drop(&mut self) {
            DROPS.fetch_add(1, Ordering::Relaxed);
        }
    }

    {
        let mut v = MyVec::new();
        for _ in 0..8 {
            v.push(Counted(0));
        }
        let _ = v.pop();
    }
    ensure!(
        DROPS.load(Ordering::Relaxed) == 8,
        "8 values constructed, 8 drops expected — no more, no less"
    );
    Ok(())
}

#[test]
fn shrink_then_read() -> Result<()> {
    let mut v = MyVec::new();
    for i in 0..100_u64 {
        v.push(i);
    }
    while v.len() > 3 {
        v.pop();
    }
    ensure!(
        v.capacity() == 128,
        "100 pushes double the buffer up to 128"
    );
    v.shrink_to_fit();
    ensure!(v.capacity() == 3, "shrink_to_fit must land on cap == len");
    ensure!(
        v.get(2) == Some(&2),
        "reads after shrink must still see the data"
    );
    let sum: u64 = v.iter().sum();
    ensure!(sum == 3, "0 + 1 + 2 survive the shrink");
    Ok(())
}

/// Address identity is a property of this native allocator, not of the
/// language: glibc trims a shrinking block in place. Miri's allocator
/// retires the old allocation on every realloc, so the observation is
/// gated off under Miri.
#[cfg(not(miri))]
#[test]
fn shrink_keeps_the_address_natively() -> Result<()> {
    let mut v = MyVec::new();
    for i in 0..100_u64 {
        v.push(i);
    }
    while v.len() > 3 {
        v.pop();
    }
    let before = v.as_ptr() as usize;
    v.shrink_to_fit();
    let after = v.as_ptr() as usize;
    ensure!(before == after, "glibc shrinks this block in place");
    Ok(())
}

#[test]
fn stress_mixed_ops() -> Result<()> {
    let mut v = MyVec::new();
    let mut pushed_sum: u64 = 0;
    let mut popped_sum: u64 = 0;
    for round in 0..3_u64 {
        for i in 0..500 {
            let value = round * 1_000 + i;
            pushed_sum = pushed_sum.wrapping_add(value);
            v.push(value);
        }
        for _ in 0..250 {
            if let Some(value) = v.pop() {
                popped_sum = popped_sum.wrapping_add(value);
            }
        }
    }
    ensure!(v.len() == 750, "3 rounds of (500 pushes - 250 pops)");
    let remaining: u64 = v.iter().sum();
    ensure!(
        popped_sum.wrapping_add(remaining) == pushed_sum,
        "every pushed value is either popped or still in the vec"
    );
    Ok(())
}
lib.rs (fixed) — the one-line repair
//! A hand-rolled growable array: `Layout`, `alloc`, `realloc`, `Drop`.
//!
//! The smallest Vec that still feels real: generic over `T`, doubles its
//! buffer through the global allocator, drops every element exactly once
//! and hands out slices via `Deref`. Zero-sized types are rejected up
//! front — they deserve their own chapter, not silently wrong math.

use std::alloc::{self, Layout};
use std::marker::PhantomData;
use std::ops::{Deref, DerefMut};
use std::ptr::NonNull;
use std::{ptr, slice};

/// A growable array over a raw heap buffer: `ptr` + `cap` + `len`.
///
/// Invariants: `len <= cap`; slots `0..len` are initialized; when
/// `cap > 0` the buffer holds exactly `cap` slots of `T`.
pub struct MyVec<T> {
    ptr: *mut T,
    cap: usize,
    len: usize,
    /// We own values of `T` and drop them; tell the drop checker.
    _own: PhantomData<T>,
}

impl<T> MyVec<T> {
    /// An empty vector; nothing is allocated until the first push.
    pub fn new() -> Self {
        assert!(
            size_of::<T>() != 0,
            "zero-sized types are out of scope here"
        );
        Self {
            ptr: NonNull::dangling().as_ptr(),
            cap: 0,
            len: 0,
            _own: PhantomData,
        }
    }

    /// Number of initialized elements.
    pub fn len(&self) -> usize {
        self.len
    }

    /// True when there are no elements.
    pub fn is_empty(&self) -> bool {
        self.len == 0
    }

    /// Number of slots the buffer holds without growing.
    pub fn capacity(&self) -> usize {
        self.cap
    }

    /// Raw pointer to the buffer (for address inspection in tests).
    pub fn as_ptr(&self) -> *const T {
        self.ptr
    }

    /// Append a value, growing the buffer when it is full.
    pub fn push(&mut self, value: T) {
        if self.len == self.cap {
            self.grow();
        }
        unsafe { self.ptr.add(self.len).write(value) };
        self.len += 1;
    }

    /// Remove and return the last element, if any.
    pub fn pop(&mut self) -> Option<T> {
        if self.len == 0 {
            return None;
        }
        self.len -= 1;
        Some(unsafe { self.ptr.add(self.len).read() })
    }

    /// Borrow the element at `index`, if it is within bounds.
    pub fn get(&self, index: usize) -> Option<&T> {
        if index >= self.len {
            return None;
        }
        Some(unsafe { &*self.ptr.add(index) })
    }

    /// Hand the unused tail of the buffer back to the allocator,
    /// so that `capacity() == len()`.
    pub fn shrink_to_fit(&mut self) {
        if self.cap == self.len {
            return;
        }
        if self.len == 0 {
            unsafe { alloc::dealloc(self.ptr.cast::<u8>(), Self::layout_for(self.cap)) };
            self.ptr = NonNull::dangling().as_ptr();
            self.cap = 0;
            return;
        }
        let old_layout = Self::layout_for(self.cap);
        let new_layout = Self::layout_for(self.len);
        // realloc retires the old allocation even when the block does
        // not move; only the pointer it returns is alive from here on.
        let trimmed =
            unsafe { alloc::realloc(self.ptr.cast::<u8>(), old_layout, new_layout.size()) };
        if trimmed.is_null() {
            alloc::handle_alloc_error(new_layout);
        }
        self.ptr = trimmed.cast::<T>();
        self.cap = self.len;
    }

    /// Double the buffer (four slots on the first call) and adopt the
    /// new pointer returned by the allocator.
    fn grow(&mut self) {
        let new_cap = if self.cap == 0 {
            4
        } else {
            self.cap.checked_mul(2).expect("capacity overflow")
        };
        let new_layout = Self::layout_for(new_cap);
        let new_ptr = if self.cap == 0 {
            unsafe { alloc::alloc(new_layout) }
        } else {
            let old_layout = Self::layout_for(self.cap);
            unsafe { alloc::realloc(self.ptr.cast::<u8>(), old_layout, new_layout.size()) }
        };
        if new_ptr.is_null() {
            alloc::handle_alloc_error(new_layout);
        }
        self.ptr = new_ptr.cast::<T>();
        self.cap = new_cap;
    }

    /// Layout for a buffer of `cap` elements, or a loud death.
    fn layout_for(cap: usize) -> Layout {
        Layout::array::<T>(cap).expect("capacity overflows the address space")
    }
}

impl<T> Deref for MyVec<T> {
    type Target = [T];

    fn deref(&self) -> &[T] {
        unsafe { slice::from_raw_parts(self.ptr, self.len) }
    }
}

impl<T> DerefMut for MyVec<T> {
    fn deref_mut(&mut self) -> &mut [T] {
        unsafe { slice::from_raw_parts_mut(self.ptr, self.len) }
    }
}

impl<T> Drop for MyVec<T> {
    /// Drop the initialized elements, then free the buffer — once each.
    fn drop(&mut self) {
        if self.cap == 0 {
            return;
        }
        unsafe {
            ptr::drop_in_place(ptr::slice_from_raw_parts_mut(self.ptr, self.len));
            alloc::dealloc(self.ptr.cast::<u8>(), Self::layout_for(self.cap));
        }
    }
}

impl<T> Default for MyVec<T> {
    fn default() -> Self {
        Self::new()
    }
}

Test environment: Intel i7-10750H (Comet Lake), 62 GB RAM, Fedora 43, glibc 2.42; native tests—rustc 1.97.1 stable, debug profile; Miri—nightly cargo 1.99.0 (2026-07-17), miri 1a833e1654 (2026-07-29). Every terminal block is reproduced verbatim from a single recorded session; timings are wall-clock measurements and medians of three warm runs, except for the separately noted one-time costs.

Newsletter

New playgrounds in your inbox

Get an email when a new playground drops, plus the occasional engineering deep-dive. No spam, unsubscribe anytime.