Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 3 - Ownership, Borrowing, and Lifetimes

“You don’t free memory in Rust. The compiler decides when to free it, and it never gets it wrong.”

This is the most important chapter in the book. Rust’s ownership model is what makes the language unique, and understanding it deeply is essential for writing secure systems code. If you come from C/C++, you are used to manually managing memory with malloc/free or new/delete. Rust replaces this with a set of compile-time rules that guarantee memory safety without garbage collection.

3.1 Ownership Rules

Every value in Rust has exactly one owner: a variable that is responsible for its lifetime. When the owner goes out of scope, the value is dropped (memory is freed, destructors run). The three fundamental rules are:

  1. Each value has exactly one owner.
  2. When the owner goes out of scope, the value is dropped.
  3. Values can be moved to a new owner or borrowed by references.
fn main() {
    let s1 = String::from("hello");  // s1 owns the String
    let s2 = s1;                     // ownership moves to s2
    // println!("{}", s1);           // ERROR: s1 no longer valid
    println!("{}", s2);              // OK: s2 is the owner
}   // s2 is dropped here, memory freed

This is a move: ownership transfers from s1 to s2. The compiler prevents use-after-free by making s1 inaccessible after the move.

🔒 Security impact: Eliminates CWE-416 (Use-After-Free) and double-free bugs. In C, transferring a string pointer without clear ownership conventions leads to double-free or use-after-free. Rust enforces this at compile time.

3.1.1 The Copy Trait

Some types are so small that copying them is cheaper than managing ownership. Types that implement the Copy trait are implicitly copied on assignment instead of moved:

fn main() {
    let x: i32 = 42;
    let y = x;        // x is copied (i32 implements Copy)
    println!("{}", x); // OK: x is still valid
    println!("{}", y); // OK: y has its own copy
}

Types that implement Copy: all integer types, f32/f64, bool, char, tuples of Copy types, and arrays of Copy types.

Types that do not implement Copy: String, Vec<T>, Box<T>, and any type that manages a resource (heap memory, file handles, etc.).

⚠️ Security note: Custom types should #[derive(Copy, Clone)] only when appropriate. Blindly deriving Copy for types containing handles or pointers can lead to logic errors.

3.1.2 Cloning When You Need Duplication

When you actually need a deep copy, use .clone():

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();   // Deep copy, both are valid
    println!("{} {}", s1, s2);
}

3.2 Borrowing and References

Instead of transferring ownership, you can borrow a value via references. There are two kinds:

  • Immutable references (&T): Allow reading but not modification. Multiple immutable borrows are allowed simultaneously.
  • Mutable references (&mut T): Allow modification. Only one mutable borrow is allowed at a time, and it cannot coexist with any immutable borrows.
fn calculate_length(s: &str) -> usize {  // borrow immutably
    s.len()
}   // s goes out of scope but since it doesn't have ownership, nothing happens

fn append_world(s: &mut String) {           // borrow mutably
    s.push_str(", world");
}

fn main() {
    let mut greeting = String::from("hello");
    
    // Immutable borrow
    let len = calculate_length(&greeting);
    println!("'{}' has length {}", greeting, len);
    
    // Mutable borrow
    append_world(&mut greeting);
    println!("{}", greeting);  // "hello, world"
}

3.2.1 The Borrowing Rules Enforced

The compiler enforces these rules strictly:

fn main() {
    let mut data = vec![1, 2, 3, 4, 5];
    
    // Rule 1: Multiple immutable borrows OK
    let r1 = &data[0];
    let r2 = &data[1];
    println!("{} {}", r1, r2);  // OK
    
    // Rule 2: Mutable borrow must be exclusive
    let r3 = &mut data;
    // let r4 = &data[0];       // ERROR: cannot borrow immutably while mutably borrowed
    r3.push(6);
    // let r5 = &mut data;      // ERROR: cannot have two mutable borrows while `r3` is still used below
    println!("{:?}", r3);
}

🔒 Security impact: These rules eliminate:

  • CWE-416 (Use-After-Free): References cannot outlive the data they reference.
  • CWE-362 (Concurrent race condition / data race): Two threads cannot have mutable access to the same data simultaneously without synchronization.
  • Iterator invalidation: Modifying a collection while iterating is a compile error.

3.2.2 Preventing Iterator Invalidation

A classic C++ bug that leads to crashes and security vulnerabilities:

// C++ - DANGEROUS: iterator invalidation
std::vector<int> v = {1, 2, 3};
for (auto it = v.begin(); it != v.end(); ++it) {
    if (*it == 2) {
        v.push_back(4);  // May reallocate, invalidating `it`
        // `it` is now dangling - use-after-free!
    }
}

Rust prevents this at compile time:

fn main() {
    let mut v = vec![1, 2, 3];
    for val in &v {           // immutable borrow of v
        // v.push(4);         // ERROR: cannot borrow mutably while borrowed immutably
        println!("{}", val);
    }
    v.push(4);                // OK: borrow ended
}

3.3 Lifetimes

