Clivern

𝙰 πš‚πš˜πšπšπš πšŠπš›πšŽ π™΄πš—πšπš’πš—πšŽπšŽπš› πšŠπš—πš π™ΎπšŒπšŒπšŠπšœπš’πš˜πš—πšŠπš• πš†πš›πš’πšπšŽπš›.

Memory Safety in Rust

17 March 2025

Traditional systems programming languages like C and C++ allow direct memory access without bounds checking, leading to buffer overflows that can cause crashes, data corruption, or security vulnerabilities. For example the following C program may access memory beyond the array causing unpredictable results

#include <stdio.h>
#include <stdint.h>

int main(void) {
    printf("Hello, world!\n");
    printf("Let's overflow the buffer!\r\n");

    uint32_t array[5] = {0, 0, 0, 0, 0};

    for (int index = 0; index < 10; index++) {
        printf("Index %d: %d\r\n", index, array[index]);
    }

    return 0;
}
Hello, world!
Let's overflow the buffer!
Index 0: 0
Index 1: 0
Index 2: 0
Index 3: 0
Index 4: 0
Index 5: 0
Index 6: 1294494416
Index 7: 7
Index 8: 1
Index 9: 0

Rust addresses this fundamental problem by implementing compile-time and runtime safety checks that prevent accessing memory outside of allocated bounds. Unlike C, where buffer overflows result in undefined behavior and potential exploitation, Rust’s memory safety guarantees ensure that out-of-bounds access either fails to compile or panics safely at runtime, making it impossible for such vulnerabilities to go unnoticed.

fn main() {
    println!("Hello, world!");
    println!("Let's try to overflow the buffer!");

    let array = [0, 0, 0, 0, 0];

    // This would cause a compile error or panic
    for index in 0..10 {
        println!("Index {}: {}", index, array[index]); // Panic on out of bounds
    }
}
Hello, world!
Let's try to overflow the buffer!
Index 0: 0
Index 1: 0
Index 2: 0
Index 3: 0
Index 4: 0

thread 'main' panicked at s.rs:9:41:
index out of bounds: the len is 5 but the index is 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Let go through Rust’s Memory Safety Features:

Ownership System

In Rust, Every value has exactly one owner and it gets deallocated when the owner goes out of the scope.

fn main() {
    // Ownership example 1: Simple ownership transfer
    let s1 = String::from("hello");
    let s2 = s1; // s1 is moved to s2, s1 is no longer valid

    // println!("{}", s1); // This would cause a compile error!
    println!("s2: {}", s2); // This works fine

    // Ownership example 2: Automatic cleanup
    {
        let s3 = String::from("world");
        println!("s3: {}", s3);
    } // s3 goes out of scope here and memory is automatically freed

    // Ownership example 3: Preventing double-free
    let s4 = String::from("rust");
    let s5 = s4.clone(); // Clone creates a deep copy, both s4 and s5 are valid
    println!("s4: {}, s5: {}", s4, s5);

    // Both s4 and s5 will be automatically dropped when they go out of scope
}

But why the following work for integers but not strings.

fn main() {
    // Ownership example 1: Simple ownership transfer
    let s1 = String::from("hello");
    let s2 = s1; // s1 is moved to s2, s1 is no longer valid

    //println!("{}", s1); // This would cause a compile error!
    println!("s2: {}", s2); // This works fine

    let k1 = 20;
    let k2 = k1;

    println!("k1 {}", k1); // This works fine
    println!("k2: {}", k2); // This works fine
}

The reason is the Copy trait:

  • String: Does not implement Copy trait (ownership transferred)
  • i32 (integers): Implements Copy trait (value duplicated)
// Copy vs Move demonstration
fn main() {
    // String - Move semantics (no Copy trait)
    let s1 = String::from("hello");
    let s2 = s1; // Move: s1 is moved to s2
    // s1 is now invalid, s2 owns the data

    // Integer - Copy semantics (has Copy trait)
    let n1 = 42;
    let n2 = n1; // Copy: n1 is copied to n2
    // Both n1 and n2 are valid and independent

    println!("n1: {}, n2: {}", n1, n2); // Both work fine

    // Other Copy types include: i32, f64, bool, char, tuples of Copy types
    let b1 = true;
    let b2 = b1; // Copy
    println!("b1: {}, b2: {}", b1, b2); // Both work fine
}

Types that implement Copy trait:

  • All integer types (i32, u64, etc.)
  • All floating-point types (f32, f64)
  • Boolean (bool)
  • Character (char)
  • Tuples containing only Copy types
  • Arrays containing only Copy types

Types that do NOT implement Copy trait:

  • String
  • Vec<T>
  • Box<T>
  • Most custom structs (unless explicitly implemented)

