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

Foreword

Rust for Secure Systems Programming

By Mounir IDRASSI

Book version 1.0.9 | April 8, 2026 | amcrypto.jp

If you are reading this book, you likely already know the landscape: buffer overflows, use-after-free bugs, integer overflows, race conditions, and injection flaws remain the dominant vulnerability classes in systems software. Despite decades of effort (static analyzers, sanitizers, coding standards, and security review), C and C++ codebases continue to produce critical vulnerabilities at an alarming rate. The fundamental problem is that C and C++ give the programmer too much power with too little guidance.

Rust changes the equation.

Rust is not a silver bullet, but it is the most significant advance in systems programming language design for secure coding in decades. Its ownership model, borrow checker, and type system work together to eliminate entire categories of memory safety and concurrency bugs at compile time, without requiring a garbage collector or runtime overhead. This is a paradigm shift: instead of finding bugs through testing and review, Rust prevents them from existing in the first place.

Who This Book Is For

This book is written for system developers who specialize in secure coding. You likely have experience with:

  • C or C++ systems programming (kernel modules, daemons, network services, embedded firmware)
  • Secure development lifecycles (SDL), threat modeling, and code review
  • Vulnerability classes defined in CWE, MITRE ATT&CK, or OWASP
  • Static analysis tools, fuzzing, and penetration testing

You do not need prior Rust experience. We assume familiarity with systems programming concepts but start from the beginning with Rust itself. What we don’t do is waste your time explaining what a pointer is. Instead, we focus on how Rust’s ownership model replaces manual memory management, how its type system prevents common vulnerability patterns, and how to leverage Rust’s guarantees in security-critical code.

How This Book Is Organized

The book is structured in five parts:

  1. Foundations (Chapters 1-4): Rust basics through a security lens, including why Rust matters, setup, ownership/borrowing, and the type system.
  2. Secure by Design (Chapters 5-8): Error handling, concurrency safety, input validation, and cryptography, including core patterns for writing secure software.
  3. Systems Programming (Chapters 9-12): Unsafe Rust, FFI, memory layout, and network programming, allowing to bridge the gap between safe abstractions and the bare metal.
  4. Assurance and Verification (Chapters 13-16): Testing, fuzzing, static analysis, and supply chain security, in order to prove your code is correct.
  5. Practical Secure Systems (Chapters 17-19): Three hands-on projects that bring everything together, a hardened TCP server, a secure parser, and deployment hardening.

A Note on Mindset

As a security-focused developer, you are accustomed to asking “what can go wrong?” Rust asks a different question: “what can the compiler prove is correct?” Learning to trust and verify the compiler is a key part of the Rust experience. But Rust also provides escape hatches (unsafe), and knowing when and how to use them safely is critical. This book spends significant time on that topic.

Conventions

Throughout this book:

  • Code examples use Rust Edition 2024 unless noted otherwise. Edition 2024 stabilized in Rust 1.85.0, so install Rust 1.85.0 or later before working through the chapters. If rustc --version reports an older toolchain, run rustup update stable first. Nightly-only features are called out explicitly when they appear.
  • Standalone Rust snippets are written to be mdbook test friendly whenever practical. Multi-file, async-runtime, or external-crate examples that stay marked ignore are illustrative excerpts; their tested counterparts live in the companion crates under companion/.
  • CI verifies the companion code with cargo clippy --workspace --all-targets --all-features -- -D warnings, cargo test --workspace, mdbook build, and a fresh host-specific snippet-helper build before running mdbook test -L .../debug/deps.
  • Security-relevant tips are marked with a 🔒 icon.
  • Common pitfalls are marked with a ⚠️ icon.
  • CWE references are provided where relevant, e.g., CWE-119 for buffer overflows.

Let’s begin.

Chapter 1 - Why Rust for Secure Systems

“The best way to write secure code is to make insecure code impossible to write.”

1.1 The Memory Safety Crisis

For over three decades, the systems programming world has relied on C and C++. These languages offer unparalleled control over hardware and memory, but that control comes at a steep security cost. Consider the data:

  • Google’s Chrome team reported that over 70% of high-severity security bugs were memory safety issues (use-after-free, buffer overflows, etc.).
  • Microsoft found that approximately 70% of vulnerabilities in their products over a decade were due to memory safety bugs.
  • The U.S. NSA, CISA, and FBI jointly recommended moving away from C/C++ to memory-safe languages.

The common vulnerability classes in C/C++ systems code include:

CWENameDescription
CWE-119Buffer OverflowWriting beyond allocated memory bounds
CWE-416Use-After-FreeAccessing memory after it has been freed
CWE-476NULL Pointer DereferenceDereferencing a null pointer
CWE-787Out-of-Bounds WriteWriting past the end of an array
CWE-125Out-of-Bounds ReadReading past the end of an array
CWE-190Integer OverflowArithmetic overflow leading to incorrect behavior
CWE-362Concurrent Race ConditionUnsynchronized shared-state access between threads
CWE-367TOCTOU Race ConditionTime-of-check/time-of-use mismatch between validation and use

CWE-367 is a more specific child of the broader CWE-362 race-condition family. We list it separately because TOCTOU deserves its own design discussion in secure systems code.

These are not theoretical risks. They are the most exploited vulnerability classes in real-world attacks, enabling remote code execution, privilege escalation, and data theft.

1.2 What Makes Rust Different

Rust addresses these problems through three pillars:

1.2.1 Ownership and Borrowing

Every value in Rust has a single owner. When the owner goes out of scope, the value is automatically deallocated. References to values are governed by strict rules:

  • You can have either one mutable reference or any number of immutable references, but not both at the same time.
  • References must always be valid (no dangling pointers).

This is enforced at compile time by the borrow checker:

// This code demonstrates a compile error:
fn main() {
    let mut data = vec![1, 2, 3];

    let first = &data[0];     // immutable borrow
    data.push(4);             // ERROR: cannot mutate while borrowed
    println!("{}", first);    // `first` is still in use
}

The compiler rejects this code. In C, this would be a potential use-after-free if push caused a reallocation. In Rust, it simply does not compile.

🔒 Security impact: Eliminates CWE-119, CWE-416, CWE-787, and CWE-125 at compile time for safe Rust code. NULL dereference prevention comes from the type system (Option<T>), not ownership alone.

1.2.2 Type System

Rust’s type system is rich and expressive. It uses:

  • Algebraic data types (enum and struct) that make illegal states unrepresentable.
  • Pattern matching that is exhaustive, the compiler ensures all cases are handled.
  • No implicit nulls, the Option<T> type explicitly represents the presence or absence of a value.
  • No implicit conversions, you must be explicit about type changes.
#[derive(Debug)]
struct User {
    name: &'static str,
}

// No null pointers. Use Option instead.
fn find_user(id: u32) -> Option<User> {
    match id {
        42 => Some(User { name: "admin" }),
        _ => None,
    }
}

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

🔒 Security impact: Eliminates CWE-476 (NULL deref) by replacing null with Option<T>. Helps eliminate CWE-190 (integer overflow): debug builds panic on overflow by default, and release builds do so when overflow-checks = true is enabled as recommended in Chapter 2.

1.2.3 Fearless Concurrency

Rust’s ownership model extends to concurrency. The type system enforces that:

  • Data races are impossible in safe Rust code.
  • Thread safety is verified at compile time via Send and Sync traits.
  • Shared state requires explicit synchronization (Mutex, RwLock, atomics).
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
        }));
    }

    for handle in handles {
        handle.join().unwrap();
    }
    println!("Result: {}", *counter.lock().unwrap());
}

🔒 Security impact: Eliminates unsynchronized data races (CWE-362) in safe Rust code. It does not eliminate higher-level race conditions such as TOCTOU (CWE-367).

1.3 What Rust Does NOT Protect Against

Honesty is important. Rust is not a complete security solution:

Threat CategoryRust Protects?
Memory safety bugs✅ Yes (in safe code)
Data races✅ Yes (in safe code)
Race conditions / TOCTOU❌ No
NULL dereferences✅ Yes (in safe code)
Logic errors❌ No
Integer overflow (release)⚠️ Wraps by default; use checked arithmetic
Side-channel attacks❌ No
Incorrect crypto usage❌ No (but APIs can guide you)
Social engineering❌ No
unsafe code bugs⚠️ Not automatically; requires review

The unsafe keyword is Rust’s escape hatch. It allows you to bypass the compiler’s safety checks for:

  • Dereferencing raw pointers
  • Calling unsafe functions (including FFI)
  • Accessing or modifying mutable statics
  • Accessing fields of unions
  • Implementing unsafe traits

⚠️ Critical rule: All unsafe code must be audited manually. We dedicate Chapter 9 to this topic.

1.4 Rust in the Security Ecosystem

Rust is increasingly adopted in security-critical domains:

  • Operating systems: Linux kernel modules (since 2022), Windows kernel components, Android (Google)
  • Web browsers: Firefox (Servo/Quantum), Chrome components
  • Networking: Cloudflare’s pingora, Linkerd service mesh
  • Cryptography: The ring crate, TLS implementations
  • Embedded: TrustZone TAs, secure bootloaders
  • Tooling: Password managers, VPN clients, endpoint security agents
  • High assurance: Formal-verification tools such as Kani and Prusti for bounded proofs and contracts (see Chapter 15)

1.5 Comparison with Other “Safe” Languages

FeatureRustC/C++GoJava/C#
Memory safetyCompile-timeNoGCGC
No GC overhead
Zero-cost abstractionsManualPartial
Compile-time data race freedomRuntime primitives only
Systems-level controlPartial
Predictable performanceWith GC pauses
FFI to CN/A
Deterministic destructionManual

Go provides memory safety through garbage collection, but it does not prevent data races between goroutines at compile time. Go’s race detector is useful, but it is a runtime tool rather than a compile-time guarantee. Java and C# provide locks, atomics, and memory-model guarantees, but they do not provide compile-time race prevention. Only Rust provides both memory safety and compile-time data race freedom without runtime GC overhead.

1.6 Summary

  • Rust eliminates the most prevalent vulnerability classes (memory safety bugs) at compile time.
  • The ownership model, type system, and concurrency guarantees work together to make insecure patterns impossible to express in safe code.
  • Rust does not protect against logic errors, side channels, or misuse of unsafe code, defense in depth is still required.
  • Rust is being adopted across the security industry for critical infrastructure.

In the next chapter, we will set up a development environment optimized for secure Rust development, including tooling for linting, formatting, and dependency auditing.

1.7 Exercises

  1. CVE Analysis: Choose a recent memory-safety CVE from a C/C++ project (e.g., from cve.org). Identify the CWE classification. Write a short explanation of how Rust’s ownership model, type system, or borrow checker would have prevented it at compile time.

  2. Threat Model Table: For a network-facing daemon you maintain or have worked on, create a threat model table like the one in §1.3. For each row, note whether Rust’s safe code would eliminate that class, and what additional mitigations would still be needed.

  3. Unsafe Audit Scope: Install cargo geiger on a Rust project (or a public crate). Run cargo geiger and identify which dependencies use unsafe. For each one, note whether the unsafe usage is expected (e.g., cryptography, FFI) or surprising.

Chapter 2 - Setting Up Your Environment

“A craftsman’s tools shape the quality of their work.”

A secure development workflow starts with a well-configured environment. In this chapter, we set up Rust with the tooling needed for security-focused development: compiler lints, formatters, dependency auditors, and IDE integration.

2.1 Installing Rust

The recommended installation method is rustup, which manages Rust toolchains and allows easy switching between stable, beta, and nightly compilers.

For third-party Cargo tools, this book pins concrete versions that were reviewed for this edition on April 8, 2026. Treat those pins as an auditable starting point rather than eternal truth: refresh them deliberately during your own dependency review cycle.

Linux and macOS

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

⚠️ Security note: Always verify the installation script. The use of --proto '=https' --tlsv1.2 forces HTTPS. For air-gapped environments, download and review the script before executing.

Windows

Download rustup-init.exe from https://rustup.rs and verify its signature. Alternatively:

winget install Rustlang.Rustup

Verifying the Installation

rustc --version
cargo --version
rustup --version

Your exact output will vary, but rustc must be 1.85.0 or newer for Edition 2024. For example:

rustc 1.94.1 (e408947bf 2026-03-25)
cargo 1.94.1 (29ea6fb6a 2026-03-24)
rustup 1.29.0 (28d1352db 2026-03-05)

2.2 Toolchain Management

Channels

Rust has three release channels:

  • Stable: Production-ready. Use this for all security-critical code.
  • Beta: Preview of the next stable release. Good for testing.
  • Nightly: Latest features, potentially unstable. Required for some tools.
# Install stable (default)
rustup default stable

# Install nightly for specific tools
rustup toolchain install nightly

# Use nightly for a specific command
cargo +nightly install cargo-fuzz --version 0.13.1 --locked

Keeping Updated

Rust releases every six weeks. Security patches are backported. Stay current:

rustup update

🔒 Security practice: Treat Rust compiler updates as security patches. New releases often include improved lints and security-relevant diagnostics.

2.2.1 no_std and Embedded Targets

One Edition 2024 change is easy to miss during upgrades: std::env::set_var and std::env::remove_var are now unsafe. Reading environment variables with std::env::var is still safe, but mutating the process environment is no longer something to do casually inside multithreaded services.

If your “systems programming” work includes firmware, kernels, bootloaders, or other bare-metal targets, the setup changes in security-relevant ways:

  • #![no_std] removes the standard library. If you still need heap allocation, pull in alloc explicitly and keep allocations bounded.
  • You need an explicit panic strategy and handler (panic-halt, panic-abort, or a board-specific reset/logging path). There is no host process to unwind through.
  • Many OS protections disappear: no ASLR, no process isolation, and no kernel-provided crash containment unless your platform adds them.
  • Hardware security features move into scope: TRNGs, secure elements, MPU/MMU rules, tamper-resistant storage, and watchdog-driven recovery.
  • The embedded-hal ecosystem gives you a portable abstraction layer for peripherals, but you still need board-specific review of clocks, memory maps, and debug interfaces.

For cross-compilation, install the exact target triple you ship:

rustup target add thumbv7em-none-eabihf
cargo build --target thumbv7em-none-eabihf --release

Treat bare-metal deployment as a different threat model, not just a smaller Linux process.

2.3 Essential Security Tooling

2.3.1 Clippy - The Linting Powerhouse

Clippy is Rust’s comprehensive linter. It catches common mistakes, unsafe patterns, and style issues:

rustup component add clippy
cargo clippy -- -W clippy::all -W clippy::pedantic

Security-relevant Clippy lints include:

LintWhat It Catches
clippy::arithmetic_side_effectsUnchecked integer operations
clippy::unwrap_usedUnchecked .unwrap() calls that can panic
clippy::expect_usedUnchecked .expect() calls
clippy::panicPotential panic points
clippy::indexing_slicingUnchecked array indexing
clippy::unwrap_in_resultunwrap() inside functions returning Result

For security-critical code, enable strict linting in clippy.toml (lowercase, dot-prefixed .clippy.toml is also accepted):

# clippy.toml
cognitive-complexity-threshold = 30

Configure Clippy linting in your CI pipeline or as a Makefile/just target. Clippy lints cannot be set via rustflags because rustflags only affects rustc, not cargo clippy:

# Baseline CI gate: fail the build on compiler warnings and low-noise panic checks
cargo clippy --workspace --all-targets --all-features -- \
  -D warnings \
  -W clippy::unwrap_used \
  -W clippy::expect_used \
  -W clippy::panic

# Additional audit pass: useful for parser-heavy and low-level code, but
# intentionally noisier because Clippy cannot prove every bounds check.
cargo clippy --workspace --all-targets --all-features -- \
  -W clippy::indexing_slicing \
  -W clippy::arithmetic_side_effects \
  -W clippy::unwrap_in_result

-D warnings promotes warning-level Clippy diagnostics to errors, so the explicit -W clippy::... flags here are mainly about which lints you want enabled and visible in the command. Read it as: “turn on these lints, then fail the build on all warnings.”

Treat clippy::indexing_slicing and clippy::arithmetic_side_effects as review aids rather than “must be zero everywhere” policy knobs. In security-sensitive parsing code they are excellent prompts for manual review, but they are heuristic and will still warn on code that already proved bounds through surrounding checks.

2.3.2 rustfmt - Consistent Code Formatting

rustup component add rustfmt
cargo fmt

Consistent formatting reduces review friction and makes anomalies easier to spot during code review. Configure rustfmt.toml:

# rustfmt.toml
edition = "2024"
max_width = 100
fn_params_layout = "Compressed"
use_field_init_shorthand = true
newline_style = "Unix"

2.3.3 cargo-audit - Dependency Vulnerability Scanner

cargo install cargo-audit --version 0.22.1 --locked
cargo audit

cargo-audit checks your Cargo.lock against the RustSec Advisory Database, which tracks known vulnerabilities in Rust crates.

Illustrative output example:

$ cargo audit
    Loaded 517 advisory records
    Scanning Cargo.lock for vulnerabilities (484 crates)

Crate:     example-crypto
Version:   1.2.3
Title:     Example advisory used for documentation
Date:      2026-04-02
ID:        RUSTSEC-XXXX-YYYY
URL:       https://rustsec.org/advisories/
Severity:  high

🔒 Security practice: Run cargo audit in your CI pipeline and block merges on known vulnerabilities.

For first-party review attestations, also evaluate cargo-vet:

cargo install cargo-vet --version 0.10.2 --locked
cargo vet init
cargo vet

cargo-vet records which crates and versions your team has reviewed, making dependency trust decisions explicit rather than tribal knowledge.

2.3.4 cargo-deny - Policy Enforcement

cargo install cargo-deny --version 0.19.0 --locked
cargo deny check

cargo-deny enforces policies on:

  • Licenses: Reject crates with incompatible licenses
  • Bans: Blacklist specific crates or versions
  • Advisories: Vulnerability checking (similar to cargo-audit)
  • Sources: Restrict dependencies to approved registries

Create deny.toml:

# deny.toml
[advisories]
db-path = "~/.cargo/advisory-db"
vulnerability = "deny"
unmaintained = "warn"

[licenses]
allow = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC"]
unlicensed = "deny"

[bans]
multiple-versions = "warn"
wildcards = "deny"

[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["sparse+https://index.crates.io/"]

2.3.5 cargo-geiger - Unsafe Code Inventory

cargo install cargo-geiger --version 0.13.0 --locked
cargo geiger --all-features

cargo-geiger does not prove a crate is unsafe, but it quickly shows where manual review effort should go. Use it to inventory unsafe code in your direct and transitive dependencies before you trust them in security-sensitive deployments.

2.3.6 cargo-outdated - Dependency Freshness

cargo install cargo-outdated --version 0.18.0 --locked
cargo outdated

Outdated dependencies may contain unpatched vulnerabilities. Keep dependencies current.

2.4 Compiler Security Flags

Configure your project to enable security-relevant compiler options:

# .cargo/config.toml
[build]
rustflags = [
    # Panic on arithmetic overflow
    "-C", "overflow-checks=on",
    # For release builds, consider:
    # "-C", "debug-assertions=off",
]
# Cargo.toml - profile settings must go here, not in .cargo/config.toml
[profile.release]
# Security-relevant profile settings
overflow-checks = true       # Enable integer overflow checks even in release
debug = true                 # Audit-friendly release profile for local crash analysis
strip = false                # Keep symbols while debugging locally
lto = true                   # Link-time optimization (removes dead code)
codegen-units = 1            # Better optimization, slower compile
panic = "abort"              # Abort on panic (smaller binary, no unwinding)
opt-level = 1                # Better debugging fidelity than "z" during local crash analysis

This is the audit-friendly variant of the hardened release profile used later in Chapter 19: keep debug = true and strip = false while doing local crash analysis, and pair that with opt-level = 1 or 2 while you still need debuggable stack traces and locals. Once debugging is done, switch to the stripped production profile and, if size is the goal, move to opt-level = "z" deliberately.

🔒 Critical setting: overflow-checks = true in release builds. By default, Rust wraps on integer overflow in release mode. For security-critical code, panicking on overflow is almost always the correct choice.

⚠️ Trade-off: panic = "abort" skips Drop during panic paths. That is useful for FFI boundaries and smaller binaries, but it also means panic-triggered cleanup such as secret zeroization will not run. Use Result for attacker-controlled failures and reserve panic = "abort" for codebases where that trade-off is explicit.

A practical default matrix:

Use caseRecommended default
Network service or CLI where panics indicate bugs and restart is acceptablepanic = "abort" if panic paths do not rely on Drop for zeroization or other critical cleanup
FFI exports or callbacks callable from C/C++panic = "abort" unless you fully contain unwinding at the boundary
Libraries whose callers may rely on catch_unwindpanic = "unwind"
Code that depends on Drop-driven cleanup on panic pathspanic = "unwind" or redesign cleanup so the panic strategy does not matter
Embedded / no_std targets with tight size budgetspanic = "abort"

If the service handles secrets, verify that zeroization and other cleanup do not rely solely on destructor execution after a panic. When they do, keep panic = "unwind" or restructure the cleanup so the panic strategy does not matter.

2.5 IDE Setup

rust-analyzer provides IDE-quality features:

rustup component add rust-analyzer

Configure for your editor:

  • VS Code: Install the rust-analyzer extension
  • Vim/Neovim: Use coc.nvim or nvim-lspconfig
  • JetBrains: Prefer RustRover. If your team is already standardized on IntelliJ IDEA Ultimate, use the bundled Rust plugin there.

Inlay Hints and Diagnostics

Enable inlay hints for type information, this is invaluable during security review:

// VS Code settings.json
{
    "rust-analyzer.inlayHints.enable": true,
    "rust-analyzer.check.command": "clippy"
}

2.6 Project Structure

Create a new project:

cargo new secure-project
cd secure-project

The default structure from cargo new is:

secure-project/
├── Cargo.toml
└── src/
    └── main.rs

For a binary crate, Cargo.lock is typically written after the first build or dependency-resolution step such as cargo check or cargo build.

If you are starting a library instead of a binary, use cargo new --lib secure-project, which creates src/lib.rs instead of src/main.rs.

For the workflow in this book, you will usually add the following as the project grows:

secure-project/
├── tests/
│   └── integration.rs
├── benches/
│   └── benchmark.rs
├── .cargo/
│   └── config.toml
├── clippy.toml
├── rustfmt.toml
└── deny.toml

🔒 Security practice: Always commit Cargo.lock for binaries. For libraries, the decision depends on whether consumers need reproducible builds.

2.7 CI/CD Security Pipeline

Here is a recommended GitHub Actions workflow for secure Rust projects:

# .github/workflows/security.yml
name: Security CI

on: [push, pull_request]

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch snapshot
        with:
          components: clippy, rustfmt

      - name: Format check
        run: cargo fmt -- --check

      - name: Clippy (strict)
        run: cargo clippy --workspace --all-targets --all-features -- -D warnings

      - name: Test
        run: cargo test --workspace

      - name: Build book
        run: mdbook build

      - name: Test book snippets
        run: bash ./scripts/test-book-snippets.sh

      - name: Audit dependencies
        run: |
          cargo install cargo-audit --version 0.22.1 --locked
          cargo audit

      - name: Deny check
        run: |
          cargo install cargo-deny --version 0.19.0 --locked
          cargo deny check

      - name: Check for outdated dependencies
        run: |
          cargo install cargo-outdated --version 0.18.0 --locked
          cargo outdated --exit-code 1

Pin third-party GitHub Actions to full commit SHAs and install reviewed crate versions with --locked in CI. Update those pins deliberately as part of your dependency review process rather than inheriting “latest” on every run.

For local Windows runs, prefer .\scripts\test-book-snippets.cmd so PowerShell execution policy does not block the helper script.

2.8 Summary

  • Install Rust via rustup and keep toolchains updated.
  • Essential security tools: clippy, rustfmt, cargo-audit, cargo-deny.
  • Enable overflow-checks = true in release builds.
  • Configure a CI pipeline that enforces linting, testing, book verification, and dependency auditing.
  • Use rust-analyzer for IDE integration with security-relevant diagnostics.

In the next chapter, we dive into Rust’s ownership model, the foundation of its memory safety guarantees.

2.9 Exercises

  1. Environment Setup: Install Rust via rustup, then install clippy, rustfmt, cargo-audit, and cargo-deny. Create a new project with cargo new secure-project and configure the following:

    • overflow-checks = true in the release profile
    • A .cargo/config.toml with strict warning flags
    • A deny.toml that restricts licenses to MIT/Apache-2.0/BSD
  2. CI Pipeline: Write a GitHub Actions workflow (or equivalent) that runs cargo clippy, cargo test, cargo audit, and cargo deny check on every pull request. Ensure the pipeline fails on any warning.

  3. Audit Practice: Run cargo audit on an existing Rust project. If there are no findings, intentionally pin an older version of a dependency with a known advisory and verify that cargo audit detects it.

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.

Chapter 4 - Type System and Pattern Matching

“Make illegal states unrepresentable.”

Rust’s type system is one of its most powerful tools for writing secure code. It combines algebraic data types, exhaustive pattern matching, and a strict stance on implicit conversions to ensure that many classes of bugs simply cannot be expressed. For security developers accustomed to fighting type confusion bugs, integer overflows, and unhandled cases in C/C++ switch statements, Rust’s type system is a breath of fresh air.

4.1 No Null - Use Option<T>

Tony Hoare, the inventor of the null reference, called it his “billion-dollar mistake.” In C/C++, any pointer can be null, and dereferencing a null pointer is undefined behavior (CWE-476). Rust eliminates null entirely from the language.

Instead, Rust uses the Option<T> enum:

#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

You must explicitly handle the absence of a value:

fn find_user(id: u32) -> Option<String> {
    if id == 42 {
        Some(String::from("admin"))
    } else {
        None
    }
}

fn main() {
    // You CANNOT use the value without checking
    let user = find_user(100);
    
    // Pattern matching - compiler ensures you handle both cases
    match user {
        Some(name) => println!("Found: {}", name),
        None => println!("User not found"),
    }
    
    // If-let for convenience
    if let Some(name) = find_user(42) {
        println!("Found: {}", name);
    }
    
    // unwrap_or_default provides a safe fallback
    let name = find_user(99).unwrap_or_default();
}

🔒 Security impact: CWE-476 (NULL Pointer Dereference) is impossible in safe Rust. Every potentially-absent value is wrapped in Option, and the compiler forces you to handle the None case.

⚠️ Caveat: .unwrap() and .expect() will panic if called on None. In security-critical code, avoid these and handle the None case explicitly.

4.2 No Exceptions - Use Result<T, E>

Rust does not have exceptions. Instead, recoverable errors are represented by Result<T, E>:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}
use std::fs::File;
use std::io::{self, Read};

fn read_config(path: &str) -> Result<String, io::Error> {
    let mut file = File::open(path)?;    // ? operator propagates errors
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

fn main() {
    match read_config("/etc/app/config.toml") {
        Ok(config) => println!("Config: {}", config),
        Err(e) => eprintln!("Failed to read config: {}", e),
    }
}

The ? operator is Rust’s error propagation mechanism. It either returns the Ok value or immediately returns the Err from the current function. It is the safe equivalent of checking return codes in C, but without the boilerplate.

🔒 Security impact: Forces explicit error handling at every level. No error is silently swallowed. Contrast with C, where forgetting to check a return value is a common source of vulnerabilities.

4.3 Algebraic Data Types and Enums

Rust enums are far more powerful than C enums. Each variant can carry data:

#![allow(unused)]
fn main() {
enum NetworkEvent {
    Connect { address: std::net::IpAddr, port: u16 },
    Data(Vec<u8>),
    Disconnect { reason: String },
    Timeout,
}

fn handle_event(event: NetworkEvent) {
    match event {
        NetworkEvent::Connect { address, port } => {
            println!("Connection from {}:{}", address, port);
        }
        NetworkEvent::Data(payload) => {
            println!("Received {} bytes", payload.len());
        }
        NetworkEvent::Disconnect { reason } => {
            println!("Disconnected: {}", reason);
        }
        NetworkEvent::Timeout => {
            println!("Connection timed out");
        }
    }
}
}

🔒 Security pattern: Use enums to model protocol states. Make invalid transitions unrepresentable:

#![allow(unused)]
fn main() {
enum ConnectionState {
    Closed,
    Handshake,
    Authenticated,
    Encrypted,
}

// You cannot accidentally process data in the Closed state
// because the type system won't allow it
}

4.3.1 Exhaustive Matching

The compiler requires that every possible case is handled:

#![allow(unused)]
fn main() {
enum Permission {
    Read,
    Write,
    Execute,
    Admin,
}

fn check_permission(perm: Permission) -> bool {
    match perm {
        Permission::Read => true,
        Permission::Write => true,
        // ERROR: non-exhaustive patterns: `Execute` and `Admin` not covered
    }
}
}

🔒 Security impact: When you add a new variant to an enum, the compiler will flag every match that doesn’t handle it. This prevents the “forgotten case” bug class, which in C switch statements can silently fall through or be mishandled.

4.3.2 #[non_exhaustive] - Future-Proofing Enums

When defining public enums in a library, mark them #[non_exhaustive] to force downstream consumers to handle future variants:

#![allow(unused)]
fn main() {
#[non_exhaustive]
#[derive(Debug)]
pub enum AuthError {
    InvalidCredentials,
    AccountLocked,
    TokenExpired,
    RateLimited,
}

#[allow(unreachable_patterns)] // Keep the wildcard to model downstream matching requirements.
fn handle_error(err: AuthError) {
    match err {
        AuthError::InvalidCredentials => println!("Bad credentials"),
        AuthError::AccountLocked => println!("Account locked"),
        AuthError::TokenExpired => println!("Token expired"),
        AuthError::RateLimited => println!("Rate limited"),
        // Downstream crates matching a public `#[non_exhaustive]` enum
        // must include a wildcard arm for future variants. Inside this
        // crate, the compiler already knows the current variants.
        _ => println!("Unknown auth error"),
    }
}
}

Inside the crate that defines AuthError, the compiler still knows every current variant, so the wildcard above is a style choice rather than an enforcement point and may trigger an unreachable_patterns warning. The #[non_exhaustive] guarantee matters at the public API boundary: downstream crates importing AuthError must include a fallback arm. If you keep the wildcard in the defining crate as documentation, add #[allow(unreachable_patterns)] with a comment explaining why it is there.

🔒 Security pattern: Use #[non_exhaustive] on public enums in library APIs. If you add a new error variant (e.g., CertificateRevoked), downstream code that already has a wildcard arm can continue compiling and handle the new case conservatively. Without #[non_exhaustive], adding a variant is a breaking change: downstream match expressions without a wildcard fail to compile, while matches that already use _ continue compiling and may route the new case through a generic fallback path.

4.4 Structs and Tuples

Structs

#![allow(unused)]
fn main() {
struct User {
    id: u32,
    username: String,
    role: Role,
    active: bool,
}

enum Role {
    Guest,
    Member,
    Admin,
}

impl User {
    fn new(id: u32, username: String, role: Role) -> Self {
        User { id, username, role, active: true }
    }
    
    fn is_admin(&self) -> bool {
        matches!(self.role, Role::Admin)
    }
}
}

Tuple Structs

#![allow(unused)]
fn main() {
struct UserId(u64);
struct User {
    id: UserId,
}

// Type safety: you can't accidentally mix UserId with a raw u64
fn get_user(id: UserId) -> Option<User> {
    Some(User { id })
}
}

🔒 Security pattern: Use newtypes (tuple structs with a single field) to prevent type confusion. A UserId(u64) is distinct from a u64, and the compiler will catch if you pass the wrong type. This prevents CWE-20 (Improper Input Validation) caused by type confusion.

Authorization with Role and Capability Types

Authentication tells you who the caller is. Authorization decides what that caller may do. Rust’s type system can make privileged paths harder to misuse by representing authority explicitly instead of threading raw booleans, strings, or ad hoc role checks through the codebase.

#![allow(unused)]
fn main() {
use std::marker::PhantomData;

#[derive(Clone, Copy)]
struct UserId(u64);

struct Guest;
struct Admin;

struct Session<Role> {
    user_id: UserId,
    _role: PhantomData<Role>,
}

struct ReadSecrets;
struct RotateKeys;

struct Capability<P>(PhantomData<P>);

fn view_audit_log(_session: &Session<Admin>) {}

fn read_secret(_cap: &Capability<ReadSecrets>, _key_id: &str) -> Option<String> {
    Some("redacted".to_string())
}

fn rotate_signing_key(_cap: &Capability<RotateKeys>) {}
}

This gives you three useful patterns:

  • RBAC with marker types: only code that has already checked policy should be able to construct Session<Admin>.
  • Capability-based security: functions accept a narrow authority token such as Capability<RotateKeys> instead of a broad “current user” handle.
  • Confused deputy defense: helpers can only exercise the authority they were explicitly handed, which is safer than reaching into ambient global state or reusing the caller’s full identity.

Keep constructors for privileged sessions and capabilities private to the module that performs the actual policy decision. That way, authorization is enforced once at the boundary and then preserved by the type system.

Put that rule into the module boundary, not just the naming convention:

#![allow(unused)]
fn main() {
mod auth {
    use std::marker::PhantomData;

    #[derive(Clone, Copy)]
    pub struct UserId(pub u64);

    pub struct Admin;
    pub struct RotateKeys;
    pub struct AuditedPolicyDecision;

    pub struct Session<Role> {
        user_id: UserId,
        _role: PhantomData<Role>,
    }

    pub struct Capability<P>(PhantomData<P>);

    pub(crate) fn admin_session(
        user_id: UserId,
        _decision: &AuditedPolicyDecision,
    ) -> Session<Admin> {
        Session {
            user_id,
            _role: PhantomData,
        }
    }

    pub(crate) fn rotate_keys_capability(
        _decision: &AuditedPolicyDecision,
    ) -> Capability<RotateKeys> {
        Capability(PhantomData)
    }
}
}

Outside auth, callers can pass Session<Admin> and Capability<RotateKeys> to privileged functions, but they cannot forge those values with a struct literal because the fields and constructors stay inside the policy-enforcing module.

4.5 Traits - Defining Shared Behavior

Traits are Rust’s answer to interfaces:

#![allow(unused)]
fn main() {
struct Credentials;
struct Session;
struct AuthError;

trait Authenticator {
    fn authenticate(&self, credentials: &Credentials) -> Result<Session, AuthError>;
    fn is_valid_session(&self, session: &Session) -> bool;
    fn revoke_session(&self, session: &mut Session);
}
}

Marker Traits for Security

Rust uses marker traits to enforce properties at compile time:

#![allow(unused)]
fn main() {
// Send: safe to transfer to another thread
// Sync: safe to share between threads via a reference

// By default, a type is Send/Sync if all its fields are.
// The compiler will ERROR if you try to send a non-Send type across threads.

use std::sync::Mutex;

struct SharedState {
    counter: Mutex<u32>,  // Mutex<T> is Send + Sync
    data: Vec<u8>,        // Vec<u8> is Send + Sync
}

// This type can be safely shared between threads.
}

🔒 Security impact: The Send and Sync traits prevent data races at compile time. If a type contains a non-thread-safe component (like Rc<T>), the compiler will refuse to let you share it across threads. This removes a major source of CWE-362-style concurrency bugs, but it does not eliminate higher-level logic races such as TOCTOU.

The TryFrom Trait - Checked Conversions

#![allow(unused)]
fn main() {
struct Port(u16);

impl TryFrom<u32> for Port {
    type Error = &'static str;
    
    fn try_from(value: u32) -> Result<Self, Self::Error> {
        u16::try_from(value)
            .map(Port)
            .map_err(|_| "Port number out of range")
    }
}

fn bind(port: Port) {
    // ...
}
}

🔒 Security pattern: Use TryFrom for all conversions where the source type is wider than the target type (e.g., u32u16, usizeu8). This prevents CWE-190 (Integer Overflow or Wraparound) and CWE-20 (Improper Input Validation).

⚠️ Avoid narrowing with as in security-critical code: an unchecked cast such as value as u16 silently truncates on overflow (CWE-197). If the conversion can fail, keep it explicit with TryFrom/try_into() and handle the error path.

When zero has no valid meaning, prefer NonZeroU32 or NonZeroUsize over a plain integer. This lets the type system reject sentinel 0 values up front and can make Option<NonZeroU32> more compact than Option<u32>. This example only demonstrates a range-checked narrowing conversion; Chapter 7 shows how to layer policy checks on top of this range constraint, including rejection of reserved port 0.

4.6 Pattern Matching Deep Dive

Pattern matching is not limited to match. Rust supports patterns in many contexts:

Destructuring

#![allow(unused)]
fn main() {
struct Packet {
    source: std::net::IpAddr,
    dest: std::net::IpAddr,
    payload: Vec<u8>,
    flags: u8,
}

// Approach 1: Destructure into individual fields
fn analyze_fields(packet: Packet) {
    let Packet { source, dest, payload, flags } = packet;
    println!("Data: {} -> {}, {} bytes, flags={}", source, dest, payload.len(), flags);
}

// Approach 2: Match with destructuring patterns
fn analyze_match(packet: Packet) {
    match packet {
        Packet { flags: 0xFF, payload, .. } => {
            println!("Control packet: {} bytes", payload.len());
        }
        Packet { source, dest, .. } => {
            println!("Data: {} -> {}", source, dest);
        }
    }
}
}

Guards

#![allow(unused)]
fn main() {
fn classify_packet(size: usize, flags: u8) -> &'static str {
    match (size, flags) {
        (0, _) => "empty",
        (1..=64, 0) => "small-control",
        (1..=64, _) => "small-data",
        (65..=1500, _) => "normal",
        (1501..=9000, _) => "jumbo",
        _ => "oversized",
    }
}
}

Slice Patterns

#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq, Eq)]
enum Command {
    Read { offset: u8, length: u8 },
    Write,
}
#[derive(Debug, PartialEq, Eq)]
enum ParseError {
    InvalidFormat,
}
fn parse_command(input: &[u8]) -> Result<Command, ParseError> {
    match input {
        [0x01, len @ 1..=255, data @ ..] if data.len() == *len as usize => {
            Ok(Command::Read { offset: data[0], length: *len })
        }
        [0x02, ..] => Ok(Command::Write),
        _ => Err(ParseError::InvalidFormat),
    }
}
}

4.7 Integer Safety

Integer handling is a major source of vulnerabilities in C/C++. Rust provides explicit options:

Checked Arithmetic

#![allow(unused)]
fn main() {
fn safe_add(a: u64, b: u64) -> Option<u64> {
    a.checked_add(b)  // Returns None on overflow
}

fn safe_multiply(a: u64, b: u64) -> Option<u64> {
    a.checked_mul(b)
}
}

Saturating Arithmetic

#![allow(unused)]
fn main() {
fn saturating_increment(counter: u8) -> u8 {
    counter.saturating_add(1)  // Stays at u8::MAX instead of wrapping
}
}

Wrapping Arithmetic

#![allow(unused)]
fn main() {
fn wrapping_hash(value: u64) -> u64 {
    value.wrapping_mul(0x5851F42D4C957F2D)  // Intentional wrapping for hash functions
}
}

🔒 Security rule: In security-critical code, always use checked_* or saturating_* arithmetic. Only use wrapping_* when the algorithm explicitly requires modular arithmetic (e.g., hash functions, ciphers).

Compiler Configuration

Enable overflow checks globally:

[profile.release]
overflow-checks = true  # Panic on overflow instead of wrapping

With overflow-checks = true, standard arithmetic on primitive integer types (+, -, *) will panic on overflow rather than silently wrapping. Explicit methods such as checked_add, wrapping_add, and saturating_add keep their documented behavior regardless of this profile flag.

4.8 Const Generics and Type-Level Programming

Rust supports const generics, allowing compile-time values as type parameters:

#![allow(unused)]
fn main() {
struct FixedBuffer<const N: usize> {
    data: [u8; N],
}

impl<const N: usize> FixedBuffer<N> {
    fn new() -> Self {
        FixedBuffer { data: [0u8; N] }
    }
    
    fn from_slice(slice: &[u8]) -> Option<Self> {
        if slice.len() != N {
            return None;
        }
        let mut buf = Self::new();
        buf.data.copy_from_slice(slice);
        Some(buf)
    }
}

type AesKey = FixedBuffer<32>;    // 256-bit key
type HmacKey = FixedBuffer<64>;  // 512-bit key
}

🔒 Security pattern: Use const generics to enforce cryptographic sizes at compile time. A function that takes FixedBuffer<32> cannot accidentally receive a 16-byte key.

4.9 Summary

  • Option<T> replaces null, eliminating CWE-476.
  • Result<T, E> forces explicit error handling, preventing silently swallowed errors.
  • Algebraic data types and exhaustive matching ensure all cases are handled.
  • Newtypes prevent type confusion (CWE-20).
  • Checked arithmetic prevents integer overflow (CWE-190).
  • Send/Sync traits prevent data races at compile time, removing a major source of CWE-362-style concurrency bugs.
  • Const generics enforce size constraints at compile time.

The type system is your first and strongest line of defense. In the next chapter, we explore how Rust’s error handling model supports secure, robust code.

4.10 Exercises

  1. Exhaustive Matching: Define an enum HttpStatus with variants for common HTTP status codes (200, 301, 403, 404, 500). Write a function classify(status: HttpStatus) -> &'static str using match. Then add a new variant (e.g., ServiceUnavailable). Observe how the compiler catches the unhandled case.

  2. Newtype for Safety: Create two newtypes UserId(u64) and SessionId(u64). Implement FromStr for both with different validation rules (e.g., UserId must be non-zero, SessionId must be within a certain range). Write a function that takes UserId and verify that passing a SessionId is a compile error.

  3. Checked Arithmetic: Write a function safe_average(values: &[u64]) -> Option<u64> that computes the average using only checked_* operations. Ensure it returns None on overflow or empty input. Test with edge cases: u64::MAX, single element, and empty slice.

  4. Const Generic Buffer: Implement a CryptoKey<const N: usize> newtype that enforces key size at compile time. Create type aliases for Aes128Key = CryptoKey<16>, Aes256Key = CryptoKey<32>, and write a function that accepts only Aes256Key. Verify that passing an Aes128Key is a compile error.

Chapter 5 - Error Handling Without Exceptions

“Errors are not exceptional. They are a normal part of system operation.”

Rust’s approach to error handling is fundamentally different from C’s return codes, C++’s exceptions, and Go’s multiple return values. It leverages the type system to make error handling explicit, type-safe, and impossible to forget. For security developers, this matters because unhandled or improperly handled errors are a leading cause of vulnerabilities, from ignoring return values to catching the wrong exception type.

5.1 The Error Handling Landscape

LanguageMechanismCan You Forget to Handle?
CReturn codes, errno✅ Yes, very common
C++Exceptions✅ Yes, catch may miss types
GoMultiple return values⚠️ Possible (but err is visible)
JavaChecked exceptions⚠️ Partially enforced
RustResult<T, E> + ?❌ No, compiler enforces handling

5.2 Result<T, E> in Depth

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),   // Success, contains a value of type T
    Err(E),  // Failure, contains an error of type E
}
}

5.2.1 Creating Results

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

fn read_bytes(file: &mut impl io::Read, count: usize) -> io::Result<Vec<u8>> {
    // Caller must bound `count` before calling; untrusted sizes can exhaust memory.
    let mut buffer = vec![0u8; count];
    match file.read_exact(&mut buffer) {
        Ok(()) => Ok(buffer),
        Err(e) => Err(e),  // Propagate the I/O error
    }
}
}

io::Result<T> is a type alias for Result<T, io::Error>.

When count originates from a file format, network packet, or other untrusted source, validate it against a maximum before allocating.

5.2.2 The ? Operator

The ? operator is the idiomatic way to propagate errors. It means: “If this result is Ok, unwrap the value. If it’s Err, return it from the current function immediately.”

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_config(path: &str) -> io::Result<String> {
    let mut file = File::open(path)?;        // ? propagates io::Error
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;     // ? propagates io::Error
    Ok(contents)
}

#[derive(Debug)]
struct Config;

#[derive(Debug)]
enum ConfigError {
    Io(io::Error),
    InvalidFormat,
}

impl From<io::Error> for ConfigError {
    fn from(error: io::Error) -> Self {
        Self::Io(error)
    }
}

fn parse_config(raw: &str) -> Result<Config, ConfigError> {
    if raw.trim().is_empty() {
        Err(ConfigError::InvalidFormat)
    } else {
        Ok(Config)
    }
}

fn load_and_parse_config(path: &str) -> Result<Config, ConfigError> {
    let raw = read_config(path)?;             // ? converts io::Error via From trait
    parse_config(&raw)
}
}

🔒 Security impact: The ? operator ensures no error is silently dropped. Every fallible operation must either be handled locally or explicitly propagated.

5.2.3 Error Conversion with From

The ? operator automatically converts errors using the From trait:

#![allow(unused)]
fn main() {
use std::io;
use std::num::ParseIntError;

#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(ParseIntError),
    InvalidData(String),
}

impl From<io::Error> for AppError {
    fn from(e: io::Error) -> Self {
        AppError::Io(e)
    }
}

impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> Self {
        AppError::Parse(e)
    }
}

fn parse_port_number(s: &str) -> Result<u16, AppError> {
    // This example follows the book's default network-service policy:
    // port 0 is reserved rather than silently accepted.
    let port: u32 = s.parse()?;           // ParseIntError → AppError via From
    if port == 0 {
        return Err(AppError::InvalidData("Port 0 is reserved".to_string()));
    }
    if port > 65535 {
        return Err(AppError::InvalidData(format!("Port {} out of range", port)));
    }
    Ok(port as u16)
}
}

5.3 Custom Error Types

For security-critical applications, define structured error types:

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

/// Security-relevant error conditions
#[derive(Debug)]
pub enum SecurityError {
    /// Authentication failed
    AuthenticationFailed { username: String, reason: String },
    /// Authorization denied
    AccessDenied { user_id: u64, resource: String, required_role: String },
    /// Input validation failure
    ValidationFailed { field: String, constraint: String },
    /// Rate limit exceeded
    RateLimited { client_ip: std::net::IpAddr, retry_after: std::time::Duration },
    /// Cryptographic operation failed
    CryptoError(String),
    /// Session expired or invalid
    InvalidSession(String),
}

impl fmt::Display for SecurityError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            SecurityError::AuthenticationFailed { username, reason } => {
                write!(f, "Authentication failed for '{}': {}", username, reason)
            }
            SecurityError::AccessDenied { user_id, resource, required_role } => {
                write!(f, "User {} denied access to '{}' (requires {})",
                       user_id, resource, required_role)
            }
            SecurityError::ValidationFailed { field, constraint } => {
                write!(f, "Validation failed for '{}' (constraint: {})",
                       field, constraint)
            }
            SecurityError::RateLimited { client_ip, retry_after } => {
                write!(f, "Rate limited {}: retry after {:?}", client_ip, retry_after)
            }
            SecurityError::CryptoError(msg) => {
                write!(f, "Cryptographic error: {}", msg)
            }
            SecurityError::InvalidSession(msg) => {
                write!(f, "Invalid session: {}", msg)
            }
        }
    }
}

impl std::error::Error for SecurityError {}
}

🔒 Security practice: Never include sensitive data (passwords, tokens, raw keys) in error messages. Error strings may be logged, displayed, or leaked to attackers. Note in the example above, username is logged but passwords are never included.

5.4 The thiserror and anyhow Crates

Treat the raw rejected value as separate from the user-facing error. Report which field failed and which constraint it violated; if operators need the original value for investigation, log it separately on a sanitized internal channel.

thiserror - Derive Error for Libraries

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book as thiserror;
// Add to Cargo.toml: thiserror = "2"
use thiserror::Error;

#[derive(Debug, Error)]
pub enum TlsError {
    #[error("handshake failed: {reason}")]
    HandshakeFailed { reason: String },

    #[error("certificate validation failed: {0}")]
    CertificateInvalid(String),

    #[error("MAC verification failed")]
    MacVerificationFailed,  // No details leaked, intentional for crypto errors

    #[error("IO error")]
    Io(#[from] std::io::Error),
}
}

🔒 Crypto error best practice: Cryptographic errors should be generic. Do not distinguish between “invalid padding” and “invalid MAC” in error messages visible to the caller. This prevents oracle attacks (e.g., padding oracle, MAC oracle).

anyhow - Flexible Errors for Applications

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::anyhow as anyhow;
// Add to Cargo.toml: anyhow = "1"
use anyhow::{Context, Result};

fn load_key(path: &str) -> Result<Vec<u8>> {
    let key = std::fs::read(path)
        .context("failed to read configured key file")?;
    Ok(key)
}
}

If the path itself is sensitive, do not include it in user-facing error context. Log the path on a trusted internal channel if you need it for debugging, and return a generic external error instead of exposing filesystem layout.

5.5 Panic vs. Result

Rust has two error categories:

CategoryMechanismRecoverable?When to Use
RecoverableResult<T, E>YesAll expected failure modes
Unrecoverablepanic!No (by default)Bugs, contract violations

When to Panic

In security-critical code, panic only for bugs, not for expected failure modes:

#![allow(unused)]
fn main() {
fn decode_port(input: &str) -> Result<u16, std::num::ParseIntError> {
    // This is an expected failure → use Result
    // BAD: input.parse::<u16>().unwrap()
    // STILL BAD: input.parse::<u16>().expect("invalid port"), still panics!
    // BEST: return Result
    input.parse()
}

fn hex_nibble(c: char) -> u8 {
    match c {
        '0'..='9' => c as u8 - b'0',
        'a'..='f' => c as u8 - b'a' + 10,
        'A'..='F' => c as u8 - b'A' + 10,
        _ => panic!("internal invariant violated: caller passed non-hex input"),
    }
}
}

⚠️ Security concern: In server applications, a panic unwinds the stack and may leave data in an inconsistent state. Do not use panics for attacker-controlled input such as packet lengths or request indexes; return a Result instead. Use panic = "abort" in Cargo.toml for a cleaner failure mode, or use std::panic::catch_unwind at FFI boundaries in builds that keep panic = "unwind".

Catching Panics at FFI Boundaries

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

fn process_data(slice: &[u8]) -> Result<i32, ()> {
    Ok(slice.len() as i32)
}

extern "C" fn exported_function(data: *const u8, len: usize) -> i32 {
    if data.is_null() {
        return -1;
    }

    let result = panic::catch_unwind(|| {
        let slice = unsafe { std::slice::from_raw_parts(data, len) };
        process_data(slice)
    });
    
    match result {
        Ok(Ok(value)) => value,
        Ok(Err(_)) => -1,      // Application error
        Err(_) => -2,          // Panic occurred
    }
}
}

⚠️ Profile interaction: catch_unwind only catches unwinding panics. If your release profile sets panic = "abort", the process aborts before this wrapper can recover. For FFI-facing libraries, either keep exported entry points panic-free or ship them in an unwind-enabled profile.

🔒 Security pattern: Wrap Rust functions called from C with catch_unwind when the ABI must survive panics and the build uses panic = "unwind". A Rust panic that crosses an FFI boundary is undefined behavior.

Chapter 10 returns to the same boundary from the ABI side: use this pattern together with its ownership, allocator, and extern "C" guidance.

5.6 Error Handling Patterns for Secure Code

5.6.1 Fail Fast, Fail Explicitly

#![allow(unused)]
fn main() {
#[derive(Debug)]
enum SecurityError {
    AuthenticationFailed { username: String, reason: String },
    AccessDenied { user_id: u64, resource: String, required_role: String },
    ValidationFailed { field: String, constraint: String },
}

#[derive(Debug)]
struct Credentials;

#[derive(Debug)]
struct Request;

#[derive(Debug)]
struct Response;

impl Request {
    fn credentials(&self) -> Credentials {
        Credentials
    }

    fn username(&self) -> &str {
        "alice"
    }

    fn resource(&self) -> &str {
        "/secure"
    }

    fn body(&self) -> &[u8] {
        b"payload"
    }
}

impl Response {
    fn success(_value: String) -> Self {
        Response
    }
}

#[derive(Debug)]
struct User {
    id: u64,
}

impl User {
    fn id(&self) -> u64 {
        self.id
    }
}

fn process_request(req: &Request) -> Result<Response, SecurityError> {
    // Validate early, reject early
    let user = authenticate(req.credentials())
        .map_err(|_| SecurityError::AuthenticationFailed {
            username: req.username().to_string(),
            reason: "Invalid credentials".to_string(),
        })?;
    
    authorize(&user, req.resource())
        .map_err(|_| SecurityError::AccessDenied {
            user_id: user.id(),
            resource: req.resource().to_string(),
            required_role: "read".to_string(),
        })?;
    
    let validated = validate_input(req.body())?;
    let result = execute(&user, &validated)?;
    Ok(Response::success(result))
}

fn authenticate(_credentials: Credentials) -> Result<User, ()> {
    Ok(User { id: 7 })
}

fn authorize(_user: &User, _resource: &str) -> Result<(), ()> {
    Ok(())
}

fn validate_input(body: &[u8]) -> Result<Vec<u8>, SecurityError> {
    if body.is_empty() {
        Err(SecurityError::ValidationFailed {
            field: "body".to_string(),
            constraint: "must not be empty".to_string(),
        })
    } else {
        Ok(body.to_vec())
    }
}

fn execute(_user: &User, _validated: &[u8]) -> Result<String, SecurityError> {
    Ok("ok".to_string())
}
}

5.6.2 Never Use unwrap() in Production Security Code

#![allow(unused)]
fn main() {
#[derive(Debug)]
enum SecurityError {
    ValidationFailed { field: String, constraint: String },
}

fn parse_env_port(env_var: String) -> Result<u16, SecurityError> {
    // BAD: env_var.parse::<u16>().unwrap()

    // GOOD: explicit error handling
    env_var.parse()
        .map_err(|_| SecurityError::ValidationFailed {
            field: "PORT".to_string(),
            constraint: "must be a valid u16".to_string(),
        })
}
}

5.6.3 Sanitize Error Messages for External Consumers

#![allow(unused)]
fn main() {
#[derive(Debug)]
enum SecurityError {
    AuthenticationFailed { username: String, reason: String },
    AccessDenied { user_id: u64, resource: String, required_role: String },
    ValidationFailed { field: String, constraint: String },
}

mod log {
    pub fn warn(_message: &str) {}
    pub fn error(_message: &str) {}
}

#[derive(Debug)]
struct Request;

#[derive(Debug)]
struct Response;

impl Request {
    fn remote_addr(&self) -> &str {
        "127.0.0.1"
    }
}

impl Response {
    fn unauthorized(_message: &str) -> Self {
        Response
    }

    fn forbidden(_message: &str) -> Self {
        Response
    }

    fn bad_request(_message: &str) -> Self {
        Response
    }

    fn internal_error(_message: &str) -> Self {
        Response
    }
}

fn process_request(_req: &Request) -> Result<Response, SecurityError> {
    Err(SecurityError::AuthenticationFailed {
        username: "alice".to_string(),
        reason: "Invalid credentials".to_string(),
    })
}

fn handle_request(req: Request) -> Response {
    match process_request(&req) {
        Ok(response) => response,
        Err(SecurityError::AuthenticationFailed { .. }) => {
            // Generic message to client, detailed message to log
            log::warn(&format!("Authentication failed from {}", req.remote_addr()));
            Response::unauthorized("Invalid credentials")
        }
        Err(SecurityError::AccessDenied { .. }) => {
            Response::forbidden("Access denied")
        }
        Err(SecurityError::ValidationFailed { .. }) => {
            Response::bad_request("Invalid input")
        }
        Err(e) => {
            // Never expose internal errors to clients
            log::error(&format!("Internal error: {:?}", e));
            Response::internal_error("Internal server error")
        }
    }
}
}

🔒 Security impact: Never leak internal error details (stack traces, file paths, SQL queries) to external clients. Use detailed logging server-side and generic responses externally. This prevents information disclosure (CWE-209).

5.7 Summary

  • Result<T, E> makes error handling explicit and compiler-enforced.
  • The ? operator provides ergonomic error propagation with automatic type conversion.
  • Define structured error types for security-relevant failure modes.
  • Panic only for bugs, never for expected failures.
  • Use catch_unwind at FFI boundaries to prevent undefined behavior.
  • Never use unwrap() in security-critical code paths.
  • Sanitize error messages before exposing them externally.

In the next chapter, we explore Rust’s concurrency model: how the ownership system extends to prevent data races and how to write concurrent code that is safe by construction.

5.8 Exercises

  1. Custom Error Type: Define a ParseError enum with at least four variants using thiserror. Implement From<std::io::Error> and From<std::num::ParseIntError> for it. Write a function that uses the ? operator with both underlying error types and verify the automatic conversion works.

  2. Error Sanitization: Write a function handle_request() that returns Result<Response, AppError> where AppError contains sensitive internal details. Then write a to_client_response() method that converts the error into a generic user-facing message (no file paths, no SQL, no stack traces). Write tests verifying that sensitive fields never appear in the client-facing output.

  3. Remove All Unwrap: Take an existing Rust project or code sample and run cargo clippy -- -W clippy::unwrap_used -W clippy::expect_used. Refactor every flagged call to use proper match, map_err, or the ? operator. Ensure all tests still pass.

  4. Panic Boundary: Write an FFI-exported function that calls an internal function which might panic. Wrap it with std::panic::catch_unwind and return appropriate error codes for success, application error, and panic. Test the panic path by intentionally dividing by zero in the inner function.

Chapter 6 - Fearless Concurrency

“Data races are not just bugs, they are security vulnerabilities.”

Concurrent programming is where most systems developers feel the most pain. In C/C++, shared mutable state protected by locks is an honor system: the compiler cannot verify that locks are acquired in the correct order, that every shared variable is protected, or that threads don’t deadlock. The result is a constant stream of concurrency vulnerabilities: unsynchronized shared-state bugs (CWE-362) and higher-level logic races such as TOCTOU (CWE-367).

Rust’s ownership system extends naturally to concurrency, enforcing thread safety at compile time. The compiler knows which data is shared, which is mutable, and whether synchronization is in place. This is “fearless concurrency”: not because concurrency is easy, but because the compiler catches the most dangerous mistakes before the code ever runs.

6.1 Thread Safety Guarantees

6.1.1 The Send and Sync Traits

Rust’s concurrency safety rests on two marker traits:

  • Send: A type is safe to transfer ownership to another thread.
  • Sync: A type is safe to share a reference between threads (i.e., &T is Send).

Most types automatically implement Send and Sync if all their fields do. The compiler rejects code that violates these constraints:

use std::rc::Rc;  // Single-threaded reference counting

fn main() {
    let data = Rc::new(vec![1, 2, 3]);
    
    std::thread::spawn(move || {
        // ERROR: `Rc<Vec<i32>>` cannot be sent between threads safely
        println!("{:?}", data);
    });
}

Rc<T> is not Send because its reference counting is not atomic. Using Arc<T> (Atomic Reference Counted) instead fixes the issue:

use std::sync::Arc;

fn main() {
    let data = Arc::new(vec![1, 2, 3]);
    
    std::thread::spawn(move || {
        println!("{:?}", data);  // OK: Arc<Vec<i32>> is Send
    });
}

🔒 Security impact: The compiler prevents you from accidentally sharing non-thread-safe types across threads. In C, using a non-thread-safe allocator or data structure in a multithreaded context is a subtle and dangerous bug.

6.1.2 Ownership Prevents Data Races

The borrow checker’s rules extend to threads:

  • You can have multiple readers (&T) OR one writer (&mut T), never both.
  • To have multiple writers, you must use explicit synchronization.
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0u64));
    let mut handles = vec![];

    for _ in 0..100 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
            // Lock automatically released when `num` goes out of scope
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    assert_eq!(*counter.lock().unwrap(), 100);
}

6.1.3 Scoped Threads Avoid Unnecessary 'static

std::thread::spawn requires the closure to own only 'static data because the new thread may outlive the caller. When the threads are guaranteed to finish before the current scope exits, prefer std::thread::scope:

use std::thread;

fn main() {
    let mut counters = [0u64; 4];

    thread::scope(|scope| {
        for counter in &mut counters {
            scope.spawn(move || {
                *counter += 1;
            });
        }
    });

    assert_eq!(counters, [1, 1, 1, 1]);
}

Each scope.spawn closure captures one &mut u64 from the loop iterator, and move is what lets the closure own that reference. That is safe because thread::scope guarantees every spawned thread exits before counters goes out of scope.

This is especially useful in parser pipelines and batch validation code: worker threads can borrow stack data safely, and the compiler guarantees they are joined before the scope returns.

On constrained systems, pair this with std::thread::Builder when you need explicit thread names or stack-size control:

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

let worker = thread::Builder::new()
    .name("frame-parser".into())
    .stack_size(256 * 1024)
    .spawn(|| {
        // Worker logic
    })?;

worker.join().unwrap();
Ok::<(), std::io::Error>(())
}

Set custom stack sizes only when you understand the recursion depth and per-thread buffer usage. Too-small stacks turn parsing bugs into hard crashes.

6.2 Synchronization Primitives

6.2.1 Mutex<T> - Mutual Exclusion

Mutex<T> provides exclusive access to T. The lock guard pattern ensures the lock is always released:

Note: The examples in this section use .unwrap() for brevity. In production security-critical code, handle mutex poisoning explicitly (see the poisoning discussion below) and avoid .unwrap() on Result types, use .unwrap_or_else(), match, or the ? operator instead.

#![allow(unused)]
fn main() {
use std::sync::Mutex;

struct Connection;

struct Database {
    connections: Mutex<Vec<Connection>>,
}

impl Database {
    fn add_connection(&self, conn: Connection) {
        let mut conns = self.connections.lock().unwrap();
        conns.push(conn);
        // Lock released here automatically
    }
    
    fn count(&self) -> usize {
        let conns = self.connections.lock().unwrap();
        conns.len()
        // Lock released here
    }
}
}

🔒 Security pattern: The RAII guard pattern ensures locks are never forgotten. In C, forgetting to unlock a mutex is a common source of deadlocks and priority inversion.

⚠️ Poisoning: If a thread panics while holding a Mutex, the mutex becomes “poisoned.” Subsequent .lock() calls return a PoisonError. Calling .unwrap() on that Result will panic. If you decide recovery is acceptable, you must handle Err(poisoned) explicitly and call poisoned.into_inner() to regain access to the guard. For security-critical code, consider poisoning a signal that shared state may no longer be trustworthy:

#![allow(unused)]
fn main() {
let mutex = std::sync::Mutex::new(vec![1u8, 2, 3]);

match mutex.lock() {
    Ok(guard) => {
        let _ = guard.len();
    }
    Err(_poisoned) => {
        // Decide: abort, recover, or use the potentially-corrupted data
        eprintln!("Mutex poisoned - data may be corrupted");
        std::process::abort();  // Safest option
    }
}
}

6.2.2 RwLock<T> - Read-Write Lock

RwLock<T> allows multiple readers OR one writer:

#![allow(unused)]
fn main() {
use std::collections::HashMap;
use std::sync::RwLock;

struct Config {
    settings: RwLock<HashMap<String, String>>,
}

impl Config {
    fn get(&self, key: &str) -> Option<String> {
        let settings = self.settings.read().unwrap();
        settings.get(key).cloned()
    }
    
    fn set(&self, key: &str, value: &str) {
        let mut settings = self.settings.write().unwrap();
        settings.insert(key.to_string(), value.to_string());
    }
}
}

6.2.3 Atomics

For lock-free programming, Rust provides atomic types:

#![allow(unused)]
fn main() {
use std::sync::atomic::{AtomicU64, Ordering};

struct AtomicCounter {
    count: AtomicU64,
}

impl AtomicCounter {
    fn new() -> Self {
        AtomicCounter { count: AtomicU64::new(0) }
    }
    
    fn increment(&self) -> u64 {
        self.count.fetch_add(1, Ordering::SeqCst)
    }
    
    fn get(&self) -> u64 {
        self.count.load(Ordering::SeqCst)
    }
}
}

🔒 Security note: Use Ordering::SeqCst (sequentially consistent) unless you can prove a weaker ordering is correct. Incorrect memory ordering can lead to subtle data races that are extremely difficult to debug. The performance difference is rarely significant for security-critical code.

That said, Acquire/Release is already the standard, correct tool for one-way publication and producer-consumer handoff patterns. Use the stronger SeqCst default when the proof is unclear; use Acquire/Release when you can state the happens-before relationship precisely.

6.2.4 OnceLock<T> and LazyLock<T> - One-Time Initialization

For lazily initialized shared state, prefer the standard library primitives over ad hoc double-checked locking:

#![allow(unused)]
fn main() {
use std::sync::{LazyLock, OnceLock};

static TRUST_ANCHORS: OnceLock<Vec<&'static str>> = OnceLock::new();
static ALLOWED_ALGORITHMS: LazyLock<Vec<&'static str>> =
    LazyLock::new(|| vec!["ed25519", "x25519"]);

fn trust_anchors() -> &'static [&'static str] {
    TRUST_ANCHORS.get_or_init(|| vec!["Corp Root CA", "Offline Recovery CA"])
}
}

OnceLock is ideal for values loaded once from configuration, certificates, or policy files. LazyLock is convenient when the initializer is fixed at compile time. Both avoid races around first-use initialization without needing an external crate.

6.2.5 Condvar - Wait for State Changes Without Spinning

Use a condition variable when threads must sleep until a predicate becomes true:

Note: As in the surrounding examples, .unwrap() is used here for brevity. In production code, handle poisoned mutexes explicitly instead of panicking.

#![allow(unused)]
fn main() {
use std::collections::VecDeque;
use std::sync::{Condvar, Mutex};

struct Queue {
    items: Mutex<VecDeque<Vec<u8>>>,
    available: Condvar,
}

impl Queue {
    fn push(&self, item: Vec<u8>) {
        let mut items = self.items.lock().unwrap();
        items.push_back(item);
        self.available.notify_one();
    }

    fn pop(&self) -> Vec<u8> {
        let mut items = self.items.lock().unwrap();
        while items.is_empty() {
            items = self.available.wait(items).unwrap();
        }
        items.pop_front().expect("queue must be non-empty after wait loop")
    }
}
}

Always wait in a while loop, not an if, because wakeups can be spurious and another thread may consume the resource before the current thread reacquires the lock.

6.3 Message Passing with Channels

Rust’s channel API encourages a “do not communicate by sharing memory; share memory by communicating” approach:

use std::sync::mpsc;
use std::thread;

struct Request {
    client_id: u64,
    payload: Vec<u8>,
}

fn main() {
    let (tx, rx) = mpsc::channel::<Request>();

    // Spawn a single worker that owns the receiver
    let worker = thread::spawn(move || {
        while let Ok(req) = rx.recv() {
            println!("Processing request {} ({} bytes)", req.client_id, req.payload.len());
        }
        println!("Channel closed, worker exiting");
    });

    // Send requests from the main thread
    for i in 0..10 {
        tx.send(Request {
            client_id: i,
            payload: vec![0u8; 64],
        }).unwrap();
    }

    // Drop the sender to signal the worker to exit
    drop(tx);
    worker.join().unwrap();
}

⚠️ Limitation: std::sync::mpsc::Receiver is not Clone. Only one thread can receive from a standard channel. For multi-consumer patterns, use crossbeam-channel or flume instead.

A more practical example with crossbeam-channel for multi-worker message distribution:

# Cargo.toml
[dependencies]
crossbeam-channel = "0.5"
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::crossbeam_channel as crossbeam_channel;
use crossbeam_channel::{bounded, Sender, Receiver};
use std::thread;

enum Command {
    Process(Vec<u8>),
    Shutdown,
}

fn worker(id: usize, rx: Receiver<Command>) {
    while let Ok(cmd) = rx.recv() {
        match cmd {
            Command::Process(data) => {
                println!("Worker {} processing {} bytes", id, data.len());
            }
            Command::Shutdown => break,
        }
    }
}

fn main() {
    let (tx, rx) = bounded::<Command>(100);  // Bounded channel prevents OOM
    
    let mut handles = vec![];
    for id in 0..4 {
        let rx = rx.clone();
        handles.push(thread::spawn(move || worker(id, rx)));
    }
    
    // Send work
    for i in 0..1000 {
        tx.send(Command::Process(vec![i as u8; 64])).unwrap();
    }
    
    // Shutdown
    for _ in 0..4 {
        tx.send(Command::Shutdown).unwrap();
    }
    
    for handle in handles {
        handle.join().unwrap();
    }
}

🔒 Security pattern: Use bounded channels to prevent unbounded memory growth (a form of denial of service). An unbounded channel allows a fast producer to exhaust memory before a slow consumer can process messages.

6.4 Async/Await Concurrency

For I/O-bound workloads, Rust’s async/await model provides efficient concurrency without threads:

extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::log as log;
use rust_secure_systems_book::deps::tokio as tokio;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::net::TcpListener;

struct State;
impl State {
    fn new() -> Self {
        Self
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let listener = TcpListener::bind("0.0.0.0:8443").await?;
    let state = Arc::new(Mutex::new(State::new()));
    
    loop {
        let (stream, addr) = listener.accept().await?;
        let state = Arc::clone(&state);
        
        tokio::spawn(async move {
            if let Err(e) = handle_connection(stream, addr, state).await {
                log::error!("Error handling {}: {}", addr, e);
            }
        });
    }
}

async fn handle_connection(
    stream: tokio::net::TcpStream,
    addr: std::net::SocketAddr,
    state: Arc<Mutex<State>>,
) -> Result<(), Box<dyn std::error::Error>> {
    // Connection handling logic
    Ok(())
}

⚠️ Security note: 0.0.0.0 binds to every interface. Use it only when you intentionally want the service exposed that broadly. For local development prefer 127.0.0.1; in production prefer the specific interface, socket-activation unit, or load-balancer attachment you actually intend to expose.

⚠️ Important difference: tokio::sync::Mutex (async) vs. std::sync::Mutex (blocking):

  • Use std::sync::Mutex when the critical section is short and CPU-bound.
  • Use tokio::sync::Mutex when you need to hold the lock across .await points.

🔒 Security note: tokio::sync::Mutex is not poisoned. If a task panics while holding it, the next acquirer gets whatever partially-updated state was left behind, with no built-in signal that anything went wrong. For security-critical state, prefer designs that avoid shared mutable data across .await points, such as a single owner task behind a channel. If the critical section is short and CPU-bound, std::sync::Mutex keeps the lock synchronous and gives you poison detection.

6.4.1 async fn in Traits

Rust 1.75 stabilized async fn in traits, which matters for security interfaces such as authenticators, audit sinks, key stores, and policy engines:

Quick syntax recap: a trait normally declares behavior as methods such as fn authenticate(&self, ...) -> Result<...>. Writing async fn in the trait keeps the same shape, but the method now returns a future that the caller .awaits.

#![allow(unused)]
fn main() {
trait Authenticator {
    async fn authenticate(&self, token: &str) -> Result<UserId, AuthError>;
}

struct UserId(u64);
#[derive(Debug)]
struct AuthError;
}

This is a good fit for internal application traits where you control both callers and implementors. For public library traits, decide up front whether implementors must return Send futures, because that requirement becomes part of the trait’s API surface and cannot be added later without a breaking change.

The stable pattern the Rust team recommends for public async traits is to expose the choice explicitly:

#[trait_variant::make(Authenticator: Send)]
pub trait LocalAuthenticator {
    async fn authenticate(&self, token: &str) -> Result<UserId, AuthError>;
}

This gives you a local non-Send trait plus a Send-capable variant for callers that need tokio::spawn or another multithreaded executor. If you also need trait objects today, keep using a boxed-future pattern or async-trait.

6.4.2 JoinSet for Scoped Task Groups

When you spawn a dynamic set of related tasks and plan to await them as a group, prefer tokio::task::JoinSet over manually collecting Vec<JoinHandle<_>> values:

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::tokio as tokio;
use tokio::task::JoinSet;

async fn check_peer(_addr: std::net::SocketAddr) -> std::io::Result<()> { Ok(()) }
async fn scan(addrs: Vec<std::net::SocketAddr>) -> std::io::Result<()> {
let mut tasks = JoinSet::new();
for addr in addrs {
    tasks.spawn(check_peer(addr));
}

while let Some(result) = tasks.join_next().await {
    result??;
}
Ok(())
}
}

JoinSet fits security-sensitive request and shutdown scopes because the set owns the task group: dropping it aborts any still-running tasks instead of silently leaving them detached. Pair it with a Semaphore when you also need a hard concurrency cap.

6.4.3 tokio::task_local! vs thread_local!

thread_local! stores one value per OS thread. That is sometimes the right tool for FFI glue or thread-affine libraries, but it is the wrong primitive for per-request async state on Tokio’s multithreaded runtime. A task spawned with tokio::spawn may resume on a different worker thread after an .await, so a thread_local! value is not a reliable request context.

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::tokio as tokio;
use std::cell::RefCell;

thread_local! {
    static REQUEST_ID_TLS: RefCell<Option<u64>> = const { RefCell::new(None) };
}

tokio::task_local! {
    static REQUEST_ID: u64;
}

async fn handle_request(request_id: u64) {
    REQUEST_ID.scope(request_id, async {
        log_request().await;
    }).await;
}

async fn log_request() {
    REQUEST_ID.with(|request_id| {
        println!("request id = {}", request_id);
    });
}
}

Use tokio::task_local! for task-scoped metadata that must survive .await points. Keep thread_local! for true thread-scoped state. A current-thread runtime or LocalSet only pins tasks to one thread; it does not make a thread-local slot private to one request, because multiple tasks on that thread still share the same TLS storage.

🔒 Security guidance:

  • Do not store per-request auth context, audit IDs, CSRF state, or raw secrets in thread_local! for async services.
  • Prefer explicit function arguments over any ambient state when practical.
  • If you truly need ambient async context, tokio::task_local! is the correct primitive.

6.5 Cancellation Safety in Async Rust

One of the most subtle security pitfalls in async Rust is cancellation safety (sometimes called “cancel safety”). When a tokio::select! branch is not chosen, or a JoinHandle is aborted, the future at the other branch is dropped mid-execution. If that future was in the middle of an operation with side effects, such as reading from a socket or holding a lock, those side effects may be lost or left in an inconsistent state.

Cancellation here means “the future is dropped.” Tokio is not asynchronously interrupting the OS thread or injecting a signal into your code.

6.5.1 The Problem

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::tokio as tokio;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;

fn process_message(_body: &[u8]) {}

/// WARNING: This function is NOT cancellation-safe.
async fn read_one_message_exact(stream: &mut TcpStream) -> std::io::Result<()> {
    // If we are cancelled after reading the first message header
    // but before reading its body, the stream is now in an inconsistent
    // state. The first message's bytes have been consumed, but we have
    // not processed them. Those bytes are irrecoverably lost to the caller.
    let mut header = [0u8; 4];
    stream.read_exact(&mut header).await?;  // May succeed
    let len = u32::from_be_bytes(header) as usize;
    let mut body = vec![0u8; len];
    stream.read_exact(&mut body).await?;     // If cancelled HERE, header bytes are gone
    process_message(&body);
    Ok(())
}
}

If read_one_message_exact is used inside tokio::select! and the other branch wins, the partial read is lost:

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::tokio as tokio;
use std::time::Duration;
async fn read_one_message_exact(
    _stream: &mut tokio::net::TcpStream,
) -> std::io::Result<()> {
    Ok(())
}
async fn demo(stream: &mut tokio::net::TcpStream) {
// DANGEROUS: may silently drop partial reads
tokio::select! {
    result = read_one_message_exact(stream) => { /* ... */ }
    _ = tokio::time::sleep(Duration::from_secs(30)) => {
        // Timeout! But we may have already consumed some bytes from `stream`.
        // The stream is now in an inconsistent state.
    }
}
}
}

🔒 Security impact: Cancellation-unsafe code can cause:

  • Data loss: Partially read messages are dropped, violating protocol integrity.
  • State desynchronization: The peer believes data was consumed; our side disagrees.
  • Protocol confusion: The stream is now misaligned, potentially parsing attacker-controlled data as message headers (CWE-1265, CWE-20).

6.5.2 Making Async Code Cancellation-Safe

A function is cancellation-safe if dropping the future at any .await point leaves the system in a consistent state. Key patterns:

Pattern 1: Use cancellation-safe operations. tokio::io::AsyncReadExt::read() (which reads up to N bytes) is cancellation-safe because it either reads data or doesn’t: no partial state. read_exact() is not cancellation-safe because it may have read some bytes but not all.

Pattern 2: Buffer and retry. Use a framed reader that buffers partial reads:

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::tokio as tokio;
use tokio::io::{AsyncReadExt, ReadHalf};
use tokio::net::TcpStream;

/// A buffered reader that tracks position and can resume after cancellation.
struct FramedReader {
    buffer: Vec<u8>,
    read_pos: usize,
}

impl FramedReader {
    fn new(max_size: usize) -> Self {
        FramedReader {
            buffer: vec![0u8; max_size],
            read_pos: 0,
        }
    }

    /// Cancellation-safe: reads one full message or nothing.
    /// If cancelled, `read_pos` still reflects previously consumed data.
    async fn read_message(
        &mut self,
        stream: &mut ReadHalf<TcpStream>,
    ) -> std::io::Result<Option<&[u8]>> {
        // Try to read more data (non-destructive on cancellation)
        if self.read_pos < 4 {
            let n = stream.read(&mut self.buffer[self.read_pos..]).await?;
            if n == 0 {
                return Ok(None); // EOF
            }
            self.read_pos += n;
            if self.read_pos < 4 {
                return Ok(None); // Need more data
            }
        }

        let len = u32::from_be_bytes([
            self.buffer[0], self.buffer[1], self.buffer[2], self.buffer[3]
        ]) as usize;

        if len > self.buffer.len() - 4 {
            return Err(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                "message too large",
            ));
        }

        // Read remaining data if needed
        while self.read_pos < 4 + len {
            let n = stream.read(&mut self.buffer[self.read_pos..]).await?;
            if n == 0 {
                return Err(std::io::Error::new(
                    std::io::ErrorKind::UnexpectedEof,
                    "connection closed mid-message",
                ));
            }
            self.read_pos += n;
        }

        // Consume the message
        let message_end = 4 + len;
        let message = &self.buffer[..message_end];
        // Note: caller should copy the data before calling advance()
        Ok(Some(message))
    }

    /// Call after successfully processing a message returned by `read_message()`.
    fn advance(&mut self) -> std::io::Result<()> {
        if self.read_pos < 4 {
            return Err(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                "advance() requires a previously buffered frame header",
            ));
        }

        let message_end = 4usize.checked_add(self.current_message_len()).ok_or_else(|| {
            std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                "frame length overflow",
            )
        })?;
        if self.read_pos < message_end {
            return Err(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                "advance() requires a complete buffered frame",
            ));
        }

        let remaining = self.read_pos - message_end;
        if remaining > 0 {
            self.buffer.copy_within(message_end..self.read_pos, 0);
        }
        self.read_pos = remaining;
        Ok(())
    }

    fn current_message_len(&self) -> usize {
        if self.read_pos < 4 {
            return 0;
        }
        u32::from_be_bytes([
            self.buffer[0], self.buffer[1], self.buffer[2], self.buffer[3]
        ]) as usize
    }
}
}

Pattern 3: Use tokio_util::codec for production. The tokio_util::codec framework handles framing and buffering for you, making it cancellation-safe by design:

# Cargo.toml
[dependencies]
tokio-util = { version = "0.7", features = ["codec"] }
bytes = "1"
#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::bytes as bytes;
use rust_secure_systems_book::deps::tokio_util as tokio_util;
use tokio_util::codec::Decoder;
use bytes::{BytesMut, Buf, BufMut};

struct LengthPrefixedCodec {
    max_length: usize,
}

impl Decoder for LengthPrefixedCodec {
    type Item = Vec<u8>;
    type Error = std::io::Error;

    fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
        if src.len() < 4 {
            return Ok(None); // Need more data
        }
        // Peek at the length without advancing the cursor
        let len = u32::from_be_bytes([src[0], src[1], src[2], src[3]]) as usize;
        if len > self.max_length {
            return Err(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                "frame too large",
            ));
        }
        if src.len() < 4 + len {
            src.reserve(4 + len - src.len());
            return Ok(None); // Need more data
        }
        // Now consume the length prefix and payload
        src.advance(4);
        Ok(Some(src.split_to(len).to_vec()))
    }
}
}

🔒 Security practice: Always use a framed codec (tokio_util::codec) or equivalent buffering layer for production async network code. It ensures cancellation safety, enforces message size limits, and prevents partial-read desynchronization.

Cancellation also matters for secret handling. If a future owns a ZeroizeOnDrop secret and is cancelled via tokio::select! or JoinHandle::abort(), dropping the future still runs Drop for those fields. That is a useful safety property, but it does not repair partially-applied protocol side effects, and it does not help on paths that skip destructors entirely.

One important caveat: if the secret is wrapped in Arc<T>, aborting one task only drops that task’s clone. Zeroization happens when the last strong reference goes away, not when any particular task is aborted. Avoid long-lived Arc clones of raw secret material; prefer request-local ownership or shared handles to an external keystore.

6.5.3 Other Async Pitfalls

PitfallDescriptionMitigation
Task starvationA busy task never yields, starving othersUse tokio::task::yield_now() in tight loops; use cooperative budgeting
Unbounded spawningtokio::spawn without limits → OOMUse a Semaphore to cap concurrency and a JoinSet to own the spawned task group
Blocking in asyncCalling std::thread::sleep or CPU-heavy work blocks the executorUse tokio::task::spawn_blocking for blocking operations
Aborted task cleanupJoinHandle::abort() drops the future; task-owned Drop guards run, but Arc-shared state may outlive the taskUse Drop guards for secrets and locks, avoid long-lived Arc copies of raw secrets, plus explicit rollback or structured concurrency for external side effects

6.6 Common Concurrency Pitfalls (and How Rust Prevents Them)

6.6.1 Deadlocks

Rust does not prevent deadlocks. If you acquire multiple locks in different orders, you can still deadlock:

use std::sync::{Arc, Mutex};

fn main() {
    let a = Arc::new(Mutex::new(0));
    let b = Arc::new(Mutex::new(0));
    
    let a1 = Arc::clone(&a);
    let b1 = Arc::clone(&b);
    let h1 = std::thread::spawn(move || {
        let _g1 = a1.lock().unwrap();
        let _g2 = b1.lock().unwrap();  // May deadlock
    });
    
    let a2 = Arc::clone(&a);
    let b2 = Arc::clone(&b);
    let h2 = std::thread::spawn(move || {
        let _g1 = b2.lock().unwrap();
        let _g2 = a2.lock().unwrap();  // May deadlock (different order!)
    });
}

🔒 Mitigation: Use a consistent lock ordering, or use a single lock to protect multiple resources. If you need timed locking or better deadlock diagnostics, parking_lot is a common production alternative to std::sync.

6.6.2 Use-After-Free in Concurrent Contexts

In C, passing a stack pointer to another thread is a common use-after-free:

// C - DANGEROUS
void* thread_func(void* arg) {
    int* data = (int*)arg;
    sleep(1);
    printf("%d\n", *data);  // data may be freed!
}

int main() {
    int value = 42;
    pthread_t tid;
    pthread_create(&tid, NULL, thread_func, &value);
    // value goes out of scope, thread still holds a pointer!
    return 0;  // Use-after-free
}

Rust prevents this at compile time:

fn main() {
    let value = 42;
    std::thread::spawn(|| {
        // ERROR: `value` does not live long enough
        println!("{}", value);
    });
}

To fix, you must explicitly move the value:

fn main() {
    let value = 42;
    std::thread::spawn(move || {
        println!("{}", value);  // OK: ownership transferred
    });
}

6.7 Summary

  • Send and Sync traits enforce thread safety at compile time.
  • Mutex<T> and RwLock<T> provide RAII-based locking that never forgets to unlock.
  • Atomics provide lock-free patterns; use SeqCst ordering unless you can prove weaker is safe.
  • OnceLock and LazyLock provide race-free one-time initialization for shared security state.
  • Condvar lets threads wait on predicates without busy-waiting; always re-check the condition in a loop.
  • Channels enable message-passing concurrency; prefer bounded channels to prevent memory exhaustion.
  • Rust prevents use-after-free in concurrent contexts but does not prevent deadlocks: use consistent lock ordering.
  • Async/await is efficient for I/O-bound workloads; understand the differences between sync and async mutexes.
  • Cancellation safety is critical in async code: always use framed codecs or buffered readers to ensure partial reads are not lost when futures are dropped.

In the next chapter, we tackle input validation: the first line of defense against injection and parsing attacks.

6.8 Exercises

  1. Thread Safety Verification: Create a struct containing an Rc<String> and attempt to send it across a thread boundary with std::thread::spawn. Observe the compiler error. Replace Rc with Arc and verify it compiles. Then add a RefCell<String> inside the Arc and observe the new error when trying to share it across threads. Replace with Mutex<String> and verify it compiles.

  2. Bounded Channel: Implement a producer-consumer pattern with a bounded channel of capacity 10. Spawn a fast producer that sends 100 messages and a slow consumer that sleeps for 100ms per message. Observe the backpressure behavior. Then switch to an unbounded channel and discuss the memory implications.

  3. Deadlock Demonstration: Write a program that intentionally deadlocks by acquiring two Mutexes in opposite orders from two threads. Confirm the deadlock (the program hangs). Then fix it by establishing a consistent lock ordering. Add a timeout using try_lock_for (from parking_lot) to detect and recover from potential deadlocks.

  4. Cancellation Safety: Write an async function that reads a 4-byte header followed by a variable-length body from a TcpStream. Use tokio::select! with a timeout. Demonstrate the data loss bug when using read_exact. Then rewrite using a Framed codec from tokio_util and verify the fix.

Chapter 7 - Input Validation and Data Sanitization

“All input is evil until proven otherwise.”

Input validation is the cornerstone of secure software. Every major vulnerability class (injection, buffer overflow, path traversal, XSS) traces back to improperly validated input. As a security developer, you know that the attack surface of any system is defined by the inputs it processes.

Rust’s type system gives you a significant advantage: many validation checks can be enforced at compile time rather than runtime. When that’s not possible, Rust’s expressive pattern matching and zero-cost abstractions make runtime validation both thorough and ergonomic.

7.1 The Validation Pyramid

Secure input handling follows three layers:

┌─────────────────────────┐
│   Type-Level Validation  │  ← Compile-time (strongest)
├─────────────────────────┤
│   Parse, Don't Validate  │  ← Runtime but structural
├─────────────────────────┤
│   Sanitization           │  ← Runtime, contextual
└─────────────────────────┘

Layer 1: Type-Level Validation

Use Rust’s type system to make invalid states unrepresentable:

#![allow(unused)]
fn main() {
// BAD: a raw string can contain anything
fn connect_unvalidated(host: &str, port: u16) { /* ... */ }

// GOOD: use newtypes that enforce validity at construction time
#[derive(Debug)]
pub enum ValidationError {
    InvalidLength,
    NullByte,
    PathSeparator,
    InvalidLabel,
    InvalidCharacter,
    InvalidHyphenPosition,
    ReservedPortZero,
    PrivilegedPort,
}

#[derive(Debug)]
pub struct Hostname(String);

impl Hostname {
    pub fn new(raw: &str) -> Result<Self, ValidationError> {
        // Validate: no null bytes, no path separators, length limits
        if raw.is_empty() || raw.len() > 253 {
            return Err(ValidationError::InvalidLength);
        }
        if raw.contains('\0') {
            return Err(ValidationError::NullByte);
        }
        if raw.contains('/') || raw.contains('\\') {
            return Err(ValidationError::PathSeparator);
        }
        // RFC 952 / RFC 1123 hostname validation
        for label in raw.split('.') {
            if label.is_empty() || label.len() > 63 {
                return Err(ValidationError::InvalidLabel);
            }
            if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
                return Err(ValidationError::InvalidCharacter);
            }
            if label.starts_with('-') || label.ends_with('-') {
                return Err(ValidationError::InvalidHyphenPosition);
            }
        }
        Ok(Hostname(raw.to_lowercase()))
    }
    
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

#[derive(Debug)]
pub struct Port(u16);

impl Port {
    pub fn new(value: u16) -> Result<Self, ValidationError> {
        if value == 0 {
            return Err(ValidationError::ReservedPortZero);
        }
        Ok(Port(value))
    }
    
    pub fn value(&self) -> u16 {
        self.0
    }
}

#[derive(Debug)]
pub struct BindPort(Port);

impl BindPort {
    pub fn new(value: u16) -> Result<Self, ValidationError> {
        let port = Port::new(value)?;
        if port.value() < 1024 {
            return Err(ValidationError::PrivilegedPort);
        }
        Ok(BindPort(port))
    }
}

fn connect(host: Hostname, port: Port) {
    // Guaranteed: host is a valid hostname, port is a valid destination port
}

fn bind(port: BindPort) {
    // Guaranteed: port satisfies this service's binding policy
}
}

🔒 Security pattern: “Parse, don’t validate.” Borrowing Alexis King’s phrasing, create types that can only be constructed with valid data. Once you have a Hostname, you never need to validate it again. The type itself is proof of validity.

⚠️ Unicode note: If you accept non-ASCII identifiers (usernames, domains, paths), normalization becomes part of validation. Normalize to a canonical form (usually NFC), reject bidirectional control characters unless you explicitly support them, and review confusable/homoglyph risks. NFC preserves distinctions that users may care about in display-oriented text. For security-sensitive identifiers, follow an explicit profile instead of choosing NFKC ad hoc: for example, PRECIS username profiles use NFC plus separate width/case mapping rules, while free-form content usually should not apply aggressive compatibility folding at all. For domain names, use an IDNA library and validate the ASCII A-label form rather than rolling your own Unicode hostname parser.

Layer 2: Parse, Don’t Validate

The principle comes from functional programming: instead of checking if data is valid and then using it, parse the data into a strongly-typed representation that is valid by construction:

#![allow(unused)]
fn main() {
use std::net::IpAddr;

#[derive(Debug)]
pub enum ParseError {
    InvalidFormat,
    InvalidPort,
    InvalidIp,
}

/// A validated network address
pub struct NetworkAddress {
    ip: IpAddr,
    port: u16,
}

impl std::str::FromStr for NetworkAddress {
    type Err = ParseError;
    
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let (ip_text, port_text) = if let Some(stripped) = s.strip_prefix('[') {
            let (host, rest) = stripped
                .split_once(']')
                .ok_or(ParseError::InvalidFormat)?;
            if host.contains('[') || !rest.starts_with(':') || rest[1..].contains(']') {
                return Err(ParseError::InvalidFormat);
            }
            (host, &rest[1..])
        } else {
            let (host, port) = s.rsplit_once(':')
                .ok_or(ParseError::InvalidFormat)?;
            if host.contains(':') {
                return Err(ParseError::InvalidFormat);
            }
            (host, port)
        };

        let port: u16 = port_text.parse()
            .map_err(|_| ParseError::InvalidPort)?;
        let ip: IpAddr = ip_text.parse()
            .map_err(|_| ParseError::InvalidIp)?;
        Ok(NetworkAddress { ip, port })
    }
}
}

Use bracketed [IPv6]:port syntax for IPv6 literals. Rejecting bare IPv6 addresses here is intentional: the final colon is ambiguous between “part of the address” and “port separator.”

Layer 3: Sanitization

For data that must be embedded in a different context (HTML, SQL, shell commands, file paths):

#![allow(unused)]
fn main() {
#[derive(Debug)]
pub enum ValidationError {
    NullByte,
    DangerousCharacter,
}

/// Quote one argument for a POSIX shell command line.
/// This is illustrative only; quoting rules differ across shells.
pub fn sanitize_posix_shell_arg(input: &str) -> Result<String, ValidationError> {
    if input.contains('\0') {
        return Err(ValidationError::NullByte);
    }
    // Reject metacharacters this tiny helper does not try to quote.
    // Internal single quotes are handled below by escaping them.
    let dangerous = ['|', ';', '&', '$', '`', '(', ')', '<', '>', '\n', '\r'];
    if input.chars().any(|c| dangerous.contains(&c)) {
        return Err(ValidationError::DangerousCharacter);
    }
    // Single-quote and escape internal single quotes
    let escaped = input.replace("'", "'\\''");
    Ok(format!("'{}'", escaped))
}
}

⚠️ Scope warning: This helper only models POSIX-style single-quote escaping for cases like sh -c .... It is not a general shell-safety API and it is not correct for PowerShell or cmd.exe.

⚠️ Best practice: Avoid shell escaping entirely. Use std::process::Command with explicit argument vectors:

#![allow(unused)]
fn main() {
use std::io;
use std::path::Path;
use std::process::Command;

fn list_directory(path: &Path) -> io::Result<String> {
    // GOOD: arguments are passed as separate strings, no shell involved
    let output = Command::new("ls")
        .arg("-la")
        .arg(path)  // No shell injection possible
        .output()?;
    Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
}

Sanitization is context-specific:

  • SQL: Use parameterized queries or your ORM’s bind API (sqlx, Diesel, etc.). Do not build SQL by concatenating attacker-controlled strings.
  • HTML / XSS: Output-encode for the exact sink (HTML text, attribute, URL, JavaScript string). Input validation helps reduce garbage data, but it is not an XSS defense on its own.
  • HTTP headers: Never splice attacker-controlled strings directly into header lines. Reject \r/\n, let a real HTTP library serialize the header map, and treat response splitting as an injection sink just like SQL or shell construction.

For example, with sqlx:

// BAD: attacker input changes the SQL structure
let sql = format!("SELECT * FROM users WHERE email = '{}'", email);

// GOOD: attacker input stays data, not SQL syntax
let row = sqlx::query("SELECT id, email FROM users WHERE email = ?")
    .bind(email)
    .fetch_optional(&pool)
    .await?;

For PostgreSQL, use $1, $2, … placeholders instead of ?.

7.2 Common Validation Patterns

7.2.1 Length Limits

#![allow(unused)]
fn main() {
#[derive(Debug)]
pub enum ValidationError {
    TooShort { min: usize, actual: usize },
    TooLong { max: usize, actual: usize },
}

pub fn validate_length(input: &[u8], min: usize, max: usize) -> Result<(), ValidationError> {
    let len = input.len();
    if len < min {
        Err(ValidationError::TooShort { min, actual: len })
    } else if len > max {
        Err(ValidationError::TooLong { max, actual: len })
    } else {
        Ok(())
    }
}
}

🔒 Security impact: Length validation prevents buffer overflows (CWE-120), denial of service via unbounded allocation (CWE-789), and resource exhaustion.

7.2.2 Whitelist Over Blacklist

#![allow(unused)]
fn main() {
// BAD: Blacklist approach (easy to miss something)
fn is_safe_filename_blacklist(name: &str) -> bool {
    !name.contains("..") && !name.contains("/")
}

// GOOD: Whitelist approach (only allow known-safe characters)
fn is_safe_filename_whitelist(name: &str) -> bool {
    name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
        && !name.starts_with('.')
        && !name.contains("..")
}
}

7.2.3 Path Traversal Prevention

#![allow(unused)]
fn main() {
use std::path::{Path, PathBuf};

#[derive(Debug)]
pub enum ValidationError {
    NullByte,
    InvalidBasePath,
    InvalidPath,
    PathTraversal,
}

/// Safely resolve a user-provided path within a base directory
pub fn safe_path(base: &Path, user_path: &str) -> Result<PathBuf, ValidationError> {
    // Reject null bytes
    if user_path.contains('\0') {
        return Err(ValidationError::NullByte);
    }
    
    let resolved = base.join(user_path);
    
    // Canonicalize and verify it's still under base
    let canonical_base = base.canonicalize()
        .map_err(|_| ValidationError::InvalidBasePath)?;
    let canonical_resolved = resolved.canonicalize()
        .map_err(|_| ValidationError::InvalidPath)?;
    
    if !canonical_resolved.starts_with(&canonical_base) {
        return Err(ValidationError::PathTraversal);
    }
    
    Ok(canonical_resolved)
}
}

🔒 Security impact: Prevents CWE-22 (Path Traversal). The canonicalization approach handles symlinks and ../ sequences correctly.

⚠️ TOCTOU warning: There is a time-of-check-to-time-of-use race between canonicalize and actually using the path. For maximum security, open the file immediately after validation and use the file descriptor.

⚠️ Existing-path limitation: canonicalize() only succeeds if the final path already exists. This helper is therefore suitable for validating an existing file or directory, not for approving a brand-new destination path. For “create a new file under this trusted base” flows, canonicalize the trusted base directory first, join the untrusted relative name without canonicalizing the final component, and then use an atomic create/open API so attackers cannot swap the target between validation and use. If your policy forbids symlinks in the final component, enforce that separately.

⚠️ Symlink policy matters: canonicalize() follows symlinks. That is correct when your rule is “the final resolved path must stay beneath this base directory.” It is not the right rule when your threat model forbids symlinks entirely. In that case, inspect components with symlink_metadata or directory-fd based APIs and reject symlinked components instead of resolving through them.

⚠️ Temporary files: Do not build temp-file paths by hand with temp_dir().join(user_controlled_name). Use an API that creates and opens the file atomically, such as the tempfile crate, so attackers cannot win a race by pre-creating a symlink or predictable filename.

7.2.4 Integer Input Validation

#![allow(unused)]
fn main() {
#[derive(Debug)]
pub enum ValidationError {
    InvalidInteger,
    ValueTooLarge,
}

pub fn parse_size(input: &str) -> Result<usize, ValidationError> {
    let value: usize = input.parse()
        .map_err(|_| ValidationError::InvalidInteger)?;
    // Apply reasonable limits
    if value > 1024 * 1024 * 1024 {  // 1 GiB max
        return Err(ValidationError::ValueTooLarge);
    }
    Ok(value)
}
}

7.2.5 SSRF Prevention Requires Destination Validation

A syntactically valid hostname is not automatically a safe outbound destination. For any server-side fetcher, webhook client, or proxy feature, combine hostname validation with post-resolution IP policy checks:

#![allow(unused)]
fn main() {
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};

fn is_public_ip(ip: IpAddr) -> bool {
    match ip {
        IpAddr::V4(v4) => {
            let [a, b, _, _] = v4.octets();
            !(v4.is_private()
                || v4.is_loopback()
                || v4.is_link_local()
                || v4.is_multicast()
                || v4 == Ipv4Addr::BROADCAST
                || v4.is_documentation()
                || v4.is_unspecified()
                // Carrier-grade NAT: 100.64.0.0/10
                || (a == 100 && (64..=127).contains(&b))
                // Benchmarking: 198.18.0.0/15
                || (a == 198 && (b == 18 || b == 19))
                // Reserved/experimental and "this network"
                || a == 0
                || a >= 240)
        }
        IpAddr::V6(v6) => {
            if let Some(mapped) = v6.to_ipv4_mapped() {
                return is_public_ip(IpAddr::V4(mapped));
            }

            let segments = v6.segments();
            !(v6.is_loopback()
                || v6.is_unique_local()
                || v6.is_unicast_link_local()
                || v6.is_multicast()
                // Documentation prefix: 2001:db8::/32
                || (segments[0] == 0x2001 && segments[1] == 0x0db8)
                || v6.is_unspecified()
                || v6 == Ipv6Addr::LOCALHOST)
        }
    }
}
}

The to_ipv4_mapped() branch closes a classic SSRF bypass: ::ffff:127.0.0.1 is still loopback, so it must be filtered the same way as plain IPv4 127.0.0.1.

Resolve the hostname immediately before connecting, reject loopback/private/link-local ranges, and re-check after redirects. Otherwise, a DNS rebinding attack can turn a “valid” hostname into access to internal services.

This example is deliberately conservative and still not exhaustive. Treat it as a starting policy and add environment-specific deny rules for your own internal ranges, overlay networks, and non-routable service endpoints.

7.2.6 Regular Expressions and ReDoS

Regular expressions are useful for token-level validation, but they are still an input-processing engine and therefore part of your denial-of-service surface.

  • The regex crate deliberately omits backreferences and look-around, so it avoids the catastrophic backtracking behavior that makes many classic ReDoS attacks possible.
  • That guarantee does not extend to backtracking engines such as fancy-regex, PCRE bindings, or ad hoc recursive parsers wrapped around regex captures.
  • Bound both pattern complexity and input size. For security-critical formats, prefer small token regexes or an explicit parser over one giant expression that mixes validation and parsing.

When you fuzz validators, include long “almost matching” inputs. Those cases are often more valuable than obviously invalid garbage because they expose superlinear behavior, excessive backtracking, and accidental quadratic parsing paths.

7.2.7 Compression Ratio Attacks

Compressed input is still attacker-controlled input. If your service accepts gzip, brotli, zip, or similar payloads, the decompressor becomes part of your denial-of-service surface.

  • Stream the decompression and count produced bytes instead of trusting the compressed size.
  • Reject payloads that exceed a hard decompressed-size limit before you write them to disk or keep extending a Vec.
  • Bound nested archives and container recursion separately. A small outer file can still expand into many inner objects.
  • Record both compressed and decompressed sizes in metrics so unusually high expansion ratios are visible during operations.

This pattern keeps the limit at the security boundary instead of after allocation has already happened:

#![allow(unused)]
fn main() {
use std::io::{self, Read};

fn read_capped<R: Read>(mut reader: R, max_output: usize) -> io::Result<Vec<u8>> {
    let mut out = Vec::new();
    let mut chunk = [0u8; 8192];

    while out.len() < max_output {
        let cap = (max_output - out.len()).min(chunk.len());
        let n = reader.read(&mut chunk[..cap])?;
        if n == 0 {
            return Ok(out);
        }
        out.extend_from_slice(&chunk[..n]);
    }

    let mut extra = [0u8; 1];
    if reader.read(&mut extra)? != 0 {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "decompressed data exceeds limit",
        ));
    }

    Ok(out)
}
}

Use the same cap with streaming APIs such as flate2, brotli, or zip rather than buffering the entire output first and checking its size afterward.

7.3 Validation for Protocol Parsing

When implementing network protocols, validation is critical at every layer:

#![allow(unused)]
fn main() {
#[derive(Debug)]
pub enum ParseError {
    TooShort,
    InvalidContentType(u8),
    InvalidVersion(u16),
    RecordTooLong(u16),
    IncompleteRecord,
}

#[derive(Debug)]
pub struct TlsRecord {
    content_type: ContentType,
    version: TlsRecordVersion,
    length: u16,
    payload: Vec<u8>,
}

#[derive(Debug)]
enum ContentType {
    Handshake,
    Alert,
    ApplicationData,
    ChangeCipherSpec,
}

#[derive(Debug)]
enum TlsRecordVersion {
    Tls10,
    Tls11,
    LegacyTls12,
}

impl TlsRecord {
    pub fn parse(data: &[u8]) -> Result<Self, ParseError> {
        if data.len() < 5 {
            return Err(ParseError::TooShort);
        }
        
        let content_type = match data[0] {
            20 => ContentType::ChangeCipherSpec,
            21 => ContentType::Alert,
            22 => ContentType::Handshake,
            23 => ContentType::ApplicationData,
            _ => return Err(ParseError::InvalidContentType(data[0])),
        };
        
        let version = match u16::from_be_bytes([data[1], data[2]]) {
            0x0301 => TlsRecordVersion::Tls10,
            0x0302 => TlsRecordVersion::Tls11,
            0x0303 => TlsRecordVersion::LegacyTls12,
            v => return Err(ParseError::InvalidVersion(v)),
        };

        // TLS 1.0/1.1 are parsed here only for record-layer archaeology.
        // Real TLS policy should reject them during handshake negotiation
        // per RFC 8996.
        
        let length = u16::from_be_bytes([data[3], data[4]]);
        
        // TLS record length must not exceed 2^14 (16384) per RFC 8446
        if length > 16384 {
            return Err(ParseError::RecordTooLong(length));
        }
        
        if data.len() < 5 + length as usize {
            return Err(ParseError::IncompleteRecord);
        }
        
        let payload = data[5..5 + length as usize].to_vec();
        
        Ok(TlsRecord { content_type, version, length, payload })
    }
}
}

🔒 Security impact: Protocol parsing is a primary attack surface. Strict validation of:

  • Field ranges (record length limits per spec)
  • Unknown values (reject unknown content types)
  • Consistency (payload length matches header)
  • Legacy version field sanity (TLS 1.3 still uses 0x0303 at the record layer)

This prevents protocol-level attacks including fuzzing, injection, and downgrade attacks. For TLS 1.3 specifically, perform downgrade checks in the handshake (supported_versions), not from the record-layer version field alone.

7.4 The serde Ecosystem - Deserialization Safety

Serde is Rust’s serialization framework. While powerful, deserialization of untrusted data requires care:

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::serde as serde;
use rust_secure_systems_book::deps::serde::Deserialize;

#[derive(Deserialize)]
#[serde(crate = "rust_secure_systems_book::deps::serde")]
struct UserInput {
    #[serde(deserialize_with = "validate_username")]
    username: String,
    email: String,
    age: u8,
}

fn validate_username<'de, D>(de: D) -> Result<String, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let s: String = String::deserialize(de)?;
    if s.len() > 64 {
        return Err(serde::de::Error::custom("username too long"));
    }
    if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
        return Err(serde::de::Error::custom("invalid characters in username"));
    }
    Ok(s)
}
}

⚠️ Security warnings for serde:

  1. Depth limits are format-specific: serde provides the framework, but the actual recursion policy comes from the format crate. serde_json keeps a default recursion limit enabled; other deserializers or custom formats may not. Verify the behavior of the specific format you expose to untrusted input.

  2. Integer overflow: When deserializing into a smaller integer type, serde will reject values that don’t fit, unlike many JSON parsers in C.

  3. Denial of service: Large allocations from untrusted input can exhaust memory. Enforce transport-level body size caps and keep parser safeguards enabled:

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::serde as serde;
use rust_secure_systems_book::deps::serde::Deserialize;
use rust_secure_systems_book::deps::serde_json as serde_json;
#[derive(Deserialize)]
#[serde(crate = "rust_secure_systems_book::deps::serde")]
struct UserInput {
    username: String,
    email: String,
    age: u8,
}
fn deserialize_with_limit(data: &[u8]) -> Result<UserInput, Box<dyn std::error::Error>> {
    const MAX_JSON_SIZE: usize = 16 * 1024;
    if data.len() > MAX_JSON_SIZE {
        return Err("input too large".into());
    }

    let mut deserializer = serde_json::Deserializer::from_slice(data);
    // serde_json enables a default recursion limit. Leave it enabled unless
    // you replace it with explicit depth tracking.
    let input = UserInput::deserialize(&mut deserializer)?;
    Ok(input)
}
}

⚠️ Security note: The example above relies on serde_json’s built-in recursion limit and adds an explicit input-size cap before deserialization. Only call disable_recursion_limit() if you replace it with another stack-safety mechanism such as serde_stacker or an explicit depth-tracked visitor.

  1. Secret types: Deserializing into a secret-bearing struct creates another live in-memory copy. If the target type derives ZeroizeOnDrop or wraps fields in secrecy, keep deserialization boundaries narrow and ensure transient copies are dropped promptly.

7.5 Summary

  • Use the validation pyramid: type-level (strongest) → parse-don’t-validate → sanitization.
  • Create newtypes that enforce validity at construction time.
  • Whitelist allowed inputs; don’t blacklist dangerous ones.
  • Prevent path traversal with canonicalization and prefix checking.
  • Normalize and review Unicode input rules before validating non-ASCII identifiers.
  • Treat SSRF as a destination policy problem, not just a hostname syntax problem.
  • Validate protocol fields against specification limits.
  • Use std::process::Command with argument vectors instead of shell escaping.
  • Be cautious with serde deserialization of untrusted data: set depth and size limits.

In the next chapter, we cover cryptography and secrets management: how to safely use cryptographic primitives and protect sensitive data in Rust applications.

7.6 Exercises

  1. Newtype Validation Library: Create validated newtypes for Email, Ipv4Address, and FilePath (safe within a base directory). Each should implement FromStr and be impossible to construct with invalid data. Write comprehensive tests including null bytes, overlength inputs, and path traversal attempts.

  2. Path Traversal Fuzzer: Write a function safe_path(base: &Path, user_input: &str) -> Result<PathBuf> that canonicalizes the result and verifies it stays within base for paths that already exist. Then write a proptest suite that generates path strings with .., symbolic links, mixed separators, and Unicode tricks. Verify your function rejects all escape attempts.

  3. Serde Depth Limit: Use serde_json to deserialize untrusted JSON with a custom visitor that rejects nesting deeper than 10 levels. Write a test that constructs a deeply nested JSON string and verifies it is rejected. Compare memory usage of parsing a 1000-level-deep JSON with and without the limit.

  4. Command Injection Prevention: Write a program that takes user input and passes it to an external command. First implement the unsafe version using shell interpolation (format string with user data), then rewrite using std::process::Command with proper argument separation. Demonstrate that the second version is immune to injection with inputs like ; rm -rf /.

Chapter 8 - Cryptography and Secrets Management

“Don’t roll your own crypto. But if you must, know exactly what you’re doing.”

Cryptography is the backbone of secure systems: authentication, encryption, integrity, and key management all depend on it. For security developers, the challenge isn’t usually inventing new algorithms; it’s using existing ones correctly. Rust’s ecosystem provides excellent cryptographic libraries, and the language’s safety guarantees eliminate many of the pitfalls that lead to vulnerabilities in C implementations (buffer overflows in crypto code, timing side channels from branches on secret data, etc.).

8.1 Choosing Cryptographic Libraries

8.1.1 The ring Crate

ring is the most widely used cryptographic library in Rust. It is a fork of BoringSSL’s crypto internals, providing:

  • Authenticated encryption (AES-GCM, ChaCha20-Poly1305)
  • Key agreement (ECDH P-256, X25519)
  • Digital signatures (ECDSA P-256, Ed25519, RSA)
  • Hashing (SHA-256, SHA-384, SHA-512)
  • HMAC
  • Constant-time comparisons

Its surface area is intentionally narrower than the full Rust crypto ecosystem. Reach for other reviewed crates when you need features outside that core, such as Argon2 password hashing, misuse-resistant AEADs like AES-GCM-SIV, extended-nonce AEADs like XChaCha20-Poly1305, or newer experimental primitives.

# Cargo.toml
[dependencies]
ring = "0.17"
#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::ring as ring;
use ring::{
    aead::{self, BoundKey, Nonce, NonceSequence},
    digest,
    hmac::{self, Key},
};

// SHA-256 hash
fn sha256(data: &[u8]) -> [u8; 32] {
    let hash = digest::digest(&digest::SHA256, data);
    let mut result = [0u8; 32];
    result.copy_from_slice(hash.as_ref());
    result
}

// HMAC-SHA256
fn hmac_sha256(key: &[u8], message: &[u8]) -> hmac::Tag {
    let key = Key::new(hmac::HMAC_SHA256, key);
    hmac::sign(&key, message)
}
}

8.1.2 The RustCrypto Ecosystem

The RustCrypto project provides pure-Rust implementations:

[dependencies]
sha2 = "0.10"
aes-gcm = "0.10"
ed25519-dalek = { version = "2", features = ["zeroize"] }
x25519-dalek = { version = "2", features = ["zeroize"] }

🔒 Recommendation: Use ring for production cryptographic operations (audited, widely deployed). Use RustCrypto crates when you need pure-Rust implementations (no C dependencies, easier cross-compilation).

For FIPS-constrained deployments, do not assume ring satisfies the requirement. Evaluate an AWS-LC-based stack such as aws-lc-rs instead, and verify the exact module status, build flags, and operating environment against your compliance boundary before you ship.

8.2 Authenticated Encryption

Always use authenticated encryption. Never use encryption without authentication (e.g., AES-CBC without HMAC). This prevents chosen-ciphertext attacks and padding oracle attacks.

AES-256-GCM with ring

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::ring as ring;
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM};

fn encrypt(
    key: &[u8; 32],
    nonce_bytes: &[u8; 12],
    aad: &[u8],
    plaintext: &[u8],
) -> Result<Vec<u8>, ring::error::Unspecified> {
    let unbound_key = UnboundKey::new(&AES_256_GCM, key)?;
    let key = LessSafeKey::new(unbound_key);
    let nonce = Nonce::assume_unique_for_key(*nonce_bytes);
    
    let mut in_out = plaintext.to_vec();
    let tag = key.seal_in_place_separate_tag(nonce, Aad::from(aad), &mut in_out)?;
    
    // Append authentication tag to ciphertext
    let mut result = in_out;
    result.extend_from_slice(tag.as_ref());
    Ok(result)
}

fn decrypt(
    key: &[u8; 32],
    nonce_bytes: &[u8; 12],
    aad: &[u8],
    ciphertext_and_tag: &[u8],
) -> Result<Vec<u8>, ring::error::Unspecified> {
    let unbound_key = UnboundKey::new(&AES_256_GCM, key)?;
    let key = LessSafeKey::new(unbound_key);
    let nonce = Nonce::assume_unique_for_key(*nonce_bytes);
    
    let mut in_out = ciphertext_and_tag.to_vec();
    let plaintext_len = key
        .open_in_place(nonce, Aad::from(aad), &mut in_out)?
        .len();
    
    Ok(in_out[..plaintext_len].to_vec())
}
}

Returning Result keeps crypto helpers aligned with Chapter 5’s “fail explicitly” rule. The fixed-size [u8; 32] key type still documents the intended AES-256 key length, but the API no longer teaches panic-based error handling as the default pattern.

If you are storing or transmitting the result, make the nonce part of the serialized format instead of expecting the caller to remember it out of band. A common layout is nonce || ciphertext || tag:

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::ring as ring;
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM};
use ring::rand::{SecureRandom, SystemRandom};
fn encrypt(
    key: &[u8; 32],
    nonce_bytes: &[u8; 12],
    aad: &[u8],
    plaintext: &[u8],
) -> Result<Vec<u8>, ring::error::Unspecified> {
    let unbound_key = UnboundKey::new(&AES_256_GCM, key)?;
    let key = LessSafeKey::new(unbound_key);
    let nonce = Nonce::assume_unique_for_key(*nonce_bytes);

    let mut in_out = plaintext.to_vec();
    let tag = key.seal_in_place_separate_tag(nonce, Aad::from(aad), &mut in_out)?;

    let mut result = in_out;
    result.extend_from_slice(tag.as_ref());
    Ok(result)
}
fn decrypt(
    key: &[u8; 32],
    nonce_bytes: &[u8; 12],
    aad: &[u8],
    ciphertext_and_tag: &[u8],
) -> Result<Vec<u8>, ring::error::Unspecified> {
    let unbound_key = UnboundKey::new(&AES_256_GCM, key)?;
    let key = LessSafeKey::new(unbound_key);
    let nonce = Nonce::assume_unique_for_key(*nonce_bytes);

    let mut in_out = ciphertext_and_tag.to_vec();
    let plaintext_len = key
        .open_in_place(nonce, Aad::from(aad), &mut in_out)
        ?
        .len();

    Ok(in_out[..plaintext_len].to_vec())
}
fn generate_nonce() -> [u8; 12] {
    let rng = SystemRandom::new();
    let mut nonce = [0u8; 12];
    rng.fill(&mut nonce).expect("OS CSPRNG failure");
    nonce
}

#[derive(Debug)]
enum StoredCiphertextError {
    Truncated,
    Crypto(ring::error::Unspecified),
}

fn encrypt_for_storage(
    key: &[u8; 32],
    aad: &[u8],
    plaintext: &[u8],
) -> Result<Vec<u8>, ring::error::Unspecified> {
    let nonce = generate_nonce();
    let ciphertext_and_tag = encrypt(key, &nonce, aad, plaintext)?;

    let mut packed = nonce.to_vec();
    packed.extend_from_slice(&ciphertext_and_tag);
    Ok(packed)
}

fn decrypt_from_storage(
    key: &[u8; 32],
    aad: &[u8],
    packed: &[u8],
) -> Result<Vec<u8>, StoredCiphertextError> {
    if packed.len() < 12 {
        return Err(StoredCiphertextError::Truncated);
    }

    let (nonce_bytes, ciphertext_and_tag) = packed.split_at(12);
    let nonce: [u8; 12] = nonce_bytes
        .try_into()
        .map_err(|_| StoredCiphertextError::Truncated)?;
    decrypt(key, &nonce, aad, ciphertext_and_tag).map_err(StoredCiphertextError::Crypto)
}
}

Here, expect("OS CSPRNG failure") is intentional: if the operating system cannot provide cryptographic randomness, fail closed instead of continuing with weaker entropy.

The storage wrapper now distinguishes a malformed serialized record (Truncated) from an AEAD authentication failure (Crypto(_)) instead of collapsing both cases into None.

Use AAD for metadata that must remain in the clear but still be authenticated: protocol version, content type, sender ID, key ID, or a packet header. If any AAD byte changes, decryption fails even though the bytes were never encrypted.

⚠️ API note: LessSafeKey is acceptable for focused examples, but it does not enforce nonce sequencing. In production, prefer a BoundKey plus a NonceSequence when one component owns nonce generation.

⚠️ Nonce API caveat: Nonce::assume_unique_for_key() does not verify uniqueness. It is a promise by the caller to ring. Back it with a durable counter, a carefully designed random-nonce scheme, or a nonce-sequence type that centralizes generation.

🔒 Critical rules for nonce management:

  1. Never reuse a nonce with the same key. AES-GCM nonce reuse reveals the XOR of plaintexts and undermines every subsequent use of that key: the attacker can recover the internal GHASH key and make later forgeries trivial. It does not directly reveal the AES key, but you should still treat any reuse as a key-compromise event: rotate the key immediately and reject both messages.
  2. For random nonces, use a cryptographically secure random number generator and limit to 2^32 encryptions per key.
  3. For counter-based nonces, track the counter securely and never reset it.

ChaCha20-Poly1305 (Alternative to AES-GCM)

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::ring as ring;
use ring::aead::{UnboundKey, CHACHA20_POLY1305};

fn create_chacha20_key(
    key_bytes: &[u8; 32],
) -> Result<UnboundKey, ring::error::Unspecified> {
    UnboundKey::new(&CHACHA20_POLY1305, key_bytes)
}
}

🔒 When to prefer ChaCha20-Poly1305: On platforms without AES hardware acceleration, ChaCha20 is faster and constant-time. On modern x86/ARM with AES-NI, AES-GCM is typically faster.

Nonce-Misuse-Resistant Alternatives

If your system cannot reliably guarantee nonce uniqueness across crashes, replicas, offline clients, or multi-writer deployments, prefer an AEAD that degrades more safely under nonce mistakes:

[dependencies]
aes-gcm-siv = "0.11"
chacha20poly1305 = "0.10"
  • AES-GCM-SIV (RFC 8452) is a misuse-resistant variant of GCM. Accidental nonce reuse is still a bug, but it is much less catastrophic than nonce reuse in classic AES-GCM.
  • XChaCha20-Poly1305 extends the nonce to 192 bits (24 bytes), which makes random-nonce designs much easier to operate safely in distributed systems and local-encryption tools.
  • ring does not currently expose these constructions, so use well-reviewed RustCrypto crates when you need them.

🔒 Design guidance:

  • If you need an AES-based construction and are worried about occasional nonce duplication, consider AES-GCM-SIV.
  • If random nonces are operationally simpler than durable counters, XChaCha20-Poly1305 is often the easiest safe choice.
  • Misuse resistance is not permission to reuse nonces deliberately. You still need uniqueness, replay handling, and a clear key lifecycle.

8.3 Key Derivation

Never use passwords directly as encryption keys. Use a key derivation function (KDF):

HKDF (for deriving keys from a shared secret)

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::ring as ring;
use ring::hkdf::{Salt, HKDF_SHA256};

/// A helper type that tells `ring` how many bytes of key material to produce.
struct KeyBytes;

impl ring::hkdf::KeyType for KeyBytes {
    fn len(&self) -> usize {
        32
    }
}

fn derive_key(
    secret: &[u8],
    salt: &[u8],
    info: &[u8],
) -> Result<[u8; 32], ring::error::Unspecified> {
    let salt = Salt::new(HKDF_SHA256, salt);
    let prk = salt.extract(secret);
    
    let mut key = [0u8; 32];
    let binding = [info];
    let okm = prk.expand(&binding, KeyBytes)?;
    okm.fill(&mut key)?;
    Ok(key)
}
}

PBKDF2 or Argon2 (for deriving keys from passwords)

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::ring as ring;
use ring::pbkdf2;
use std::num::NonZeroU32;

const PBKDF2_ITERATION_COUNT: u32 = 600_000;  // Example baseline only; production systems should store per-hash parameters and override policy from validated config.
const PBKDF2_ITERATIONS: NonZeroU32 =
    const { NonZeroU32::new(PBKDF2_ITERATION_COUNT).unwrap() };

fn derive_key_from_password(password: &str, salt: &[u8]) -> [u8; 32] {
    let mut key = [0u8; 32];
    pbkdf2::derive(
        pbkdf2::PBKDF2_HMAC_SHA256,
        PBKDF2_ITERATIONS,
        salt,
        password.as_bytes(),
        &mut key,
    );
    key
}

fn verify_password(password: &str, salt: &[u8], expected: &[u8; 32]) -> bool {
    pbkdf2::verify(
        pbkdf2::PBKDF2_HMAC_SHA256,
        PBKDF2_ITERATIONS,
        salt,
        password.as_bytes(),
        expected,
    ).is_ok()
}
}

🔒 Security notes:

  • ring::pbkdf2::verify uses constant-time comparison internally.
  • The 600_000 example matches OWASP’s current PBKDF2-HMAC-SHA256 baseline at the time of writing. NIST SP 800-132 is older and more general: it recommends choosing the count as large as acceptable for users, with 1,000 as a historical minimum. Treat iteration counts as a time-sensitive policy knob, not a timeless constant from a book.
  • Keeping the typed NonZeroU32 as a const turns an accidental zero into a compile-time failure instead of a panic on first use. The unwrap() here executes in the const initializer, not at runtime.
  • Prefer Argon2id for new systems and general password storage.
  • Keep PBKDF2-HMAC-SHA256 for FIPS-constrained environments or legacy interoperability where Argon2id is not an option.
  • Use a unique 16+ byte random salt per password.
  • Store the algorithm parameters with the password verifier so you can raise the cost over time.
  • In real services, load the cost parameter from validated startup configuration instead of baking a book example constant into the binary forever.

For PBKDF2, keep the parameters and verifier together instead of storing loose fields:

#![allow(unused)]
fn main() {
struct StoredPbkdf2Hash {
    iterations: u32,
    salt: Vec<u8>,
    hash: [u8; 32],
}
}

When you raise the PBKDF2 iteration count, keep verifying each password with the parameters stored alongside that user’s hash. After a successful login, immediately derive a new hash with the current policy and replace the stored record. This “rehash on login” pattern lets you migrate gradually without breaking existing accounts or silently keeping old work factors forever.

For new password storage, prefer the Argon2 PHC string format shown below because it bundles the algorithm, parameters, salt, and hash into one verifier string.

Argon2 is the winner of the 2015 Password Hashing Competition and is recommended by OWASP over PBKDF2 because it is resistant to GPU/ASIC attacks:

[dependencies]
argon2 = "0.5"
password-hash = { version = "0.5", features = ["std"] }
rand_core = { version = "0.6", features = ["std"] }
#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::argon2 as argon2;
use rust_secure_systems_book::deps::password_hash as password_hash;
use rust_secure_systems_book::deps::rand_core as rand_core;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use argon2::password_hash::SaltString;
use rand_core::OsRng;

fn hash_password(password: &str) -> Result<String, password_hash::errors::Error> {
    let salt = SaltString::generate(&mut OsRng);
    let argon2 = Argon2::default();
    
    let hash = argon2.hash_password(password.as_bytes(), &salt)?;
    Ok(hash.to_string())
}

fn parse_password_hash(
    hash: &str,
) -> Result<PasswordHash<'_>, password_hash::errors::Error> {
    PasswordHash::new(hash)
}

fn verify_password(password: &str, parsed_hash: &PasswordHash<'_>) -> bool {
    Argon2::default()
        .verify_password(password.as_bytes(), parsed_hash)
        .is_ok()
}
}

⚠️ Authentication timing pitfall: Constant-time byte comparison is not enough if your surrounding control flow still leaks information. If a login path returns immediately for “user not found” but runs Argon2 for “wrong password”, an attacker can distinguish the two cases from latency. For username/password authentication, always run the password check against either the real stored hash or a fixed dummy Argon2 hash, then return the same external error in both cases.

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::argon2 as argon2;
use rust_secure_systems_book::deps::password_hash as password_hash;
use argon2::{Argon2, PasswordVerifier};
use password_hash::PasswordHashString;

const DUMMY_HASH: &str =
    "$argon2id$v=19$m=65536,t=2,p=1$c29tZXNhbHQ$CTFhFdXPJO1aFaMaO6Mm5c8y7cJHAph8ArZWb2GRPPc";

#[derive(Debug)]
struct UserRecord {
    password_hash: PasswordHashString,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AuthError {
    InvalidCredentials,
}

struct Authenticator {
    dummy_hash: PasswordHashString,
}

impl Authenticator {
    fn new() -> Self {
        Self {
            dummy_hash: PasswordHashString::new(DUMMY_HASH)
                .expect("hardcoded dummy hash must be valid"),
        }
    }

    fn verify_login(
        &self,
        user: Option<&UserRecord>,
        password: &str,
    ) -> Result<(), AuthError> {
        let hash_to_check = user
            .map(|record| record.password_hash.password_hash())
            .unwrap_or_else(|| self.dummy_hash.password_hash());

        let password_ok = Argon2::default()
            .verify_password(password.as_bytes(), &hash_to_check)
            .is_ok();

        if user.is_some() && password_ok {
            Ok(())
        } else {
            Err(AuthError::InvalidCredentials)
        }
    }
}
}

Pre-validate PHC strings when you load or migrate account records, not from unchecked database text inside the hot authentication path. A malformed stored hash is an internal integrity problem to alert on, not a second externally visible login outcome.

🔒 Argon2 parameters (per OWASP recommendations):

  • Memory: 19 MiB (19,456 KiB) minimum
  • Iterations: 2 time cost (passes) minimum
  • Parallelism: 1 degree of parallelism minimum
  • Adjust upward based on your hardware and acceptable verification latency

At the time of writing, Argon2::default() in the argon2 0.5 crate maps to Argon2id v19 with those same baseline parameters: m=19*1024 KiB, t=2, p=1. When parameter reviewability matters, spell them out explicitly instead of relying on a default.

⚠️ Legacy migration note: bcrypt is still common in deployed systems. Do not choose it for a new design when Argon2id is available, but keep a bcrypt verifier in migration paths so existing password hashes can be checked once and then rehashed to Argon2id after a successful login.

8.4 Digital Signatures

Ed25519 Signatures

Ed25519 is the recommended signature scheme for most applications: it is fast, compact (64-byte signatures, 32-byte public keys), and immune to many side-channel issues that affect ECDSA:

[dependencies]
ed25519-dalek = { version = "2", features = ["rand_core", "zeroize"] }
rand_core = { version = "0.6", features = ["std"] }
#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::ed25519_dalek as ed25519_dalek;
use rust_secure_systems_book::deps::rand_core as rand_core;
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier};
use rand_core::OsRng;

fn sign_example() {
    let mut csprng = OsRng;
    let signing_key = SigningKey::generate(&mut csprng);
    let verifying_key = signing_key.verifying_key();
    
    let message = b"important document";
    
    // Sign
    let signature: Signature = signing_key.sign(message);
    
    // Verify
    assert!(verifying_key.verify(message, &signature).is_ok());
    
    // Tampered message fails verification
    assert!(verifying_key.verify(b"tampered document", &signature).is_err());
}
}

SigningKey::generate is only available when the crate’s rand_core feature is enabled, so make sure your dependency configuration includes it. The separate rand_core dependency supplies an OsRng that matches the trait version ed25519-dalek expects.

🔒 Security notes:

  • Ed25519 uses deterministic signatures (no per-signature randomness needed), eliminating the catastrophic nonce-reuse vulnerability that affects ECDSA.
  • Always verify signatures before trusting signed data.

8.4.1 RSA for Legacy Interoperability

Ed25519 should be your default for new designs, but RSA is still common in X.509 PKI, S/MIME, PGP ecosystems, HSM-backed deployments, and older compliance-driven environments. When you must interoperate with RSA:

  • Prefer RSA-PSS for signatures over PKCS#1 v1.5.
  • Use at least 2048-bit keys, with 3072-bit keys common for longer-lived deployments.
  • Treat RSA as compatibility baggage, not a model for new protocol design.

8.5 Key Exchange

X25519 → HKDF → AEAD Pipeline

The standard pattern for establishing a shared secret and encrypting data is: X25519 key agreement, HKDF key derivation, then AEAD encryption:

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::ring as ring;
use ring::{
    agreement::{self, UnparsedPublicKey, X25519},
    hkdf::{Salt, HKDF_SHA256},
    aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM},
};

fn key_exchange_pipeline() -> Result<(), ring::error::Unspecified> {
    // 1. Each party generates an ephemeral key pair
    let rng = ring::rand::SystemRandom::new();
    let alice_private = agreement::EphemeralPrivateKey::generate(&X25519, &rng)?;
    let alice_public = alice_private.compute_public_key()?;
    
    let bob_private = agreement::EphemeralPrivateKey::generate(&X25519, &rng)?;
    let bob_public = bob_private.compute_public_key()?;
    
    // 2. Key agreement - both parties derive the same shared secret
    let alice_shared = agreement::agree_ephemeral(
        alice_private,
        &UnparsedPublicKey::new(&X25519, bob_public.as_ref()),
        |shared_secret| -> Result<[u8; 32], ring::error::Unspecified> {
            // 3. Derive encryption key from shared secret using HKDF
            let salt = Salt::new(HKDF_SHA256, b"session-salt");
            let prk = salt.extract(shared_secret);
            
            struct KeyLen;
            impl ring::hkdf::KeyType for KeyLen {
                fn len(&self) -> usize { 32 }
            }
            
            let mut key_bytes = [0u8; 32];
            prk.expand(&[b"encryption-key"], KeyLen)?
                .fill(&mut key_bytes)?;
            Ok::<[u8; 32], ring::error::Unspecified>(key_bytes)
        },
    )??;
    
    // 4. Encrypt with the derived key
    let unbound_key = UnboundKey::new(&AES_256_GCM, &alice_shared)?;
    let key = LessSafeKey::new(unbound_key);
    // ... use key.seal_in_place_separate_tag / open_in_place
    Ok(())
}
}

🔒 Key exchange security:

  • Always use ephemeral key pairs (generate fresh per session) for forward secrecy.
  • Use HKDF to derive encryption keys: never use the raw shared secret directly.
  • Include context info in HKDF expansion (e.g., b"encryption-key", b"mac-key") to derive different keys for different purposes.

8.5.1 Key Rotation and Key Lifetimes

Cryptography often fails operationally, not mathematically. A secure design needs a rotation plan before the first key is deployed:

  • Version every long-lived key and attach a key ID to ciphertexts, signatures, or wrapped data.
  • Decrypt with old versions, encrypt/sign with the newest version during rollouts. This “dual-read, single-write” pattern lets you rotate without flag days.
  • Separate key-encryption keys from data/session keys so most rotations only require re-wrapping smaller keys instead of bulk re-encryption.
  • Define normal lifetime and emergency revocation rules for each key class: TLS certificates, signing keys, API credentials, master keys, and derived data keys should not all share the same schedule.
  • Rehearse compromised-key response: how you revoke trust, distribute new keys, invalidate old sessions, and audit which data was exposed.

🔒 Practical rule: Session keys should usually be ephemeral and rotated automatically. Long-lived keys should have explicit creation dates, activation windows, retirement windows, and owners.

8.5.2 Post-Quantum Readiness

NIST approved its first post-quantum FIPS standards on August 13, 2024: ML-KEM for key establishment, plus ML-DSA and SLH-DSA for signatures. For Rust systems being designed now, the immediate goal is not “replace everything overnight” but “make migration operationally possible.”

  • Design for crypto agility: encode algorithm identifiers, key IDs, version fields, and negotiation rules so you can rotate algorithms without redesigning the protocol.
  • Protect long-lived confidentiality: if captured traffic must stay secret for many years, evaluate hybrid key establishment that combines a classical component such as X25519 with a post-quantum KEM when your protocol stack supports it.
  • Separate experimentation from deployment: benchmark size, latency, certificate-chain impact, and interoperability before promoting post-quantum algorithms into production defaults.
  • Use established ecosystems: for Rust, the pqcrypto family and RustCrypto crates such as ml-kem and ml-dsa are the obvious starting points for evaluation.

⚠️ Migration rule: Prefer hybrid and crypto-agile designs during transition periods. Do not invent your own KEM combiner or signature format.

8.6 Secrets Management

8.6.1 The zeroize Crate - Secure Memory Wiping

Cryptographic keys and passwords must be zeroed from memory after use. Rust does not guarantee this by default: variables on the stack or heap may persist until the memory is reused.

[dependencies]
zeroize = { version = "1", features = ["derive"] }
#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
extern crate zeroize;
use zeroize::{Zeroize, ZeroizeOnDrop};

#[derive(Zeroize, ZeroizeOnDrop)]
struct SecretKey {
    key: [u8; 32],
    nonce: [u8; 12],
}

fn use_key() {
    let key = SecretKey {
        key: [0xAB; 32],
        nonce: [0x00; 12],
    };
    
    // Use key...
    
    // When `key` goes out of scope, it is automatically zeroed
    // The memory will contain all zeros before being freed
}
}

🔒 Critical pattern: Use #[derive(Zeroize, ZeroizeOnDrop)] for types containing secrets. This ensures memory is wiped on ordinary drop paths even if the function exits early (for example via ?).

⚠️ Panic strategy caveat: With panic = "abort" from Chapter 2 §2.4, Drop-based zeroization still runs on normal returns but does not run on panic paths.

⚠️ Clone caveat: If a secret type also implements Clone, each clone is an independent secret copy with its own lifecycle. Avoid cloning secrets unless you are deliberately zeroizing every copy.

⚠️ Deserialize caveat: If a secret-bearing type also implements serde::Deserialize, every successful parse creates another live secret instance. Treat deserialization boundaries the same way you treat Clone: minimize copies and ensure each instance is dropped on its own lifecycle.

Cancellation in async code does not change this guarantee. As discussed in Chapter 6, aborting a Tokio task works by dropping the future, so ZeroizeOnDrop fields are wiped on that drop path too. The exceptions are the usual ones: process aborts, deliberate leaks such as mem::forget, or reference cycles that prevent Drop from ever running.

One important caveat is shared ownership. If a secret is wrapped in Arc<T>, aborting one task only drops that task’s clone. The secret is zeroized when the last strong reference disappears, not when any individual task is cancelled. Avoid long-lived Arc clones of raw secret material unless you are deliberately managing every copy’s lifetime.

8.6.2 The secrecy Crate - Encapsulating Secrets

[dependencies]
secrecy = "0.10"
#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::secrecy as secrecy;
use secrecy::{ExposeSecret, SecretString};

struct Session;
impl Session {
    fn new() -> Self {
        Self
    }
}
#[derive(Debug)]
struct AuthError;

fn authenticate(password: SecretString) -> Result<Session, AuthError> {
    // The password cannot be accidentally logged, printed, or serialized
    // It can only be accessed via expose_secret()
    
    let pw_bytes = password.expose_secret().as_bytes();
    // ... verify password
    
    // This would not compile:
    // println!("Password: {}", password);  // SecretString doesn't impl Display
    // log::info!("Trying {}", password);   // Doesn't impl Debug either
    
    Ok(Session::new())
}
}

🔒 Security pattern: Secret<T> prevents secrets from being accidentally:

  • Printed to logs (no Debug/Display)
  • Serialized (no Serialize)
  • Exposed without an explicit ExposeSecret call, which makes secret-handling sites easier to audit

secrecy helps prevent accidental disclosure, but it does not make comparisons constant-time by itself. When comparing secrets, use an API that explicitly documents constant-time behavior.

8.6.3 Constant-Time Comparison

Comparing secrets (passwords, MACs, tokens) with normal equality operators leaks timing information:

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::ring as ring;
use ring::constant_time::verify_slices_are_equal;

fn verify_token(provided: &[u8], expected: &[u8]) -> bool {
    verify_slices_are_equal(provided, expected).is_ok()
}
}

🔒 Security impact: Prevents timing side-channel attacks (CWE-208). A normal == comparison returns false as soon as it finds a mismatching byte, leaking information about which bytes match.

8.6.4 Constant-Time Selection and Branching

Constant-time comparison is only part of the story. Secret-dependent branching such as if secret_bit == 1 { ... } or match secret_byte { ... } can still leak timing information through control flow, cache activity, and branch prediction.

Rust does not make Spectre-style or cache side channels disappear. Constant-time source code still needs review against compiler transformations and the behavior of the target CPU and runtime environment.

For low-level cryptographic code, prefer the subtle crate’s constant-time building blocks:

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::subtle as subtle;
use subtle::{Choice, ConditionallySelectable, ConstantTimeEq};

fn select_mask(secret_bit: u8, limited: u32, full: u32) -> u32 {
    let choice = Choice::from(secret_bit & 1);
    u32::conditional_select(&limited, &full, choice)
}

fn tags_match(provided: &[u8; 32], expected: &[u8; 32]) -> Choice {
    provided.ct_eq(expected)
}
}

Choice is intentionally not a normal bool; it nudges you toward constant-time APIs instead of accidentally branching on secret material. Use ordinary if/match only on public values that are already safe to reveal.

⚠️ Verification note: Treat constant-time behavior as a property of the compiled code on the targets you actually ship. Source-level review is necessary but not sufficient. Tools such as dudect (statistical timing tests) and ctgrind (secret-dependent control-flow and memory-access checks) help validate whether a “constant-time” path still behaves that way after optimization on a specific platform.

When benchmarking these paths with criterion or ad hoc timing loops, pass both the secret inputs and the observed outputs through std::hint::black_box. Otherwise LLVM may fold away work that is present in production and give you a false sense of constant-time behavior.

8.6.5 Hardware-Backed and OS-Managed Key Storage

For high-value long-lived keys, keep private-key operations inside a keystore or hardware boundary rather than loading raw key bytes into the application process. Typical options include PKCS#11 devices and HSMs (cryptoki), TPM-backed keys (tss-esapi over tpm2-tss), and OS-managed stores such as DPAPI, Credential Manager, macOS Keychain, Linux keyrings, or Secret Service.

Use these when host compromise, compliance requirements, or signing authority justify the operational cost. Design the application around key handles and operations such as sign, decrypt, or unwrap; avoid exporting the private key unless migration or backup requires it. Chapter 19 returns to the production deployment tradeoffs.

8.7 Random Number Generation

Use cryptographically secure random number generators (CSPRNGs) for all security-sensitive operations:

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::hex as hex;
use rust_secure_systems_book::deps::ring as ring;
use ring::rand::{SecureRandom, SystemRandom};

fn generate_nonce() -> [u8; 12] {
    let rng = SystemRandom::new();
    let mut nonce = [0u8; 12];
    rng.fill(&mut nonce).expect("OS CSPRNG failure");
    nonce
}

fn generate_api_token() -> String {
    let rng = SystemRandom::new();
    let mut bytes = [0u8; 32];
    rng.fill(&mut bytes).expect("OS CSPRNG failure");
    hex::encode(bytes)
}
}

⚠️ Prefer ring::rand::SystemRandom for all cryptographic purposes in production: it is explicit, auditable, and always backed by the OS CSPRNG. The rand ecosystem also offers cryptographically secure generators, but SystemRandom makes the security intent clearer and keeps key generation tied directly to the operating system RNG.

In small examples, panicking on rng.fill(...) is acceptable because the only sensible response to OS CSPRNG failure is to abort or surface the error, never to keep running with a weaker fallback.

8.8 TLS with rustls

For secure network communication, use rustls instead of OpenSSL:

[dependencies]
rustls = "0.23"
tokio-rustls = "0.26"
webpki-roots = "0.26"
#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::rustls as rustls;
use rust_secure_systems_book::deps::webpki_roots as webpki_roots;
use rustls::{ClientConfig, RootCertStore};
use webpki_roots::TLS_SERVER_ROOTS;

fn create_tls_client_config() -> ClientConfig {
    let mut root_certs = RootCertStore::empty();
    root_certs.extend(TLS_SERVER_ROOTS.iter().cloned());
    
    ClientConfig::builder()
        .with_root_certificates(root_certs)
        .with_no_client_auth()
}
}

Revocation note: with_root_certificates(...) loads trust anchors, but it does not configure a CRL or OCSP policy by itself. If certificate revocation matters in your environment, add an explicit verifier configuration or terminate TLS in infrastructure that enforces revocation; short-lived certificates are usually easier to operate than revocation-by-default for ordinary service fleets, but they are not a substitute for revocation in high-assurance environments where one compromised key could unlock a fleet or a critical trust domain.

8.8.1 Certificate Pinning for Internal Services

For private APIs, control planes, and service-to-service calls, CA validation alone may be too broad. Certificate pinning adds an application-controlled trust decision on top of normal PKI validation:

  • Prefer pinning a public key (for example, the SPKI hash), not an entire leaf certificate, so routine certificate renewal does not force an outage.
  • Maintain at least one backup pin before deployment. Pinning without a backup is an outage plan, not a security plan.
  • Keep hostname and chain validation enabled. Pinning complements PKI; it does not replace it.
  • Use pinning for a small, known set of internal endpoints. Do not apply hardcoded pins to arbitrary public websites or general-purpose clients.

With rustls, the usual pattern is to keep the normal verifier and add one more check that compares the validated peer certificate’s public key against an out-of-band allowlist. Avoid “dangerous” configurations that disable certificate verification entirely.

🔒 Why rustls over OpenSSL:

  • Memory-safe implementation (no buffer overflows in TLS parsing)
  • No unsafe code in the TLS state machine
  • Simpler API reduces misuse risk
  • Active security auditing

8.8.2 Token-Based Authentication: JWT and PASETO

Bearer tokens are common for API authentication, but verification must be stricter than “the library decoded it successfully.”

  • Pin the algorithm in configuration and reject tokens whose header does not match it. Never let the token choose the verification algorithm.
  • Verify the signature first, then validate claims such as exp, nbf, iss, aud, and any application-specific scope or tenant claims.
  • Keep expirations short and rotate signing keys with explicit kid values. Reject unknown key IDs instead of falling back to a default key.
  • Treat claims as authorization input only after issuer and audience validation succeeds.

If you need JWT ecosystem interoperability, crates such as jsonwebtoken are common choices. If you control both ends of the protocol and do not need JWT compatibility, PASETO crates such as pasetors reduce some historical footguns by making algorithm choices less header-driven.

⚠️ Do not accept alg: none, mix symmetric and asymmetric algorithms for the same issuer, or trust unsigned header fields to select keys or verification behavior.

8.9 Common Cryptographic Mistakes (and How Rust Helps)

MistakeC/C++ ConsequenceRust Prevention
Nonce reuseCatastrophic (GCM)API design can make nonce ownership explicit; BoundKey + NonceSequence helps
Missing authPadding oraclering API requires AEAD
Timing leakSide-channelring::constant_time module
Key not zeroedMemory disclosurezeroize crate with ZeroizeOnDrop
Weak RNGPredictable keysSystemRandom is always a CSPRNG
Hardcoded keysSource code leakUse Secret<T>, env variables, or vaults

8.10 Summary

  • Use ring or well-audited RustCrypto crates: never implement crypto primitives yourself.
  • Always use authenticated encryption (AES-GCM, ChaCha20-Poly1305).
  • Use AAD to authenticate unencrypted headers and protocol metadata.
  • Never reuse nonces with the same key for AEAD ciphers.
  • Prefer nonce-misuse-resistant AEADs such as AES-GCM-SIV or XChaCha20-Poly1305 when nonce coordination is operationally hard.
  • Derive keys from passwords using Argon2id (preferred) or PBKDF2 with proper parameters and salts.
  • Use Ed25519 for digital signatures; use X25519 + HKDF for key exchange.
  • Plan key rotation and emergency revocation up front; cryptographic agility is an operational requirement.
  • Use zeroize to wipe secrets from memory; use secrecy to encapsulate them.
  • Use hardware-backed or OS-managed keystores for high-value long-lived keys.
  • Always use constant-time comparison for secrets.
  • Plan post-quantum migration now for long-lived systems, and prefer hybrid transitions over abrupt algorithm replacement.
  • Use rustls for TLS instead of OpenSSL.
  • Use certificate pinning only as an additional control for a narrow set of internal endpoints.
  • Treat token verification as cryptography plus policy: pin algorithms, verify signatures, and validate claims.
  • Use SystemRandom for all cryptographic random number generation.

In the next chapter, we enter the world of unsafe Rust, where the compiler’s safety guarantees are manually maintained.

8.11 Exercises

  1. Encrypt/Decrypt Roundtrip: Using ring, implement AES-256-GCM encryption and decryption with proper nonce management. Use a counter-based nonce scheme. Write tests for: successful roundtrip, tampered ciphertext (should fail), wrong key (should fail), and replayed nonce (demonstrate that nonce reuse leaks the XOR of plaintexts and explain why real AES-GCM reuse also breaks authentication).

  2. Key Derivation Pipeline: Implement a password-based key derivation pipeline: generate a random salt, derive a 256-bit key using PBKDF2 with 600,000 iterations, encrypt a message, then decrypt and verify. Store only the salt and ciphertext. Ensure the key is zeroized after use with the zeroize crate.

  3. Constant-Time Comparison Test: Write two comparison functions: one using == on byte slices and one using ring::constant_time::verify_slices_are_equal. Benchmark both with timing measurements and demonstrate that the standard comparison leaks timing information on the position of the first differing byte. (Use criterion for statistically sound benchmarking.)

  4. TLS Server: Using rustls and tokio-rustls, create a minimal TLS server that accepts a single connection, reads a message, and echoes it back. Use self-signed certificates generated with the rcgen crate. Test with openssl s_client to verify the connection.

  5. Argon2 Password Hasher: Implement a user registration and login flow using Argon2id. Store the PHC-format hash string (which includes salt and parameters). Verify that: (a) correct passwords succeed, (b) wrong passwords fail, (c) the hash string reveals no information about the password, (d) verification takes approximately the same time for valid and invalid users.

  6. Key Exchange Simulation: Implement the X25519 → HKDF → AES-256-GCM pipeline for two parties (Alice and Bob). Verify that: (a) both derive the same encryption key, (b) Alice can encrypt a message that Bob decrypts, (c) using a different key pair produces a different shared secret (forward secrecy), (d) tampering with a ciphertext causes decryption to fail.

  7. Rotation and Pinning Plan: Design a key-management plan for an internal service. Define which keys are ephemeral, which are long-lived, how key IDs are encoded, how “decrypt old / encrypt new” rollout works, how compromised keys are revoked, and how an SPKI pin set with at least one backup pin is distributed to clients.

Chapter 9 - Unsafe Rust: When and How

“With great power comes great responsibility, and a lot of code review.”

Unsafe Rust is the escape hatch that allows you to bypass the compiler’s safety checks. It exists because there are things the compiler cannot verify: interfacing with C libraries, implementing low-level data structures, hardware access, and performance-critical optimizations that require raw pointer manipulation.

For security developers, unsafe is the most critical area of Rust code. Every unsafe block is a potential source of memory corruption, and must be audited with the same rigor you’d apply to C code.

9.1 What unsafe Enables

Inside an unsafe block, you can:

  1. Dereference raw pointers (*const T, *mut T)
  2. Call unsafe functions (including FFI functions)
  3. Access or modify mutable statics
  4. Implement unsafe traits
  5. Access fields of unions

⚠️ What unsafe does NOT disable: The borrow checker still operates. unsafe does not turn off the type system, lifetimes, or other compile-time checks. It only allows the five operations listed above.

9.2 The Safety Invariant

Every unsafe block comes with an implicit contract: you must ensure that safe code cannot cause undefined behavior through your unsafe code.

This is the “soundness” property: safe Rust code can never cause undefined behavior, even if it tries. If safe code can cause UB through your unsafe abstraction, your code is unsound and that’s a bug.

#![allow(unused)]
fn main() {
// UNSOUND: safe code can cause UB
pub struct BadSlice<T> {
    ptr: *mut T,
    len: usize,
}

impl<T> BadSlice<T> {
    pub fn get(&self, index: usize) -> &T {
        unsafe {
            // BUG: no bounds check!
            &*self.ptr.add(index)
        }
    }
}

// A safe caller can trigger out-of-bounds read:
fn exploit() {
    let mut data = [1, 2, 3];
    let slice = BadSlice { ptr: data.as_mut_ptr(), len: 3 };
    let _val = slice.get(100);  // Out-of-bounds! UB through safe code!
}
}

The fix: add bounds checking:

#![allow(unused)]
fn main() {
pub struct BadSlice<T> {
    ptr: *mut T,
    len: usize,
}

impl<T> BadSlice<T> {
    pub fn get(&self, index: usize) -> Option<&T> {
        if index >= self.len {
            return None;
        }
        unsafe {
            Some(&*self.ptr.add(index))
        }
    }
}
}

🔒 Golden rule of unsafe: Safe code wrapping your unsafe abstraction must never be able to cause undefined behavior.

9.3 Unsafe Best Practices

9.3.1 Minimize the Scope of unsafe

#![allow(unused)]
fn main() {
static VALUE: u32 = 42;
fn get_pointer() -> *const u32 { &VALUE }
fn process_value(_value: u32) {}
fn store_result(_value: u32) {}
fn log_action(_action: &str) {}

// BAD: large unsafe block
unsafe {
    let ptr = get_pointer();
    let value = *ptr;
    process_value(value);
    store_result(value);
    log_action("processed");
}

// GOOD: isolate the unsafe operation
let value = unsafe { *get_pointer() };  // Only the deref is unsafe
process_value(value);
store_result(value);
log_action("processed");
}

9.3.2 Document Safety Invariants

Every unsafe function and unsafe block must have a # Safety comment:

#![allow(unused)]
fn main() {
/// Reads a u32 from the given byte slice at the specified offset.
///
/// # Safety
///
/// The caller must ensure:
/// - `data` is valid for reads of 4 bytes starting at `offset`
/// - `offset + 4 <= data.len()`
pub unsafe fn read_u32_at(data: &[u8], offset: usize) -> u32 {
    let ptr = data.as_ptr().add(offset) as *const u32;
    std::ptr::read_unaligned(ptr)
}
}

🔒 Security practice: During code review, require that every unsafe block has a # Safety comment explaining why the operation is safe. If the comment is missing or incomplete, the code should not be merged.

9.3.3 Prefer Safe Abstractions

Wrap unsafe code in safe APIs:

#![allow(unused)]
fn main() {
use std::ptr::NonNull;

#[derive(Debug)]
pub enum RawVecError {
    CapacityOverflow,
    LayoutOverflow,
}

/// A vec-like structure that tracks allocated but uninitialized memory.
pub struct RawVec<T> {
    ptr: NonNull<T>,
    cap: usize,
}

impl<T> RawVec<T> {
    pub fn new() -> Self {
        let cap = if std::mem::size_of::<T>() == 0 { usize::MAX } else { 0 };
        RawVec {
            ptr: NonNull::dangling(),
            cap,
        }
    }
    
    pub fn with_capacity(capacity: usize) -> Result<Self, RawVecError> {
        let mut rv = Self::new();
        if capacity > 0 && std::mem::size_of::<T>() != 0 {
            rv.grow(capacity)?;
        }
        Ok(rv)
    }
    
    fn grow(&mut self, min_cap: usize) -> Result<(), RawVecError> {
        assert!(std::mem::size_of::<T>() != 0, "zero-sized types never allocate");
        let doubled = self.cap.checked_mul(2)
            .ok_or(RawVecError::CapacityOverflow)?;
        let new_cap = min_cap.max(doubled);
        let new_layout = std::alloc::Layout::array::<T>(new_cap)
            .map_err(|_| RawVecError::LayoutOverflow)?;
        
        let new_ptr = if self.cap == 0 {
            // SAFETY: `new_layout` came from `Layout::array::<T>` for a
            // non-zero-sized `T`, so it describes a valid fresh allocation.
            unsafe { std::alloc::alloc(new_layout) }
        } else {
            let old_layout = std::alloc::Layout::array::<T>(self.cap)
                .map_err(|_| RawVecError::LayoutOverflow)?;
            // SAFETY: `self.ptr` came from the current allocation with
            // `old_layout`, and `&mut self` guarantees exclusive access while
            // `realloc` may move or free the old pointer. No alias can observe
            // the pre-reallocation pointer after this call returns.
            unsafe { std::alloc::realloc(self.ptr.as_ptr() as *mut u8, old_layout, new_layout.size()) }
        };
        
        self.ptr = match NonNull::new(new_ptr as *mut T) {
            Some(ptr) => ptr,
            None => std::alloc::handle_alloc_error(new_layout),
        };
        self.cap = new_cap;
        Ok(())
    }
    
    /// Returns a pointer to the buffer.
    /// 
    /// # Safety
    /// The caller must not write beyond `self.cap` elements.
    pub fn ptr(&mut self) -> *mut T {
        self.ptr.as_ptr()
    }
    
    pub fn capacity(&self) -> usize {
        self.cap
    }
}

impl<T> Drop for RawVec<T> {
    fn drop(&mut self) {
        if self.cap > 0 && std::mem::size_of::<T>() > 0 {
            let layout = match std::alloc::Layout::array::<T>(self.cap) {
                Ok(layout) => layout,
                Err(_) => unreachable!(
                    "RawVec invariant violated: drop recomputed an impossible layout overflow"
                ),
            };
            unsafe {
                std::alloc::dealloc(self.ptr.as_ptr() as *mut u8, layout);
            }
        }
    }
}
}

This version turns size arithmetic failures into normal errors. It still mirrors the standard library’s usual OOM behavior by delegating null allocations to handle_alloc_error; if you need attacker-controlled growth to fail recoverably instead of aborting, prefer fallible reserve patterns such as Chapter 18’s try_reserve_exact example. In teaching examples, prefer a detectable panic over unreachable_unchecked(): if an invariant is ever broken, you want a crash you can investigate, not silent undefined behavior.

9.3.4 UnsafeCell<T> Is the Foundation of Interior Mutability

If shared state can be mutated through a shared reference, the storage must live inside UnsafeCell<T> (or a safe abstraction built on top of it, such as Cell, RefCell, Mutex, or atomics):

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

pub struct SecretBox {
    bytes: UnsafeCell<[u8; 32]>,
}

impl SecretBox {
    pub fn new(bytes: [u8; 32]) -> Self {
        Self {
            bytes: UnsafeCell::new(bytes),
        }
    }

    pub fn with_bytes<R>(&self, f: impl FnOnce(*mut [u8; 32]) -> R) -> R {
        f(self.bytes.get())
    }
}
}

UnsafeCell only opts out of Rust’s usual “shared references are immutable” rule. It does not provide synchronization by itself. If the value is shared across threads, you still need a sound synchronization strategy.

9.3.5 Pointer Provenance Still Matters

Raw pointers are not just integer addresses; they are expected to be derived from the live allocation they access. In practice, that means:

  • Derive pointers from real references or existing raw pointers (as_ptr, as_mut_ptr, addr_of!, NonNull).
  • Keep pointer arithmetic within the original allocation.
  • Do not reconstruct pointers from guessed integers and assume they are valid to dereference.

The exact formal model continues to evolve, but the review rule is stable: if a pointer did not come from the allocation it is used on, treat the code as suspect.

9.4 Common Unsafe Patterns and Pitfalls

9.4.1 Raw Pointer Dereferencing

#![allow(unused)]
fn main() {
fn deref_example() {
    let mut value = 42u32;
    let ptr: *mut u32 = &mut value;
    
    unsafe {
        // Direct dereference
        *ptr = 100;
        println!("{}", *ptr);
        
        // Offset arithmetic
        let arr = [1u32, 2, 3, 4];
        let arr_ptr = arr.as_ptr();
        let second = *arr_ptr.add(1);  // arr[1]
    }
}
}

⚠️ Pitfall: Pointer arithmetic can go out of bounds. Rust does not check pointer arithmetic at runtime. You must validate bounds yourself.

9.4.2 Transmute - Type Punning

std::mem::transmute reinterprets bytes as a different type. It is extremely dangerous:

#![allow(unused)]
fn main() {
// DANGEROUS: transmute can violate invariants
unsafe {
    let bytes: [u8; 4] = [0x41, 0x42, 0x43, 0x44];
    let value: u32 = std::mem::transmute(bytes);  // Endianness-dependent!
    
    // NEVER transmute references between types of different sizes
    // NEVER transmute &T to &mut T
}
}

🔒 Security rule: Avoid transmute for type punning and unrelated layout conversions. Prefer dedicated APIs such as from_ne_bytes, to_be_bytes, MaybeUninit, or crates like zerocopy/bytemuck where appropriate.

9.4.3 Uninitialized Memory

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

fn initialize_array() -> [u32; 100] {
    let mut arr = MaybeUninit::<[u32; 100]>::uninit();
    let ptr = arr.as_mut_ptr() as *mut u32;

    for i in 0..100 {
        unsafe { ptr.add(i).write(42) };
    }

    // SAFETY: Every element was initialized exactly once above.
    unsafe { arr.assume_init() }
}
}

⚠️ Never use std::mem::zeroed() for types where all-zeros is not a valid representation (e.g., references, NonNull). Use MaybeUninit instead.

9.4.4 Shared Mutable State

A common unsound pattern: deriving &mut T from &T:

#![allow(unused)]
fn main() {
// UNSOUND: violates the aliasing rules
fn evil<T>(reference: &T) -> &mut T {
    unsafe {
        &mut *(reference as *const T as *mut T)
    }
}
}

This is undefined behavior because it violates Rust’s aliasing model: you promised the compiler that reference is immutable, but then you mutate through it. The compiler may have optimized based on the immutability promise.

9.4.5 unsafe impl Send/Sync

The Send and Sync traits are automatically derived by the compiler, but sometimes you need to implement them manually for types containing raw pointers or non-thread-safe data. This is unsafe because incorrect implementations can cause data races:

#![allow(unused)]
fn main() {
use std::sync::atomic::{AtomicPtr, Ordering};

struct SharedRawBuf<T> {
    ptr: AtomicPtr<T>,
    cap: usize,
}

// SAFETY: SharedRawBuf uses AtomicPtr for thread-safe pointer access.
// All mutations go through atomic operations, so the type can be safely
// shared between threads (Sync) and moved between threads (Send).
unsafe impl<T: Send> Send for SharedRawBuf<T> {}
unsafe impl<T: Send> Sync for SharedRawBuf<T> {}
}

⚠️ Pitfall: Implementing Send for a type that is not thread-safe (e.g., contains Rc<T> or Cell<T>) will cause data races. Always verify that all internal state is properly synchronized before implementing these traits.

🔒 Security practice: Only implement Send/Sync when the type’s internal synchronization guarantees thread safety. If unsure, do not implement them: the compiler’s auto-derivation is conservative and correct by default.

9.4.6 Forbidding unsafe in Safe Code

For security-critical projects, you can use lints to control where unsafe is allowed:

#![allow(unused)]
fn main() {
// In lib.rs or main.rs - disallow unsafe code entirely
#![forbid(unsafe_code)]
}
#![allow(unused)]
fn main() {
// Allow unsafe only in specific modules
#![deny(unsafe_code)]

mod safe_module {
    // No unsafe allowed here
}

#[allow(unsafe_code)]
mod raw_bindings {
    // Unsafe allowed only in this module
    pub unsafe fn raw_read(ptr: *const u8) -> u8 {
        *ptr
    }
}
}

In Edition 2024, unsafe_op_in_unsafe_fn warns by default. On older editions, enable it explicitly to require unsafe blocks even inside unsafe fn:

#![allow(unused)]
fn main() {
// In lib.rs on Edition 2021 or earlier
#![warn(unsafe_op_in_unsafe_fn)]

unsafe fn process_raw(ptr: *const u8) -> u8 {
    // Without the lint, this would compile without an extra `unsafe` block.
    // With the lint, you must write:
    unsafe { *ptr }
}
}

🔒 Security practice: Use #![forbid(unsafe_code)] at the crate level for libraries that should have zero unsafe code. Use #![deny(unsafe_code)] with #[allow(unsafe_code)] on specific modules to concentrate unsafe code in auditable locations. Enable unsafe_op_in_unsafe_fn to ensure every individual unsafe operation is explicitly marked.

9.5 Auditing Unsafe Code

9.5.1 The Unsafe Audit Checklist

When reviewing unsafe code, verify:

  • Validity: Are all pointers valid for the intended read/write?
  • Bounds: Is all pointer arithmetic within allocated bounds?
  • Alignment: Are pointer casts properly aligned?
  • Alias: Are mutable references unique? No concurrent &mut?
  • Interior mutability: Is shared mutation routed through UnsafeCell or a sound primitive built on it?
  • Initialization: Is all read memory initialized?
  • Provenance: Are raw pointers derived from the live allocation they access?
  • Thread safety: Is shared state properly synchronized?
  • Lifetime: Do references not outlive the data they point to?
  • Soundness: Can safe code cause UB through this abstraction?
  • Documentation: Is there a # Safety comment?

9.5.2 Tools for Unsafe Code

ToolPurpose
cargo miriDetects undefined behavior by interpreting your MIR
loom (loom::model in tests)Model checker for concurrent code (detects data races, atomic violations)
PrustiFormal verification of Rust programs using Viper
cargo geigerCounts unsafe lines in your dependencies
cargo crevCommunity code review for crates
cargo vetStructured supply-chain auditing

Using Miri

Miri is the most important tool for auditing unsafe code. It runs your program in a virtual machine that is instrumented to detect undefined behavior at runtime:

# Install nightly and miri
rustup toolchain install nightly
rustup component add miri --toolchain nightly
cargo +nightly miri test

Miri detects:

  • Use of uninitialized memory
  • Out-of-bounds pointer arithmetic
  • Violation of the aliasing model (Stacked Borrows)
  • Invalid values (e.g., None in a NonZero type)
  • Use after free
  • Data races (enabled by default)
fn main() {
    let mut data = vec![1, 2, 3];
    let ptr = data.as_mut_ptr();
    
    // Miri will catch this:
    unsafe {
        let _oob = *ptr.add(100);  // Out of bounds!
    }
}

🔒 Security practice: Run cargo miri test in CI for any crate that contains unsafe code. Miri does not prove the absence of bugs, but it is exceptionally effective at finding them.

For stricter pointer-provenance diagnostics, add -Zmiri-strict-provenance. If you want to experiment with Miri’s alternative aliasing model, add -Zmiri-tree-borrows. These checks are separate from the data-race detector.

⚠️ Limitations: Miri supports many std::thread patterns, but it cannot execute arbitrary FFI, real network I/O, or many OS-specific interactions. For such code, extract the pure logic into testable functions and run Miri on those pieces.

Using Loom for Concurrency Testing

loom is a model checker that systematically explores all possible thread interleavings to find data races and concurrency bugs. It replaces std::sync primitives with mock versions that explore different execution orderings:

# [dev-dependencies]
# loom = "0.7"
#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::loom as loom;
// tests/concurrency.rs
use loom::sync::atomic::{AtomicUsize, Ordering};
use loom::sync::Arc;
use loom::thread;

#[test]
fn test_atomic_counter() {
    loom::model(|| {
        let counter = Arc::new(AtomicUsize::new(0));
        let c1 = Arc::clone(&counter);
        let c2 = Arc::clone(&counter);
        
        let t1 = thread::spawn(move || {
            c1.fetch_add(1, Ordering::SeqCst);
        });
        
        let t2 = thread::spawn(move || {
            c2.fetch_add(1, Ordering::SeqCst);
        });
        
        t1.join().unwrap();
        t2.join().unwrap();
        
        assert_eq!(counter.load(Ordering::SeqCst), 2);
    });
}
}

🔒 Security practice: Use loom to test any unsafe data structure that is accessed from multiple threads (e.g., custom lock implementations, lock-free queues, concurrent hash maps). It explores interleavings that are nearly impossible to trigger in regular testing.

Using Prusti for Formal Verification

Prusti is a verification tool that uses the Viper infrastructure to formally prove properties about Rust code. It uses specification annotations to state what should be true:

#![allow(unused)]
fn main() {
// Prusti uses the `prusti-contracts` crate for specifications
// Currently requires a custom toolchain; see https://github.com/viperproject/prusti-dev

// Example (conceptual, requires Prusti toolchain):
// #[requires(x >= 0)]
// #[ensures(result >= 0)]
// fn safe_sqrt(x: i32) -> i32 {
//     // Prusti verifies at compile time that the postcondition holds
//     ...
// }
}

Prusti is still research-grade and best suited to small, focused proofs rather than “turn it on for the whole crate” CI. For security-critical components, it can provide mathematical guarantees that go beyond what testing can achieve, but treat it as a high-assurance specialist tool, not a drop-in replacement for normal review and testing.

9.6 Summary

  • unsafe allows five specific operations that bypass safety checks.
  • The soundness contract: safe code must never cause UB through your unsafe abstraction.
  • Minimize unsafe scope, document safety invariants, wrap in safe APIs.
  • UnsafeCell is the only legal foundation for interior mutability; it does not replace synchronization.
  • Raw-pointer provenance matters: derive pointers from real allocations, not guessed addresses.
  • Use MaybeUninit instead of zeroed() for uninitialized memory.
  • Avoid transmute; use safe alternatives.
  • Use Miri to detect UB in unsafe code, Loom to test concurrent data structures, and Prusti for formal verification when mathematical guarantees are required.
  • Audit all unsafe code with the checklist.
  • Use Miri to detect undefined behavior in tests.

In the next chapter, we explore the Foreign Function Interface: calling C code from Rust and vice versa, which is inherently unsafe and requires careful security consideration.

9.7 Exercises

  1. Soundness Audit: Review the following unsound code. Identify both bugs, explain which safety invariant is violated, and fix it:

    #![allow(unused)]
    fn main() {
    pub struct FastMap<V> {
        entries: Vec<Option<(String, V)>>,
        size: usize,
    }
    impl<V> FastMap<V> {
        pub fn insert(&mut self, key: String, value: V) {
            let hash = key.len() % self.entries.len();
            unsafe { *self.entries.get_unchecked_mut(hash) = Some((key, value)); }
            self.size += 1;
        }
    }
    }

    Consider both the empty-table case (self.entries.len() == 0, so the modulo panics) and the unchecked write path.

  2. Safe Wrapper: Write a safe wrapper around a raw pointer-based circular buffer. The unsafe internals should use MaybeUninit<T> for the backing array. The public API must be fully safe: push(), pop(), and get() should never cause UB regardless of how they are called. Add a # Safety comment to every unsafe block, using the checklist in §9.5.1 to explain validity, bounds, aliasing/lifetimes, initialization, synchronization assumptions, and why safe callers cannot trigger UB.

  3. Miri Exploration: Write a small function that creates undefined behavior (e.g., use-after-free via raw pointer, or out-of-bounds access via get_unchecked). Run it under normal execution (it may appear to work), then run it under cargo miri and observe the detection. Fix the bug and verify Miri passes.

Chapter 10 - Foreign Function Interface

“The boundary between Rust and C is the most dangerous place in your codebase.”

Real-world systems need to interact with existing C libraries, operating system APIs, and legacy codebases. Rust’s FFI (Foreign Function Interface) allows seamless interop with C, but every FFI boundary is a security checkpoint: data crosses from the safe, compiler-verified world of Rust into the unsafe, manually-managed world of C, and vice versa.

10.1 Calling C from Rust

10.1.1 Basic FFI Declarations

#![allow(unused)]
fn main() {
unsafe extern "C" {
    // Declare external C functions
    fn malloc(size: usize) -> *mut std::ffi::c_void;
    fn free(ptr: *mut std::ffi::c_void);
    fn strlen(s: *const std::ffi::c_char) -> usize;
}

fn c_string_length(s: &std::ffi::CStr) -> usize {
    unsafe {
        strlen(s.as_ptr())
    }
}
}

If you already have a Rust &CStr, prefer s.to_bytes().len() in real code. The strlen call here is purely to demonstrate declaring and calling a C function from Rust.

In Edition 2024, extern blocks are explicitly unsafe because Rust cannot verify that the foreign signatures are correct.

🔒 Security rules for calling C:

  1. Assume the C function can corrupt memory.
  2. Validate all inputs before passing to C.
  3. Validate all outputs from C before using them.
  4. Ensure C code cannot cause Rust’s destructor-based cleanup to misbehave.
  5. Be aware that C code may call longjmp or raise C++ exceptions, which is UB if it crosses into Rust frames.

10.1.2 The bindgen Tool

Manually writing FFI bindings is error-prone. Use bindgen to generate them from C headers:

cargo install bindgen-cli --version 0.71 --locked
bindgen /usr/include/openssl/ssl.h -o ssl_bindings.rs

Or use the build.rs pattern:

extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::bindgen as bindgen;
// build.rs
use std::env;
use std::path::PathBuf;

fn main() {
    let bindings = bindgen::Builder::default()
        .header("wrapper.h")
        .parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
        .generate()
        .expect("Unable to generate bindings");
    
    let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("Couldn't write bindings!");
}
#![allow(unused)]
fn main() {
// src/lib.rs
#[cfg(any())]
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
}

10.1.3 String Interoperability

Converting between Rust strings and C strings is a common source of bugs:

#![allow(unused)]
fn main() {
use std::ffi::{CStr, CString, c_char};

/// SAFELY call a C function that takes a null-terminated string
fn call_c_with_string(input: &str) -> Result<i32, std::ffi::NulError> {
    // CString ensures null-termination and no embedded null bytes
    let c_string = CString::new(input)?;
    
    let result = unsafe {
        c_function_taking_string(c_string.as_ptr())
    };
    
    Ok(result)
}

/// Borrow from an existing `&CStr` when some other Rust value already proves
/// the lifetime.
fn borrow_c_string(raw: &CStr) -> Option<&str> {
    raw.to_str().ok()
}

/// # Safety
/// `raw` must point to a valid, NUL-terminated C string for the duration of
/// this call.
unsafe fn receive_c_string_owned(raw: *const c_char) -> Option<String> {
    if raw.is_null() {
        return None;
    }
    
    unsafe {
        CStr::from_ptr(raw).to_str().ok().map(|s| s.to_owned())
    }
}

unsafe extern "C" {
    fn c_function_taking_string(s: *const c_char) -> i32;
}
}

receive_c_string_owned stays unsafe even with a null check. Rust still cannot prove that a non-null pointer is valid, correctly terminated, properly aligned, or alive for the required lifetime.

Do not invent a lifetime from a raw pointer and return &'a str directly. If the C API gives you only *const c_char, copying into an owned String is the safest general default. Return a borrowed &str only when some other Rust value, such as an existing &CStr, already anchors the lifetime.

⚠️ Security pitfalls:

  • Embedded nulls: CString::new() rejects strings with embedded \0. In C, a null byte terminates the string. If you need to pass binary data, use *const u8 with an explicit length.
  • Lifetime: C strings returned from C functions may be freed by the C library. Copy the data if you need it to outlive the C function’s lifetime.
  • UTF-8 validation: C strings are not necessarily valid UTF-8. Use CStr::to_str() which validates.

10.2 Calling Rust from C

10.2.1 Exporting Rust Functions

#![allow(unused)]
fn main() {
#[derive(Debug)]
enum ProcessingError {
    Empty,
}
#[repr(C)]
pub struct AddResult {
    pub success: bool,
    pub value: i32,
    pub error_code: i32,
}

#[unsafe(no_mangle)]
pub extern "C" fn rust_add_checked(a: i32, b: i32) -> AddResult {
    match a.checked_add(b) {
        Some(value) => AddResult {
            success: true,
            value,
            error_code: 0,
        },
        None => AddResult {
            success: false,
            value: 0,
            error_code: 1,
        },
    }
}

#[unsafe(no_mangle)]
/// # Safety
/// `data` must either be null or point to `len` readable bytes for the
/// duration of this call.
pub unsafe extern "C" fn process_buffer(data: *const u8, len: usize) -> i32 {
    // Validate inputs
    if data.is_null() {
        return -1;
    }
    if len > MAX_BUFFER_SIZE {
        return -2;
    }
    
    // Wrap in catch_unwind to prevent panics from crossing FFI boundary
    let result = std::panic::catch_unwind(|| {
        let slice = unsafe { std::slice::from_raw_parts(data, len) };
        process_data(slice)
    });
    
    match result {
        Ok(Ok(value)) => value,
        Ok(Err(_)) => -3,
        Err(_) => -4,  // Panic occurred
    }
}

const MAX_BUFFER_SIZE: usize = 1024 * 1024;  // 1 MiB limit

fn process_data(data: &[u8]) -> Result<i32, ProcessingError> {
    // Safe Rust processing
    if data.is_empty() {
        return Err(ProcessingError::Empty);
    }
    Ok(data.len() as i32)
}
}

In Edition 2024, no_mangle is an unsafe attribute, so exported symbols are written as #[unsafe(no_mangle)]. Pointer validation does not make the function safe to call from Rust: the caller still owns the obligation to pass a live, correctly sized buffer.

Avoid sentinel return values for arithmetic helpers unless the ABI guarantees one value is impossible. Returning 0 on overflow, for example, silently conflates failure with a valid sum. A small #[repr(C)] status struct or explicit out-parameter keeps the contract unambiguous.

🔒 Security checklist for Rust functions called from C:

  1. ✅ Mark raw-pointer entry points unsafe extern "C" fn so the safety contract is explicit on the Rust side.
  2. ✅ Validate all pointer arguments (null check), while remembering that null checks do not prove provenance, alignment, or lifetime.
  3. ✅ Validate all size/length arguments (bounds, max limits).
  4. ✅ Wrap in catch_unwind to prevent panics from crossing FFI boundary.
  5. ✅ Use #[unsafe(no_mangle)] (Edition 2024) and extern "C" for a stable ABI.
  6. ✅ Never panic across the FFI boundary, it’s undefined behavior.

When the foreign side is explicitly prepared to receive unwinding, stable Rust also offers extern "C-unwind":

#![allow(unused)]
fn main() {
#[unsafe(no_mangle)]
pub extern "C-unwind" fn rust_callback_entry() {
    // Use this ABI only when the non-Rust caller documents unwind support.
}
}

Use extern "C" by default. extern "C-unwind" defines the ABI for interfaces that intentionally participate in unwinding; it is not a general excuse to let ordinary panics escape.

10.2.2 Exporting Rust Types

For complex interop, use #[repr(C)] to ensure C-compatible layout:

#![allow(unused)]
fn main() {
#[repr(C)]
pub struct FfiResult {
    pub success: u8,  // 1 = success, 0 = failure
    pub value: i64,
    pub error_code: i32,
    pub error_message: [u8; 256],
}

#[unsafe(no_mangle)]
pub extern "C" fn compute(x: i64, y: i64) -> FfiResult {
    match x.checked_mul(y) {
        Some(value) => FfiResult {
            success: 1,
            value,
            error_code: 0,
            error_message: [0; 256],
        },
        None => {
            let mut msg = [0u8; 256];
            let err = b"multiplication overflow";
            msg[..err.len()].copy_from_slice(err);
            FfiResult {
                success: 0,
                value: 0,
                error_code: 1,
                error_message: msg,
            }
        }
    }
}
}

10.3 The cc and bindgen Crates for Building C Code

10.3.1 Compiling C Code with cc

When you need to include C source code in your Rust project:

# [build-dependencies]
# cc = "1"
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::cc as cc;
// build.rs
fn main() {
    cc::Build::new()
        .file("src/legacy_crypto.c")
        .flag("-O2")
        .flag("-Wall")
        .flag("-Wextra")
        .flag("-Werror")          // Treat warnings as errors
        .flag("-fstack-protector-strong")  // Stack canaries
        .flag("-D_FORTIFY_SOURCE=2")       // Fortify source
        .compile("legacy_crypto");
}

🔒 Security practice: Apply the same hardening flags to C code that you would in a pure C project:

  • -fstack-protector-strong: Stack canaries
  • -D_FORTIFY_SOURCE=2: Buffer overflow detection in glibc functions
  • -fPIC: Position-independent code (for shared libraries)
  • -Wl,-z,noexecstack: Non-executable stack
  • -Wl,-z,relro: Read-only relocations
  • -Wl,-z,now: Full RELRO

10.3.2 Generating C Headers with cbindgen

When exposing Rust functions to C, you need C header files. cbindgen generates them automatically from your Rust code, ensuring the headers stay in sync:

cargo install cbindgen --version 0.29.2 --locked
cbindgen --config cbindgen.toml --crate my-lib --output my_lib.h

Or integrate into build.rs:

extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::cbindgen as cbindgen;
use std::{env, path::PathBuf};
// build.rs
fn main() {
    let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
    let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()).join("my_lib.h");
    
    let config = cbindgen::Config::from_file("cbindgen.toml")
        .expect("Unable to read cbindgen.toml");
    
    cbindgen::Builder::new()
        .with_crate(crate_dir)
        .with_config(config)
        .generate()
        .expect("Unable to generate C bindings")
        .write_to_file(out_path);
}

🔒 Security practice: Use cbindgen instead of writing headers manually. Manual headers can drift from the actual Rust function signatures, leading to type mismatches that cause undefined behavior at the FFI boundary. Write generated artifacts to OUT_DIR (or another explicit build output path), not a hardcoded target/ path that breaks custom target directories and cross-compilation setups.

10.4 Dangerous C Patterns

10.4.1 longjmp and C++ Exceptions

⚠️ Critical: Never let longjmp (C) or C++ exceptions cross the Rust/C boundary. Doing so is undefined behavior: it bypasses Rust destructors, leaking resources and potentially corrupting the program state:

#![allow(unused)]
fn main() {
struct ResourceGuard;
fn acquire_resource() -> ResourceGuard {
    ResourceGuard
}
unsafe fn c_library_do_jump() {}
// UNSOUND: if c_library_do_jump() calls longjmp, Rust destructors are bypassed
unsafe {
    let mut guard = acquire_resource();
    c_library_do_jump();  // If this longjmps, guard.drop() is never called!
}
}

If a C library uses longjmp, wrap it so the jump is caught on the C side before returning to Rust:

// C wrapper
int safe_c_operation(void) {
    if (setjmp(buf) == 0) {
        return c_library_do_jump();  // Normal path
    } else {
        return -1;  // longjmp was caught, return error to Rust
    }
}

10.4.2 Using -C panic=abort for FFI Safety

When Rust code is called from C, a Rust panic that crosses the FFI boundary is undefined behavior. While catch_unwind can catch most panics, the safest approach is to compile with -C panic=abort:

# Cargo.toml
[profile.release]
panic = "abort"  # Panic immediately aborts the process instead of unwinding

With panic = "abort":

  • Panics terminate the process immediately: they cannot cross the FFI boundary.
  • No unwinding tables: smaller binary, reduced attack surface.
  • catch_unwind becomes a no-op (panics are no longer catchable).

⚠️ Trade-off: With panic = "abort", you lose the ability to catch panics. Ensure all error handling uses Result rather than relying on panic catching.

See Chapter 5 for the panic-vs-Result design guidance that should shape the code before you rely on an aborting profile.

10.4.3 Signal Handlers and Thread-Local State

Signal handlers are a special FFI trap because they run in an async-signal-safe context, not in an ordinary Rust execution environment. Whether the handler is installed from C or from Rust, do not allocate, lock a mutex, log through a normal formatter, or touch most runtime/library facilities from the handler. Set an atomic flag, write a byte to a self-pipe or eventfd, and let ordinary code handle the real shutdown or recovery work.

Thread-local state deserves the same caution. A callback from C into Rust may run on a foreign thread that never initialized the Rust-side context you expected, and a signal can interrupt code while TLS-backed state is mid-update. Treat thread_local! values as thread-scoped implementation details, not as a cross-language global state mechanism.

10.5 Ownership Across the FFI Boundary

The most dangerous aspect of FFI is ownership confusion. Who allocates? Who frees?

Pattern 1: Rust Allocates, Rust Frees

#![allow(unused)]
fn main() {
const MAX_ALLOCATION: usize = 1024 * 1024;
#[unsafe(no_mangle)]
pub extern "C" fn create_buffer(size: usize) -> *mut u8 {
    if size == 0 || size > MAX_ALLOCATION {
        return std::ptr::null_mut();
    }
    let boxed = vec![0u8; size].into_boxed_slice();
    Box::into_raw(boxed) as *mut u8
}

#[unsafe(no_mangle)]
/// # Safety
/// `ptr` and `size` must come from `create_buffer`; `size` is the exact byte
/// length originally requested from `create_buffer`; and this function must be
/// called at most once for a given allocation.
pub unsafe extern "C" fn free_buffer(ptr: *mut u8, size: usize) {
    if !ptr.is_null() {
        unsafe {
            // Reconstruct the boxed slice using the original byte length.
            let slice = std::ptr::slice_from_raw_parts_mut(ptr, size);
            let _ = Box::from_raw(slice);
        }
    }
}
}

Pattern 2: C Allocates, Rust Uses

#![allow(unused)]
fn main() {
pub struct CBuffer {
    ptr: *mut u8,
    len: usize,
}

impl CBuffer {
    /// # Safety
    /// `ptr` must be valid for reads of `len` bytes, allocated by C.
    pub unsafe fn from_c(ptr: *mut u8, len: usize) -> Option<Self> {
        if ptr.is_null() || len == 0 {
            return None;
        }
        Some(CBuffer { ptr, len })
    }
    
    pub fn as_slice(&self) -> &[u8] {
        unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
    }
}

// Do NOT implement Drop to free the memory C owns it.
// If C expects Rust to free it, use the appropriate C deallocator.
}

🔒 Golden rule: Never mix allocators. If C allocates with malloc, free with free. If Rust allocates with Box<[u8]> or Vec, reconstruct the matching Rust type on the free path. Mixing allocators is undefined behavior.

10.6 Callbacks and Function Pointers

C Calling Rust Callbacks

#![allow(unused)]
fn main() {
type Callback = extern "C" fn(i32, *const u8, usize) -> i32;

#[unsafe(no_mangle)]
pub extern "C" fn register_callback(cb: Option<Callback>) -> i32 {
    match cb {
        Some(callback) => {
            // Store the callback safely
            unsafe {
                GLOBAL_CALLBACK = Some(callback);
            }
            0
        }
        None => -1,
    }
}

static mut GLOBAL_CALLBACK: Option<Callback> = None;
}

⚠️ Thread safety: static mut is inherently unsafe for concurrent access. Use Mutex or atomic operations:

#![allow(unused)]
fn main() {
use std::sync::Mutex;

type Callback = extern "C" fn(i32, *const u8, usize) -> i32;

static GLOBAL_CALLBACK: Mutex<Option<Callback>> = Mutex::new(None);

#[unsafe(no_mangle)]
pub extern "C" fn register_callback(cb: Option<Callback>) -> i32 {
    std::panic::catch_unwind(|| register_callback_safe(cb)).unwrap_or(-2)
}

fn register_callback_safe(cb: Option<Callback>) -> i32 {
    match GLOBAL_CALLBACK.lock() {
        Ok(mut guard) => {
            *guard = cb;
            0
        }
        Err(poisoned) => {
            let mut guard = poisoned.into_inner();
            *guard = None;
            -1
        }
    }
}
}

If shared callback state is poisoned, clear it and return an error instead of calling .expect(...) and panicking across the FFI boundary.

10.7 Summary

  • FFI is inherently unsafe: every boundary crossing requires careful validation.
  • Use bindgen to generate bindings instead of writing them manually.
  • Always validate inputs before passing to C and outputs from C.
  • Use catch_unwind when Rust code is called from C to prevent panics from crossing the boundary.
  • Use #[repr(C)] for stable ABI compatibility.
  • Never mix allocators: match allocation and deallocation between Rust and C.
  • Apply C hardening flags to any C code compiled in your project.
  • Document ownership semantics clearly at every FFI boundary.

In the next chapter, we explore Rust’s memory layout controls for systems programming.

10.8 Exercises

  1. Safe CString Wrapper: Write a function call_c_with_args(cmd: &str, args: &[&str]) -> Result<i32, FfiError> that converts Rust strings to CString, calls a C function (simulate with a mock), and properly handles null bytes, NUL termination, and lifetime issues. Handle all error cases without unwrap().

  2. Rust Library for C: Create a Rust library that exports three functions via #[unsafe(no_mangle)] extern "C": a string reverser, a buffer processor (with input validation), and a stateful counter. Write a C header file for it. Wrap every function in catch_unwind. Write tests in C that call these functions with various inputs including NULL pointers, zero-length buffers, and extremely large sizes.

  3. Ownership Across FFI: Implement two FFI patterns: (a) Rust allocates a buffer, passes it to C, C fills it, Rust frees it; (b) C allocates, Rust processes, C frees. Use #[repr(C)] structs to pass metadata. Write tests verifying no memory leaks using a custom allocator.

Chapter 11 - Memory Layout and Low-Level Control

“Know your memory. Know your adversary.”

Systems programming requires precise control over memory layout: network protocols define wire formats, hardware registers have fixed offsets, and kernel structures must match ABI conventions. Rust provides tools for controlling memory layout while maintaining safety at the boundaries.

11.1 Representations: repr, Alignment, and Padding

11.1.1 Default Rust Layout

By default, Rust’s compiler is free to reorder struct fields and add padding for alignment:

#![allow(unused)]
fn main() {
struct NetworkHeader {
    version: u8,     // 1 byte
    flags: u8,       // 1 byte
    length: u16,     // 2 bytes (might be padded)
    seq: u32,        // 4 bytes
    ack: u32,        // 4 bytes
}
// Size: might be 12, 14, or 16 bytes depending on compiler decisions
}

This is fine for internal use but dangerous for parsing external data.

11.1.2 #[repr(C)] - C-Compatible Layout

#![allow(unused)]
fn main() {
#[repr(C)]
struct CNetworkHeader {
    version: u8,    // offset 0
    flags: u8,      // offset 1
    length: u16,    // offset 2
    seq: u32,       // offset 4
    ack: u32,       // offset 8
}
// Size: exactly 12 bytes on common ABIs, with no interior padding in this layout
}

#[repr(C)] guarantees:

  • Fields are laid out in declaration order.
  • Padding follows C ABI rules for the target platform.
  • The struct can be safely passed across FFI boundaries.

🔒 Security pattern: Always use #[repr(C)] for structs that:

  • Map to hardware registers
  • Define network wire formats
  • Are shared with C code
  • Are cast from raw byte arrays

11.1.3 #[repr(C, packed)] - No Padding

#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct PackedHeader {
    version: u8,
    length: u16,  // No padding, but may be misaligned!
    seq: u32,
}
}

⚠️ Danger: Packed structs can cause unaligned memory access, which is:

  • Undefined behavior on some architectures (ARM, SPARC)
  • Slower on x86
  • Potentially a security issue (different behavior on different platforms)

🔒 Rule: Only use #[repr(packed)] for network packet parsing where the wire format has no padding. Always use read_unaligned and write_unaligned:

#![allow(unused)]
fn main() {
use std::{mem::size_of, ptr};

#[repr(C, packed)]
struct PackedHeader {
    version: u8,
    length: u16,
    seq: u32,
}

fn read_packed_header(data: &[u8]) -> Option<PackedHeader> {
    if data.len() < size_of::<PackedHeader>() {
        return None;
    }
    let ptr = data.as_ptr() as *const PackedHeader;
    Some(unsafe { ptr::read_unaligned(ptr) })
}
}

11.1.4 #[repr(u8)], #[repr(i32)] - Enum Size Control

#![allow(unused)]
fn main() {
#[repr(u8)]
enum PacketType {
    Syn = 0x01,
    Ack = 0x02,
    Data = 0x03,
    Fin = 0x04,
}
// Guaranteed: sizeof(PacketType) == 1
}

11.1.5 #[repr(transparent)] - Single-Field Wrapper

#![allow(unused)]
fn main() {
#[repr(transparent)]
struct WrappedU64(u64);

// WrappedU64 has the exact same layout as u64
// Useful for newtypes that need FFI compatibility
}

11.2 Safe Parsing of Binary Data

11.2.1 The zerocopy Crate

Parsing binary data without copying is both a performance and security concern. Once you have a #[repr(C)] header type that derives the required zerocopy traits, parsing is straightforward:

[dependencies]
zerocopy = "0.8"
#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::zerocopy::TryFromBytes;
use rust_secure_systems_book::zerocopy_examples::TcpHeader;

fn parse_tcp_header(data: &[u8]) -> Option<&TcpHeader> {
    TcpHeader::try_ref_from_bytes(data).ok()
}
}

🔒 Security advantage: zerocopy verifies that:

  • The data is large enough for the type
  • Alignment requirements are met
  • No uninitialized memory is read

11.2.2 Manual Byte Parsing with from_be_bytes

For simpler cases, use the standard library’s byte conversion:

#![allow(unused)]
fn main() {
fn parse_u16_be(data: &[u8], offset: usize) -> Option<u16> {
    let end = offset.checked_add(2)?;
    let bytes: [u8; 2] = data.get(offset..end)?.try_into().ok()?;
    Some(u16::from_be_bytes(bytes))
}

fn parse_u32_be(data: &[u8], offset: usize) -> Option<u32> {
    let end = offset.checked_add(4)?;
    let bytes: [u8; 4] = data.get(offset..end)?.try_into().ok()?;
    Some(u32::from_be_bytes(bytes))
}
}

🔒 Security practice: Always use explicit endianness (from_be_bytes, from_le_bytes) rather than platform-dependent casts. Network protocols are big-endian; x86 is little-endian, while Wasm is specified as little-endian. Mixing them up is a subtle and dangerous bug.

11.3 Alignment and the align Representation

11.3.1 Controlling Alignment

#![allow(unused)]
fn main() {
#[repr(C, align(16))]  // 16-byte aligned (useful for SIMD, DMA buffers)
struct AlignedBuffer {
    data: [u8; 4096],
}
}

🔒 Security relevance: Some low-level interfaces benefit from aligned buffers:

  • SIMD and DMA interfaces may require or strongly prefer specific alignment
  • Alignment can reduce performance variance and avoid unaligned-access traps on some targets
  • Do not assume a crypto primitive requires a specific alignment unless its API or hardware manual says so

You cannot combine packed and align on the same struct. The compiler rejects that combination because packed lowers alignment while align(N) raises it. If you need a packed wire-format view and an aligned working buffer, keep them as separate types and copy or parse between them explicitly.

11.3.2 Cache-Line Alignment

#![allow(unused)]
fn main() {
#[repr(C, align(64))]  // Common cache-line size on modern x86-64/ARM64; verify on your target
struct CacheLineAligned {
    // Align the counter to reduce false sharing between adjacent instances.
    counter: std::sync::atomic::AtomicU64,
}
}

🔒 Security relevance: Reduces false sharing between threads, which can otherwise increase timing noise and contention. Treat this as a performance and isolation aid, not as a standalone side-channel defense.

11.3.3 Pin<T> and Address Stability

Most Rust values may move when ownership changes. That is fine for ordinary data, but self-referential types, intrusive data structures, and many async state machines rely on a stable address once initialized. Pin<&mut T> and Pin<Box<T>> are the tools that express “this value must not move again in safe code.”

Security relevance: if a type stores internal raw pointers or hands its address to foreign code, accidental movement can invalidate those pointers and turn a logic bug into memory unsafety in the surrounding unsafe code. Pinning does not make a type safe by itself, but it is a core part of making address-sensitive abstractions sound.

11.4 Working with Raw Memory

11.4.1 The Global Allocator

Rust uses the system allocator by default. For security-sensitive applications, you can use a custom allocator:

#![allow(unused)]
fn main() {
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{compiler_fence, Ordering};

/// A simple allocator that zeroizes memory on free
struct SecureAllocator;

unsafe impl GlobalAlloc for SecureAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        unsafe { System.alloc(layout) }
    }
    
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        // `GlobalAlloc::dealloc` is only called with a valid pointer that came
        // from the paired allocator, so zeroize first and then free it.
        for i in 0..layout.size() {
            unsafe { ptr.add(i).write_volatile(0); }
        }
        compiler_fence(Ordering::SeqCst);
        unsafe { System.dealloc(ptr, layout) };
    }
}

#[global_allocator]
static GLOBAL: SecureAllocator = SecureAllocator;
}

Because the zeroing loop already uses write_volatile, the compiler_fence here is a compiler barrier, not a hardware synchronization primitive. It stops the optimizer from moving or eliding the wipe without implying any cross-core memory-ordering requirement. Placing the fence after the loop is enough: it prevents the compiler from sinking the wipe past the eventual deallocation, so you do not need a fence between each volatile write.

⚠️ Note: This is a simplified example. A production secure allocator should also:

  • Lock pages containing keys (prevent swapping to disk)
  • Use mlock/VirtualLock to prevent paging
  • Use a zeroization primitive that is guaranteed not to be optimized away
  • Guard against heap metadata corruption

11.4.2 The zeroize Crate - Practical Memory Wiping

The zeroize crate gives you a practical way to wipe specific buffers before release, unlike naive manual loops that the compiler may optimize away:

[dependencies]
zeroize = { version = "1", features = ["derive"] }
#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
extern crate zeroize;
use zeroize::{Zeroize, ZeroizeOnDrop};

struct CryptoKey {
    material: [u8; 32],
}

impl Drop for CryptoKey {
    fn drop(&mut self) {
        self.material.zeroize();
    }
}

// Or use the derive macro for automatic zeroization:
#[derive(Zeroize, ZeroizeOnDrop)]
struct SessionKey {
    key: [u8; 32],
    iv: [u8; 12],
}
// On normal drop paths, both `key` and `iv` are zeroized before release.

// Also works with Vec and other heap-allocated types:
#[derive(Zeroize, ZeroizeOnDrop)]
struct SecureBuffer {
    data: Vec<u8>,
}
}

🔒 Security practice: Use zeroize (with the derive feature) instead of manual zeroing loops. The crate is designed so the wipe operation itself is not optimized away, but it only affects the buffer you zeroize and only on code paths where zeroization runs. It does not erase copies you already made, and it cannot help if the process aborts or exits before Drop (see Chapter 2 §2.4 on panic = "abort").

Core dumps are another separate leak path: a crash can snapshot process memory before later cleanup would have happened, including live secrets that would normally be wiped on Drop. Chapter 19 section 19.6 shows how to disable core dumps for production services that handle sensitive material.

11.4.3 Safe Pointer Access with &raw (and addr_of!)

When working with structs that contain fields you cannot safely create (e.g., a MaybeUninit field), Edition 2024 prefers &raw const expr and &raw mut expr. They are the direct syntax behind the older addr_of! and addr_of_mut! macros and let you obtain raw pointers without creating intermediate references:

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

#[repr(C)]
struct PacketBuffer {
    header: [u8; 4],
    payload: MaybeUninit<[u8; 1024]>,
}

impl PacketBuffer {
    fn new() -> Self {
        PacketBuffer {
            header: [0; 4],
            payload: MaybeUninit::uninit(),
        }
    }
    
    fn write_payload(&mut self, data: &[u8]) {
        let payload_ptr = &raw mut self.payload;
        unsafe {
            let raw = (*payload_ptr).as_mut_ptr() as *mut u8;
            raw.copy_from_nonoverlapping(data.as_ptr(), data.len().min(1024));
        }
    }
    
    fn read_header(&self) -> &[u8; 4] {
        // `header` is always initialized, so a normal shared reference is fine.
        &self.header
    }
}
}

The older macros still work and are useful in older code or macro-heavy contexts, but &raw const / &raw mut is the modern direct form.

In this example, &raw mut self.payload is the important operation because payload is the maybe-uninitialized field. You do not need &raw for every other initialized field in the same struct.

🔒 Security practice: Prefer &raw const / &raw mut (or addr_of! / addr_of_mut! in older code) over &self.field or &mut self.field when the struct may contain uninitialized data. Creating a reference to uninitialized memory is instant UB, even if you never read through it.

11.4.4 Memory Locking (Prevent Swapping)

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
#[cfg(unix)]
use rust_secure_systems_book::deps::libc as libc;
use rust_secure_systems_book::deps::windows_sys as windows_sys;
#[cfg(unix)]
fn lock_memory(ptr: *const u8, len: usize) -> Result<(), std::io::Error> {
    let result = unsafe { libc::mlock(ptr as *const libc::c_void, len) };
    if result != 0 {
        return Err(std::io::Error::last_os_error());
    }
    Ok(())
}

#[cfg(windows)]
fn lock_memory(ptr: *const u8, len: usize) -> Result<(), std::io::Error> {
    use windows_sys::Win32::System::Memory::VirtualLock;
    let result = unsafe { VirtualLock(ptr.cast(), len) };
    if result == 0 {
        return Err(std::io::Error::last_os_error());
    }
    Ok(())
}
}

⚠️ Operational caveat: Treat memory locking as a deployment requirement, not a best-effort hint. On Linux, a successful mlock means the whole covered range is locked; failures such as EAGAIN, ENOMEM, or EPERM should be handled as hard failures. In practice, unprivileged processes are constrained by RLIMIT_MEMLOCK and sometimes CAP_IPC_LOCK. On Windows, VirtualLock is limited by the process working-set size.

⚠️ System caveat: Locking pages prevents ordinary swapping, not every persistence path. Suspend-to-disk and hibernation can still write RAM contents to disk, so disk encryption and platform sleep policy still matter for high-value secrets.

🔒 Security impact: Prevents sensitive data (keys, passwords) from being written to the swap file, where they could persist after the process exits (CWE-316: Cleartext Storage of Sensitive Information).

11.5 Stack and Heap Security

11.5.1 Stack Observability and Protection

Rust already enables stack probing/stack-clash protection on mainstream targets, but stable Rust does not enable stack canaries for Rust code by default. That is less alarming than it sounds for safe Rust because the compiler rules out the classic out-of-bounds stack writes that canaries were designed to catch. The concern returns once you add unsafe code, inline assembly, or C/C++ objects to the same binary. Keep frame pointers for profiling and post-mortem analysis, and treat C/C++ stack canaries as a separate hardening step for any non-Rust objects you compile:

# .cargo/config.toml
[build]
rustflags = ["-C", "force-frame-pointers=yes"]

If you build C/C++ code via the cc crate, apply -fstack-protector-strong to those objects separately. For RELRO, NX, and PIE settings, use the linker hardening flags from Chapter 19. Frame pointers improve observability; they are not a canary mechanism.

11.5.2 Guard Pages

Rust’s default allocator does not place guard pages between heap allocations, neither in debug nor release mode. Guard pages exist at stack boundaries (enforced by the OS), not between individual heap allocations. For heap-level guard page protection, use a hardened allocator like GWP-ASan or run under AddressSanitizer (ASan):

# Run with ASan to detect heap buffer overflows
RUSTFLAGS="-Zsanitizer=address" cargo +nightly run

If you do not need allocator-specific behavior, omit #[global_allocator] entirely. This note applies to declarations such as static GLOBAL: std::alloc::System = std::alloc::System;, which simply rebind the default allocator to itself. The earlier SecureAllocator example is not a no-op: it installs a real wrapper that changes deallocation behavior.

11.6 Summary

  • Use #[repr(C)] for FFI-compatible and wire-format structures.
  • Use #[repr(u8)] / #[repr(i32)] to control enum size.
  • Use #[repr(C, align(N))] for alignment-critical data.
  • Prefer zerocopy crate for safe binary parsing.
  • Always use explicit endianness for network data.
  • Zero sensitive memory before freeing (use zeroize crate).
  • Lock memory pages containing secrets (prevent swapping).
  • Verify release hardening (RELRO/NX/PIE) and apply stack-protector flags to any C/C++ objects you compile.

In the next chapter, we cover secure network programming: building network services that resist common attacks.

11.7 Exercises

  1. Wire-Format Parser: Define a #[repr(C)] struct representing a simplified IPv4 header (version, IHL, total length, TTL, protocol, source IP, dest IP). Use the zerocopy crate to parse a raw byte slice into the struct. Write tests with valid packets, truncated packets, and misaligned data. Verify that all invalid inputs return an error.

  2. Endianness Trap: Write two versions of a u32 parser: one using from_be_bytes and one using from_le_bytes. Feed both the same byte sequence [0x00, 0x01, 0x02, 0x03]. Verify the results differ. Then write a function that parses a TCP header from bytes using correct big-endian for all multi-byte fields.

  3. Aligned Buffer: Create a 16-byte-aligned buffer type using #[repr(C, align(16))]. Implement methods to safely write and read data. Verify alignment at runtime using pointer arithmetic. Discuss why aligned buffers can matter for SIMD or DMA paths, and why AES-NI itself does not require 16-byte alignment for correctness.

Chapter 12 - Secure Network Programming

“The network is not trustworthy. Design accordingly.”

Network services are the most exposed attack surface in any system. Every connection could be an attacker probing for vulnerabilities: buffer overflows in parsers, resource exhaustion through connection floods, injection attacks through malformed input, and timing attacks through crafted requests.

Rust’s memory safety eliminates many traditional network vulnerabilities, but secure network programming requires more than memory safety. This chapter covers the patterns and practices for building robust, attack-resistant network services.

12.1 Connection Handling

12.1.1 Basic TCP Server with tokio

extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::log as log;
use rust_secure_systems_book::deps::tokio as tokio;
use tokio::net::TcpListener;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use std::sync::Arc;

const MAX_CONNECTIONS: usize = 1000;
const MAX_PAYLOAD_SIZE: usize = 64 * 1024; // 64 KiB payload
const MAX_FRAME_SIZE: usize = 4 + MAX_PAYLOAD_SIZE; // length prefix + payload

struct ServerState {
    connection_count: std::sync::atomic::AtomicUsize,
}

fn try_acquire_connection(state: &ServerState) -> bool {
    state.connection_count
        .fetch_update(
            std::sync::atomic::Ordering::SeqCst,
            std::sync::atomic::Ordering::SeqCst,
            |current| (current < MAX_CONNECTIONS).then_some(current + 1),
        )
        .is_ok()
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let state = Arc::new(ServerState {
        connection_count: std::sync::atomic::AtomicUsize::new(0),
    });
    
    let listener = TcpListener::bind("0.0.0.0:8443").await?;
    println!("Server listening on port 8443");
    
    loop {
        let (stream, addr) = listener.accept().await?;
        let state = Arc::clone(&state);
        
        // Connection limiting
        if !try_acquire_connection(&state) {
            log::warn!("Rejecting connection from {}: limit reached", addr);
            drop(stream);
            continue;
        }
        
        tokio::spawn(async move {
            if let Err(e) = handle_connection(stream, addr, &state).await {
                log::error!("Error handling {}: {}", addr, e);
            }
            state.connection_count
                .fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
        });
    }
}

async fn handle_connection(
    mut stream: tokio::net::TcpStream,
    addr: std::net::SocketAddr,
    _state: &ServerState,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    // Set timeouts
    stream.set_nodelay(true)?;
    
    let mut buffer = vec![0u8; MAX_FRAME_SIZE];
    let mut buffered = 0usize;
    
    loop {
        // Read with timeout
        let n = tokio::time::timeout(
            std::time::Duration::from_secs(30),
            stream.read(&mut buffer[buffered..])
        ).await??;
        
        if n == 0 {
            if buffered == 0 {
                break; // Connection closed cleanly
            }
            return Err("connection closed mid-frame".into());
        }

        buffered += n;

        // TCP is a byte stream: one read may contain part of a frame
        // or several complete frames back-to-back.
        while let Some((response, consumed)) = process_message(&buffer[..buffered])? {
            let framed_response = build_frame(&response)?;
            tokio::time::timeout(
                std::time::Duration::from_secs(10),
                stream.write_all(&framed_response)
            ).await??;

            buffered -= consumed;
            if buffered > 0 {
                buffer.copy_within(consumed..consumed + buffered, 0);
            }
        }
    }
    
    Ok(())
}

fn process_message(
    data: &[u8],
) -> Result<Option<(Vec<u8>, usize)>, Box<dyn std::error::Error + Send + Sync>> {
    // Validate message structure
    if data.len() < 4 {
        return Ok(None);
    }
    
    let declared_len = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
    if declared_len > MAX_PAYLOAD_SIZE {
        return Err("declared message length too large".into());
    }

    let frame_len = 4usize
        .checked_add(declared_len)
        .ok_or("length overflow")?;
    if frame_len > MAX_FRAME_SIZE {
        return Err("frame too large".into());
    }

    // Verify we have the complete frame (4-byte header + payload)
    if data.len() < frame_len {
        return Ok(None);
    }
    
    // Return only the payload (excluding the 4-byte length prefix)
    Ok(Some((data[4..frame_len].to_vec(), frame_len)))
}

fn build_frame(payload: &[u8]) -> std::io::Result<Vec<u8>> {
    if payload.len() > MAX_PAYLOAD_SIZE {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            "response payload too large",
        ));
    }
    let len = payload.len() as u32;
    let mut frame = len.to_be_bytes().to_vec();
    frame.extend_from_slice(payload);
    Ok(frame)
}

The 64 KiB / 68 KiB limits here are example ceilings, not universal defaults. Derive them from your actual protocol and typical message sizes. Oversized defaults increase per-connection memory use and make amplification-by-buffering easier during load.

⚠️ Security note: Binding to 0.0.0.0 exposes the service on every interface. Use it only when that exposure is intentional. For development prefer 127.0.0.1; in production prefer the specific interface, socket-activation unit, or load-balancer attachment you actually want reachable.

If the service must accept both IPv4 and IPv6, make that choice explicit. 0.0.0.0 is IPv4-only, while [::] is IPv6 and may or may not accept IPv4-mapped connections depending on the target OS and the IPV6_V6ONLY setting. Test the exact dual-stack behavior you intend to ship instead of assuming one listener covers both families everywhere.

This buffering is not optional. TCP preserves byte order, not message boundaries, so a secure server must handle both partial frames and multiple frames delivered in one read.

On Unix, broken-pipe SIGPIPE delivery is a classic networking footgun. In ordinary Rust executables, the standard runtime ignores SIGPIPE process-wide during startup, and std::net / Tokio also handle TCP sockets so a dead peer normally becomes BrokenPipe / EPIPE instead of process termination. That broader process-wide default is helpful, but it matters when you embed Rust into a larger C program or call C libraries that expect the default SIGPIPE disposition. Revisit SIGPIPE when you drop to raw libc writes, interact with pipes or child stdio, deliberately change the signal disposition, or need to preserve a non-Rust host program’s expectations.

🔒 Security measures in this server:

  1. Connection limiting: Prevents resource exhaustion (CWE-400)
  2. Read timeouts: Prevents slowloris attacks (CWE-400)
  3. Write timeouts: Prevents blocked clients from consuming resources
  4. Message size limits: Prevents memory exhaustion (CWE-789)
  5. TCP_NODELAY: Disables Nagle’s algorithm, preventing the latency amplification caused by its interaction with the peer’s delayed-ACK timer
  6. Explicit framing buffer: Correctly handles fragmented and coalesced TCP reads

Note: The examples above use .unwrap() in a few places (e.g., on lock() results and join handles) for readability. In production code, replace these with proper error handling, especially around mutex acquisition where poisoning may indicate data corruption from a panicked thread.

12.1.2 Rate Limiting

#![allow(unused)]
fn main() {
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};

struct RateLimiter {
    clients: Mutex<HashMap<std::net::IpAddr, ClientRecord>>,
    max_requests: usize,
    window: Duration,
    max_tracked_clients: usize,
}

struct ClientRecord {
    count: usize,
    window_start: Instant,
}

impl RateLimiter {
    fn new(max_requests: usize, window: Duration, max_tracked_clients: usize) -> Self {
        RateLimiter {
            clients: Mutex::new(HashMap::new()),
            max_requests,
            window,
            max_tracked_clients,
        }
    }
    
    fn check(&self, addr: std::net::IpAddr) -> bool {
        let mut clients = self.clients.lock().unwrap();
        let now = Instant::now();

        if !clients.contains_key(&addr) {
            clients.retain(|_, record| {
                now.duration_since(record.window_start) <= self.window * 2
            });
            if clients.len() >= self.max_tracked_clients {
                return false;
            }
        }
        
        let record = clients.entry(addr).or_insert_with(|| ClientRecord {
            count: 0,
            window_start: now,
        });
        
        if now.duration_since(record.window_start) > self.window {
            record.count = 0;
            record.window_start = now;
        }
        
        record.count += 1;
        record.count <= self.max_requests
    }
    
    /// Clean up old entries periodically
    fn cleanup(&self) {
        let mut clients = self.clients.lock().unwrap();
        let now = Instant::now();
        clients.retain(|_, record| {
            now.duration_since(record.window_start) <= self.window * 2
        });
    }
}
}

🔒 Security impact: Rate limiting prevents brute-force attacks (CWE-307), denial of service (CWE-770), and credential stuffing. Apply per-IP and per-user limits.

Per-IP maps are only a baseline defense. In IPv6-heavy deployments, attackers can rotate source addresses quickly enough to fill exact-address state tables, so bound the map and consider subnet aggregation (for example /64) or authenticated/user-based limits in front of the service.

Rust’s default HashMap hasher is randomly keyed, which makes collision attacks harder for attacker-controlled keys. Treat that as a security property: if you replace it with a fixed-seed or non-random hasher for speed, you are trading throughput for less HashDoS resistance on untrusted input.

For authenticated endpoints, enforce a second quota keyed by the stable account identity rather than trusting network address alone:

#![allow(unused)]
fn main() {
use std::net::IpAddr;

#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
struct UserId(u64);

#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
enum RateLimitKey {
    Ip(IpAddr),
    User(UserId),
}
}

Check both keys after authentication succeeds. That way, an attacker who rotates source IPs still hits the per-user limit for the targeted account or API token.

If you keep per-client state in memory, schedule cleanup rather than leaving the helper unused:

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::tokio as tokio;
use std::sync::Arc;
use std::time::Duration;

struct RateLimiter;
impl RateLimiter { fn cleanup(&self) {} }
fn spawn_rate_limiter_cleanup(limiter: Arc<RateLimiter>) {
    tokio::spawn(async move {
        let mut interval = tokio::time::interval(Duration::from_secs(60));
        loop {
            interval.tick().await;
            limiter.cleanup();
        }
    });
}
}

12.2 TLS Configuration

12.2.1 Server TLS with rustls

[dependencies]
tokio-rustls = "0.26"
rustls = "0.23"
#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::rustls as rustls;
use rustls::{
    ServerConfig,
    pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject},
};
use std::sync::Arc;

fn create_tls_config(
    cert_path: &str,
    key_path: &str,
) -> Result<Arc<ServerConfig>, Box<dyn std::error::Error>> {
    let certs = CertificateDer::pem_file_iter(cert_path)?
        .collect::<Result<Vec<_>, _>>()?;
    let key = PrivateKeyDer::from_pem_file(key_path)?;
    
    let config = ServerConfig::builder()
        .with_no_client_auth()
        .with_single_cert(certs, key)?;
    
    // Harden TLS configuration
    let mut config = config;
    config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
    // rustls enforces:
    // - TLS 1.2 minimum (TLS 1.3 preferred)
    // - No RC4, DES, or other weak ciphers
    // - No compression (CRIME attack prevention)
    // - No renegotiation (renegotiation attack prevention)
    
    Ok(Arc::new(config))
}
}

Current rustls exposes PEM parsing through rustls::pki_types::pem::PemObject, so you do not need a separate rustls-pemfile dependency for this pattern.

The config object is only half the story. You still need to wrap accepted TCP streams with tokio-rustls:

extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::rustls as rustls;
use rust_secure_systems_book::deps::tokio as tokio;
use rust_secure_systems_book::deps::tokio_rustls as tokio_rustls;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor;

fn create_tls_config(
    cert_path: &str,
    key_path: &str,
) -> Result<Arc<rustls::ServerConfig>, Box<dyn std::error::Error>> {
    unimplemented!()
}
async fn handle_tls_client<S>(_stream: S) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
where
    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
    Ok(())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = create_tls_config("server.crt", "server.key")?;
let acceptor = TlsAcceptor::from(config);
let listener = TcpListener::bind("0.0.0.0:8443").await?;

loop {
    let (tcp_stream, addr) = listener.accept().await?;
    let acceptor = acceptor.clone();

    tokio::spawn(async move {
        match acceptor.accept(tcp_stream).await {
            Ok(tls_stream) => {
                if let Err(err) = handle_tls_client(tls_stream).await {
                    eprintln!("{}: {}", addr, err);
                }
            }
            Err(err) => eprintln!("TLS handshake failed for {}: {}", addr, err),
        }
    });
}
}

If you need to track a scoped group of child connection tasks for a request fan-out, shutdown phase, or bounded worker pool, prefer JoinSet over manually storing JoinHandles in a Vec. Dropping the set aborts unfinished tasks instead of letting them linger detached.

🔒 TLS hardening checklist:

  • ✅ Use TLS 1.2 minimum (prefer TLS 1.3)
  • ✅ Use AEAD ciphers only (AES-GCM, ChaCha20-Poly1305)
  • ✅ Use ECDHE key exchange (forward secrecy)
  • ✅ Disable TLS compression (CRIME attack)
  • ✅ Set ALPN protocols explicitly
  • ✅ Use strong certificates (ECDSA P-256 or Ed25519)
  • ✅ Implement certificate pinning for internal services

The basic rustls builders shown here cover chain validation, hostname checks, and certificate expiry, but they do not establish a revocation policy by themselves. If your deployment depends on CRLs or OCSP, configure the verifier accordingly or enforce revocation at an upstream proxy or service mesh; short-lived certificates are often simpler to operate.

12.2.2 Mutual TLS for Service-to-Service Traffic

with_no_client_auth() is appropriate for public-facing services where clients authenticate at the application layer. For internal RPC, admin APIs, and other service-to-service traffic, prefer mutual TLS: configure a client-certificate verifier from your internal CA roots, require every client to present a certificate, and map the validated subject or SAN to an expected service identity.

Treat mTLS as authentication input, not just encryption. Reject missing or expired client certificates, rotate your client CA set deliberately, and still authorize each peer for the specific operations it is allowed to perform.

In zero-trust terms, the network path is not the trust boundary. mTLS gives you a cryptographic identity for each peer, but you still need per-service authorization, narrow trust domains, and audit trails for which identity invoked which action.

Apply the same revocation thinking to client certificates. Internal PKI designs often rely more on short-lived certs than on online revocation checks, but that should be an explicit policy decision rather than an unstated default.

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::rustls as rustls;
use rustls::{
    RootCertStore, ServerConfig, server::WebPkiClientVerifier,
    pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject},
};
use std::sync::Arc;

fn create_mtls_config(
    cert_path: &str,
    key_path: &str,
    client_ca_path: &str,
) -> Result<Arc<ServerConfig>, Box<dyn std::error::Error>> {
    let certs = CertificateDer::pem_file_iter(cert_path)?
        .collect::<Result<Vec<_>, _>>()?;
    let key = PrivateKeyDer::from_pem_file(key_path)?;

    let mut client_roots = RootCertStore::empty();
    for cert in CertificateDer::pem_file_iter(client_ca_path)? {
        client_roots.add(cert?)?;
    }

    let client_verifier = WebPkiClientVerifier::builder(client_roots.into()).build()?;

    let config = ServerConfig::builder()
        .with_client_cert_verifier(client_verifier)
        .with_single_cert(certs, key)?;

    Ok(Arc::new(config))
}
}

12.3 Defense Against Network Attacks

12.3.1 Preventing Buffer Overflows in Parsers

Rust’s safe code is immune to buffer overflows, but parser logic errors can still cause denial of service:

#![allow(unused)]
fn main() {
const MAX_MESSAGE_SIZE: usize = 64 * 1024;
#[derive(Debug)]
enum ParseError {
    TooShort,
    TooLong(usize),
    Incomplete,
}

fn parse_length_prefixed_message(data: &[u8]) -> Result<&[u8], ParseError> {
    if data.len() < 4 {
        return Err(ParseError::TooShort);
    }
    
    let declared_len = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
    
    // Validate declared length against actual data
    if declared_len > MAX_MESSAGE_SIZE {
        return Err(ParseError::TooLong(declared_len));
    }
    
    if data.len() - 4 < declared_len {
        return Err(ParseError::Incomplete);
    }
    
    Ok(&data[4..4 + declared_len])
}
}

12.3.2 Preventing Integer Overflow in Length Fields

#![allow(unused)]
fn main() {
const MAX_MESSAGE_SIZE: usize = 64 * 1024;

fn safe_length_add(a: usize, b: usize) -> Option<usize> {
    a.checked_add(b).filter(|&sum| sum <= MAX_MESSAGE_SIZE)
}
}

🔒 Security impact: Network protocols with length fields are a primary attack vector. Always:

  1. Validate declared lengths against limits.
  2. Use checked arithmetic for length calculations.
  3. Never trust a length field from the network without bounds checking.

DNS is another security boundary, not just a lookup mechanism. For outbound clients, pin the resolver path you trust, defend against DNS rebinding when hostnames eventually authorize private-network access, and prefer authenticated resolver transports (DNS-over-TLS / DNS-over-HTTPS, or DNSSEC-aware infrastructure) when your deployment depends on hostile networks. For the post-resolution destination policy check, see Chapter 7 section 7.2.5.

If you need an in-process encrypted resolver, hickory-resolver exposes ready-made configurations for DNS-over-TLS and DNS-over-HTTPS:

use hickory_resolver::Resolver;
use hickory_resolver::config::ResolverConfig;
use hickory_resolver::name_server::TokioConnectionProvider;

let resolver = Resolver::builder_with_config(
    ResolverConfig::cloudflare_tls(), // or `cloudflare_https()` for DoH
    TokioConnectionProvider::default(),
).build();

let ips = resolver.lookup_ip("api.example.com.").await?;

This example is marked ignore because resolver constructors and presets can shift across crate releases; keep the exact version you ship in a real integration test.

APIs that accept hostnames through ToSocketAddrs or string-based TcpStream::connect resolve names as part of generic address conversion and may block the current thread. If your outbound policy depends on SSRF filtering, DNS rebinding defenses, or a specific resolver transport, resolve with your chosen resolver first, validate each returned SocketAddr, and only then connect.

Prefer a reviewed resolver configuration over ad hoc plaintext DNS for services that make authorization or routing decisions from hostnames.

12.3.3 Preventing Amplification Attacks

#![allow(unused)]
fn main() {
// BAD: a 100-byte query can trigger a 6.4 KiB response (64x amplification)
async fn handle_query_unbounded(query: &[u8]) -> Vec<u8> {
    vec![0u8; query.len().saturating_mul(64)]
}

// GOOD: limit response size
const MAX_RESPONSE_SIZE: usize = 4096;
#[derive(Debug)]
enum QueryError {
    ResponseTooLarge,
}
fn generate_response(query: &[u8]) -> Result<Vec<u8>, QueryError> {
    Ok(query.to_vec())
}
async fn handle_query_bounded(query: &[u8]) -> Result<Vec<u8>, QueryError> {
    let response = generate_response(query)?;
    if response.len() > MAX_RESPONSE_SIZE {
        return Err(QueryError::ResponseTooLarge);
    }
    Ok(response)
}
}

12.3.4 Timeouts and Deadlines

Always use timeouts for network operations:

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::tokio as tokio;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::time::{timeout, Duration};

async fn handle_with_deadlines(
    stream: &mut tokio::net::TcpStream,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let read_deadline = Duration::from_secs(30);
    let write_deadline = Duration::from_secs(10);
    let total_deadline = Duration::from_secs(300);  // 5 min max session
    
    let result = timeout(total_deadline, async {
        // Read with per-operation timeout
        let mut buf = [0u8; 4096];
        let n = timeout(read_deadline, stream.read(&mut buf)).await??;
        
        // Process...
        
        // Write with per-operation timeout
        timeout(write_deadline, stream.write_all(b"response")).await??;
        
        Ok::<(), std::io::Error>(())
    }).await?;
    
    result?;
    Ok(())
}
}

🔒 Security impact: Timeouts prevent:

  • Slowloris attacks: Attacker holds connections open with partial requests
  • Slow read attacks: Attacker reads very slowly to consume server memory
  • Resource exhaustion: Long-running connections consuming memory and file descriptors

12.4 Logging and Monitoring

The examples here use the log crate for clarity; for production async services see §19.4.1 for the tracing-based approach with structured fields and request spans.

Security-Relevant Logging

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::log as log;
use log::{warn, error};

use std::net::SocketAddr;
enum SecurityEvent {
    AuthenticationFailure { addr: SocketAddr, username: String },
    RateLimitExceeded { addr: SocketAddr },
    InvalidInput { addr: SocketAddr, reason: String },
    TlsError { addr: SocketAddr, error: String },
}

fn sanitize_log_field(value: &str) -> String {
    value.chars().flat_map(|ch| ch.escape_default()).collect()
}

fn log_security_event(event: &SecurityEvent) {
    match event {
        SecurityEvent::AuthenticationFailure { addr, username } => {
            let username = sanitize_log_field(username);
            warn!(
                "Authentication failure: addr={}, username={}",
                addr, username
            );
        }
        SecurityEvent::RateLimitExceeded { addr } => {
            warn!("Rate limit exceeded: addr={}", addr);
        }
        SecurityEvent::InvalidInput { addr, reason } => {
            let reason = sanitize_log_field(reason);
            warn!("Invalid input: addr={}, reason={}", addr, reason);
        }
        SecurityEvent::TlsError { addr, error } => {
            let error = sanitize_log_field(error);
            error!("TLS error: addr={}, error={}", addr, error);
        }
    }
}
}

⚠️ Logging security:

  • Never log passwords, tokens, or session keys.
  • Log enough detail for incident response but not enough to aid attackers.
  • Sanitize attacker-controlled strings before logging. Escape \n, \r, and other control characters in usernames, filenames, and validation errors to prevent log injection (CWE-117).
  • Use structured logging for machine parsing (SIEM integration), but remember it only helps if the serializer correctly escapes field values.

12.5 Summary

  • Use connection limiting, rate limiting, and timeouts to prevent DoS attacks.
  • Always set read/write/session timeouts on network connections.
  • Use rustls for TLS: memory-safe by design.
  • Validate all length fields from network data with checked arithmetic.
  • Limit both request and response sizes to prevent amplification attacks.
  • Log security events without exposing sensitive data or trusting raw attacker-controlled strings.
  • Apply the principle of least privilege: bind to specific interfaces, use firewall rules.

In the next chapter, we move to Part IV: testing and verification strategies for proving your code is secure.

12.6 Exercises

  1. Hardened Echo Server: Build a tokio-based TCP echo server with: connection limiting (max 100 concurrent), per-IP rate limiting (max 10 requests per second), read timeout (30s), write timeout (10s), and message size limit (64 KiB). Test each defense individually by writing a client that attempts each attack vector.

  2. TLS Configuration Audit: Set up a TLS server using rustls. Connect to it using openssl s_client and verify: TLS 1.2 and TLS 1.3 behave as configured, weak cipher suites are rejected, and the certificate chain is valid. Then attempt a TLS 1.1 client and confirm the handshake fails because current rustls does not support TLS 1.1.

  3. Protocol Fuzzer: Write a simple length-prefixed protocol parser (4-byte big-endian length + payload). Create a fuzz target using cargo-fuzz that feeds arbitrary bytes to the parser. Run the fuzzer for at least 10 minutes and report any crashes or hangs found.

Chapter 13 - Testing Strategies for Secure Code

“Program testing can be used to show the presence of bugs, but never to show their absence.” Edsger W. Dijkstra

Testing security-critical code requires a different mindset than testing functionality. You’re not just verifying that the code works correctly for valid inputs: you must also verify that it fails safely for every possible invalid, malicious, or unexpected input. This chapter covers Rust testing strategies with a security focus.

13.1 Rust Testing Fundamentals

13.1.1 Unit Tests

Rust has built-in test support:

#![allow(unused)]
fn main() {
// In src/lib.rs or src/parse.rs
#[derive(Debug, PartialEq)]
pub enum ParseError {
    NotANumber,
    OutOfRange(u32),
    ReservedPortZero,
}

pub fn parse_port(input: &str) -> Result<u16, ParseError> {
    let value: u32 = input.parse().map_err(|_| ParseError::NotANumber)?;
    if value == 0 {
        return Err(ParseError::ReservedPortZero);
    }
    if value > 65535 {
        return Err(ParseError::OutOfRange(value));
    }
    Ok(value as u16)
}

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn valid_port() {
        assert_eq!(parse_port("80").unwrap(), 80);
        assert_eq!(parse_port("443").unwrap(), 443);
        assert_eq!(parse_port("65535").unwrap(), 65535);
    }
    
    #[test]
    fn port_zero() {
        assert!(matches!(parse_port("0"), Err(ParseError::ReservedPortZero)));
    }
    
    #[test]
    fn port_too_large() {
        assert!(matches!(parse_port("65536"), Err(ParseError::OutOfRange(65536))));
        assert!(matches!(parse_port("99999"), Err(ParseError::OutOfRange(_))));
    }
    
    #[test]
    fn port_not_a_number() {
        assert!(matches!(parse_port("abc"), Err(ParseError::NotANumber)));
        assert!(matches!(parse_port(""), Err(ParseError::NotANumber)));
    }
    
    #[test]
    fn port_negative() {
        assert!(matches!(parse_port("-1"), Err(ParseError::NotANumber)));
    }
    
    #[test]
    fn port_overflow() {
        assert!(matches!(
            parse_port("9999999999999999999999"),
            Err(ParseError::NotANumber)
        ));
    }
}
}

Run with: cargo test

This parser now matches the Chapter 7 policy that reserves port 0. If your own protocol intentionally allows ephemeral-port requests, make that exception explicit in the parser and document the policy difference in the tests.

Rust also gives you two pragmatic test attributes that matter in security work:

#![allow(unused)]
fn main() {
#[test]
#[should_panic(expected = "internal invariant violated")]
fn invariant_checks_trip_in_tests() {
    panic!("internal invariant violated");
}

#[test]
#[ignore = "slow integration test against external dependency"]
fn hsm_roundtrip() {
    // Run explicitly with: cargo test -- --ignored
}
}

Use #[should_panic] for invariants and programmer errors, not for attacker-controlled input paths that should return Result.

13.1.2 Integration Tests

Tests in tests/ have access to your crate’s public API only:

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::my_secure_app as my_secure_app;
// tests/integration.rs
use my_secure_app::{Authenticator, Session};

#[test]
fn test_authentication_flow() {
    let auth = Authenticator::new();
    
    // Successful authentication
    let session = auth.authenticate("admin", "correct_password")
        .expect("authentication should succeed");
    
    assert!(session.is_valid());
    
    // Failed authentication
    let result = auth.authenticate("admin", "wrong_password");
    assert!(result.is_err());
}
}

13.1.3 Test Organization for Security

tests/
├── authentication.rs        # Auth-related tests
├── authorization.rs         # Permission checks
├── input_validation.rs      # Input boundary tests
├── crypto.rs                # Crypto operation tests
├── concurrency.rs           # Thread safety tests
└── common/
    └── mod.rs               # Shared test utilities

13.2 Security-Specific Test Patterns

The larger examples in this section are marked rust,ignore because they rely on surrounding helpers, constants, or fixtures that are not repeated in every excerpt. In a real codebase, prefer ordinary runnable tests over permanently ignored ones.

13.2.1 Boundary Value Testing

#[cfg(test)]
mod boundary_tests {
    use super::*;
    
    #[test]
    fn buffer_at_exact_limit() {
        let data = vec![0u8; MAX_BUFFER_SIZE];
        assert!(process_buffer(&data).is_ok());
    }
    
    #[test]
    fn buffer_one_over_limit() {
        let data = vec![0u8; MAX_BUFFER_SIZE + 1];
        assert!(process_buffer(&data).is_err());
    }
    
    #[test]
    fn buffer_empty() {
        assert!(process_buffer(&[]).is_err());
    }
    
    #[test]
    fn buffer_max_usize() {
        // User-input-sized allocations should fail through `Result`, not panic.
        assert!(try_allocate_buffer(usize::MAX).is_err());
    }
    
    #[test]
    fn integer_boundary_values() {
        assert_eq!(safe_add(u64::MAX, 0), Some(u64::MAX));
        assert_eq!(safe_add(u64::MAX, 1), None);  // Overflow
        assert_eq!(safe_add(0, 0), Some(0));
        assert_eq!(safe_add(u64::MAX / 2, u64::MAX / 2 + 1), Some(u64::MAX));
    }
}

If you are explicitly testing a panic-only invariant, use #[should_panic] as in §13.1.1. Reserve catch_unwind for FFI containment and other boundary cases from Chapter 5, not for ordinary attacker-controlled input paths.

13.2.2 Negative Testing - Testing Failure Modes

#[cfg(test)]
mod negative_tests {
    use super::*;
    
    #[test]
    fn reject_null_bytes_in_hostname() {
        let result = Hostname::new("evil\0.example.com");
        assert!(result.is_err());
    }
    
    #[test]
    fn reject_path_traversal() {
        let base = std::path::Path::new("/var/data");
        assert!(safe_path(base, "../../../etc/passwd").is_err());
        assert!(safe_path(base, "..\\..\\windows\\system32").is_err());
        assert!(safe_path(base, "/etc/passwd").is_err());
    }
    
    #[test]
    fn reject_oversized_input() {
        let huge = "A".repeat(1_000_000);
        let result = parse_username(&huge);
        assert!(result.is_err());
    }
    
    #[test]
    fn reject_special_characters() {
        for char in &['\0', '\n', '\r', '\t', '\\', '\'', '"'] {
            let input = format!("user{}name", char);
            let result = parse_username(&input);
            assert!(result.is_err(), "Should reject character: {:?}", char);
        }
    }
    
    #[test]
    fn reject_empty_input() {
        assert!(parse_username("").is_err());
        assert!(parse_username("   ").is_err());
    }
}

13.2.3 Property-Based Security Invariants

#[cfg(test)]
mod invariant_tests {
    use super::*;

    // Assume the surrounding module provides concrete `encrypt`, `decrypt`,
    // `generate_random_key`, `generate_random_bytes`, and `generate_nonce`
    // helpers. Replace them with your real primitives in a concrete codebase.

    /// Property: encryption followed by decryption always returns the original plaintext
    #[test]
    fn encrypt_decrypt_roundtrip() {
        for _ in 0..100 {
            let key = generate_random_key();
            let plaintext = generate_random_bytes(256);
            let nonce = generate_nonce();
            
            let ciphertext = encrypt(&key, &nonce, &plaintext).unwrap();
            let decrypted = decrypt(&key, &nonce, &ciphertext).unwrap();
            
            assert_eq!(plaintext, decrypted);
        }
    }
    
    /// Property: encrypted data is never identical to plaintext
    #[test]
    fn encryption_is_not_identity() {
        let key = generate_random_key();
        let nonce = generate_nonce();
        let plaintext = b"Hello, World!";
        
        let ciphertext = encrypt(&key, &nonce, plaintext).unwrap();
        
        assert_ne!(&plaintext[..], &ciphertext[..plaintext.len()]);
    }
    
    /// Property: same plaintext with different nonces produces different ciphertext
    #[test]
    fn nonce_varies_ciphertext() {
        let key = generate_random_key();
        let plaintext = b"Same message";
        
        let nonce1 = generate_nonce();
        let nonce2 = generate_nonce();
        
        let ct1 = encrypt(&key, &nonce1, plaintext).unwrap();
        let ct2 = encrypt(&key, &nonce2, plaintext).unwrap();
        
        assert_ne!(ct1, ct2);
    }
    
    /// Property: any modification to ciphertext causes decryption to fail
    #[test]
    fn tamper_detection() {
        let key = generate_random_key();
        let nonce = generate_nonce();
        let plaintext = b"sensitive data";
        
        let mut ciphertext = encrypt(&key, &nonce, plaintext).unwrap();
        
        // Flip one bit
        ciphertext[0] ^= 0x01;
        
        let result = decrypt(&key, &nonce, &ciphertext);
        assert!(result.is_err(), "Tampered ciphertext should be rejected");
    }
}

13.2.4 Testing Error Messages Don’t Leak Sensitive Data

#[cfg(test)]
mod information_disclosure_tests {
    use super::*;
    
    #[test]
    fn error_messages_dont_contain_secrets() {
        let secret_key = "super_secret_key_12345";
        let result = authenticate(secret_key, "wrong_data");
        
        let error_string = format!("{:?}", result);
        assert!(!error_string.contains(secret_key));
        assert!(!error_string.contains("secret"));
    }
    
    #[test]
    fn authentication_errors_are_generic() {
        let result1 = authenticate("unknown_user", "password");
        let result2 = authenticate("known_user", "wrong_password");
        
        // Both should return the same generic error
        // (prevents user enumeration)
        assert_eq!(
            format!("{}", result1.unwrap_err()),
            format!("{}", result2.unwrap_err())
        );
    }
}

13.3 Testing Concurrent Code

#[cfg(test)]
mod concurrency_tests {
    use super::*;
    use std::sync::Arc;
    use std::thread;
    
    #[test]
    fn concurrent_access_to_shared_state() {
        let state = Arc::new(MutexProtectedState::new());
        let mut handles = vec![];
        
        for _ in 0..100 {
            let state = Arc::clone(&state);
            handles.push(thread::spawn(move || {
                state.increment();
            }));
        }
        
        for handle in handles {
            handle.join().unwrap();
        }
        
        assert_eq!(state.count(), 100);
    }
    
    #[test]
    fn test_no_data_race() {
        use std::sync::atomic::{AtomicUsize, Ordering};
        
        let counter = Arc::new(AtomicUsize::new(0));
        let mut handles = vec![];
        
        for _ in 0..10 {
            let counter = Arc::clone(&counter);
            handles.push(thread::spawn(move || {
                for _ in 0..1000 {
                    counter.fetch_add(1, Ordering::SeqCst);
                }
            }));
        }
        
        for handle in handles {
            handle.join().unwrap();
        }
        
        assert_eq!(counter.load(Ordering::SeqCst), 10_000);
    }
}

13.4 Testing Unsafe Code

#[cfg(test)]
mod unsafe_tests {
    use super::*;
    
    #[test]
    fn test_safe_wrapper_prevents_oob() {
        let mut buffer = SafeBuffer::new(10);
        buffer.write(0, &[1, 2, 3]).unwrap();
        
        // In-bounds access works
        assert_eq!(buffer.read(0, 3).unwrap(), &[1, 2, 3]);
        
        // Out-of-bounds access fails gracefully
        assert!(buffer.read(8, 4).is_err());  // Would need 12 bytes, only have 10
        assert!(buffer.write(10, &[1]).is_err());  // Start at end
    }
    
    #[test]
    fn test_safe_wrapper_null_handling() {
        let result = SafeBuffer::from_raw(std::ptr::null_mut(), 10);
        assert!(result.is_err());
    }
}

13.5 Doc Tests - Testable Documentation

#![allow(unused)]
fn main() {
/// Validates that a username contains only alphanumeric characters and underscores.
///
/// # Examples
///
/// ```
/// use my_secure_app::validate_username;
///
/// assert!(validate_username("john_doe123").is_ok());
/// assert!(validate_username("").is_err());
/// assert!(validate_username("user@evil").is_err());
/// let long = "a".repeat(65);
/// assert!(validate_username(&long).is_err());
/// ```
#[derive(Debug, PartialEq, Eq)]
pub enum ValidationError {
    Empty,
    TooLong,
    InvalidCharacters,
}

pub fn validate_username(username: &str) -> Result<(), ValidationError> {
    if username.is_empty() {
        return Err(ValidationError::Empty);
    }
    if username.len() > 64 {
        return Err(ValidationError::TooLong);
    }
    if !username.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
        return Err(ValidationError::InvalidCharacters);
    }
    Ok(())
}
}

Doc tests run with cargo test and serve as both documentation and regression tests.

13.6 Continuous Testing in CI

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        rust: [stable, beta, nightly]
    
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # reviewed snapshot
        with:
          toolchain: ${{ matrix.rust }}
      
      - name: Build
        run: cargo build --verbose
      
      - name: Run tests
        run: cargo test --verbose
      
      - name: Run tests (all features)
        run: cargo test --all-features --verbose
      
      - name: Run tests (release mode)
        run: cargo test --release --verbose
      
      - name: Run Miri (nightly only)
        if: matrix.rust == 'nightly'
        run: |
          rustup component add miri --toolchain nightly
          cargo +nightly miri test

13.6.1 Faster, More Isolated Runs with cargo-nextest

cargo test is the baseline, but larger security suites often benefit from cargo-nextest:

  • Better failure reporting and timeout handling
  • Process-level isolation between tests
  • Easier sharding across CI workers
cargo install cargo-nextest --version 0.9.132 --locked
cargo nextest run --workspace --all-features

For parser, auth, and protocol suites, this isolation helps catch hidden cross-test coupling and makes flaky failures easier to diagnose.

13.6.2 Coverage Reporting with cargo-llvm-cov

Coverage does not prove security, but it quickly shows which validation branches, parser error paths, and unsafe wrappers are still untested:

cargo install cargo-llvm-cov --version 0.8.5 --locked
cargo llvm-cov --workspace --all-features --html

Prioritize coverage review on authentication, parsing, deserialization, unsafe wrappers, and failure paths rather than chasing a global percentage.

13.6.3 Mutation Testing with cargo-mutants

Coverage tells you what executed; mutation testing tells you whether the tests would fail if the security logic were wrong. cargo-mutants flips conditionals, comparison operators, and other small pieces of code, then reruns your tests to see whether the suite notices:

cargo install cargo-mutants --version 27.0.0 --locked
cargo mutants --workspace --all-features

This is especially valuable for authentication checks, parser bounds checks, rate limiting, and unsafe wrappers. A surviving mutant around >= vs >, allow/deny logic, or an omitted validation branch is a strong signal that the tests are not actually enforcing the intended security property.

13.7 Summary

  • Write comprehensive unit tests for all security-critical functions.
  • Test boundary values: empty, minimum, maximum, and overflow cases.
  • Use negative testing to verify rejection of malicious inputs.
  • Test security invariants as properties (encryption roundtrips, tamper detection).
  • Verify error messages don’t leak sensitive information.
  • Test concurrent code with many threads to surface data races.
  • Test unsafe code wrappers to ensure the safe interface prevents UB.
  • Use doc tests for testable documentation.
  • Run tests across stable, beta, and nightly with Miri for UB detection.
  • Use cargo-nextest for faster, better-isolated CI runs on large suites.
  • Use cargo-llvm-cov to find untested validation and error-handling paths.
  • Use cargo-mutants to check whether tests actually fail when critical logic is changed.

In the next chapter, we go beyond manual test cases to explore fuzzing and property-based testing: automated techniques for finding bugs you didn’t think to test for.

13.8 Exercises

  1. Boundary Test Suite: Write a function parse_ipv4(s: &str) -> Result<[u8; 4], ParseError> that parses dotted-quad IPv4 addresses. Write a comprehensive test suite covering: all valid addresses, leading zeros, negative numbers, too many octets, too few octets, non-numeric input, overflow (>255), empty string, embedded null bytes, and Unicode characters.

  2. Error Message Audit: Write a function that handles authentication failures. Write tests that verify: error messages logged server-side contain useful debugging info, but the Display representation sent to clients contains only generic messages (“Authentication failed”). Use assert!(!format!("{}", err).contains("password")) to verify no sensitive data leaks.

  3. Concurrency Stress Test: Write a thread-safe counter using Arc<Mutex<u64>>. Write a test that spawns 100 threads, each incrementing the counter 10,000 times. Assert the final value is exactly 1,000,000. Then replace the Mutex with an AtomicU64 and benchmark the performance difference.

Chapter 14 - Fuzzing and Property-Based Testing

“The best test cases are the ones you didn’t think to write.”

Manual testing can only cover inputs you anticipate. Fuzzing and property-based testing generate inputs automatically, discovering bugs through random exploration. For security-critical code (parsers, decoders, protocol handlers) these techniques are essential because attackers will send inputs you never imagined.

14.1 Property-Based Testing with proptest

Property-based testing generates random inputs that satisfy specified constraints and checks that properties (invariants) hold for all of them.

14.1.1 Basic Setup

# Cargo.toml
[dev-dependencies]
proptest = "1"

14.1.2 Testing Parser Properties

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::proptest as proptest;
#[derive(Debug, PartialEq)]
enum ParseError {
    NotANumber,
    OutOfRange(u32),
}
fn parse_port(input: &str) -> Result<u16, ParseError> {
    let value: u32 = input.parse().map_err(|_| ParseError::NotANumber)?;
    if value > 65535 {
        return Err(ParseError::OutOfRange(value));
    }
    Ok(value as u16)
}
struct Hostname(String);
impl Hostname {
    fn new(input: &str) -> Result<Self, ()> {
        if input.contains('\0') {
            Err(())
        } else {
            Ok(Self(input.to_string()))
        }
    }
}
fn generate_nonce() -> [u8; 12] {
    [0u8; 12]
}
fn encrypt(key: &[u8], nonce: &[u8; 12], plaintext: &[u8]) -> Result<Vec<u8>, ()> {
    let ciphertext = plaintext
        .iter()
        .enumerate()
        .map(|(i, byte)| byte ^ key[i % key.len()] ^ nonce[i % nonce.len()])
        .collect();
    Ok(ciphertext)
}
fn decrypt(key: &[u8], nonce: &[u8; 12], ciphertext: &[u8]) -> Result<Vec<u8>, ()> {
    encrypt(key, nonce, ciphertext)
}
fn buffer_write(buf: &mut [u8], offset: usize, data: &[u8]) -> Result<(), ()> {
    if offset > buf.len() {
        return Err(());
    }
    let end = offset.saturating_add(data.len()).min(buf.len());
    let len = end.saturating_sub(offset);
    buf[offset..end].copy_from_slice(&data[..len]);
    Ok(())
}
use proptest::prelude::*;

proptest! {
    #[test]
    fn parse_port_roundtrip(port in 0u16..=65535) {
        let s = port.to_string();
        let parsed = parse_port(&s).unwrap();
        prop_assert_eq!(parsed, port);
    }
    
    #[test]
    fn parse_port_rejects_invalid(
        input in any::<String>().prop_filter("not a valid u16", |s| s.parse::<u16>().is_err())
    ) {
        prop_assert!(parse_port(&input).is_err());
    }
    
    #[test]
    fn hostname_rejects_null_bytes(s in ".*\\0.*") {
        prop_assert!(Hostname::new(&s).is_err());
    }
    
    #[test]
    fn encryption_roundtrip(
        key in prop::collection::vec(any::<u8>(), 32),
        plaintext in prop::collection::vec(any::<u8>(), 0..1024)
    ) {
        let nonce = generate_nonce();
        let ciphertext = encrypt(&key, &nonce, &plaintext).unwrap();
        let decrypted = decrypt(&key, &nonce, &ciphertext).unwrap();
        prop_assert_eq!(plaintext, decrypted);
    }
    
    #[test]
    fn buffer_operations_never_panic(
        size in 0usize..1024,
        offset in 0usize..2048,
        data in prop::collection::vec(any::<u8>(), 0..512)
    ) {
        let mut buf = vec![0u8; size];
        // This should never panic, even with invalid offsets
        let _ = buffer_write(&mut buf, offset, &data);
    }
}
}

14.1.3 Custom Strategies

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::proptest as proptest;
use rust_secure_systems_book::parse_http_header;
use proptest::prelude::*;

/// Generate valid HTTP header strings
fn http_header_strategy() -> impl Strategy<Value = (String, String)> {
    // Header name: alphanumeric + dash
    let name = "[a-zA-Z][a-zA-Z0-9\\-]{0,50}";
    // Header value: printable ASCII, no CR/LF
    let value = "[!-~ \t]{0,200}";
    
    (name, value)
}

proptest! {
    #[test]
    fn http_headers_parsed_correctly((name, value) in http_header_strategy()) {
        let header_line = format!("{}: {}", name, value);
        let parsed = parse_http_header(&header_line).unwrap();
        prop_assert_eq!(parsed.name, name);
        prop_assert_eq!(parsed.value, value.trim_start_matches(|c| c == ' ' || c == '\t'));
    }
}

/// Generate valid IP packets
fn ip_packet_strategy() -> impl Strategy<Value = Vec<u8>> {
    // Version + IHL
    Just(0x45u8)
        // DSCP/ECN, Total Length (will be fixed up)
        .prop_flat_map(|version_ihl| {
            (Just(version_ihl), any::<u8>(), prop::collection::vec(any::<u8>(), 18..1500))
        })
        .prop_map(|(vihl, dscp_ecn, rest)| {
            let mut packet = vec![vihl, dscp_ecn, 0, 0]; // Length placeholder
            packet.extend_from_slice(&rest);
            let len = packet.len() as u16;
            packet[2..4].copy_from_slice(&len.to_be_bytes());
            packet
        })
}
}

14.1.4 Regression Testing with proptest

When proptest finds a failing case, it persists the failing input:

# .proptest-regressions/port_parser.txt
# Seeds for failure in proptest test suite
cc 75 29 7a 79 9a 49 37 09 4e f1 00 63 96 55 38

This file should be committed to version control to ensure the failing case is always re-tested.

14.2 Fuzzing with cargo-fuzz

Fuzzing goes beyond property-based testing by using code coverage feedback to guide input generation toward unexplored code paths. This makes it dramatically more effective at finding bugs in complex parsers and decoders.

14.2.1 Setup

# Install cargo-fuzz (requires nightly)
rustup toolchain install nightly
cargo +nightly install cargo-fuzz --version 0.13.1 --locked

# Create a fuzz target
cargo +nightly fuzz init
cargo +nightly fuzz add parse_message

14.2.2 Writing Fuzz Targets

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
extern crate libfuzzer_sys;
use rust_secure_systems_book::my_secure_app as my_secure_app;
// fuzz/fuzz_targets/parse_message.rs
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
    // The fuzz target should call the function you want to test
    // with arbitrary byte input.
    //
    // If this function panics or causes UB, the fuzzer will report the input.
    let _ = my_secure_app::parse_message(data);
});
}

For structured fuzzing with arbitrary:

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
extern crate arbitrary;
extern crate libfuzzer_sys;
use rust_secure_systems_book::my_secure_app as my_secure_app;
// fuzz/fuzz_targets/parse_packet.rs
use arbitrary::Arbitrary;

#[derive(Debug, arbitrary::Arbitrary)]
struct PacketInput {
    version: u8,
    flags: u8,
    payload: Vec<u8>,
}

libfuzzer_sys::fuzz_target!(|input: PacketInput| {
    let mut raw = vec![input.version, input.flags];
    let len = (input.payload.len() as u16).to_be_bytes();
    raw.extend_from_slice(&len);
    raw.extend_from_slice(&input.payload);
    
    let _ = my_secure_app::parse_packet(&raw);
});
}

14.2.3 Running the Fuzzer

# Run fuzzer for a specified duration
cargo +nightly fuzz run parse_message -- -max_total_time=3600

# Run without sanitizers (for baseline)
cargo +nightly fuzz run parse_message -s none -- -max_total_time=3600

# Run with address sanitizer (detects buffer overflows, use-after-free)
cargo +nightly fuzz run parse_message -s address

# Reproduce a crash
cargo +nightly fuzz run parse_message fuzz/artifacts/parse_message/crash-<hash>

14.2.4 Fuzzing Best Practices

cargo-fuzz is the default choice for libFuzzer-based coverage-guided fuzzing in Rust, but it is not the only engine. AFL.rs is also worth knowing when you want AFL/AFL++ style workflows or need to compare engines on the same parser. honggfuzz-rs is another practical option when you want a mature multi-process engine with good multicore support.

🔒 Fuzzing strategy for security-critical code:

  1. Fuzz every parser: Network protocol parsers, file format decoders, configuration file parsers, HTTP header parsers.

  2. Fuzz cryptographic implementations: Verify that crypto operations don’t panic, abort, or leak data with malformed inputs.

  3. Use corpus minimization: After finding interesting inputs, minimize them:

# Minimize a corpus
cargo +nightly fuzz cmin parse_message

# Minimize a specific crash
cargo +nightly fuzz tmin parse_message fuzz/artifacts/parse_message/crash-<hash>
  1. Seed with real data: Provide valid sample inputs as a starting corpus:
mkdir -p fuzz/corpus/parse_message
cp tests/fixtures/*.bin fuzz/corpus/parse_message/
  1. Run continuously in CI:
# .github/workflows/fuzz.yml
name: Fuzz
on:
  schedule:
    - cron: '0 2 * * *'  # Daily at 2 AM

jobs:
  fuzz:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # reviewed snapshot
        with:
          toolchain: nightly
      - run: cargo +nightly install cargo-fuzz --version 0.13.1 --locked
      
      - name: Fuzz parse_message (10 minutes)
        run: cargo +nightly fuzz run parse_message -- -max_total_time=600
      
      - name: Fuzz parse_packet (10 minutes)
        run: cargo +nightly fuzz run parse_packet -- -max_total_time=600
  1. Probe algorithmic complexity, not just crashes: Regex validators, recursive parsers, and decompression logic can fail by becoming too slow rather than by panicking. The standard regex crate avoids classic catastrophic backtracking for its supported syntax, but that does not make regex processing free on attacker-controlled data: worst-case searches still scale with both pattern size and input size. Keep the input caps from Chapter 7 even when you use regex, and treat backtracking engines such as fancy-regex and custom parsers as higher-risk code paths that can still go superlinear. Add long, nearly-matching inputs to your corpus and keep dedicated timing or iteration-count tests for suspicious code paths.

14.2.5 Differential Fuzzing

When you have two implementations of the same format or algorithm, fuzz them against each other. This is especially valuable during rewrites, parser hardening, or “safe Rust replacement for legacy C” projects:

#![allow(unused)]
fn main() {
extern crate libfuzzer_sys;
fn legacy_c_parser(_data: &[u8]) -> Result<Vec<u8>, ()> { Ok(Vec::new()) }
fn rust_parser(_data: &[u8]) -> Result<Vec<u8>, ()> { Ok(Vec::new()) }
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
    let reference = legacy_c_parser(data);
    let candidate = rust_parser(data);

    assert_eq!(candidate.is_ok(), reference.is_ok());

    if let (Ok(left), Ok(right)) = (candidate, reference) {
        assert_eq!(left, right);
    }
});
}

Differential fuzzing catches a different class of bug than ordinary crash fuzzing: semantic disagreement. One parser accepting inputs the other rejects, or two implementations normalizing fields differently, can be just as security-relevant as a panic.

14.3 Guided Fuzzing for Security

14.3.1 Fuzzing for Memory Safety (Miri + Fuzzing)

Combine Miri with fuzzing to detect undefined behavior. You need a test harness that reads corpus files and passes them to the parser:

# Use an environment variable rather than a positional CLI argument:
# extra words after `cargo test --` are interpreted by the libtest harness as
# test filters, so `cargo ... test -- "$file"` can skip the test entirely.
for file in fuzz/corpus/parse_message/*; do
    CORPUS_FILE="$file" cargo +nightly miri test --test miri_corpus -- --exact miri_corpus_entry
done

⚠️ Practicality note: Running Miri once per corpus file is extremely slow. Use it on minimized corpora, targeted reproducers, or periodic CI jobs rather than every large fuzz corpus on every edit.

// tests/miri_corpus.rs - test harness for running Miri on corpus files
extern crate rust_secure_systems_book;
use rust_secure_systems_book::my_secure_app as my_secure_app;
use std::env;
use std::fs;

#[test]
fn miri_corpus_entry() {
    if let Ok(path) = env::var("CORPUS_FILE") {
        let data = fs::read(&path).expect("failed to read corpus file");
        let _ = my_secure_app::parse_message(&data);
    }
}

14.3.2 Fuzzing for Side-Channel Resistance

While fuzzing doesn’t directly detect timing side channels, you can fuzz the error paths to ensure they don’t reveal information:

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
extern crate libfuzzer_sys;
use rust_secure_systems_book::my_secure_app::authenticate;
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
    // Both of these should produce the same externally observable error type.
    let result1 = authenticate("unknown_user", data);
    let result2 = authenticate("known_user", data);
    
    // Error types should be identical (prevent user enumeration)
    assert_eq!(
        std::mem::discriminant(&result1),
        std::mem::discriminant(&result2)
    );
});
}

14.3.3 ThreadSanitizer and MemorySanitizer

AddressSanitizer is not the only sanitizer worth running. For concurrent or low-level code, add standalone sanitizer jobs alongside fuzzing:

# Data races in real threaded code
RUSTFLAGS="-Zsanitizer=thread" cargo +nightly test

# Uninitialized-memory reads
RUSTFLAGS="-Zsanitizer=memory" cargo +nightly test
  • ThreadSanitizer (TSan) catches data races in executed code paths, including cases involving real threads, FFI, and I/O that Miri cannot run directly.
  • MemorySanitizer (MSan) catches uses of uninitialized memory, but it requires the whole stack to be instrumented. If you call into C/C++ code, those dependencies generally need MSan-enabled builds too.
  • Sanitizer availability is target-dependent and still best treated as a nightly audit job. Keep the commands in CI or a dedicated review script rather than assuming every developer machine supports them.

14.4 QuickCheck Alternative

quickcheck is another property-based testing framework:

[dev-dependencies]
quickcheck = "1"
quickcheck_macros = "1"
#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::quickcheck_macros as quickcheck_macros;
fn my_encode(data: &[u8]) -> Vec<u8> {
    data.to_vec()
}
fn my_decode(data: &[u8]) -> Result<Vec<u8>, ()> {
    Ok(data.to_vec())
}
use quickcheck_macros::quickcheck;

#[quickcheck]
fn sort_is_idempotent(vec: Vec<u32>) -> bool {
    let mut sorted1 = vec.clone();
    sorted1.sort();
    let mut sorted2 = sorted1.clone();
    sorted2.sort();
    sorted1 == sorted2
}

#[quickcheck]
fn decode_encode_roundtrip(data: Vec<u8>) -> bool {
    let encoded = my_encode(&data);
    let decoded = my_decode(&encoded);
    decoded == Ok(data)
}
}

14.5 Structured Fuzzing with cargo-fuzz and arbitrary

# In fuzz/Cargo.toml
[dependencies]
arbitrary = { version = "1", features = ["derive"] }
#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
extern crate arbitrary;
extern crate libfuzzer_sys;
use rust_secure_systems_book::TestServer;
// Define complex input structures
use arbitrary::Arbitrary;

#[derive(Debug, arbitrary::Arbitrary)]
enum FuzzAction {
    Connect { host: String, port: u16 },
    Send { data: Vec<u8> },
    Disconnect,
    Authenticate { username: String, password: Vec<u8> },
}

libfuzzer_sys::fuzz_target!(|actions: Vec<FuzzAction>| {
    let mut server = TestServer::new();
    for action in actions {
        let _ = server.process(action);  // Should never panic
    }
});
}

14.6 Summary

  • Property-based testing (proptest, quickcheck): Generates random inputs to test invariants. Good for mathematical properties and roundtrip tests.
  • Fuzzing (cargo-fuzz): Coverage-guided input generation. Best for parsers, decoders, and protocol handlers.
  • Differential fuzzing is useful when you have a reference implementation, older parser, or wire-compatible rewrite to compare against.
  • Fuzz every input-processing function, especially network parsers.
  • Use structured fuzzing (arbitrary) for complex input types.
  • Run fuzzing continuously in CI and maintain a seed corpus.
  • Use sanitizers (AddressSanitizer, ThreadSanitizer, MemorySanitizer) and Miri alongside fuzzing to detect memory safety and concurrency issues.
  • Save and commit regression test cases for all discovered bugs.

In the next chapter, we cover static analysis and code auditing: tools and techniques for finding security issues without executing code.

14.7 Exercises

  1. Proptest a Parser: Write a simple HTTP header parser using proptest. Generate valid header strings and verify roundtrip correctness (parse then serialize produces the same string). Generate invalid strings (null bytes, extremely long values, CR/LF injection) and verify they are rejected.

  2. Fuzz Target: Create a fuzz target for a URL parser. Run cargo-fuzz for at least 30 minutes. Examine any crashes or hangs found. Minimize the crashing input, analyze the root cause, fix the bug, and add a regression test.

  3. Custom Arbitrary: Implement Arbitrary for a custom TlvPacket struct using the arbitrary crate. Write a structured fuzz target that generates semantically valid TLV packets, mutates them, and feeds them to your parser. Compare the coverage achieved with structured vs. unstructured fuzzing.

  4. Sanitizer Pass: Take a concurrent or FFI-heavy example from Chapters 6, 9, or 10 and run it under ThreadSanitizer or MemorySanitizer on nightly. Record what prerequisites were needed (supported target, instrumented dependencies, clean rebuild, etc.), capture any finding you get, and either fix it or explain why the run was clean.

Chapter 15 - Static Analysis and Auditing

“The compiler is your first auditor. Make it work harder.”

Static analysis examines code without executing it, finding potential vulnerabilities through pattern matching, data flow analysis, and formal methods. Rust’s compiler already performs more static analysis than C compilers, but additional tools can catch subtler issues, especially in unsafe code and dependency management.

15.1 Compiler Warnings as Security Tools

15.1.1 Essential Warning Flags

# .cargo/config.toml
[build]
rustflags = [
    "-D", "warnings",                    # Treat all warnings as errors
    "-D", "future-incompatible",         # Future-breaking changes
    "-D", "unused",                      # Unused code = dead code = potential confusion
]

# Note: Clippy lints cannot be set via rustflags, they only work with `cargo clippy`.
# Use a CI step or Makefile target instead:
#   cargo clippy -- -W clippy::all -W clippy::pedantic

15.1.2 Security-Relevant Compiler Lints

LintSecurity Relevance
unused_unsafeUnnecessary unsafe blocks
unused_mutUnnecessary mutability
unreachable_patternsLogic errors in match
dead_codeUnused code (potential confusion)
unused_variablesUnused values (potential logic error)
unused_importsUnused dependencies
elided_lifetimes_in_pathsHidden lifetime relationships

15.2 Clippy for Security Auditing

Clippy is Rust’s primary linting tool and catches many security-relevant patterns:

# Baseline CI gate
cargo clippy --workspace --all-targets --all-features -- \
    -W clippy::unwrap_used \
    -W clippy::expect_used \
    -W clippy::panic \
    -D warnings

# Review-oriented audit pass for low-level and parser-heavy code
cargo clippy --workspace --all-targets --all-features -- \
    -W clippy::indexing_slicing \
    -W clippy::arithmetic_side_effects \
    -W clippy::unwrap_in_result \
    -W clippy::wildcard_enum_match_arm

The second pass is meant to surface code worth reviewing, not to force every warning to zero mechanically. Bounds-checked parsing code often still triggers these heuristics because Clippy does not prove all surrounding invariants.

15.2.1 Key Security Lints

#![allow(unused)]
fn main() {
type Error = &'static str;

// clippy::unwrap_used - Catches unchecked unwrap() calls
fn bad_example(result: Result<i32, Error>) -> i32 {
    result.unwrap()  // WARNING: called unwrap() on a Result
}

// clippy::indexing_slicing - Catches unchecked array indexing
fn bad_indexing(arr: &[u8], i: usize) -> u8 {
    arr[i]  // WARNING: indexing may panic
}

// clippy::arithmetic_side_effects - Catches unchecked arithmetic
fn bad_math(a: u64, b: u64) -> u64 {
    a * b  // WARNING: integer arithmetic detected
}

// Better alternative: preserve the failure state explicitly.
fn good_example(result: Result<i32, Error>) -> Result<i32, Error> {
    let value = result.map_err(|_| "validation failed")?;
    Ok(value)
}

fn good_indexing(arr: &[u8], i: usize) -> Option<u8> {
    arr.get(i).copied()  // Safe: returns Option
}

fn good_math(a: u64, b: u64) -> Option<u64> {
    a.checked_mul(b)  // Safe: returns None on overflow
}
}

15.2.2 Custom Clippy Configuration

Create clippy.toml in your project root:

# clippy.toml
# Disallow these methods in security-critical code
disallowed-methods = [
    { path = "std::slice::get_unchecked", reason = "unsafe method requires manual bounds verification" },
    { path = "std::slice::get_unchecked_mut", reason = "unsafe method requires manual bounds verification" },
    { path = "std::ptr::read", reason = "unsafe, use safe alternative" },
    { path = "std::ptr::write", reason = "unsafe, use safe alternative" },
]

# Cognitive complexity limit (complex code hides bugs)
cognitive-complexity-threshold = 25

# Maximum number of lines per function
too-many-lines-threshold = 100

# Disallowed crate features
disallowed-types = [
    { path = "std::sync::Once", reason = "Prefer OnceLock<T> when you need to store an initialized value. Keep Once for genuine 'run this side effect exactly once' cases." },
]

15.2.3 Mechanical Fixes with cargo fix and cargo clippy --fix

Use auto-fix tooling for low-risk mechanical cleanup before the human audit begins:

cargo fix --all-targets --all-features
cargo clippy --fix --all-targets --all-features

This is useful for routine compiler suggestions, edition migrations, and straightforward lint cleanups. It is not a substitute for manual review of authentication logic, parser boundaries, error handling, or unsafe code. Review every generated diff before merging; “the tool changed it” is not a security argument.

15.3 Dependency Auditing

15.3.1 cargo audit

cargo install cargo-audit --version 0.22.1 --locked
cargo audit

Illustrative output example:

    Loaded 517 advisory records
    Scanning Cargo.lock for vulnerabilities (484 crates)

ID:       RUSTSEC-XXXX-YYYY
Crate:    example-crypto
Version:  1.2.3
Title:    Example advisory used for documentation
Date:     2026-04-02
URL:      https://rustsec.org/advisories/
Severity: high
Solution: upgrade to a reviewed fixed release

🔒 Security practice: Run cargo audit in CI and block merging on high-severity findings.

For CI integration and dashboards, use machine-readable output:

cargo audit --json > audit.json

Parse the JSON in your pipeline if you need to gate on specific severities, archive reviewed exceptions with the rest of the build artifacts, or convert findings into SARIF for GitHub Code Scanning and similar dashboards.

15.3.2 cargo geiger - Counting Unsafe Code

cargo install cargo-geiger --version 0.13.0 --locked
cargo geiger --all-features
Metric output format: x/y
    x = unsafe code used by the build
    y = total unsafe code found

Functions  Expressions  Impls  Traits  Methods  Dependency
0/0        0/0          0/0    0/0     0/0       my_secure_app
0/0        0/0          0/0    0/0     0/0       ├── my_lib
42/42      180/180      0/0    0/0     12/12     ├── ring  ⚠️  crypto, expected
3/3        15/15        0/0    0/0     1/1       ├── parking_lot
0/0        0/0          0/0    0/0     0/0       └── serde

🔒 Security practice: Review any crate with significant unsafe code. Especially scrutinize crates with:

  • Unsafe functions exposed in the public API
  • Raw pointer manipulation
  • FFI to C libraries
  • Custom allocators

⚠️ Tooling note: cargo-geiger is useful, but it can lag the newest compiler releases. Pin the version you rely on in CI and keep a manual fallback such as rg -n "\\bunsafe\\b" for quick inventory work.

15.3.3 cargo crev - Community Code Review

cargo install cargo-crev --version 0.26.5 --locked
cargo crev import trust --from-url https://github.com/crev-dev/crev-proofs
cargo crev verify

crev is a web-of-trust system where community members review crates and publish their findings. You can:

  • See which crates have been reviewed by trusted reviewers
  • Review crates yourself and publish findings
  • Configure trust requirements for your project

15.3.4 cargo vet - Supply Chain Auditing

cargo install cargo-vet --version 0.10.2 --locked
cargo vet init
cargo vet

cargo vet requires that every dependency has been explicitly reviewed:

# supply-chain/audits.toml
[[audits.my-crypto-lib]]
who = "Security Team <security@example.com>"
criteria = "safe-to-deploy"
version = "1.2.3"
notes = "Reviewed for memory safety, no unsafe code, constant-time operations verified"

15.4 Additional Audit Tooling

15.4.1 cargo-careful - Extra Runtime Checking

cargo-careful complements Miri: it runs your normal test or binary workflow with a specially prepared standard library and extra checks enabled, but at much closer-to-native speed than an interpreter.

cargo install cargo-careful --version 0.4.10 --locked
cargo +nightly careful test

What it is good for:

  • Re-running larger integration suites with extra checking around undefined behavior
  • Exercising unsafe-heavy code paths that are too slow or too environment-dependent for Miri
  • Building a nightly-only audit job that is stricter than regular cargo test

Optional sanitizer integration is also available:

cargo +nightly careful test -Zcareful-sanitizer=address

⚠️ Operational notes:

  • cargo-careful requires a recent nightly toolchain.
  • On first use it may need the rustc-src component so it can prepare the careful sysroot.
  • Treat it as complementary to Miri, not a replacement. Use Miri for smaller, precise UB-focused tests; use cargo-careful for broader suites that need more realistic execution.

15.4.2 cargo-semver-checks

For library maintainers, cargo-semver-checks detects accidental API-breaking changes between versions. This is critical because a breaking change in a security library can silently break downstream security guarantees:

cargo install cargo-semver-checks --version 0.47.0 --locked

# Check current code against the last published version
cargo semver-checks

# Compare against a specific baseline version
cargo semver-checks --baseline-version 1.2.0

# Compare against a specific branch or commit
cargo semver-checks --baseline-rev abc1234

How It Works

cargo-semver-checks compares the public API surface of two versions of your crate using the Rustdoc JSON output. It checks for over 80 types of breaking changes, including:

  • Removed public items: A public function, type, or trait was deleted.
  • Changed function signatures: A parameter type changed, a new required parameter was added, or the return type changed.
  • Changed trait bounds: A trait implementation was removed or bounds were tightened.
  • Changed enum variants: A variant was removed or its fields changed.
  • Changed struct fields: A public field was removed or made private.

Example output:

--- failure struct_pub_field_missing: pub field removed from pub struct ---

Description:
A public struct field has been removed. This breaks code that accesses or constructs the struct using the field name.

Ref: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.47.0/src/lints/struct_pub_field_missing.ron

Failed in:
  struct SecurityConfig: missing field key_rotation_days in ./src/config.rs:15
  struct SecurityConfig: missing field max_retries in ./src/config.rs:15

--- failure trait_method_missing: pub trait method removed ---

Description:
A method was removed from a public trait. This breaks any implementation of that trait.

Ref: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.47.0/src/lints/trait_method_missing.ron

Failed in:
  trait Authenticator: missing method validate_token in ./src/auth.rs:8

🔒 Security relevance: If your security library removes a validation method or changes a function signature, downstream code may silently skip a security check. Running cargo-semver-checks in CI ensures you catch these before publishing.

# .github/workflows/security.yml addition
- name: Semver checks
  run: |
    cargo install cargo-semver-checks --version 0.47.0 --locked
    cargo semver-checks

15.4.3 cargo-expand - Audit Generated Macro Code

Procedural macros are a compile-time trust boundary. Before you sign off on a crate that leans heavily on derives or attribute macros, inspect the generated Rust:

cargo install cargo-expand --locked
cargo expand --lib my_module

Use this during audits of macros such as serde derives, async runtime entry-point attributes, or custom proc-macros from less familiar dependencies. Read the expanded output like ordinary Rust: look for hidden allocations, unchecked unwraps, surprising trait impls, and network or filesystem access in generated helpers.

15.4.4 cargo-machete - Trim Unused Dependencies

Every unnecessary dependency is extra review surface. cargo-machete finds crates listed in Cargo.toml that are no longer referenced by the code:

cargo install cargo-machete --locked
cargo machete

Treat the output as an audit queue, not as blind auto-removal. Feature-gated code, build scripts, tests, and proc-macro expansion can all make a dependency appear unused when it still matters, but the tool is excellent at surfacing “why is this even here?” questions.

15.4.5 Research-Oriented Analyzers

Tools such as RUDRA and MIRAI are worth knowing when you audit unsafe or high-assurance Rust. RUDRA focuses on panic-safety and Send/Sync unsoundness patterns; MIRAI uses abstract interpretation to reason about contracts and invariants. Both are best treated as specialist review aids rather than default CI gates, but they can uncover issues ordinary linting misses.

15.5 Manual Code Audit Checklist for Rust

15.5.1 Unsafe Code Audit

For each unsafe block:

  • Scope: Is the unsafe block as small as possible?
  • Safety comment: Is there a # Safety comment explaining why it’s safe?
  • Bounds: Are all pointer accesses within bounds?
  • Alignment: Are pointer casts properly aligned?
  • Alias: Are mutable references unique (no aliasing violations)?
  • Initialization: Is all read memory initialized?
  • Thread safety: Is shared state synchronized?
  • Lifetime: Do references not outlive the data?
  • Soundness: Can safe code trigger UB through this?

15.5.2 Cryptographic Code Audit

  • Are AEAD ciphers used (not raw AES-CBC)?
  • Are nonces unique per key?
  • Are keys zeroed after use (zeroize)?
  • Are comparisons constant-time?
  • Are random numbers from a CSPRNG?
  • Are passwords hashed with proper KDF (Argon2id, bcrypt)?
  • Are key derivation parameters appropriate (iterations, salt)?

15.5.3 Error Handling Audit

  • Are all fallible operations handled (no unwrap() in production)?
  • Do error messages avoid leaking internal details?
  • Are crypto errors generic (no oracle attacks)?
  • Are panics caught at FFI boundaries?

15.5.4 Concurrency Audit

  • Is lock ordering consistent (no deadlock potential)?
  • Are there bounded channels (not unbounded)?
  • Are Send/Sync implementations correct for custom types?
  • Is shared mutable state properly synchronized?

15.6 Formal Methods: Kani and Prusti

15.6.1 Kani for Bounded Model Checking

Kani is especially useful for parser arithmetic, bounds checks, and state-machine invariants because it exhaustively explores bounded inputs instead of relying on random testing:

# Install Kani
cargo install kani-verifier --version 0.67.0 --locked

# Run proofs in the current crate
cargo kani
#[kani::proof]
fn frame_length_never_overflows() {
    let payload_len: usize = kani::any();
    kani::assume(payload_len <= 16 * 1024);

    let frame_len = payload_len.checked_add(4).unwrap();
    assert!(frame_len >= 4);
    assert!(frame_len <= 16 * 1024 + 4);
}

Use Kani when you want high assurance on bounded properties such as “length arithmetic never overflows”, “this index stays in bounds”, or “all enum states transition legally”.

15.6.2 Prusti for Contracts

For contract-style verification, Prusti lets you express preconditions, postconditions, and invariants:

# Install Prusti (requires specific Rust version)
# See: https://github.com/viperproject/prusti-dev

Prusti allows you to specify preconditions, postconditions, and invariants that the verifier proves:

#![allow(unused)]
fn main() {
// Requires Prusti annotations
// #[requires(idx < slice.len())]
// #[ensures(result == slice[idx])]
fn safe_get(slice: &[u8], idx: usize) -> u8 {
    slice[idx]
}
}

Creusot is another option in this space when you want Rust-oriented deductive verification with machine-checked contracts. Like Prusti, it demands more annotation work than testing or fuzzing, so reserve it for small high-assurance components such as parsers, authorization kernels, and arithmetic-heavy invariants.

15.7 Summary

  • Treat compiler warnings as security findings; enable -D warnings.
  • Use Clippy with security-focused lints (unwrap_used, indexing_slicing, arithmetic_side_effects).
  • Use cargo fix and cargo clippy --fix for mechanical cleanup, then review the generated diffs manually.
  • Run cargo audit in CI to catch known dependency vulnerabilities.
  • Use cargo geiger to quantify unsafe code in dependencies.
  • Use cargo vet or cargo crev for supply chain auditing.
  • Use cargo-careful on nightly for broader runtime checking of unsafe-heavy code and integration tests.
  • Use cargo semver-checks to detect accidental breaking API changes that could compromise downstream security.
  • Use cargo-expand to inspect proc-macro output and cargo-machete to remove dead dependency surface.
  • Follow the code audit checklists for unsafe, crypto, error handling, and concurrency.
  • Use Kani for bounded proofs and Prusti for contract-style verification when you need the highest assurance.

In the next chapter, we cover supply chain security: protecting your build pipeline from tampering and compromise.

15.8 Exercises

  1. Clippy Security Audit: Configure Clippy with the security lint set from this chapter (unwrap_used, indexing_slicing, arithmetic_side_effects, panic). Run it on a codebase you maintain (or use one of the earlier chapter exercises). Catalog every warning, classify each as true positive or false positive, and fix the true positives. Write a summary of which lints caught the most issues.

  2. Dependency Audit Report: Run cargo audit, cargo geiger, and cargo deny check on a real project. For each crate that contains unsafe code (per cargo geiger), investigate whether it has been audited, how many open issues it has, and when it was last updated. Write a risk assessment for the top 3 most concerning dependencies.

  3. Custom Audit Checklist: Create a project-specific audit checklist by combining the checklists from §15.5 with your own domain-specific requirements (e.g., “no unwrap in authentication paths”, “all file paths validated against directory traversal”). Apply it to one of the projects from Chapters 17 or 18 and report findings.

  4. Careful Nightly Audit: Install cargo-careful and run cargo +nightly careful test on a crate that contains parsing logic or unsafe code. Compare the result and runtime against regular cargo test and, if feasible, against cargo +nightly miri test. Document which bugs each tool class is best at finding.

Chapter 16 - Supply Chain Security

“Your code is only as secure as your weakest dependency.”

Modern software is built on layers of dependencies, each of which represents trust. A single compromised dependency can introduce a backdoor into every application that uses it. The Rust ecosystem, centered around crates.io, has experienced supply chain attacks including typo-squatting, dependency confusion, and maintainer account compromise.

This chapter covers how to protect your Rust project from supply chain attacks: from dependency selection to build reproducibility.

16.1 The Threat Model

16.1.1 Attack Vectors

Attack VectorExampleImpact
Malicious crateAttacker publishes crate with backdoorRemote code execution
Typosquattingructls instead of rustlsBackdoored dependency
Dependency confusionInternal crate name matches public crateCode from public source runs
Account compromiseAttacker gains publish accessMalicious version pushed
ProtestwareMaintainer intentionally sabotagesData destruction, disruption
Build system compromiseTampered CI/CD pipelineBackdoored binaries
Registry compromisecrates.io or npm attackedAll packages affected

16.1.2 Real-World Incidents

  • event-stream (npm, 2018): Attacker became maintainer of popular package and added cryptocurrency-stealing code.
  • ua-parser-js (npm, 2021): Maintainer’s account compromised, malicious versions published.
  • crates.io typosquatting campaigns: Malicious crates with lookalike names have been published to steal data or execute code during builds.
  • colors.js/faker.js (npm, 2022): Maintainer deliberately broke their own packages.

16.2 Dependency Selection Criteria

16.2.1 Evaluating Crate Trustworthiness

Before adding a dependency, evaluate:

Code Quality Signals:

  • Does the crate have comprehensive tests?
  • Is there CI/CD with security checks?
  • Is there a security policy (SECURITY.md)?
  • Are dependencies minimal? (Fewer transitive dependencies = smaller attack surface)

Maintenance Signals:

  • Is the crate actively maintained? (Recent commits, responsive issues)
  • How many maintainers have publish access?
  • How long has the crate existed?
  • Are there known security advisories?

Community Signals:

  • How many GitHub stars / crates.io downloads?
  • Is it recommended by authoritative sources?
  • Has it been audited by a third party?

Code Safety:

  • How much unsafe code does it contain? (cargo geiger)
  • Does it use FFI to C libraries? (Adds C attack surface)
  • Does it use build scripts (build.rs)? (Can execute arbitrary code at build time)
  • Does it ship procedural macros? (Also executes code at compile time)

16.2.2 Prefer the Standard Library and Core Ecosystem

#![allow(unused)]
fn main() {
// PREFER: Standard library
use std::sync::Mutex;

// OVER: Third-party alternatives (unless they provide clear benefits)
// use some_custom_mutex::Mutex;
}

The standard library and closely-governed crates (like those under the rust-lang organization) have the highest trust level.

16.2.3 Minimal Dependencies

Every dependency is a liability. Audit regularly:

cargo tree --duplicates     # Find duplicate versions
cargo tree --depth 1        # See direct dependencies
cargo tree --invert ring    # See who depends on a specific crate

16.3 Pinning and Locking Dependencies

16.3.1 Commit Cargo.lock

For binaries, always commit Cargo.lock:

git add Cargo.lock
git commit -m "Lock dependency versions for reproducible builds"

🔒 Security impact: Ensures every build uses the exact same dependency versions. Without Cargo.lock, a cargo build might pull a newer (potentially compromised) version.

16.3.2 Restrict Version Ranges

# BROAD: allows any compatible 1.x version
[dependencies]
serde = "1"

# TIGHTER: sets a minimum compatible version, but still allows newer 1.x releases
[dependencies]
serde = "1.0.195"

# EXACT: pin one precise version in Cargo.toml (use sparingly)
[dependencies]
serde = "=1.0.195"

# BEST for applications: commit Cargo.lock and review updates deliberately

⚠️ Balance: Cargo version requirements are ranges unless you use =. For most binaries, keep exact reproducibility in Cargo.lock and update it deliberately rather than hard-pinning every dependency in Cargo.toml.

16.4 cargo-deny for Policy Enforcement

cargo install cargo-deny --version 0.19.0 --locked
cargo deny init
cargo deny check

16.4.1 License Compliance

# deny.toml
[licenses]
allow = [
    "MIT",
    "Apache-2.0",
    "BSD-2-Clause",
    "BSD-3-Clause",
    "ISC",
    "0BSD",
    "Unlicense",
    "Zlib",
]
unlicensed = "deny"
# Reject copyleft licenses in proprietary software
deny = ["GPL-2.0-only", "GPL-3.0-only", "AGPL-3.0-only"]

16.4.2 Ban Dangerous Crates

# deny.toml
[bans]
# Ban crates with known issues
deny = [
    { name = "openssl", wrappers = ["my-openssl-wrapper"] },  # Only allow via wrapper
    { name = "chrono", version = "<0.4.20" },                 # Old versions have soundness bugs
]

# Reject wildcard dependencies
wildcards = "deny"

# Warn on multiple versions of the same crate
multiple-versions = "warn"

16.4.3 Source Restrictions

# deny.toml
[sources]
unknown-registry = "deny"    # Only crates.io
unknown-git = "deny"         # No git dependencies without explicit allow
allow-registry = ["sparse+https://index.crates.io/"]
allow-git = ["https://github.com/your-org/your-private-crate"]

# Add private registries or additional approved git sources explicitly as needed.

[bans.build]
# Start strict, then allow only reviewed compile-time crates that genuinely
# need build scripts or proc-macro-adjacent tooling.
allow-build-scripts = ["ring", "aws-lc-sys"]
executables = "deny"
interpreted = "warn"
enable-builtin-globs = true
include-dependencies = true

This does not sandbox malicious Rust code in a reviewed build script. What it does give you is visibility and policy enforcement around compile-time crates and script-like artifacts, which is still useful when you are inventorying build-time risk. An empty allow-list is a good audit starting point when you want the build to fail closed and show you every compile-time dependency, but it is not a realistic copy-paste baseline for most working Rust workspaces.

16.4.4 cargo-supply-chain for Publisher Visibility

cargo-deny tells you whether a dependency violates policy. cargo-supply-chain answers a different question: who are you implicitly trusting across the full dependency graph?

cargo install cargo-supply-chain --locked
cargo supply-chain publishers
cargo supply-chain crates

Use it to spot one-off publishers, surprising maintainer concentration, or dependency subtrees published by accounts you have never reviewed. That context is especially useful when assessing typosquatting and maintainer-account compromise risks, and it complements the deeper per-version review flows from cargo-vet.

16.5 Build Reproducibility

16.5.1 Reproducible Builds

A reproducible build produces identical output given the same source code, regardless of the build environment. This enables third-party verification that a binary was built from the claimed source.

# Build with reproducibility settings
RUSTFLAGS="--remap-path-prefix=/build/workdir=." \
CARGO_PROFILE_RELEASE_LTO=true \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 \
cargo build --release --frozen --target x86_64-unknown-linux-gnu

--frozen already implies --locked and --offline, so you do not need to spell those flags separately.

Key settings for reproducibility:

# Cargo.toml
[profile.release]
lto = true
codegen-units = 1
strip = "symbols"
panic = "abort"

# .cargo/config.toml
[env]
RUSTFLAGS = "--remap-path-prefix=/build/workdir=."
SOURCE_DATE_EPOCH = "1700000000"  # Helps external tools that honor it

Absolute paths in debug info, non-deterministic linkers, and build.rs scripts are common causes of Rust build drift. SOURCE_DATE_EPOCH can help surrounding tools, but reproducible Rust builds primarily depend on path remapping, fixed toolchains, deterministic build scripts, and a committed lockfile.

Be precise about strip: strip = true means debuginfo, not full symbol stripping. Use strip = "symbols" when you intentionally want the more aggressive setting shown here.

For crash forensics, keep a separate profile or symbol artifact with debug info intact. A release artifact cannot be both aggressively stripped for distribution and rich in local symbols for post-mortem analysis at the same time.

16.5.2 Binary Verification

# Compare builds
sha256sum target/release/my-binary

# Build in Docker for consistency
docker run --rm -v "$(pwd)":/home/rust/src messense/rust-musl-cross:x86_64-musl cargo build --release

16.6 Private Registries and Air-Gapped Builds

16.6.1 Vendoring Dependencies

For air-gapped environments:

# Vendor all dependencies into the source tree
mkdir -p .cargo
cargo vendor vendor > .cargo/vendor-config.toml

# Merge the emitted [source] configuration into .cargo/config.toml
# instead of overwriting any existing target, rustflags, or env settings.
# If you do not already have .cargo/config.toml, rename vendor-config.toml to config.toml.

# The vendor/ directory is now self-contained
# Build without network access
cargo build --offline --frozen

🔒 Security benefit: No network access needed during build. The vendored dependencies can be audited, scanned, and archived.

16.6.2 Private Registry

For organizations:

# .cargo/config.toml
[registries]
my-registry = { index = "https://my-company.com/crates-index" }

[dependencies]
my-crate = { version = "1.0", registry = "my-registry" }

16.7 Build Script (build.rs) Security

build.rs scripts run arbitrary code at build time. This is a significant attack surface:

// build.rs
fn main() {
    // This runs at compile time with full system access
    // A compromised dependency's build.rs could:
    // - Read environment variables (secrets, API keys)
    // - Exfiltrate source code
    // - Modify generated code
    // - Install backdoors
    
    println!("cargo:rerun-if-changed=src/wrapper.h");
}

🔒 Mitigation strategies:

  1. Audit all build.rs scripts in your dependency tree.
  2. Use cargo deny check bans with a [bans.build] policy to inventory and gate compile-time crates, scripts, and embedded executables.
  3. Build in sandboxed environments (Docker, chroot).
  4. Use --frozen to require an existing lockfile and prevent network access during builds:
cargo build --frozen

16.7.1 Procedural Macro Security

Procedural macros are the other major compile-time trust boundary. A proc-macro crate is compiled and then executed by rustc during macro expansion, so it can read environment variables, inspect the filesystem, and perform network I/O just like a hostile build.rs.

Common derive crates such as serde, thiserror, and tokio-macros are widely trusted, but they are still code execution on the build host. Audit proc-macro crates alongside build.rs, minimize them in high-assurance workspaces, and keep CI/build environments sandboxed so compile-time code cannot reach long-lived credentials or unrelated source trees.

16.8 CI/CD Pipeline Security

# .github/workflows/build.yml
name: Secure Build

on: [push, pull_request]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          fetch-depth: 0  # Full history for auditing
      
      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch snapshot
      
      # Verify the existing lock file can be used as-is
      - name: Verify lock file
        run: cargo check --locked
      
      # Audit dependencies
      - name: Security audit
        run: |
          cargo install cargo-audit --version 0.22.1 --locked
          cargo audit
      
      # Check dependency policy
      - name: Deny check
        run: |
          cargo install cargo-deny --version 0.19.0 --locked
          cargo deny check
      
      # Build and test
      - name: Build
        run: cargo build --release
      
      - name: Test
        run: cargo test
      
      # Generate SBOM (Software Bill of Materials)
      - name: Generate SBOM
        run: |
          cargo install cargo-cyclonedx --version 0.5.9 --locked
          cargo cyclonedx

SBOM generation is inventory, not analysis. Add a follow-up CI gate that scans the generated CycloneDX document with your artifact scanner (for example, grype or trivy) and fails the build on newly introduced advisories. That matters when downstream packaging pulls in native libraries, OS packages, or other release metadata that is easier to reason about from the SBOM than from Cargo metadata alone.

Treat CI tooling the same way you treat application dependencies: pin GitHub Actions to reviewed SHAs, pin Cargo-installed tools to reviewed versions, and refresh those pins deliberately.

For release artifacts, pair SBOM generation with binary metadata or provenance. cargo auditable, Sigstore signing, and SLSA-style attestations strengthen the link between the reviewed source tree and the artifact you actually ship.

16.8.1 Trusted Publishing for crates.io

For release workflows, prefer crates.io Trusted Publishing via OpenID Connect (OIDC) instead of storing long-lived registry API tokens in CI secrets. crates.io supports trusted publishing from GitHub Actions and GitLab CI.

# .github/workflows/publish.yml
name: Publish

on:
  push:
    tags: ["v*"]

permissions:
  contents: read
  id-token: write

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # reviewed snapshot
        with:
          toolchain: stable
      - run: cargo publish --locked

Configure the trust relationship once in crates.io, then remove stored crates.io tokens from repository secrets. If your CI provider cannot use OIDC, prefer short-lived credentials issued by a secret manager over repository-scoped long-lived tokens.

Keep dependency updates small and reviewable: enable Dependabot or Renovate for Rust crates, GitHub Actions, and container base images so that supply-chain changes arrive as normal code review.

16.9 Summary

  • Evaluate every dependency for trustworthiness before adoption.
  • Minimize dependency count; prefer the standard library.
  • Commit Cargo.lock for reproducible builds.
  • Use cargo-deny to enforce license, source, and ban policies.
  • Use cargo-supply-chain to see who publishes and maintains the crates you implicitly trust.
  • Vendor dependencies for air-gapped builds.
  • Audit build.rs scripts: they have full system access at build time.
  • Build in sandboxed environments with --frozen --offline.
  • Generate and maintain a Software Bill of Materials (SBOM).
  • Implement supply chain security checks in CI/CD.
  • Prefer Trusted Publishing (OIDC) over long-lived crates.io tokens in CI.
  • Use automated update PRs so dependency drift is reviewed incrementally.

In the next chapter, we begin Part V with a hands-on project: building a hardened TCP server that applies everything we’ve learned.

16.10 Exercises

  1. Dependency Vetting: Choose a crate you use in production (or pick serde_json or tokio). Perform a full cargo vet-style review: read the unsafe code, check the test coverage, review the issue tracker for security bugs, and verify the maintainer’s identity. Write a one-page audit summary with your recommendation (safe-to-deploy, review-needed, or do-not-use).

  2. Vendored Build: Configure a project to build fully offline using cargo vendor. Verify the build succeeds without network access. Modify one vendored dependency maliciously (add a println! that exfiltrates data) and demonstrate that the change is detectable via checksum verification.

  3. Supply Chain CI Pipeline: Create a GitHub Actions workflow that runs on every PR: cargo audit (block on vulnerabilities), cargo deny check (enforce license and source policies), cargo geiger (report unsafe code count), and cargo cyclonedx (generate SBOM). Make the workflow fail if any check does not pass. As a bonus, add a tag-based publish workflow that uses Trusted Publishing instead of a stored crates.io token.

Chapter 17 - Building a Hardened TCP Server

“Theory without practice is empty. Practice without theory is blind.”

This chapter brings together everything from the previous chapters into a complete, production-quality hardened TCP server. We’ll build a secure echo server with TLS, rate limiting, connection management, and security-relevant logging. Every design decision will reference the security principles covered earlier in the book.

The companion crate in this repository lives at companion/ch17-hardened-server. The snippets below use that package layout directly so Chapter 19 can deploy the same binary without translation.

17.1 Design and Threat Model

Threat Model

ThreatMitigationChapter
Unencrypted communicationTLS via rustlsCh 8, 12
Resource exhaustion (DoS)Connection limits, timeoutsCh 12
Slowloris attacksRead timeoutsCh 12
Brute-force attacksConnection-attempt throttling + per-request rate limiting for both valid and malformed framesCh 12
Memory exhaustionBounded buffers, size limitsCh 7
Buffer overflowsRust’s memory safetyCh 3
Data racesRust’s concurrency modelCh 6
Information disclosureSanitized logging, no secret leaksCh 5, 19
Dependency vulnerabilitiescargo-audit, cargo-denyCh 15, 16
Panic-induced crashespanic = "abort", explicit error handling, and poison recovery for shared stateCh 5, 6

Architecture

Client → TLS Termination → Connection Handler → Request Parser → Business Logic → Response
              ↓                    ↓                    ↓
          rustls            Rate Limiter         Input Validation
                            Conn Counter          (newtypes)

17.2 Project Setup

Because the real example lives in a workspace, the package manifest and the release profile live in different files. Cargo only honors [profile.*] settings from the workspace root, so the deployed companion binary inherits its hardening flags from the top-level Cargo.toml.

# companion/ch17-hardened-server/Cargo.toml
[package]
name = "ch17-hardened-server"
version = "0.1.0"
edition.workspace = true

[dependencies]
tokio.workspace = true
tokio-rustls.workspace = true
rustls.workspace = true
log.workspace = true
env_logger.workspace = true
thiserror.workspace = true

[dev-dependencies]
proptest.workspace = true
# Cargo.toml (workspace root excerpt)
[workspace]
members = [
    "companion/ch10-ffi",
    "companion/ch12-networking",
    "companion/ch17-hardened-server",
    "companion/ch19-hardening",
]
resolver = "3"

[workspace.package]
edition = "2024"

[profile.release]
overflow-checks = true
lto = true
codegen-units = 1
panic = "abort"
strip = "symbols"
opt-level = "z"

17.3 Secure Types

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book as thiserror;
// src/types.rs
use thiserror::Error;

/// Maximum number of concurrent connections
pub const MAX_CONNECTIONS: usize = 1000;

/// Maximum message size (64 KiB)
pub const MAX_MESSAGE_SIZE: usize = 64 * 1024;

/// Read timeout in seconds
pub const READ_TIMEOUT_SECS: u64 = 30;

/// Write timeout in seconds
pub const WRITE_TIMEOUT_SECS: u64 = 10;

/// TLS handshake timeout in seconds
pub const TLS_HANDSHAKE_TIMEOUT_SECS: u64 = 10;

/// Maximum session duration in seconds
pub const MAX_SESSION_SECS: u64 = 300;

/// Grace period for in-flight connections during shutdown
pub const SHUTDOWN_GRACE_SECS: u64 = 30;

/// Rate limit: max connection attempts per minute per IP
pub const CONNECTION_ATTEMPT_RATE_LIMIT: usize = 60;

/// Rate limit: max requests per minute per IP
pub const RATE_LIMIT: usize = 60;

/// Bound tracked client state between cleanup cycles
pub const MAX_TRACKED_CLIENTS: usize = 8192;

/// A validated message with a non-empty payload and guaranteed bounds
#[derive(Debug)]
pub struct Message(Vec<u8>);

impl Message {
    pub fn from_bytes(data: &[u8]) -> Result<Self, ProtocolError> {
        if data.len() > MAX_MESSAGE_SIZE {
            return Err(ProtocolError::MessageTooLarge {
                size: data.len(),
                max: MAX_MESSAGE_SIZE,
            });
        }
        // Validate message format: 4-byte length prefix + payload
        if data.len() < 4 {
            return Err(ProtocolError::IncompleteHeader);
        }
        let declared_len = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
        if declared_len == 0 {
            return Err(ProtocolError::EmptyMessage);
        }
        if declared_len > MAX_MESSAGE_SIZE - 4 {
            return Err(ProtocolError::DeclaredLengthTooLarge(declared_len));
        }
        if data.len() < 4 + declared_len {
            return Err(ProtocolError::IncompleteMessage {
                expected: 4 + declared_len,
                actual: data.len(),
            });
        }
        Ok(Message(data[..4 + declared_len].to_vec()))
    }
    
    pub fn payload(&self) -> &[u8] {
        &self.0[4..]
    }
    
    pub fn as_bytes(&self) -> &[u8] {
        &self.0
    }
}

/// Create an echo response message
pub fn echo_response(payload: &[u8]) -> Result<Vec<u8>, ProtocolError> {
    if payload.is_empty() {
        return Err(ProtocolError::EmptyMessage);
    }
    if payload.len() > MAX_MESSAGE_SIZE - 4 {
        return Err(ProtocolError::MessageTooLarge {
            size: payload.len() + 4,
            max: MAX_MESSAGE_SIZE,
        });
    }
    let len = payload.len() as u32;
    let mut response = len.to_be_bytes().to_vec();
    response.extend_from_slice(payload);
    Ok(response)
}

#[derive(Debug, Error)]
pub enum ProtocolError {
    #[error("message too large: {size} bytes (max {max})")]
    MessageTooLarge { size: usize, max: usize },

    #[error("empty message")]
    EmptyMessage,

    #[error("incomplete header")]
    IncompleteHeader,

    #[error("declared length too large: {0}")]
    DeclaredLengthTooLarge(usize),

    #[error("incomplete message: expected {expected}, got {actual}")]
    IncompleteMessage { expected: usize, actual: usize },
}
}

Size caps are necessary but not sufficient for robustness under memory pressure. Rust’s default allocation path still calls handle_alloc_error; with panic = "abort", that usually terminates the process. For request paths that allocate from attacker-influenced lengths, prefer fallible reservation APIs so overload becomes an ordinary error:

#![allow(unused)]
fn main() {
fn copy_frame_fallible(frame: &[u8]) -> std::io::Result<Vec<u8>> {
    let mut out = Vec::new();
    out.try_reserve_exact(frame.len())
        .map_err(|_| std::io::Error::other("out of memory while buffering frame"))?;
    out.extend_from_slice(frame);
    Ok(out)
}
}

This server already keeps a fixed read buffer to reduce per-read allocation, but the same review rule still applies to copies you make after validation.

The 64 KiB cap is an example policy for a small framed service. In a real protocol, derive this from the wire specification and observed traffic profile, then lower it for constrained deployments rather than copying the book’s ceiling blindly.

17.4 Rate Limiter

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::log as log;
// src/rate_limiter.rs
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::{Mutex, MutexGuard};
use std::time::{Duration, Instant};

pub struct RateLimiter {
    clients: Mutex<HashMap<IpAddr, ClientRecord>>,
    max_requests: usize,
    window: Duration,
    max_tracked_clients: usize,
}

struct ClientRecord {
    count: usize,
    window_start: Instant,
}

impl RateLimiter {
    pub fn new(max_requests: usize, window: Duration, max_tracked_clients: usize) -> Self {
        RateLimiter {
            clients: Mutex::new(HashMap::new()),
            max_requests,
            window,
            max_tracked_clients,
        }
    }
    
    pub fn check(&self, addr: IpAddr) -> bool {
        let mut clients = self.lock_clients();
        let now = Instant::now();

        if !clients.contains_key(&addr) {
            let double_window = self.window * 2;
            clients.retain(|_, record| {
                now.duration_since(record.window_start) <= double_window
            });
            if clients.len() >= self.max_tracked_clients {
                log::warn!(
                    "Rate limiter state full ({} tracked clients); rejecting {}",
                    self.max_tracked_clients,
                    addr
                );
                return false;
            }
        }
        
        let record = clients.entry(addr).or_insert_with(|| ClientRecord {
            count: 0,
            window_start: now,
        });
        
        if now.duration_since(record.window_start) > self.window {
            record.count = 0;
            record.window_start = now;
        }
        
        record.count += 1;
        
        if record.count > self.max_requests {
            log::warn!("Rate limit exceeded for {}", addr);
            false
        } else {
            true
        }
    }
    
    /// Remove expired entries to prevent memory growth
    pub fn cleanup(&self) {
        let mut clients = self.lock_clients();
        let now = Instant::now();
        let double_window = self.window * 2;
        clients.retain(|_, record| now.duration_since(record.window_start) <= double_window);
    }

    fn lock_clients(&self) -> MutexGuard<'_, HashMap<IpAddr, ClientRecord>> {
        match self.clients.lock() {
            Ok(guard) => guard,
            Err(poisoned) => {
                log::error!("Rate limiter state poisoned; clearing state and recovering");
                let mut guard = poisoned.into_inner();
                guard.clear();
                guard
            }
        }
    }
}
}

Bounding the map prevents untrusted clients from turning rate-limit state into an unbounded memory sink. It is still a baseline policy: in IPv6-heavy environments, exact-address limits are often paired with /64 aggregation, authenticated quotas, or an upstream proxy that can shed abusive sources earlier.

17.5 TLS Configuration

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::rustls as rustls;
// src/tls.rs
use rustls::{
    ServerConfig,
    pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject},
};
use std::sync::Arc;

pub fn create_server_config(
    cert_path: &str,
    key_path: &str,
) -> Result<Arc<ServerConfig>, Box<dyn std::error::Error>> {
    let certs = CertificateDer::pem_file_iter(cert_path)?
        .collect::<Result<Vec<_>, _>>()?;
    let key = PrivateKeyDer::from_pem_file(key_path)?;
    
    let config = ServerConfig::builder()
        .with_no_client_auth()
        .with_single_cert(certs, key)?;
    
    Ok(Arc::new(config))
}
}

This keeps the PEM parsing path inside rustls itself, which avoids carrying an extra helper crate just to load certificates and keys.

Operational note: This server configuration loads certificates and private keys, but revocation policy is separate. If your deployment requires CRL or OCSP enforcement, configure it explicitly in your verifier or enforce it at a proxy or service mesh; otherwise prefer short-lived certificates and deliberate rotation.

17.6 Connection Handler

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::companion::ch17_hardened_server as ch17_hardened_server;
use rust_secure_systems_book::deps::log as log;
use rust_secure_systems_book::deps::tokio as tokio;
// src/handler.rs
use ch17_hardened_server::rate_limiter::RateLimiter;
use ch17_hardened_server::types::*;
use std::io;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::time::{timeout, Duration};

pub struct ConnectionHandler {
    admission_limiter: Arc<RateLimiter>,
    request_limiter: Arc<RateLimiter>,
    connection_count: Arc<AtomicUsize>,
}

pub struct ConnectionPermit {
    connection_count: Arc<AtomicUsize>,
}

const RATE_LIMIT_RESPONSE: &[u8] = b"rate limit exceeded";

impl Drop for ConnectionPermit {
    fn drop(&mut self) {
        self.connection_count.fetch_sub(1, Ordering::SeqCst);
    }
}

impl ConnectionHandler {
    pub fn new(admission_limiter: Arc<RateLimiter>, request_limiter: Arc<RateLimiter>) -> Self {
        ConnectionHandler {
            admission_limiter,
            request_limiter,
            connection_count: Arc::new(AtomicUsize::new(0)),
        }
    }
    
    pub fn connection_count(&self) -> usize {
        self.connection_count.load(Ordering::SeqCst)
    }
    pub fn try_admit(&self, addr: SocketAddr) -> Option<ConnectionPermit> {
        // Reserve capacity before the expensive TLS handshake begins without
        // ever letting the counter exceed MAX_CONNECTIONS.
        let current = match self.connection_count.fetch_update(
            Ordering::SeqCst,
            Ordering::SeqCst,
            |current| (current < MAX_CONNECTIONS).then_some(current + 1),
        ) {
            Ok(previous) => previous,
            Err(_) => {
                log::warn!("Rejecting connection from {}: limit reached", addr);
                return None;
            }
        };

        if !self.admission_limiter.check(addr.ip()) {
            self.connection_count.fetch_sub(1, Ordering::SeqCst);
            log::warn!("Rate limited before TLS handshake: {}", addr);
            return None;
        }

        log::info!("Admitted connection from {} (total: {})", addr, current + 1);

        Some(ConnectionPermit {
            connection_count: Arc::clone(&self.connection_count),
        })
    }

    pub async fn handle<S>(
        &self,
        mut stream: S,
        addr: SocketAddr,
        _permit: ConnectionPermit,
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
    where
        S: AsyncRead + AsyncWrite + Unpin,
    {
        log::info!("Handling established connection from {}", addr);

        // Handle with overall session timeout
        let result = timeout(
            Duration::from_secs(MAX_SESSION_SECS),
            self.handle_inner(&mut stream, addr),
        ).await;
        
        match result {
            Ok(Ok(())) => log::info!("Connection from {} closed normally", addr),
            Ok(Err(e)) => {
                if e.downcast_ref::<io::Error>()
                    .is_some_and(|io_error| io_error.kind() == io::ErrorKind::PermissionDenied)
                {
                    log::warn!("Connection from {} closed after rate limiting", addr);
                } else {
                    log::error!("Error handling {}: {}", addr, e);
                }
            }
            Err(_) => log::warn!("Session timeout for {}", addr),
        }

        Ok(())
    }
    
    async fn handle_inner<S>(
        &self,
        stream: &mut S,
        addr: SocketAddr,
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
    where
        S: AsyncRead + AsyncWrite + Unpin,
    {
        let mut buffer = vec![0u8; MAX_MESSAGE_SIZE];
        let mut buffered: usize = 0;
        
        loop {
            // Read with timeout, accumulating partial reads into buffer
            let n = timeout(
                Duration::from_secs(READ_TIMEOUT_SECS),
                stream.read(&mut buffer[buffered..]),
            ).await??;
            
            if n == 0 {
                if buffered == 0 {
                    break; // Connection closed
                }
                return Err(std::io::Error::new(
                    std::io::ErrorKind::UnexpectedEof,
                    "connection closed mid-frame",
                ).into());
            }
            
            buffered += n;
            
            while buffered > 0 {
                // Count both valid and malformed frames once enough bytes are
                // buffered to classify them. Incomplete fragments are not
                // charged yet.
                let message = match Message::from_bytes(&buffer[..buffered]) {
                    Ok(msg) => {
                        if !self.request_limiter.check(addr.ip()) {
                            log::warn!("Per-request rate limit exceeded for {}", addr);
                            let error_response = echo_response(RATE_LIMIT_RESPONSE)?;
                            timeout(
                                Duration::from_secs(WRITE_TIMEOUT_SECS),
                                stream.write_all(&error_response),
                            ).await??;
                            return Err(io::Error::new(
                                io::ErrorKind::PermissionDenied,
                                "rate limit exceeded",
                            ).into());
                        }
                        msg
                    },
                    Err(ProtocolError::IncompleteHeader) | Err(ProtocolError::IncompleteMessage { .. }) => {
                    // Need more data - keep reading
                    break;
                    }
                    Err(e) => {
                    if !self.request_limiter.check(addr.ip()) {
                        log::warn!("Per-request rate limit exceeded for {}", addr);
                        let error_response = echo_response(RATE_LIMIT_RESPONSE)?;
                        timeout(
                            Duration::from_secs(WRITE_TIMEOUT_SECS),
                            stream.write_all(&error_response),
                        ).await??;
                        return Err(io::Error::new(
                            io::ErrorKind::PermissionDenied,
                            "rate limit exceeded",
                        ).into());
                    }
                    log::warn!("Invalid message from {}: {}", addr, e);
                    // Keep parser details in logs, not on the wire.
                    let error_response = echo_response(b"invalid request")?;
                    timeout(
                        Duration::from_secs(WRITE_TIMEOUT_SECS),
                        stream.write_all(&error_response),
                    ).await??;
                    buffered = 0;
                    break;
                    }
                };
                // Echo response
                let response = echo_response(message.payload())?;
                
                timeout(
                    Duration::from_secs(WRITE_TIMEOUT_SECS),
                    stream.write_all(&response),
                ).await??;
                
                // Shift unconsumed bytes to the front of the buffer
                let consumed = message.as_bytes().len();
                buffered -= consumed;
                if buffered > 0 {
                    buffer.copy_within(consumed..consumed + buffered, 0);
                }
            }
        }
        
        Ok(())
    }
}
}

Passing ConnectionPermit by value is the RAII part of the design: explicit increment/decrement pairs are easy to miss on early returns, timeout branches, or future refactors, while Drop keeps the counter balanced on every normal exit path.

17.7 Main Server

extern crate rust_secure_systems_book;
use rust_secure_systems_book::companion::ch17_hardened_server as ch17_hardened_server;
use rust_secure_systems_book::deps::env_logger as env_logger;
use rust_secure_systems_book::deps::log as log;
use rust_secure_systems_book::deps::tokio as tokio;
use rust_secure_systems_book::deps::tokio_rustls as tokio_rustls;
// src/main.rs
use ch17_hardened_server::{handler::ConnectionHandler, rate_limiter::RateLimiter, tls, types};
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::watch;
use tokio::task::JoinSet;
use tokio::time::{timeout, Duration};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    env_logger::Builder::from_env(
        env_logger::Env::default().default_filter_or("info")
    ).init();
    
    let admission_limiter = Arc::new(RateLimiter::new(
        types::CONNECTION_ATTEMPT_RATE_LIMIT,
        std::time::Duration::from_secs(60),
        types::MAX_TRACKED_CLIENTS,
    ));
    let request_limiter = Arc::new(RateLimiter::new(
        types::RATE_LIMIT,
        std::time::Duration::from_secs(60),
        types::MAX_TRACKED_CLIENTS,
    ));
    
    let handler = Arc::new(ConnectionHandler::new(
        Arc::clone(&admission_limiter),
        Arc::clone(&request_limiter),
    ));

    // Hardened server: require TLS configuration at startup and fail fast if it
    // is missing. Development-only plain TCP belongs in a separate example.
    let cert_path = std::env::var("TLS_CERT_PATH")
        .map_err(|_| "TLS_CERT_PATH must be set for the hardened server")?;
    let key_path = std::env::var("TLS_KEY_PATH")
        .map_err(|_| "TLS_KEY_PATH must be set for the hardened server")?;
    let config = tls::create_server_config(&cert_path, &key_path)?;
    let tls_acceptor = tokio_rustls::TlsAcceptor::from(config);
    
    let listener = TcpListener::bind("0.0.0.0:8443").await?;
    log::info!("Server listening on 0.0.0.0:8443 with TLS enabled");
    
    let (shutdown_tx, shutdown_rx) = watch::channel(false);
    let mut tasks = JoinSet::new();

    // Periodic cleanup task
    let cleanup_admission_limiter = Arc::clone(&admission_limiter);
    let cleanup_request_limiter = Arc::clone(&request_limiter);
    let mut cleanup_shutdown = shutdown_rx.clone();
    tasks.spawn(async move {
        let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
        loop {
            tokio::select! {
                _ = interval.tick() => {
                    cleanup_admission_limiter.cleanup();
                    cleanup_request_limiter.cleanup();
                }
                changed = cleanup_shutdown.changed() => {
                    if changed.is_err() || *cleanup_shutdown.borrow() {
                        break;
                    }
                }
            }
        }
    });

    let shutdown = shutdown_signal();
    tokio::pin!(shutdown);
    
    loop {
        tokio::select! {
            biased;

            result = &mut shutdown => {
                result?;
                log::info!("Shutdown signal received; stopping new accepts");
                break;
            }

            accepted = listener.accept() => {
                let (stream, addr) = accepted?;
                if let Err(e) = stream.set_nodelay(true) {
                    log::warn!("Failed to configure TCP_NODELAY for {}: {}", addr, e);
                    continue;
                }

                let Some(permit) = handler.try_admit(addr) else {
                    continue;
                };

                let handler = Arc::clone(&handler);
                let tls_acceptor = tls_acceptor.clone();
                
                tasks.spawn(async move {
                    let tls_stream = match timeout(
                        Duration::from_secs(types::TLS_HANDSHAKE_TIMEOUT_SECS),
                        tls_acceptor.accept(stream),
                    ).await {
                        Ok(Ok(tls_stream)) => {
                            log::info!("TLS handshake completed for {}", addr);
                            tls_stream
                        }
                        Ok(Err(e)) => {
                            log::error!("TLS handshake failed for {}: {}", addr, e);
                            return;
                        }
                        Err(_) => {
                            log::warn!("TLS handshake timeout for {}", addr);
                            return;
                        }
                    };
                    
                    if let Err(e) = handler.handle(tls_stream, addr, permit).await {
                        log::error!("Fatal error for {}: {}", addr, e);
                    }
                    // `permit` was moved into `handle`; it is dropped when
                    // `handle` returns, decrementing the connection count on
                    // every exit path instead of relying on a hand-written
                    // `fetch_sub` at each return site.
                });
            }
        }
    }

    drop(listener);
    let _ = shutdown_tx.send(true);

    match timeout(
        Duration::from_secs(types::SHUTDOWN_GRACE_SECS),
        wait_for_tasks(&mut tasks),
    ).await {
        Ok(()) => log::info!("Shutdown completed cleanly"),
        Err(_) => {
            log::warn!(
                "Graceful shutdown timed out after {}s; aborting remaining tasks",
                types::SHUTDOWN_GRACE_SECS
            );
            tasks.abort_all();
            wait_for_tasks(&mut tasks).await;
        }
    }

    Ok(())
}

async fn wait_for_tasks(tasks: &mut JoinSet<()>) {
    while let Some(result) = tasks.join_next().await {
        if let Err(e) = result {
            if e.is_cancelled() {
                log::info!("Task cancelled during shutdown");
            } else {
                log::error!("Task failed during shutdown: {}", e);
            }
        }
    }
}

#[cfg(unix)]
async fn shutdown_signal() -> std::io::Result<()> {
    use tokio::signal::unix::{SignalKind, signal};

    let mut terminate = signal(SignalKind::terminate())?;
    tokio::select! {
        _ = tokio::signal::ctrl_c() => Ok(()),
        _ = terminate.recv() => Ok(()),
    }
}

#[cfg(not(unix))]
async fn shutdown_signal() -> std::io::Result<()> {
    tokio::signal::ctrl_c().await
}

⚠️ Security note: 0.0.0.0 is intentional here because this example represents the externally reachable production service. For local development bind 127.0.0.1; in production prefer the specific interface, socket-activation unit, or load-balancer path you actually intend to expose.

If the production service must accept both IPv4 and IPv6, do not assume this listener is enough. An explicit IPv4 bind keeps the example simple and portable, but real deployments often need either a second IPv6 listener or a deliberate [::]:8443 socket after verifying the target platform’s IPV6_V6ONLY behavior.

Admission control happens before tls_acceptor.accept(), so connection floods consume a bounded number of slots and cannot trigger unlimited concurrent handshakes. A separate per-request limiter runs once enough bytes are buffered to classify a frame, so malformed requests also consume the same request budget and one long-lived TLS session cannot bypass brute-force throttling.

Note: The handler already accepts any AsyncRead + AsyncWrite + Unpin stream, so the same code works with TcpStream, tokio_rustls::server::TlsStream<TcpStream>, and test doubles.

Production shutdown is part of hardening too. The main loop above implements the bounded-drain pattern directly: listen for SIGTERM (or Ctrl+C during development), stop accepting new sockets, notify the housekeeping task, wait up to SHUTDOWN_GRACE_SECS for in-flight connections, then abort anything still running. That avoids half-written responses, leaked permits, and the operational habit of using SIGKILL for routine restarts.

17.8 Tests

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::proptest as proptest;
// src/types.rs
#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    #[test]
    fn test_message_validation_valid() {
        let payload = b"hello";
        let mut data = (payload.len() as u32).to_be_bytes().to_vec();
        data.extend_from_slice(payload);
        
        let msg = Message::from_bytes(&data).unwrap();
        assert_eq!(msg.payload(), payload);
    }

    #[test]
    fn test_message_validation_too_large() {
        let large_size = MAX_MESSAGE_SIZE + 1;
        let data = vec![0u8; large_size];
        assert!(Message::from_bytes(&data).is_err());
    }

    #[test]
    fn test_message_validation_incomplete() {
        // Only 2 bytes of header
        assert!(Message::from_bytes(&[0, 0]).is_err());
    }

    #[test]
    fn test_zero_length_frame_rejected() {
        assert!(matches!(
            Message::from_bytes(&[0, 0, 0, 0]),
            Err(ProtocolError::EmptyMessage)
        ));
    }

    #[test]
    fn test_message_validation_declared_length_mismatch() {
        let mut data = 1000u32.to_be_bytes().to_vec();
        data.extend_from_slice(b"short");
        assert!(Message::from_bytes(&data).is_err());
    }

    #[test]
    fn test_echo_response_format() {
        let response = echo_response(b"test").unwrap();
        let len = u32::from_be_bytes([response[0], response[1], response[2], response[3]]);
        assert_eq!(len, 4);
        assert_eq!(&response[4..], b"test");
    }

    proptest! {
        #[test]
        fn message_roundtrip(payload in prop::collection::vec(any::<u8>(), 1..1000)) {
            let response = echo_response(&payload).unwrap();
            let msg = Message::from_bytes(&response).unwrap();

            assert_eq!(msg.payload(), &payload[..]);
        }
    }
}
}

If you later split shared code into src/lib.rs, the same test cases can be moved into tests/ as integration tests with minimal changes.

17.9 Security Review Checklist

Review this server against our security checklist:

  • Memory safety: No unsafe code (safe Rust throughout)
  • Integer overflow: overflow-checks = true in release, checked length parsing
  • Input validation: Message::from_bytes validates length, bounds, format
  • Connection limiting: MAX_CONNECTIONS enforced before the TLS handshake starts
  • Rate limiting: Connection-attempt throttling before the handshake and per-IP request limiting for both valid and malformed frames once they are parseable
  • Timeouts: Read, write, and session timeouts
  • Logging: Security-relevant logs with no sensitive data
  • Error handling: No unwrap() in production paths; mutex poisoning is recovered explicitly
  • TLS: rustls is required; the server refuses to start without TLS_CERT_PATH and TLS_KEY_PATH
  • DoS protection: Bounded message sizes, handshake/session timeouts, and both admission/request rate limits

17.10 Summary

This server demonstrates the practical application of secure Rust development:

  1. Types enforce validity: Message validates at construction time
  2. Defense in depth: Connection limits, rate limits, timeouts, message size limits
  3. Safe concurrency: Arc, AtomicUsize, Mutex with clear ownership
  4. No unsafe code: The entire server is safe Rust
  5. Comprehensive testing: Unit tests and property tests around the protocol layer

In the next chapter, we build a secure binary parser: another common security-critical component.

Note: This server uses the log crate for simplicity. See §19.4.1 for a drop-in path to replace log/env_logger with tracing once you need structured JSON logs, request spans, or SIEM-friendly fields.

17.11 Exercises

  1. Mutual TLS: Extend create_server_config() to require and verify client certificates for privileged peers. Add tests for: valid client certificate accepted, unknown CA rejected, expired client certificate rejected, and plaintext clients rejected before application data is processed.

  2. TLS Handshake Drain: Extend the shutdown path so it tracks in-flight TLS handshakes separately from established sessions. On shutdown, stop accepting new sockets, wait up to SHUTDOWN_GRACE_SECS for both handshakes and active sessions to drain, then abort anything still stuck. Add tests that verify the connection counter returns to zero and that a stalled handshake cannot hang shutdown forever.

  3. Load Test: Write a load-testing client that opens 500 concurrent connections and sends 100 messages per connection. Verify that: (a) the server never exceeds MAX_CONNECTIONS, (b) the rate limiter triggers for abusive clients, (c) no panics or errors appear in the server logs. Measure throughput and latency percentiles (p50, p99).

Chapter 18 - Secure Parser Construction

“Parsers are the gateway to every system. Secure the gateway.”

Binary parsers are among the most security-critical components in any system. History shows that parser bugs account for a disproportionate share of critical vulnerabilities: buffer overflows in image decoders, integer overflows in packet parsers, and logic errors in protocol state machines. This chapter builds a secure binary protocol parser using Rust’s type system to prevent common parser vulnerabilities.

18.1 The Parser Threat Model

VulnerabilityCWEHow Rust Helps
Buffer over-readCWE-125Bounds-checked slicing
Buffer over-writeCWE-787Bounds-checked indexing
Integer overflow in lengthCWE-190Checked arithmetic
Uninitialized memory readCWE-908MaybeUninit required for unsafe
Type confusionCWE-843Strong type system
Denial of service (OOM)CWE-789Size limits
State machine confusionCWE-1265Type-state pattern

18.2 Design Principles for Secure Parsers

  1. Parse, don’t validate: Transform raw bytes into strongly-typed structures.
  2. Fail fast: Reject invalid input at the earliest possible point.
  3. No panics: Use Result for all fallible operations.
  4. Bounded allocation: Never allocate based on untrusted size fields without limits.
  5. No unsafe: Parsers should be implementable entirely in safe Rust.

18.2.1 Parser Combinators with nom

This chapter uses a hand-written parser because explicit control flow is easy to audit, makes allocation limits obvious, and keeps every boundary check visible. That is not the only defensible choice. nom is a mature parser-combinator library and can be a good fit when you want declarative composition without writing pointer arithmetic by hand.

From a security perspective, three nom design choices matter:

  • Choose the right mode: complete parsers treat missing bytes as hard errors, while streaming parsers return Incomplete. For sockets and framed protocols, that distinction is part of your threat model.
  • Bound input before parsing. A combinator library does not remove the need for maximum message sizes, checked length arithmetic, or duplicate-field policy.
  • Keep parsers inspectable. Favor small named combinators and explicit error mapping over deeply nested expressions that hide which branch consumed input.

Use nom when it improves clarity; use a hand-written parser when explicit state transitions and bounds checks are easier to review. For security-critical formats, “shorter code” is only a win if the rejection behavior stays obvious.

18.2.2 Allocation Failure and Parser Depth

Size limits prevent obvious OOM bugs, but they do not guarantee allocation succeeds under pressure. Rust’s default Vec growth path still calls handle_alloc_error; with panic = "abort" elsewhere in your deployment, that can become process termination. When you must buffer attacker-controlled lengths, reserve fallibly and turn memory pressure into a normal parse error:

#![allow(unused)]
fn main() {
fn copy_value_fallible(data: &[u8]) -> Result<Vec<u8>, &'static str> {
    let mut out = Vec::new();
    out.try_reserve_exact(data.len())
        .map_err(|_| "allocation failed while buffering parser input")?;
    out.extend_from_slice(data);
    Ok(out)
}
}

Also distinguish byte-size limits from stack-depth limits. The TLV parser in this chapter is iterative, so deeply nested input cannot blow the stack. If you write a recursive parser for nested formats, carry an explicit depth counter and reject excessive nesting; otherwise, “valid but deep” input can still crash the process. Helpers such as stacker or serde_stacker are worth evaluating when recursion is unavoidable.

18.3 Example: A Secure TLV (Type-Length-Value) Parser

18.3.1 Type Definitions

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
extern crate self as thiserror;
pub use rust_secure_systems_book::deps::thiserror::Error;
pub use rust_secure_systems_book::deps::thiserror::*;
// src/tlv.rs
use std::fmt;

/// Maximum single TLV value size (1 MiB).
/// Tune this to the largest field your protocol actually needs.
const MAX_VALUE_SIZE: usize = 1024 * 1024;

/// Maximum total message size (16 MiB).
/// This is a teaching/example ceiling, not a universal default.
/// Derive the real value from the protocol spec and lower it on
/// memory-constrained deployments.
const MAX_TOTAL_SIZE: usize = 16 * 1024 * 1024;

/// TLV type tags with semantic meaning.
///
/// Note: We do **not** use `#[repr(u8)]` for the wire-format mapping here.
/// `Extension(u8)` is a catch-all for many possible extension tag bytes, so the
/// enum's in-memory discriminant is not the same thing as the protocol tag.
/// Instead, the `from_byte` and `as_byte` methods handle the tag↔enum mapping
/// manually.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TlvTag {
    Padding,
    KeyId,
    Algorithm,
    Iv,
    Ciphertext,
    AuthTag,
    Aad,
    Certificate,
    Signature,
    Timestamp,
    Nonce,
    Extension(u8),
}

impl TlvTag {
    fn from_byte(byte: u8) -> Self {
        match byte {
            0x00 => TlvTag::Padding,
            0x01 => TlvTag::KeyId,
            0x02 => TlvTag::Algorithm,
            0x03 => TlvTag::Iv,
            0x04 => TlvTag::Ciphertext,
            0x05 => TlvTag::AuthTag,
            0x06 => TlvTag::Aad,
            0x07 => TlvTag::Certificate,
            0x08 => TlvTag::Signature,
            0x09 => TlvTag::Timestamp,
            0x0A => TlvTag::Nonce,
            other => TlvTag::Extension(other),
        }
    }
    
    fn as_byte(&self) -> u8 {
        match self {
            TlvTag::Padding => 0x00,
            TlvTag::KeyId => 0x01,
            TlvTag::Algorithm => 0x02,
            TlvTag::Iv => 0x03,
            TlvTag::Ciphertext => 0x04,
            TlvTag::AuthTag => 0x05,
            TlvTag::Aad => 0x06,
            TlvTag::Certificate => 0x07,
            TlvTag::Signature => 0x08,
            TlvTag::Timestamp => 0x09,
            TlvTag::Nonce => 0x0A,
            TlvTag::Extension(b) => *b,
        }
    }

    fn is_reserved_extension_value(byte: u8) -> bool {
        // `from_byte` maps 0x00..=0x0A to named variants, so this guard mainly
        // defends manual construction such as `TlvTag::Extension(0x01)`.
        matches!(byte, 0x00..=0x0A)
    }
}

/// A parsed TLV record with validated bounds
#[derive(Debug, Clone)]
pub struct TlvRecord {
    tag: TlvTag,
    value: Vec<u8>,
}

impl TlvRecord {
    pub fn tag(&self) -> TlvTag {
        self.tag
    }
    
    pub fn value(&self) -> &[u8] {
        &self.value
    }
    
    /// Construct a TLV record with validation
    pub fn new(tag: TlvTag, value: Vec<u8>) -> Result<Self, ParseError> {
        if value.len() > MAX_VALUE_SIZE {
            return Err(ParseError::ValueTooLarge {
                size: value.len(),
                max: MAX_VALUE_SIZE,
            });
        }
        if let TlvTag::Extension(byte) = tag {
            if TlvTag::is_reserved_extension_value(byte) {
                return Err(ParseError::ReservedExtensionTag { byte });
            }
        }
        Ok(TlvRecord { tag, value })
    }
    
    /// Serialize to bytes
    pub fn to_bytes(&self) -> Vec<u8> {
        let mut bytes = Vec::with_capacity(5 + self.value.len());
        bytes.push(self.tag.as_byte());
        let len = self.value.len() as u32;
        bytes.extend_from_slice(&len.to_be_bytes());
        bytes.extend_from_slice(&self.value);
        bytes
    }
}

/// A collection of TLV records (a TLV message)
#[derive(Debug, Clone)]
pub struct TlvMessage {
    records: Vec<TlvRecord>,
}

impl TlvMessage {
    pub fn records(&self) -> &[TlvRecord] {
        &self.records
    }
    
    pub fn get(&self, tag: TlvTag) -> Option<&TlvRecord> {
        self.records.iter().find(|r| r.tag == tag)
    }
    
    /// Parse a TLV message from raw bytes.
    ///
    /// This format treats every non-padding tag as unique. Rejecting duplicates
    /// keeps the representation canonical and avoids higher layers disagreeing
    /// about whether "first wins" or "last wins".
    pub fn parse(data: &[u8]) -> Result<Self, ParseError> {
        if data.len() > MAX_TOTAL_SIZE {
            return Err(ParseError::MessageTooLarge {
                size: data.len(),
                max: MAX_TOTAL_SIZE,
            });
        }
        
        let mut records: Vec<TlvRecord> = Vec::new();
        // A fixed 256-byte bitmap is cheap and allocation-free for a u8 tag
        // space. For wider or sparse tag spaces, prefer a HashSet or similar.
        let mut seen_tags = [false; 256];
        let mut offset = 0usize;
        
        while offset < data.len() {
            // Need at least 5 bytes for tag + length
            if offset.checked_add(5).ok_or(ParseError::IntegerOverflow)? > data.len() {
                return Err(ParseError::IncompleteHeader { offset });
            }
            
            let tag = TlvTag::from_byte(data[offset]);
            offset += 1;
            
            // Read 4-byte big-endian length (network byte order)
            let length = u32::from_be_bytes([
                data[offset],
                data[offset + 1],
                data[offset + 2],
                data[offset + 3],
            ]) as usize;
            offset += 4;
            
            // Validate length
            if length > MAX_VALUE_SIZE {
                return Err(ParseError::ValueTooLarge {
                    size: length,
                    max: MAX_VALUE_SIZE,
                });
            }
            
            // Check we have enough data
            let end = offset.checked_add(length).ok_or(ParseError::IntegerOverflow)?;
            if end > data.len() {
                return Err(ParseError::IncompleteValue {
                    expected: end,
                    available: data.len(),
                });
            }
            
            // Extract value (skip padding)
            let value = data[offset..end].to_vec();
            offset = end;
            
            if !matches!(tag, TlvTag::Padding) {
                let tag_byte = tag.as_byte() as usize;
                if seen_tags[tag_byte] {
                    return Err(ParseError::DuplicateTag { tag });
                }
                seen_tags[tag_byte] = true;
                records.push(TlvRecord::new(tag, value)?);
            }
        }
        
        Ok(TlvMessage { records })
    }
    
    /// Serialize to bytes
    pub fn to_bytes(&self) -> Vec<u8> {
        let mut bytes = Vec::new();
        for record in &self.records {
            bytes.extend_from_slice(&record.to_bytes());
        }
        bytes
    }
}

#[derive(Debug)]
pub enum ParseError {
    MessageTooLarge { size: usize, max: usize },
    IncompleteHeader { offset: usize },
    ValueTooLarge { size: usize, max: usize },
    IncompleteValue { expected: usize, available: usize },
    DuplicateTag { tag: TlvTag },
    ReservedExtensionTag { byte: u8 },
    IntegerOverflow,
}

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ParseError::MessageTooLarge { size, max } => {
                write!(f, "message too large: {size} bytes (max {max})")
            }
            ParseError::IncompleteHeader { offset } => {
                write!(f, "incomplete header at offset {offset}")
            }
            ParseError::ValueTooLarge { size, max } => {
                write!(f, "value too large: {size} bytes (max {max})")
            }
            ParseError::IncompleteValue { expected, available } => {
                write!(f, "incomplete value: expected {expected} bytes, have {available}")
            }
            ParseError::DuplicateTag { tag } => {
                write!(f, "duplicate tag not allowed: {tag:?}")
            }
            ParseError::ReservedExtensionTag { byte } => {
                write!(f, "extension tag byte collides with a named tag: 0x{byte:02X}")
            }
            ParseError::IntegerOverflow => f.write_str("integer overflow in length calculation"),
        }
    }
}

impl std::error::Error for ParseError {}
}

Rejecting duplicate non-padding tags is a deliberate security choice. If your protocol genuinely allows repeated fields, model them explicitly as a multi-valued field rather than relying on an accessor like get() to silently pick one occurrence.

18.4 Type-State Pattern for Protocol State Machines

The type-state pattern uses Rust’s type system to encode protocol states, making invalid transitions unrepresentable:

#![allow(unused)]
fn main() {
// src/protocol.rs
use std::io;
use std::net::TcpStream;
struct Credentials;
#[derive(Debug)]
struct AuthError;
fn verify_credentials(_credentials: &Credentials) -> Result<u64, AuthError> {
    Ok(7)
}

/// A connection in the initial (unauthenticated) state
pub struct Unauthenticated;

/// A connection in the authenticated state
pub struct Authenticated {
    user_id: u64,
}

/// A connection in the encrypted state
pub struct Encrypted {
    user_id: u64,
    session_key: [u8; 32],
}

/// A protocol connection that enforces state transitions at compile time
pub struct Connection<S> {
    stream: TcpStream,
    state: S,
}

impl Connection<Unauthenticated> {
    pub fn new(stream: TcpStream) -> Self {
        Connection {
            stream,
            state: Unauthenticated,
        }
    }
    
    /// Authenticate the connection. Only callable in the Unauthenticated state.
    pub fn authenticate(self, credentials: &Credentials) -> Result<Connection<Authenticated>, AuthError> {
        let user_id = verify_credentials(credentials)?;
        Ok(Connection {
            stream: self.stream,
            state: Authenticated { user_id },
        })
    }
}

impl Connection<Authenticated> {
    /// Upgrade to encrypted. Only callable in the Authenticated state.
    pub fn upgrade_to_encrypted(self, key: [u8; 32]) -> Connection<Encrypted> {
        Connection {
            stream: self.stream,
            state: Encrypted {
                user_id: self.state.user_id,
                session_key: key,
            },
        }
    }
    
    /// Send data in the clear (authenticated but not encrypted)
    pub fn send(&mut self, data: &[u8]) -> io::Result<()> {
        // ...
        Ok(())
    }
}

impl Connection<Encrypted> {
    /// Send encrypted data. Only available in Encrypted state.
    pub fn send_encrypted(&mut self, data: &[u8]) -> io::Result<()> {
        // Encrypt with self.state.session_key
        Ok(())
    }
    
    pub fn user_id(&self) -> u64 {
        self.state.user_id
    }
}

// This code will NOT compile:
fn exploit(conn: Connection<Unauthenticated>) {
    // conn.send(b"data");  // ERROR: no method `send` on Connection<Unauthenticated>
    // conn.send_encrypted(b"data");  // ERROR: no method on Unauthenticated
}
}

🔒 Security impact: The type-state pattern prevents:

  • Sending data before authentication
  • Sending unencrypted data after the connection is upgraded
  • Accessing encrypted features without establishing a session key
  • All enforced at compile time, not runtime

18.5 Fuzzing the Parser

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
extern crate libfuzzer_sys;
use rust_secure_systems_book::tlv_parser as tlv_parser;
// fuzz/fuzz_targets/tlv_parser.rs
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
    // The parser should never panic, regardless of input
    let _ = tlv_parser::TlvMessage::parse(data);
});
}
#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
extern crate arbitrary;
extern crate libfuzzer_sys;
use rust_secure_systems_book::tlv_parser as tlv_parser;
// fuzz/fuzz_targets/tlv_roundtrip.rs
use arbitrary::Arbitrary;

#[derive(Debug, arbitrary::Arbitrary)]
struct TlvInput {
    records: Vec<(u8, Vec<u8>)>,
}

libfuzzer_sys::fuzz_target!(|input: TlvInput| {
    // Build a valid TLV message
    let mut bytes = Vec::new();
    for (tag, value) in &input.records {
        if value.len() > 1024 * 1024 { continue; }
        bytes.push(*tag);
        let len = (value.len() as u32).to_be_bytes();
        bytes.extend_from_slice(&len);
        bytes.extend_from_slice(value);
    }
    
    // Parse it back
    if let Ok(msg) = tlv_parser::TlvMessage::parse(&bytes) {
        // Roundtrip: serialize and parse again
        let re_serialized = msg.to_bytes();
        let reparsed = tlv_parser::TlvMessage::parse(&re_serialized).unwrap();
        assert_eq!(msg.records().len(), reparsed.records().len());
    }
});
}

18.6 Property-Based Tests

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::proptest as proptest;
use rust_secure_systems_book::tlv_parser as tlv_parser;
// tests/tlv_properties.rs
use proptest::prelude::*;
use tlv_parser::*;

fn tlv_message_strategy() -> impl Strategy<Value = Vec<(u8, Vec<u8>)>> {
    proptest::collection::btree_map(
        any::<u8>().prop_filter("padding is skipped and duplicates are rejected", |tag| *tag != 0x00),
        proptest::collection::vec(any::<u8>(), 0..1024),
        0..20,
    )
    .prop_map(|records| records.into_iter().collect())
}

proptest! {
    #[test]
    fn parse_roundtrip(records in tlv_message_strategy()) {
        // Build message
        let mut bytes = Vec::new();
        for (tag, value) in &records {
            bytes.push(*tag);
            let len = (value.len() as u32).to_be_bytes();
            bytes.extend_from_slice(&len);
            bytes.extend_from_slice(value);
        }
        
        // Parse
        let msg = TlvMessage::parse(&bytes).unwrap();
        
        // Re-serialize
        let re_bytes = msg.to_bytes();
        
        // Re-parse
        let reparsed = TlvMessage::parse(&re_bytes).unwrap();
        
        assert_eq!(records.len(), reparsed.records().len());
    }
    
    #[test]
    fn parser_never_panics(data in proptest::collection::vec(any::<u8>(), 0..65536)) {
        let _ = TlvMessage::parse(&data);  // Should never panic
    }
    
    #[test]
    fn oversized_values_rejected(value in proptest::collection::vec(any::<u8>(), 1024 * 1024 + 1..1024 * 1024 + 100)) {
        let mut bytes = vec![0x01];  // tag
        let len = (value.len() as u32).to_be_bytes();
        bytes.extend_from_slice(&len);
        bytes.extend_from_slice(&value);
        
        assert!(TlvMessage::parse(&bytes).is_err());
    }
}
}

18.7 Summary

This parser demonstrates key security principles:

  1. Type-driven parsing: TlvTag enum constrains valid tag values.
  2. Bounded allocation: Every size field is validated against limits before allocation.
  3. Checked arithmetic: All offset calculations use checked_add.
  4. No panics: All errors return Result, never panic.
  5. Type-state pattern: Protocol states are encoded in types, preventing invalid transitions.
  6. Fuzzing: The parser is designed to be fuzzable with no panics on any input.
  7. Canonical encoding: Duplicate non-padding tags are rejected so higher layers never guess which value “wins”.
  8. Roundtrip property: Serialization followed by parsing produces equivalent results for canonical messages.
  9. Iterative structure: The parser avoids attacker-controlled recursion depth and the stack-overflow risk that comes with it.

In the final chapter, we cover deployment hardening: how to build, configure, and deploy Rust applications for maximum security in production.

18.8 Exercises

  1. Streaming Parser: Extend the TlvMessage parser to support incremental (streaming) parsing: it should accept partial data, return Incomplete when more bytes are needed, and resume parsing when more data arrives. This is essential for TCP-based protocols where a message may arrive in multiple read() calls.

  2. Parser Combinators with nom: Rewrite the TLV parser using the nom crate. Compare the code size, error quality, and performance against the hand-written parser. Discuss which approach is better for security auditing (fewer lines vs. more explicit control).

  3. Fuzzing the Parser: Create a cargo-fuzz target for the TlvMessage parser. Run it for at least 30 minutes. If any crashes or hangs are found, minimize the input, analyze the root cause, fix the bug, and add a regression test.

Chapter 19 - Deployment Hardening and Release

“Secure code deployed insecurely is insecure.”

Writing secure Rust code is necessary but not sufficient. How you compile, package, deploy, and operate your software determines its real-world security posture. This chapter covers the full deployment pipeline, from compiler hardening flags to runtime protections, from container security to monitoring.

This chapter is intentionally generic, but the concrete commands use the Chapter 17 companion service from this repository where that makes verification less ambiguous. When you see ch17-hardened-server, substitute your own package or binary name in a different codebase.

19.1 Compilation Hardening

19.1.1 Release Profile Configuration

# Cargo.toml
[profile.release]
# Security-relevant settings
overflow-checks = true       # Panic on integer overflow (prevents CWE-190)
lto = true                   # Link-time optimization (removes dead code, reduces attack surface)
codegen-units = 1            # Single codegen unit (better optimization, no parallel shortcuts)
panic = "abort"              # Abort on panic (smaller binary, no unwinding table attack surface)
strip = "symbols"            # Strip debug symbols from release binary
opt-level = "z"              # Optimize for size (reduces binary footprint and attack surface)

# Optional alternative for crash analysis:
# debug = 1                  # Smaller than full debug info; useful for post-mortem builds

panic = "abort" is a tradeoff, not a universal default. It reduces unwinding machinery, but it also skips Drop on panic paths. If your cleanup or zeroization strategy relies on destructors, verify that an aborting build is acceptable or ship a separate unwinding/debug artifact for those operational needs.

Be precise about strip: strip = true means debuginfo, not full symbol stripping. Use strip = "symbols" when you intentionally want the more aggressive setting shown here.

Chapter 2 intentionally uses debug = true because that profile is for local crash analysis and interactive debugging. Here, debug = 1 is the smaller compromise for hardened release artifacts that still need some post-mortem symbol information. If you only need filename and line-number backtraces, debug = "line-tables-only" is smaller still.

19.1.2 Linker Hardening Flags

# .cargo/config.toml (Linux)
[build]
rustflags = [
    # Keep frame pointers for profiling and post-mortem analysis
    "-C", "force-frame-pointers=yes",
    
    # Link-time hardening
    "-C", "link-arg=-Wl,-z,noexecstack",    # Non-executable stack (NX bit)
    "-C", "link-arg=-Wl,-z,relro",          # Partial RELRO (read-only relocations)
    "-C", "link-arg=-Wl,-z,now",            # Full RELRO (resolve all symbols at load)
    
    # Position-independent executable (required for ASLR)
    "-C", "relocation-model=pie",
]

# Stable Rust/Linux already enables PIE, NX, and RELRO for ordinary binaries.
# These extra linker flags are mostly useful when you want the intent to be
# explicit or when you also link C/assembly objects.
#
# Note: For C dependencies compiled via the `cc` crate, set environment variables:
#   CFLAGS="-O2 -D_FORTIFY_SOURCE=2 -fstack-protector-strong"
# These are C compiler flags, not Rust linker flags.
# They apply during compilation of C code, not at link time. On stable Rust,
# stack-smashing protection for Rust code itself is not enabled via these flags.

[target.x86_64-unknown-linux-gnu]
rustflags = [
    # CET feature names are target- and toolchain-specific.
    # There is no universal `+cet` umbrella flag in rustc/LLVM.
    "-C", "target-feature=+shstk",  # Shadow stack when exposed by your toolchain
    # "-C", "target-feature=+ibt",  # Indirect Branch Tracking on toolchains that expose it
]

Treat CET as advanced hardening, not a copy-paste default. Verify support with rustc --print target-features --target x86_64-unknown-linux-gnu, then confirm the resulting binary actually carries the properties you expect. Make CI fail closed here: if the requested target feature is unavailable or the final binary does not show the expected hardening property, stop the release rather than assuming the flag “probably worked.”

19.1.3 Windows-Specific Hardening

# .cargo/config.toml (Windows)
[build]
rustflags = [
    # Control Flow Guard (CFG)
    "-C", "control-flow-guard=yes",
    
    # Dynamic Base (ASLR)
    "-C", "link-arg=/DYNAMICBASE",
    
    # High Entropy ASLR
    "-C", "link-arg=/HIGHENTROPYVA",
    
    # Data Execution Prevention (DEP/NX)
    "-C", "link-arg=/NXCOMPAT",
]

19.1.4 Security Comparison: Hardening Technologies

ProtectionWhat It PreventsLinuxWindows
NX/DEPCode execution on stack/heapEnabled by default; reinforce when linking non-Rust objects/NXCOMPAT
ASLRFixed-address attacksPIE enabled by default/DYNAMICBASE
Stack canariesStack buffer overflow-fstack-protector for C/C++ code; Rust SSP is nightly-only today/GS for MSVC-compiled C/C++
RELROGOT overwriteFull RELRO enabled by default on mainstream targetsN/A (ELF-specific mitigation)
Indirect-call hardeningIndirect-call hijackingCET/LLVM CFI when your toolchain and CPU support them-C control-flow-guard=yes
Fortifyglibc buffer overflow-D_FORTIFY_SOURCE=2 for C dependenciesN/A

19.2 Container Security

19.2.1 Multi-Stage Docker Build

The Dockerfile below uses the Chapter 17 companion service as a real workspace example. If your project is a single crate rather than a workspace, simplify the COPY lines accordingly. In a workspace, Cargo validates every member during the dependency-caching stage, so the example copies every member manifest and creates minimal placeholder targets before the first cargo build.

# Dockerfile

# Stage 1: Build
FROM rust:stable-slim AS builder
# For production, replace the moving tag with a reviewed digest-pinned image.

WORKDIR /app

# Cache dependencies
COPY Cargo.toml Cargo.lock ./
COPY book-snippets/lib.rs book-snippets/lib.rs
COPY companion/ch10-ffi/Cargo.toml companion/ch10-ffi/Cargo.toml
COPY companion/ch12-networking/Cargo.toml companion/ch12-networking/Cargo.toml
COPY companion/ch17-hardened-server/Cargo.toml companion/ch17-hardened-server/Cargo.toml
COPY companion/ch19-hardening/Cargo.toml companion/ch19-hardening/Cargo.toml
RUN mkdir -p companion/ch10-ffi/src \
             companion/ch12-networking/src \
             companion/ch17-hardened-server/src \
             companion/ch19-hardening/src \
 && printf "" > companion/ch10-ffi/src/lib.rs \
 && printf "" > companion/ch12-networking/src/lib.rs \
 && printf "" > companion/ch17-hardened-server/src/lib.rs \
 && printf "fn main() {}\n" > companion/ch17-hardened-server/src/main.rs \
 && printf "" > companion/ch19-hardening/src/lib.rs
RUN cargo build --locked --release -p ch17-hardened-server

# Build actual application
COPY companion/ch17-hardened-server ./companion/ch17-hardened-server
RUN cargo build --locked --release -p ch17-hardened-server

# Stage 2: Minimal runtime
FROM gcr.io/distroless/cc-debian12:nonroot

# Copy only the binary
COPY --from=builder /app/target/release/ch17-hardened-server /usr/local/bin/secure-server

# Re-assert the non-root runtime identity explicitly.
USER nonroot:nonroot

EXPOSE 8443

ENTRYPOINT ["secure-server"]

🔒 Security features of this Dockerfile:

  1. Multi-stage build: Build tools, source code, and build-time dependencies are not in the final image.
  2. Distroless base: No shell, no package manager, minimal attack surface.
  3. Non-root user: Runs as nonroot by default.
  4. No unnecessary files: Only the compiled binary is copied.

ch17-hardened-server is the only binary crate in this workspace example, so it gets a placeholder main.rs. The other workspace members are libraries, so empty lib.rs files are enough for Cargo’s dependency-caching pass.

19.2.2 Container Hardening with Podman/Docker

# docker-compose.yml (or podman kube)
services:
  secure-server:
    image: secure-server:latest
    security_opt:
      - no-new-privileges:true     # Prevent privilege escalation
    cap_drop:
      - ALL                         # Drop all capabilities
    read_only: true                 # Read-only filesystem
    tmpfs:
      - /tmp:noexec,nosuid,size=100m  # Writable temp with restrictions
    volumes:
      - ./certs:/run/certs:ro
    ports:
      - "8443:8443"
    environment:
      - RUST_LOG=info
      - TLS_CERT_PATH=/run/certs/server.crt
      - TLS_KEY_PATH=/run/certs/server.key
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 512M             # Prevent resource exhaustion
        reservations:
          cpus: "0.5"
          memory: 128M

Only add seccomp:seccomp-profile.json after you have produced a profile that is strictly tighter than the runtime default for the exact image you deploy.

No capability is added here because the sample server binds to port 8443, which is not a privileged port on Linux. Only add NET_BIND_SERVICE if you must bind below 1024.

If you do need privileged startup behavior, prefer privilege separation over granting those powers to the long-lived worker. Common patterns include systemd socket activation, a tiny privileged parent that opens sockets or files and passes file descriptors to the Rust service, or a short bootstrap phase that drops to an unprivileged UID/GID before parsing untrusted input. Keep the component that touches attacker-controlled data as the least-privileged process in the design.

19.2.3 Dropping Privileges After Startup

On Unix-like systems, the order matters: drop supplementary groups first, then the primary GID, then the UID. If you change UID too early, later group changes can fail and you may keep authority you no longer intended to keep.

use nix::unistd::{setgid, setgroups, setuid, Gid, Uid};

fn drop_privileges(uid: u32, gid: u32) -> nix::Result<()> {
    // Drop supplementary groups first, then GID, then UID.
    setgroups(&[])?;
    setgid(Gid::from_raw(gid))?;
    setuid(Uid::from_raw(uid))?;
    Ok(())
}

Call this only after binding privileged ports, opening key files, or receiving file descriptors from a privileged parent, and before accepting any untrusted input. If privilege dropping fails, abort startup rather than continuing in a half-privileged state.

Enable RUST_BACKTRACE=1 only during controlled debugging sessions, not as a standing production setting. Backtraces leak internal module names, file-system paths, and library layout details that help attackers profile the target and sometimes reveal more than the external error contract intended (CWE-209).

Secure IPC for Privilege-Separated Components

Privilege separation only works if the boundary between the privileged helper and the unprivileged worker is hardened too. Treat local IPC exactly like a network trust boundary: authenticate the peer, bound message sizes, and minimize the authority carried over the channel.

  • Unix domain sockets: place pathname sockets in a directory owned by root or the service account, use restrictive mode bits, and prefer peer-credential checks such as SO_PEERCRED / LOCAL_PEERCRED before authorizing privileged actions. On Linux, remember that abstract-namespace sockets bypass filesystem permissions entirely.
  • Windows named pipes: create the pipe with an explicit DACL that grants access only to the intended users or service SIDs. Do not rely on permissive defaults for an admin or broker endpoint.
  • Shared memory and memfd_create: prefer passing already-open handles over global names, cap the mapped size up front, validate every offset and length, and seal immutable memfd objects so a lower-privilege peer cannot rewrite data after validation.

If the high-privilege side only needs to open a socket or file once, prefer passing that already-open descriptor or handle and then keeping the long-lived parser or request worker unprivileged. Do not let the privileged side become a general command dispatcher.

19.2.4 Seccomp Profile

The safest default is to keep Docker or Podman’s built-in seccomp profile. Do not replace it with a short denylist: that often weakens isolation by allowing more syscalls than the runtime would have permitted by default.

When you do need a custom profile, derive it from the runtime default profile for the exact engine version you deploy, trace the syscalls your service actually needs, and then tighten from there. The example below is an illustrative excerpt, not a complete profile:

{
    "defaultAction": "SCMP_ACT_ERRNO",
    "architectures": ["SCMP_ARCH_X86_64"],
    "syscalls": [
        {
            "comment": "Excerpt only. Start from the runtime default profile, then trim to the exact syscall set your service uses.",
            "names": [
                "accept4", "bind", "brk", "clock_gettime",
                "close", "epoll_create1", "epoll_ctl", "epoll_pwait",
                "exit", "exit_group", "futex", "getrandom",
                "listen", "madvise", "mmap", "mprotect",
                "munmap", "nanosleep", "read", "recvfrom",
                "rt_sigaction", "rt_sigprocmask", "sendto", "setsockopt",
                "socket", "write"
            ],
            "action": "SCMP_ACT_ALLOW"
        }
    ]
}

Treat this as process guidance, not a copy-paste policy. A production seccomp allowlist must be derived from the exact binary, libc, and container runtime you deploy. If you are not ready to maintain a custom allowlist, keeping the runtime default profile is safer than shipping an incomplete one.

🔒 Security impact: Seccomp restricts the system calls available to the process. Even if an attacker achieves arbitrary code execution, they are limited to the syscalls you leave available, dramatically reducing what they can do.

19.2.5 WebAssembly (Wasm) for Application Sandboxing

Containers and seccomp harden an entire service. Wasm sandboxing is useful when you need to isolate one component inside that service such as an untrusted plugin, policy bundle, parser, or customer-supplied transformation.

Why Wasm for Security?

Wasm offers a principled in-process sandbox:

  • Linear memory isolation: A module can only access its own linear memory.
  • Controlled imports/exports: The host decides exactly which capabilities exist.
  • No ambient file/network access: Capabilities must be passed in explicitly.
  • Resource limits: Execution can be metered and memory growth capped.

Compiling Rust to Wasm

rustup target add wasm32-unknown-unknown
cargo build --target wasm32-unknown-unknown --release

For production integration, prefer wasm32-wasip2 or wasm32-wasip1 when you need WASI-style system interfaces with explicit capability control.

Sandboxed Plugin Architecture with Wasmtime

# Cargo.toml
[dependencies]
wasmtime = { version = "43.0.0", default-features = false, features = ["anyhow", "cranelift", "runtime", "std"] }
anyhow = "1"
#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::anyhow as anyhow;
use rust_secure_systems_book::deps::wasmtime as wasmtime;
use anyhow::{Context, Result, bail};
use wasmtime::*;

struct HostState {
    limits: StoreLimits,
    max_log_bytes: usize,
}

fn checked_guest_range(
    ptr: u32,
    len: u32,
    memory_len: usize,
    max_len: usize,
) -> Result<(usize, usize)> {
    let start = usize::try_from(ptr).context("guest offset does not fit usize")?;
    let len = usize::try_from(len).context("guest length does not fit usize")?;

    if len > max_len {
        bail!("guest buffer too large");
    }

    let end = start.checked_add(len).context("guest offset overflow")?;
    if end > memory_len {
        bail!("guest range outside linear memory");
    }

    Ok((start, end))
}

fn run_untrusted_plugin(wasm_bytes: &[u8], input: &[u8]) -> Result<Vec<u8>> {
    let mut config = Config::new();
    config.cranelift_debug_verifier(true);
    config.consume_fuel(true);

    let engine = Engine::new(&config)?;
    let module = Module::from_binary(&engine, wasm_bytes)?;

    let mut store = Store::new(
        &engine,
        HostState {
            limits: StoreLimitsBuilder::new()
                .memory_size(1 << 20)
                .instances(1)
                .build(),
            max_log_bytes: 256,
        },
    );
    store.limiter(|state| &mut state.limits);
    store.set_fuel(10_000)?;

    let log_func = Func::wrap(
        &mut store,
        |mut caller: Caller<'_, HostState>, ptr: u32, len: u32| -> wasmtime::Result<()> {
            let memory = caller
                .get_export("memory")
                .and_then(|export| export.into_memory())
                .ok_or_else(|| wasmtime::Error::msg("module has no exported memory"))?;
            let max_log_bytes = caller.data().max_log_bytes;
            let data = memory.data(&caller);
            let (start, end) = checked_guest_range(ptr, len, data.len(), max_log_bytes)
                .map_err(wasmtime::Error::msg)?;
            let message =
                std::str::from_utf8(&data[start..end]).map_err(wasmtime::Error::msg)?;
            println!("Plugin log: {message}");
            Ok(())
        },
    );

    let instance = Instance::new(&mut store, &module, &[Extern::Func(log_func)])?;
    let process = instance.get_typed_func::<(u32, u32), (u32, u32)>(&mut store, "process")?;
    let memory = instance
        .get_memory(&mut store, "memory")
        .ok_or_else(|| anyhow::anyhow!("module has no exported memory"))?;

    let input_len = u32::try_from(input.len()).context("input too large for guest ABI")?;
    let input_memory_len = memory.data(&store).len();
    let (input_start, input_end) =
        checked_guest_range(0, input_len, input_memory_len, input_memory_len / 2)?;
    memory.data_mut(&mut store)[input_start..input_end].copy_from_slice(input);

    let (output_ptr, output_len) = process.call(&mut store, (0, input_len))?;
    let data = memory.data(&store);
    let (output_start, output_end) =
        checked_guest_range(output_ptr, output_len, data.len(), data.len())?;

    Ok(data[output_start..output_end].to_vec())
}
}

The sample disables wasmtime default features because this sandbox only needs the basic runtime and JIT. Trimming unused cache, profiling, and component-model features reduces both dependency surface and maintenance overhead. On current Wasmtime releases, the extra anyhow feature keeps ordinary host setup code ergonomic, while fallible host callbacks still return wasmtime::Result so trap conversion stays explicit at the Wasm boundary.

Security properties in this design:

  1. Fuel limiting: Prevents infinite loops and CPU exhaustion.
  2. Resource limits: Store::limiter caps future memory and instance growth.
  3. Explicit imports: The module only gets the host functions you expose.
  4. Host-side validation: The same checked range helper validates imported log buffers and returned output ranges before the host dereferences guest memory.

Wasm Security Considerations

ConcernMitigation
Side-channel attacksPrefer constant-time host code; do not assume Wasm alone solves microarchitectural leaks
Spectre-type attacksUse a maintained runtime such as Wasmtime and follow its mitigation guidance
Resource exhaustionSet fuel, timeouts, and memory limits
Malicious modulesVerify signatures or hashes before loading
Host call safetyValidate every pointer, length, enum, and handle crossing the boundary

⚠️ Important: Wasm sandboxing protects the host from the module. It does not make the module’s internal logic correct or side-channel-free.

19.3 Binary Hardening Verification

After building, verify the hardening was applied:

Linux (checksec)

APP_BIN=ch17-hardened-server

# Install checksec
sudo apt install checksec

# Verify binary hardening
checksec --file=target/release/$APP_BIN

# Expected output on a stable pure-Rust build:
# RELRO        STACK CANARY  NX         PIE       RPATH     RUNPATH   Symbols
# Full RELRO   No canary found NX enabled PIE enabled No RPATH No RUNPATH No Symbols
#
# If you compile C/C++ objects with stack protectors, or build Rust with
# nightly-only SSP support, the canary column may instead show "Canary found".

Manual Verification

# Check for NX (non-executable stack)
readelf -l target/release/$APP_BIN | grep GNU_STACK
# Should show flags: RW (no E = not executable)

# Check for PIE (position-independent)
readelf -h target/release/$APP_BIN | grep Type
# Should show: DYN (Position-Independent Executable)

# Check for GNU_RELRO segment (presence only)
readelf -l target/release/$APP_BIN | grep RELRO
# Should show: GNU_RELRO

# Check for full RELRO (eager binding)
readelf -d target/release/$APP_BIN | grep BIND_NOW
# Should show: BIND_NOW

# Check symbol stripping
file target/release/$APP_BIN
# Should show: "stripped"

19.4 Runtime Monitoring and Observability

19.4.1 Structured Logging with the tracing Crate

For production security monitoring, the tracing crate provides structured, async-aware logging with spans that track request context. It is far more powerful than the basic log crate for security observability:

# Cargo.toml
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::deps::tracing as tracing;
use rust_secure_systems_book::deps::tracing_subscriber as tracing_subscriber;
// src/logging.rs
use std::sync::OnceLock;
use tracing::{error, info, warn};
use tracing_subscriber::{fmt, EnvFilter};

static LOGGING_INIT: OnceLock<()> = OnceLock::new();

fn sanitize_log_field(value: &str) -> String {
    value.chars().flat_map(|ch| ch.escape_default()).collect()
}

/// Initialize structured JSON logging for production
pub fn init_logging() {
    let _ = LOGGING_INIT.get_or_init(|| {
        let subscriber = fmt()
            .json()                           // Structured JSON output for log aggregation
            .with_env_filter(
                EnvFilter::try_from_default_env()
                    .unwrap_or_else(|_| EnvFilter::new("info"))
            )
            .with_target(true)                // Include module path
            .with_thread_ids(true)            // Identify threads
            .with_file(true)                  // Source file
            .with_line_number(true)           // Line number
            .finish();

        let _ = tracing::subscriber::set_global_default(subscriber);
    });
}

/// Log a security-relevant event with structured fields
pub fn log_security_event(
    event_type: &str,
    severity: SecurityEventSeverity,
    source_ip: Option<std::net::IpAddr>,
    user_id: Option<u64>,
    details: &str,
) {
    let details = sanitize_log_field(details);

    match severity {
        SecurityEventSeverity::Info => {
            info!(
                event_type,
                source_ip = ?source_ip,
                user_id = ?user_id,
                details = %details,
                "Security event"
            );
        }
        SecurityEventSeverity::Warning => {
            warn!(
                event_type,
                source_ip = ?source_ip,
                user_id = ?user_id,
                details = %details,
                "Security event"
            );
        }
        SecurityEventSeverity::Critical => {
            error!(
                event_type,
                source_ip = ?source_ip,
                user_id = ?user_id,
                details = %details,
                "Security event"
            );
        }
    }
}

#[derive(Debug, Clone, Copy)]
pub enum SecurityEventSeverity {
    Info,
    Warning,
    Critical,
}
}

Initialize the subscriber once during startup. Do not call logging initialization from request paths, and do not assume repeated .init() calls are harmless in tests or embedded runtimes. Keep event_type on a controlled internal taxonomy, and sanitize control characters in any attacker-controlled details string before it reaches the sink. JSON output helps, but only if the serializer escapes field values correctly.

19.4.2 Request Tracing with Spans

Spans attach context to all log events within a request, enabling you to trace a request from acceptance through processing to response:

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::companion::ch19_hardening as ch19_hardening;
use rust_secure_systems_book::deps::tokio as tokio;
use rust_secure_systems_book::deps::tracing as tracing;
use ch19_hardening::logging::{log_security_event, SecurityEventSeverity};
use tracing::{instrument, info_span, Instrument};
use std::net::SocketAddr;

fn generate_connection_id() -> u64 { 1 }
async fn read_message(_stream: &mut tokio::net::TcpStream) -> std::io::Result<Vec<u8>> {
    Ok(b"ping".to_vec())
}
async fn process_message(message: &[u8]) -> std::io::Result<Vec<u8>> {
    Ok(message.to_vec())
}
async fn write_response(
    _stream: &mut tokio::net::TcpStream,
    _response: &[u8],
) -> std::io::Result<()> {
    Ok(())
}

async fn handle_connection(
    mut stream: tokio::net::TcpStream,
    addr: SocketAddr,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    // Create a span for this connection with identifying fields
    let span = info_span!(
        "connection",
        peer_addr = %addr,
        connection_id = %generate_connection_id(),
    );
    
    async move {
        tracing::info!("Connection accepted");
        
        // All log events inside this block include peer_addr and connection_id
        loop {
            let message = read_message(&mut stream).await?;
            tracing::info!(message_size = message.len(), "Message received");
            
            match process_message(&message).await {
                Ok(response) => {
                    write_response(&mut stream, &response).await?;
                    tracing::info!(response_size = response.len(), "Response sent");
                }
                Err(e) => {
                    // Security events are automatically associated with this connection
                    log_security_event(
                        "invalid_input",
                        SecurityEventSeverity::Warning,
                        Some(addr.ip()),
                        None,
                        &format!("Message rejected: {}", e),
                    );
                    tracing::warn!(error = %e, "Message processing failed");
                    break;
                }
            }
        }
        
        tracing::info!("Connection closed");
        Ok(())
    }.instrument(span).await
}
}

🔒 Security benefits of structured tracing:

  1. Correlation: Every event is tagged with connection ID, peer address, and user ID, enabling post-incident analysis.
  2. Machine-readable: JSON output integrates with SIEM systems (Splunk, ELK, Datadog) for automated alerting.
  3. Contextual spans: A security event in a handler automatically includes the full request context: no manual threading of parameters.
  4. Audit trail: Structured logs serve as an audit trail for compliance (SOC 2, PCI-DSS).

Do not treat all structured logs as security audit logs. Operational logs optimize for debugging and throughput; they may be sampled, redacted differently, or dropped under load. Audit logs need a stricter schema, a separate or append-only sink, restricted writers, synchronized clocks, and tamper-evident retention. On Linux, tracing-journald or a syslog/auditd sink is a common way to route security events separately from high-volume application logs.

If host compromise is in scope, forward security events off the box quickly or add signing / append-only guarantees in your logging pipeline. Local flat files alone are not trustworthy forensic evidence after an attacker gains write access to the machine.

⚠️ Critical rule: Never log secrets. Add fields to the deny list:

#![allow(unused)]
fn main() {
// Alternative: simply never include secrets in span fields or event arguments.
// If you must log a field that sometimes contains secrets, mask it:
fn mask_token(token: &str) -> String {
    let len = token.chars().count();
    match len {
        0..=8 => "****".to_string(),
        9..=16 => {
            let prefix: String = token.chars().take(2).collect();
            let suffix: String = token.chars().skip(len - 2).collect();
            format!("{prefix}****{suffix}")
        }
        _ => {
            let prefix: String = token.chars().take(4).collect();
            let suffix: String = token.chars().skip(len - 4).collect();
            format!("{prefix}****{suffix}")
        }
    }
}
}

Keep the masking logic character-aware so valid UTF-8 tokens cannot panic the logging path.

This masking helper is for display and logging only. It is not constant-time and must never replace constant-time secret comparisons.

Treat attacker-controlled strings as structured fields, not preformatted log lines. If you still have to feed a line-oriented sink, strip or encode \r and \n first so request data cannot forge extra log entries.

19.4.3 Security Event Taxonomy

Define a consistent set of security event types across your application for reliable alerting:

#![allow(unused)]
fn main() {
/// Security event types for consistent logging and alerting
pub mod security_events {
    // Authentication events
    pub const AUTH_SUCCESS: &str = "auth.success";
    pub const AUTH_FAILURE: &str = "auth.failure";
    pub const AUTH_LOCKOUT: &str = "auth.lockout";
    pub const AUTH_TOKEN_REFRESH: &str = "auth.token_refresh";
    
    // Authorization events
    pub const ACCESS_GRANTED: &str = "access.granted";
    pub const ACCESS_DENIED: &str = "access.denied";
    pub const PRIVILEGE_ESCALATION_ATTEMPT: &str = "access.privilege_escalation";
    
    // Input validation events
    pub const INPUT_REJECTED: &str = "input.rejected";
    pub const INPUT_SIZE_EXCEEDED: &str = "input.size_exceeded";
    pub const MALFORMED_REQUEST: &str = "input.malformed";
    
    // Rate limiting
    pub const RATE_LIMIT_EXCEEDED: &str = "rate_limit.exceeded";
    pub const CONNECTION_LIMIT_EXCEEDED: &str = "rate_limit.connections";
    
    // TLS/cryptography
    pub const TLS_HANDSHAKE_FAILED: &str = "tls.handshake_failed";
    pub const TLS_CERTIFICATE_INVALID: &str = "tls.cert_invalid";
    pub const CRYPTO_OPERATION_FAILED: &str = "crypto.operation_failed";
    
    // Resource exhaustion
    pub const MEMORY_PRESSURE: &str = "resource.memory_pressure";
    pub const TASK_TIMEOUT: &str = "resource.task_timeout";
}
}

19.4.4 Health Checks and Metrics

#![allow(unused)]
fn main() {
// src/metrics.rs
use std::sync::atomic::{AtomicU64, Ordering};

pub struct ServerMetrics {
    pub connections_accepted: AtomicU64,
    pub connections_rejected: AtomicU64,
    pub messages_processed: AtomicU64,
    pub errors: AtomicU64,
    pub auth_failures: AtomicU64,
    pub rate_limits_triggered: AtomicU64,
}

impl ServerMetrics {
    pub fn new() -> Self {
        ServerMetrics {
            connections_accepted: AtomicU64::new(0),
            connections_rejected: AtomicU64::new(0),
            messages_processed: AtomicU64::new(0),
            errors: AtomicU64::new(0),
            auth_failures: AtomicU64::new(0),
            rate_limits_triggered: AtomicU64::new(0),
        }
    }
    
    pub fn snapshot(&self) -> MetricsSnapshot {
        MetricsSnapshot {
            connections_accepted: self.connections_accepted.load(Ordering::Relaxed),
            connections_rejected: self.connections_rejected.load(Ordering::Relaxed),
            messages_processed: self.messages_processed.load(Ordering::Relaxed),
            errors: self.errors.load(Ordering::Relaxed),
            auth_failures: self.auth_failures.load(Ordering::Relaxed),
            rate_limits_triggered: self.rate_limits_triggered.load(Ordering::Relaxed),
        }
    }
}

impl Default for ServerMetrics {
    fn default() -> Self {
        Self::new()
    }
}

pub struct MetricsSnapshot {
    pub connections_accepted: u64,
    pub connections_rejected: u64,
    pub messages_processed: u64,
    pub errors: u64,
    pub auth_failures: u64,
    pub rate_limits_triggered: u64,
}
}

19.5 Secret Management in Production

19.5.1 Environment Variables

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::companion::ch19_hardening as ch19_hardening;
use ch19_hardening::secrets::SecretError;
use rust_secure_systems_book::deps::hex as hex;
use rust_secure_systems_book::deps::zeroize as zeroize;
use std::env;
use zeroize::Zeroize;

fn load_secret(key: &str) -> Result<Vec<u8>, SecretError> {
    let mut value = env::var(key).map_err(|_| SecretError::NotFound(key.to_string()))?;
    let decoded = hex::decode(&value).map_err(|_| SecretError::InvalidFormat);
    value.zeroize();
    decoded
}
}

The companion implementation uses this exact pattern in companion/ch19-hardening/src/secrets.rs, so the secret buffer is zeroized on both success and decode failure.

For long-lived secrets such as TLS private keys, pair zeroization with page locking (mlock/VirtualLock) when the platform allows it so the secret is less likely to be swapped to disk.

If you also compile with panic = "abort", remember that these cleanup hooks still run on normal return paths but not on panic paths. Do not make panic-driven cleanup part of your secret-handling design.

⚠️ Limitation: Environment variables are visible in /proc/<pid>/environ on Linux, and in containerized deployments they often surface through orchestration metadata such as docker inspect. In Edition 2024, mutating the process environment with std::env::set_var or std::env::remove_var is unsafe because the environment is unsynchronized process-global state: another thread may read it through getenv or equivalent while you mutate it. That makes “read then delete” a poor secret-management pattern for multithreaded services.

Prefer runtime secret injection instead:

  • Vault / cloud secret-manager SDKs that fetch secrets on demand
  • Permission-restricted files such as /run/secrets/<name> in Docker Swarm and similar platforms
  • Platform key stores such as DPAPI, Credential Manager, or kernel-backed secret stores

Core dumps are a separate concern from Drop-based wiping. A crashing process can write its entire address space to disk, including keys, passwords, or tokens that are still live and have not reached their zeroization path yet. Disable core dumps in production unless you operate a tightly controlled, encrypted, access-restricted crash-dump pipeline.

Outside systemd, apply the same policy before launch with ulimit -c 0. In containers, use the runtime equivalent such as docker run --ulimit core=0. When core dumps are enabled at all, review the host’s kernel.core_pattern too, because it controls where those memory snapshots land.

19.5.2 Files with Restricted Permissions

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use std::fs::File;
use std::io::Read;
use rust_secure_systems_book::companion::ch19_hardening as ch19_hardening;
use ch19_hardening::secrets::SecretError;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;

fn load_secret_from_file(path: &str) -> Result<Vec<u8>, SecretError> {
    let mut file = File::open(path)?;

    #[cfg(unix)]
    {
        // Validate the already-open handle to avoid a path-swap race.
        let metadata = file.metadata()?;
        let mode = metadata.permissions().mode();
        if mode & 0o077 != 0 {
            return Err(SecretError::InsecurePermissions {
                path: path.to_string(),
                mode: format!("{:o}", mode & 0o777),
            });
        }
    }

    let mut data = Vec::new();
    file.read_to_end(&mut data)?;
    Ok(data)
}
}

This pattern avoids a TOCTOU race on Unix by validating permissions on the same opened file handle that is later read. On Windows, std does not expose DACL inspection, so prefer Credential Manager, DPAPI, or a dedicated secret store, or validate ACLs with platform APIs before relying on raw secret files.

🔒 Security practice: On Unix, secret files should usually be mode 0400 or 0600 and owned by the service user.

19.5.3 Vault Integration (HashiCorp Vault, AWS Secrets Manager)

#![allow(unused)]
fn main() {
extern crate rust_secure_systems_book;
use rust_secure_systems_book::companion::ch19_hardening as ch19_hardening;
use ch19_hardening::secrets::SecretError;
use std::future::Future;

async fn load_from_vault<F, Fut>(
    secret_id: &str,
    fetch_secret: F,
) -> Result<Vec<u8>, SecretError>
where
    F: FnOnce(&str) -> Fut,
    Fut: Future<Output = Result<Option<String>, SecretError>>,
{
    let secret_string = fetch_secret(secret_id)
        .await?
        .ok_or_else(|| SecretError::NotFound(secret_id.to_string()))?;

    Ok(secret_string.into_bytes())
}
}

19.5.4 Rotation in Long-Running Async Services

Loading a secret once at startup is the easy case. Long-lived Tokio services need a reload protocol that avoids dropping live traffic or mixing partially initialized state into request handling:

  • Refresh before expiry, not at expiry. If the secret source gives you a lease or TTL, renew with slack and jitter while the old credential is still valid.
  • Build replacements off to the side. Parse PEM, verify certificate/key pairs, construct the new rustls::ServerConfig, and warm any dependent clients before publishing anything.
  • Publish atomically. Store the active credential or config behind one shared indirection and swap the whole Arc<T> only after validation succeeds.
  • Keep “decrypt old / encrypt new” semantics during rollout. New outbound sessions or ciphertexts use the newest version, but readers and verifiers must accept the previous version until the retirement window closes.
  • Separate existing and new sessions. Existing TLS connections keep using the config they were created with; only new handshakes should observe the swapped config.
  • Emit reload telemetry. Track the active key ID, reload success/failure, and time-to-expiry so an expired lease becomes a page, not a surprise outage.

With the rustls pattern from Chapter 12, this means creating the TlsAcceptor from the current shared Arc<ServerConfig> for each new handshake instead of baking one immutable config into process startup. If a credential cannot be reloaded safely in process, prefer a controlled rolling restart over ad hoc in-place mutation of shared state.

For public internet-facing services, automate certificate issuance and renewal instead of treating TLS rotation as a manual runbook. In Rust, rustls-acme provides rustls-oriented certificate management, while instant-acme is a lower-level ACME client if you need custom control-plane logic. However you implement it, validate the new chain before publication, emit renewal telemetry, and page before a renewal failure turns into an expiry outage.

19.5.5 Hardware-Backed Keys and Platform Keystores

When compromise of the application host must not expose raw private keys, move key material out of ordinary process memory. Typical examples include CA roots, code-signing keys, long-lived TLS identities, payment keys, and any key subject to regulatory controls.

Practical options in Rust:

  • HSM / smartcard / cloud HSM via PKCS#11: use a PKCS#11 wrapper such as cryptoki and keep signing, unwrap, or decrypt operations inside the device boundary.
  • TPM 2.0: use tss-esapi (backed by the platform tpm2-tss stack) when you need machine-bound keys, measured-boot attestation, or sealed secrets tied to platform state.
  • Platform keystores: prefer OS-managed stores such as DPAPI or Credential Manager on Windows, macOS Keychain, Linux kernel keyring, or desktop Secret Service integrations for application credentials.

🔒 Design rule: Prefer handles and cryptographic operations over exporting raw key bytes. Your application should ask the keystore to sign, decrypt, or unwrap; it should not routinely materialize long-lived private keys in heap memory.

⚠️ Operational note: Hardware-backed storage does not replace rotation, backup, quorum controls, or audit logging. Plan for key loss, device replacement, and recovery before moving production secrets into hardware.

19.6 OS-Level Hardening

19.6.1 systemd Service Hardening

For Linux deployments using systemd, apply security directives to restrict the service at the OS level:

# /etc/systemd/system/secure-server.service
[Unit]
Description=Secure Rust Server
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/secure-server
Restart=on-failure
RestartSec=5

# Run as a dedicated non-root user
User=secure-server
Group=secure-server

# Sandbox directives
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
LockPersonality=yes

# Restrict filesystem access
ReadWritePaths=/var/lib/secure-server /var/log/secure-server
ReadOnlyPaths=/etc/secure-server/config.toml

# Restrict address families used by the service
# This does not restrict specific port numbers.
# Requires systemd 235+
RestrictAddressFamilies=AF_INET AF_INET6

# System call filtering
SystemCallFilter=@system-service
SystemCallFilter=~@mount @privileged @reboot @swap
SystemCallArchitectures=native

# Resource limits
LimitNOFILE=4096
MemoryMax=512M
TasksMax=50
CPUWeight=50
# Disable core dumps so crashes do not persist secret memory to disk
LimitCORE=0

# Environment
Environment=RUST_LOG=info
Environment=TLS_CERT_PATH=/etc/secure-server/server.crt
Environment=TLS_KEY_PATH=/etc/secure-server/server.key

[Install]
WantedBy=multi-user.target

If you need to restrict the service to specific listen ports, combine RestrictAddressFamilies= with socket activation, container/network policy, or host firewall rules. Address-family filtering alone is not a port-level control.

LimitCORE=0 matters for secret-bearing services because a core dump captures process memory at crash time. Without it, a crash can persist live key material or tokens to disk even when the application uses zeroize on normal drop paths.

If you launch the same binary outside systemd, enforce the equivalent policy with ulimit -c 0 or the container runtime’s core-limit setting before exec.

🔒 Security features:

  1. NoNewPrivileges: Prevents the process from gaining additional privileges via setuid/setgid.
  2. ProtectSystem=strict: Makes the entire filesystem read-only except explicitly listed paths.
  3. PrivateTmp: Gives the service its own private /tmp directory.
  4. RestrictAddressFamilies: Limits the service to IPv4/IPv6 sockets, reducing the reachable kernel API surface.
  5. SystemCallFilter: Restricts system calls, even arbitrary code execution is limited.
  6. MemoryMax/TasksMax: Prevents resource exhaustion from affecting the rest of the system.
  7. LimitCORE=0: Prevents crash dumps from persisting live process memory, including secrets, to disk.

Verify the hardening is effective:

# Check the security properties of a running service
systemd-analyze security secure-server

# Expected output:
# NAME                                  DESCRIPTION
 # ...
# Overall exposure level for secure-server: 0.2 LOW

19.6.2 Landlock (Unprivileged Linux Sandboxing)

Landlock is useful when you want a Linux service to restrict its own filesystem access from inside the process, without requiring root to install a system-wide profile. That makes it a good complement to seccomp and systemd units: the service can start normally, open the exact files it needs, and then drop future path access for the rest of its lifetime.

use landlock::{
    ABI, Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr,
    RulesetCreatedAttr,
};

let abi = ABI::V1;
let access_all = AccessFs::from_all(abi);
let access_read = AccessFs::from_read(abi);

let status = Ruleset::default()
    .handle_access(access_all)?
    .create()?
    .add_rule(PathBeneath::new(PathFd::new("/etc/secure-server")?, access_read))?
    .restrict_self()?;

Apply Landlock after opening the sockets, configuration files, and log destinations the service genuinely needs. It does not replace seccomp or container policy; it narrows filesystem authority from inside the already-running process.

19.6.3 AppArmor / SELinux Profiles

For additional Mandatory Access Control (MAC), create an AppArmor profile:

# Generate a baseline profile
aa-genprof /usr/local/bin/secure-server

# The profile restricts:
# - Which files can be read/written
# - Which network ports can be bound/connected
# - Which capabilities can be used
# - Which syscalls are permitted

An example AppArmor profile:

#include <tunables/global>
/usr/local/bin/secure-server {
  #include <abstractions/base>
  
  # Network: allow IPv4/IPv6 TCP sockets.
  # Use host firewall rules, socket activation, or container policy to pin this
  # to port 8443.
  network inet tcp,
  network inet6 tcp,
  
  # Read config and certs
  /etc/secure-server/* r,
  
  # Write logs
  /var/log/secure-server/* rw,
  
  # Data directory
  /var/lib/secure-server/** rw,
  
  # Deny everything else
  deny /** w,
  deny /** l,
}

🔒 Security practice: Use at least one layer of OS sandboxing or MAC (systemd sandboxing, Landlock, AppArmor, or SELinux) for any production Rust service. Defense in depth, even if the application has a memory corruption vulnerability in unsafe code, the OS-level restrictions limit what an attacker can do.

19.7 Release Checklist

Before deploying a Rust application to production:

Build Verification

  • Build with cargo build --release using hardened flags
  • Verify binary hardening (checksec: RELRO, NX, PIE, and canary status for any C/C++ objects)
  • Run full test suite (cargo test --all-features)
  • Run clippy with security lints (cargo clippy -- -W clippy::unwrap_used)
  • Run cargo audit - no known vulnerabilities
  • Run cargo deny check - all policies pass
  • Run fuzzing targets (at least 1 hour each)

Binary Verification

  • Binary is stripped (no debug symbols in production)
  • Binary size is reasonable (no unexpectedly large binary)
  • overflow-checks = true enabled in release profile
  • Static linking for musl (fully static binary) or verified shared library versions

Deployment Verification

  • Container uses distroless or minimal base image
  • Runs as non-root user
  • Read-only filesystem with writable tmpfs
  • Seccomp profile applied
  • All capabilities dropped except required ones
  • Resource limits set (CPU, memory)

Runtime Verification

  • Structured logging enabled
  • Security metrics exported (prometheus, cloudwatch, etc.)
  • Health check endpoint available
  • Alerting configured for auth failures, rate limits, errors
  • TLS certificates valid, monitored for expiry, and renewed by a tested automation path

Documentation

  • Security policy documented (SECURITY.md)
  • Threat model documented
  • Incident response plan in place
  • Dependency list (SBOM) generated and archived

To satisfy the SBOM item above, a practical default is CycloneDX’s Cargo plugin:

cargo install cargo-cyclonedx
cargo cyclonedx

Archive the generated CycloneDX file or files with the release artifacts. Like other Cargo-based inspection tools, do not run it on untrusted source trees, because it invokes Cargo internally.

19.8 Summary

  • Enable all available stable binary hardening: NX, ASLR, RELRO, CFG where supported, and stack canaries for any C/C++ objects you compile.
  • Use multi-stage Docker builds with distroless base images.
  • Run as non-root with minimal capabilities, seccomp filtering, and optional Landlock path restrictions.
  • Use Wasm runtimes such as Wasmtime when you must isolate untrusted in-process extensions or parsers.
  • Verify hardening with checksec or manual checks.
  • Load secrets from vaults or permission-restricted runtime files, never hardcode or rely on environment variables for long-lived production secrets.
  • Use hardware-backed or platform-managed keystores for high-value long-lived keys when compromise of the host must not reveal raw key material.
  • Use structured tracing (tracing crate with JSON output) for security event logging, with consistent event types and spans that carry request context.
  • Integrate logs with a SIEM for automated alerting on security events.
  • Apply OS-level hardening: systemd sandboxing, Landlock, AppArmor/SELinux, resource limits.
  • Follow the release checklist for every production deployment.

This concludes the book. You now have the knowledge and practical skills to write secure systems software in Rust—from the language fundamentals through production deployment. The security landscape evolves constantly; continue learning, continue auditing, and continue building systems that are secure by design.

19.9 Exercises

  1. Hardened Build Pipeline: Set up a complete hardened build pipeline for one of the projects from Chapters 17 or 18: configure .cargo/config.toml with all hardening flags, verify the binary with checksec (Linux) or Dumpbin /headers (Windows), build a distroless Docker image, and run it with all capabilities dropped. Document each step and its security benefit.

  2. systemd Service Unit: Write a systemd service unit for the Chapter 17 TCP server with all the sandboxing directives from §19.6.1. Deploy it on a Linux VM, run systemd-analyze security to check the exposure score, and attempt an escape (e.g., try to write to /etc, try to load a kernel module). Verify each attempt is blocked.

  3. Incident Response Drill: Simulate a security incident: deliberately introduce a vulnerability into one of the chapter projects (e.g., a panic! in a network handler, or a logging statement that leaks a secret). Deploy the service, trigger the vulnerability, and use the structured logs and metrics to: (a) detect the incident, (b) identify the affected component, (c) trace the timeline. Write an incident report.


  • The Rust Programming Language (Klabnik & Nichols): Official Rust book
  • Rust for Rustaceans (Jon Gjengset): Advanced Rust patterns
  • Rust in Action (Tim McNamara): Systems programming with Rust
  • The CERT C Secure Coding Standard: Security patterns that apply universally
  • OWASP Application Security Verification Standard (ASVS): Security requirements
  • MITRE CWE Database: Comprehensive weakness enumeration

Appendix B: Essential Crates for Security

CratePurposeTrust Level
argon2Password hashing (Argon2id)High (PHC winner, widely reviewed)
ringCryptographyHigh (BoringSSL-derived, audited)
rustlsTLSHigh (memory-safe, audited)
ed25519-dalekEd25519 signaturesHigh (widely reviewed)
x25519-dalekX25519 key agreementHigh (widely reviewed)
serdeSerializationHigh (serde-rs project, widely used)
zeroizeMemory wipingHigh (widely audited)
subtleConstant-time selection and comparison helpersHigh (Dalek ecosystem)
secrecySecret encapsulationHigh
anyhowApplication-level error contextHigh
thiserrorError typesHigh
proptestProperty testingHigh
tokioAsync runtimeHigh
tracingStructured loggingHigh

Appendix C: Security Audit Resources

  • RustSec Advisory Database: https://rustsec.org/
  • Clippy Lint List: https://rust-lang.github.io/rust-clippy/master/
  • Miri Documentation: https://github.com/rust-lang/miri
  • cargo-geiger: https://github.com/rust-secure-code/cargo-geiger
  • cargo-vet: https://mozilla.github.io/cargo-vet/