Every reference in Rust has a lifetime: the scope for which the reference is valid. Most of the time, lifetimes are implicit and inferred. But when the compiler cannot determine the relationship between reference lifetimes, you must annotate them explicitly.

3.3.1 The Problem Lifetimes Solve

Consider this function:

#![allow(unused)]
fn main() {
// What does the compiler need to know?
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}
}

The compiler cannot know whether the returned reference came from x or y. We must specify the relationship:

#![allow(unused)]
fn main() {
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
}

The annotation <'a> says: “the returned reference lives as long as the shorter of x and y’s lifetimes.”

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let s1 = String::from("long string");
    let result;
    {
        let s2 = String::from("short");
        result = longest(s1.as_str(), s2.as_str());
        println!("{}", result);  // OK: s2 still alive
    }
    // println!("{}", result);  // ERROR: s2 is dead, result may refer to it
}

🔒 Security impact: Lifetimes guarantee that no reference can outlive the data it points to. This is the compile-time equivalent of proving the absence of dangling pointers.

3.3.2 Lifetime Elision Rules

You don’t always need to write lifetime annotations. The compiler applies three rules:

  1. Each reference parameter gets its own lifetime.
  2. If there’s exactly one input lifetime, it’s assigned to all output lifetimes.
  3. If there are multiple input lifetimes but one is &self or &mut self, that lifetime is assigned to all outputs.
// Elided (implicit)
fn first_word(s: &str) -> &str

// Expanded (explicit)
fn first_word<'a>(s: &'a str) -> &'a str

3.3.3 Struct Lifetimes

Structs that hold references must specify lifetimes:

#![allow(unused)]
fn main() {
struct Parser<'a> {
    input: &'a str,
    position: usize,
}

impl<'a> Parser<'a> {
    fn new(input: &'a str) -> Self {
        Parser { input, position: 0 }
    }
    
    fn peek(&self) -> Option<char> {
        self.input.chars().nth(self.position)
    }
}
}

This ensures the Parser cannot outlive the input string it references.

⚠️ Performance note: chars().nth(self.position) is O(n) because UTF-8 strings are not indexable by character offset. Fine for a teaching example, but hot parsers should track byte offsets or iterate once with char_indices().

3.4 Common Patterns for Security Developers

3.4.1 The Drop Trait - Deterministic Cleanup

Rust’s Drop trait is the equivalent of a C++ destructor. On normal return and unwinding paths, it runs deterministically when a value goes out of scope:

struct SecureBuffer {
    data: Vec<u8>,
}

impl Drop for SecureBuffer {
    fn drop(&mut self) {
        // Zero the buffer before freeing memory using volatile writes
        // to prevent the compiler from optimizing away the zeroing.
        for byte in self.data.iter_mut() {
            unsafe { std::ptr::write_volatile(byte, 0); }
        }
        // A barrier after the wipe is enough: it keeps the optimizer from
        // moving the Vec's eventual deallocation ahead of these volatile writes.
        std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst);
        // Vec will be freed after this
    }
}

fn main() {
    let key = SecureBuffer {
        data: vec![0xDE, 0xAD, 0xBE, 0xEF],
    };
    // When `key` goes out of scope, the buffer is zeroed and freed
}

The volatile writes are already the observable side effect here. The barrier is about compiler reordering, not inter-core synchronization, so a heavier hardware fence is not required for this example.

🔒 Security pattern: Use Drop to hook cleanup of sensitive data (cryptographic keys, passwords, tokens) on normal destruction paths. This is the Rust equivalent of calling SecureZeroMemory on Windows or explicit_bzero on POSIX before releasing the buffer.

⚠️ Panic behavior matters: Drop runs on normal return and during unwinding, but it does not run if the process aborts. If you set panic = "abort" for FFI or hardening reasons, do not assume Drop-based wiping protects panic paths.

⚠️ Important: A naive loop like for byte in data.iter_mut() { *byte = 0; } can be optimized away by LLVM because the Vec is about to be deallocated and the writes appear to have no observable effect. We show the write_volatile + compiler-fence pattern first so you can see the optimization hazard that motivates zeroize; in practice you should prefer the zeroize crate, as shown below:

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
extern crate zeroize;
use zeroize::{Zeroize, ZeroizeOnDrop};

#[derive(Zeroize, ZeroizeOnDrop)]
struct SecureBuffer {
    data: Vec<u8>,
}
}

Neither Drop nor zeroize is a complete secret-lifecycle guarantee. They do not run on paths such as panic = "abort", std::process::exit, or deliberate leaks like mem::forget, and they do not erase copies of the secret that were already made elsewhere.

⚠️ Clone caveat: If a secret-bearing type implements Clone, every clone is a distinct copy that must also be zeroized. Prefer move-only secret types unless you have a clear lifecycle for each copy.

Deserialization has the same lifecycle cost: every serde::Deserialize call materializes another live secret value. Keep secret-bearing DTOs narrow and zeroize transient copies promptly.

3.4.2 ManuallyDrop<T> - Controlled Destruction Boundaries