Same applies for functions - Copy types are duplicated when passed to functions, while non-Copy types are moved and ownership is transferred.

// Ownership in functions
fn take_ownership(s: String) {
    println!("I own this string: {}", s);
} // s goes out of scope and is dropped

fn make_copy(i: i32) {
    println!("I copied this integer: {}", i);
} // i goes out of scope, but i32 is Copy so no cleanup needed

fn main() {
    let s = String::from("hello");
    take_ownership(s); // s is moved into the function
    // println!("{}", s); // Error! s is no longer valid

    let x = 5;
    make_copy(x); // x is copied into the function
    make_copy(x); // x is copied into the function
    println!("x is still valid: {}", x); // x is still valid
}

Borrowing Rules

Rust’s borrowing system enforces strict rules to prevent data races at compile time. Immutable references (&T) allow multiple readers to access data simultaneously, while mutable references (&mut T) provide exclusive write access to a single writer, ensuring that data cannot be modified while being read.

fn main() {
    let mut data = vec![1, 2, 3, 4, 5];

    // Multiple immutable references allowed
    let r1 = &data;
    let r2 = &data;
    let r3 = &data;
    println!("r1: {:?}", r1);
    println!("r2: {:?}", r2);
    println!("r3: {:?}", r3);

    // Drop immutable references before creating mutable one
    let _ = r1;
    let _ = r2;
    let _ = r3;

    // Only one mutable reference allowed
    let r4 = &mut data;
    r4.push(6);
    println!("r4: {:?}", r4);

    // This would cause a compile error:
    // let r5 = &data; // Error: cannot borrow as immutable
    // let r6 = &mut data; // Error: cannot borrow as mutable
    // println!("{:?} {:?}", r5, r6);

    demonstrate_borrowing();
}

// Example: Function that reads data
fn read_data(data: &Vec<i32>) {
    println!("Reading data: {:?}", data);
}

// Example: Function that modifies data
fn modify_data(data: &mut Vec<i32>) {
    data.push(42);
}

fn demonstrate_borrowing() {
    let mut numbers = vec![1, 2, 3];

    read_data(&numbers); // Immutable borrow
    modify_data(&mut numbers); // Mutable borrow
    read_data(&numbers); // Immutable borrow again

    println!("Final: {:?}", numbers);
}

Lifetime Management

Rust’s lifetime system tracks reference validity through lifetime annotations, preventing dangling pointers and use-after-free errors by ensuring that references never outlive the data they point to, providing compile-time guarantees for memory safety.

// Example 1: Lifetime annotations in structs
struct Person<'a> {
    name: &'a str,
    age: u32,
}

fn main() {
    let name = String::from("Alice");

    let person = Person {
        name: &name, // Reference must live as long as Person
        age: 30,
    };

    println!("Name: {}, Age: {}", person.name, person.age);
} // name and person go out of scope safely

// Example 2: Function with lifetime parameters
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn demonstrate_longest() {
    let string1 = String::from("short");
    let string2 = String::from("longer string");

    let result = longest(&string1, &string2);
    println!("The longest string is: {}", result);

    // Both string1 and string2 must live at least as long as result
}

// Example 3: Lifetime elision (compiler infers lifetimes)
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

// Example 4: Preventing dangling references
fn create_reference() -> &'static str {
    "This is a static string" // 'static lifetime - lives for entire program
}

// This would NOT compile (dangling reference):
/*
fn bad_function() -> &str {
    let s = String::from("hello");
    &s // Error: s goes out of scope, but reference would remain
}
*/

Bounds Checking

Rust’s bounds checking system validates array and slice access at runtime, panicking on out-of-bounds access instead of allowing undefined behavior, while providing safe indexing through the get() method that returns Option<T> for graceful error handling.

fn main() {
    let arr = [1, 2, 3, 4, 5];

    // Example 1: Safe indexing with get()
    match arr.get(2) {
        Some(value) => println!("Index 2: {}", value),
        None => println!("Index 2: Out of bounds!"),
    }

    match arr.get(10) {
        Some(value) => println!("Index 10: {}", value),
        None => println!("Index 10: Out of bounds!"),
    }

    // Example 2: Direct indexing (panics on out of bounds)
    println!("Index 1: {}", arr[1]); // Safe: within bounds

    // This would panic at runtime:
    // println!("Index 10: {}", arr[10]); // Panic: index out of bounds

    // Example 3: Safe iteration
    for (i, &value) in arr.iter().enumerate() {
        println!("Index {}: {}", i, value);
    }

    // Example 4: Working with slices
    let slice = &arr[1..4]; // Safe slice creation
    println!("Slice: {:?}", slice);

    // Example 5: Safe array access in loops
    let indices = [0, 2, 4, 6]; // Some indices are out of bounds
    for &index in &indices {
        match arr.get(index) {
            Some(val) => println!("arr[{}] = {}", index, val),
            None => println!("arr[{}] is out of bounds", index),
        }
    }
}