Most code should let Rust run destructors automatically. ManuallyDrop<T> exists for the narrower cases where you need to suppress that automatic Drop temporarily, such as handing ownership across an FFI boundary or forcing one resource to outlive another during teardown:

#![allow(unused)]
fn main() {
use std::mem::ManuallyDrop;

struct OwnedFd(i32);

impl Drop for OwnedFd {
    fn drop(&mut self) {
        // Real code would call close(self.0) or the platform equivalent.
        println!("closing fd {}", self.0);
    }
}

fn transfer_fd_to_foreign_code(fd: OwnedFd) -> i32 {
    let fd = ManuallyDrop::new(fd);
    fd.0
}
}

⚠️ Security note: ManuallyDrop disables Rust’s automatic cleanup, so a mistake becomes a leak or double-free bug immediately. Use it only at clearly documented ownership boundaries and keep exactly one deallocation path for the wrapped value.

3.4.3 Interior Mutability - RefCell<T> and Cell<T>

Sometimes you need to mutate data even when there are immutable references to it. Rust provides safe interior mutability types:

#![allow(unused)]
fn main() {
use std::cell::RefCell;

struct Logger {
    messages: RefCell<Vec<String>>,
}

impl Logger {
    fn log(&self, msg: &str) {
        // Borrow mutably through the RefCell, even though self is immutable
        self.messages.borrow_mut().push(msg.to_string());
    }
    
    fn dump(&self) -> Vec<String> {
        self.messages.borrow().clone()
    }
}
}

⚠️ Security note: RefCell enforces borrowing rules at runtime instead of compile time. If you violate the rules (e.g., calling borrow_mut() while a borrow() is active), the program panics. Use RefCell only when you cannot satisfy the borrow checker at compile time, and ensure your usage patterns cannot lead to panics.

3.4.4 Self-Referential Types and Pin

Self-referential types (structs where one field references another) are a challenge in Rust. Most developers encounter this indirectly through async fn: the compiler-generated future may hold references into its own state machine, so it must not be moved after polling begins.

Pin<P> is the tool Rust uses to express that guarantee. Pinning does not magically make arbitrary self-references safe; it only promises that the pointee will not move after it has been pinned. If you think you need a hand-written self-referential struct, first try a simpler design: store offsets instead of references, split the owned buffer from the parsed view, or borrow from an external owner instead of borrowing from self.

🔒 Security relevance: Pin matters when implementing low-level async runtimes, protocol state machines, or custom pointer types. Used correctly, it prevents bugs where internal references are invalidated by moves. Used incorrectly with unsafe, it can recreate the same dangling-reference class Rust normally eliminates.

3.5 Ownership Compared to C/C++ Patterns

PatternC/C++Rust
Single ownerManual conventionEnforced by compiler
Shared ownershipShared pointers (shared_ptr)Arc<T> (atomic reference count)
Unique ownershipunique_ptrBox<T> (or plain binding)
BorrowingRaw pointers, no rulesReferences with compile-time rules
Lifetime managementManual / RAIICompiler-enforced RAII
Double-freePossibleImpossible (ownership transfer)
Use-after-freePossibleImpossible (borrow checker)
Dangling pointerPossibleImpossible (lifetimes)

3.6 Summary

  • Every value has exactly one owner; when the owner is dropped, the value is freed.
  • References borrow values without taking ownership, governed by strict rules.
  • The borrow checker enforces: either one mutable reference or any number of immutable references.
  • Lifetimes ensure references cannot outlive the data they reference.
  • Drop provides deterministic cleanup on normal destruction paths; use it to hook secure wiping, but do not assume it runs on abort or forced-exit paths.
  • ManuallyDrop<T> is for explicit ownership-transfer and destruction-order boundaries; use it sparingly and document who frees the value.
  • Interior mutability (RefCell, Cell) moves borrow checking to runtime; use sparingly.

Understanding ownership is the foundation. In the next chapter, we explore Rust’s type system and how it prevents entire categories of security bugs.

3.7 Exercises

  1. Borrow Checker Exploration: Write a function that attempts to hold an immutable reference to a Vec while pushing a new element. Observe the compiler error. Then fix the code by restructuring it so the borrow ends before the mutation. Document which CWE class this prevents.

  2. Lifetime Annotations: Write a function first_two_words(s: &str) -> (&str, &str) that returns the first two space-separated words. Add explicit lifetime annotations. Then write a main that demonstrates a case where the compiler correctly rejects use of the result after the original String is dropped.

  3. Custom Drop: Implement a struct SecureBuffer that holds a Vec<u8> and implements both wipe(&mut self) and Drop. Write a test that calls wipe() and verifies the buffer is zeroed before deallocation, then have Drop call the same wipe logic. Do not read memory after drop; that would itself be undefined behavior.

  4. RefCell Safety: Create a RefCell<Vec<i32>> and write code that attempts to call .borrow() while a .borrow_mut() is active. Observe the runtime panic. Compare this to what would happen in C++ with undefined behavior.