// Example 6: Function that safely processes array data
fn safe_process_array(arr: &[i32]) {
    for i in 0..10 { // Try to access indices 0-9
        match arr.get(i) {
            Some(value) => println!("Processing value {} at index {}", value, i),
            None => {
                println!("Stopping at index {} (out of bounds)", i);
                break;
            }
        }
    }
}

Type Safety

Rust’s type safety eliminates common memory vulnerabilities by preventing null pointer dereferencing through the use of Option<T> instead of null pointers, preventing data races through strict borrowing rules, and eliminating buffer overflows through comprehensive bounds checking.

// Example 1: No null pointer dereferencing with Option<T>
fn find_user(id: u32) -> Option<String> {
    if id == 1 {
        Some("Alice".to_string())
    } else {
        None // No null pointer, just None
    }
}

fn main() {
    let user = find_user(1);

    match user {
        Some(name) => println!("Found user: {}", name),
        None => println!("User not found"),
    }

    // Safe unwrapping with defaults
    let user_name = user.unwrap_or("Unknown".to_string());
    println!("User name: {}", user_name);
}

// Example 2: Type safety with Result<T, E> for error handling
fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        Err("Division by zero".to_string())
    } else {
        Ok(a / b)
    }
}

fn demonstrate_result() {
    let result = divide(10, 2);
    match result {
        Ok(value) => println!("Result: {}", value),
        Err(error) => println!("Error: {}", error),
    }

    let bad_result = divide(10, 0);
    match bad_result {
        Ok(value) => println!("Result: {}", value),
        Err(error) => println!("Error: {}", error),
    }
}

// Example 4: Type safety with enums
enum Message {
    Text(String),
    Number(i32),
    Empty,
}

fn process_message(msg: Message) {
    match msg {
        Message::Text(text) => println!("Text message: {}", text),
        Message::Number(num) => println!("Number message: {}", num),
        Message::Empty => println!("Empty message"),
    }
}

Move Semantics

Rust’s move semantics ensure that values are moved rather than copied by default, preventing accidental deep copies and their associated performance bugs, while enabling zero-cost abstractions that compile to efficient machine code without runtime overhead.

fn main() {
    // Example 1: Move semantics prevent accidental deep copies
    let data1 = vec![1, 2, 3, 4, 5]; // Vec allocates memory on heap
    let data2 = data1; // Move: data1 is moved to data2 (no deep copy!)

    // println!("{:?}", data1); // Error! data1 is no longer valid
    println!("data2: {:?}", data2); // data2 now owns the data

    // In C++, this would be: Vec<int> data2 = data1; // Expensive deep copy!
    // In Rust, this is just a pointer move - O(1) operation

    // Example 2: Move semantics with large data structures
    let large_string1 = "A".repeat(1000000); // 1MB string
    let large_string2 = large_string1; // Move: just pointer transfer, no copying!

    println!("String length: {}", large_string2.len());
    // No performance cost - just moved the ownership

    // Example 3: Move semantics in function calls
    let expensive_data = vec![1; 1000000]; // Large vector

    process_data(expensive_data); // Moved into function, no copy
    // expensive_data is no longer valid here

    // If we wanted to keep it, we'd need to clone:
    let another_data = vec![2; 1000000];
    process_data(another_data.clone()); // Expensive deep copy
    println!("Still have: {:?}", another_data); // Still valid
}

fn process_data(data: Vec<i32>) {
    println!("Processing {} elements", data.len());
    // data is dropped here when function ends
}
// Example 4: Move semantics enable zero-cost abstractions
fn main() {
    let numbers = vec![1, 2, 3, 4, 5];

    // This iterator chain doesn't copy the data multiple times
    let result: Vec<i32> = numbers
        .into_iter()        // Move numbers into iterator
        .map(|x| x * 2)     // Transform each element
        .filter(|&x| x > 5) // Filter elements
        .collect();         // Collect into new Vec

    println!("Result: {:?}", result);

    // numbers is moved and no longer available
    // Each transformation is applied lazily without copying
}

// Example 5: Preventing accidental deep copies
fn demonstrate_copy_vs_move() {
    let original = vec![1, 2, 3];

    // Move semantics - no copy
    let moved = original; // original is no longer valid
    println!("Moved: {:?}", moved);

    // If we need a copy, we must be explicit
    let copied = moved.clone(); // Explicit deep copy
    println!("Original moved: {:?}", moved); // Still valid
    println!("Copy: {:?}", copied); // Independent copy

    // This prevents expensive accidental copies
}