pager-lang 0.2.0

Virtual and executable memory management for JIT and runtimes.
Documentation

Overview

A region is a block of page-aligned virtual memory whose access permissions can be changed independently of the rest of the address space. The intended discipline is write xor execute (W^X): create a region writable, fill it with code, flip it to executable, and run it — so the same page is never writable and runnable at the same time, closing off a large class of exploits.

The whole API is four things:

  • Region — an owned block of page-aligned virtual memory, freed when dropped.
  • Protection — the access a region permits, in the useful combinations of read, write, and execute.
  • PagerError — the reason an operation failed, carrying the OS error code where one applies.
  • page_size — the host page size, the unit regions are measured in.

Installation

[dependencies]
pager-lang = "0.2"

Or from the terminal:

cargo add pager-lang

Quick Start

use pager_lang::{Protection, Region};

// 1. Reserve a writable region (rounded up to whole pages).
let mut code = Region::new(4096).expect("map a region");

// 2. Write machine code into it (here a harmless x86 pattern: nop; nop; ret).
code.write(0, &[0x90, 0x90, 0xC3]).expect("write while writable");

// 3. The W^X flip: make it read+execute. It is no longer writable.
code.protect(Protection::ReadExecute).expect("flip to executable");
assert!(code.as_mut_slice().is_none());

// 4. `code.as_ptr()` now points at executable memory. Transmuting it to a function
//    pointer and calling it is `unsafe` and the caller's responsibility — see
//    `examples/jit_function.rs` for a complete, runnable JIT on x86-64.

How it works

Step Call What happens
Reserve Region::new(len) mmap / VirtualAlloc a read/write region of len bytes, rounded up to whole pages.
Guard (optional) Region::with_guard_pages(len) Flank the region with inaccessible pages so an overrun faults instead of corrupting memory.
Write region.write(offset, &bytes) Bounds-checked copy into the region while it is writable.
Flip region.protect(Protection::ReadExecute) mprotect / VirtualProtect to read+execute; the region stops being writable.
Run region.as_ptr() → transmute → call Caller's unsafe step: run the generated code.
Free drop The region returns its pages to the OS automatically.

The safe accessors enforce the discipline: as_mut_slice returns None unless the region is writable, and as_slice returns None unless it is readable, so the safe API can never hand out a slice that would fault.

API Overview

For a complete reference with examples, see docs/API.md.

Error handling

Every fallible operation returns Result<_, PagerError>; the crate never panics on a recoverable condition. The OS-level variants carry the raw error code (errno on Unix, GetLastError on Windows) so the underlying cause is not lost.

use pager_lang::{PagerError, Region};

match Region::new(usize::MAX) {
    Err(PagerError::SizeOverflow) => { /* too large to round to whole pages */ }
    Err(e) => eprintln!("map failed: {e}"),
    Ok(region) => { /* use it */ }
}

Performance

Performance is a hard constraint, not an afterthought (see REPS.md). The hot operations are thin wrappers over a single syscall each, with no allocation of their own; page_size is queried once and cached, and write is a bounds-checked memcpy.

Latest local Criterion means (cargo bench --bench bench --all-features, x86-64 Linux / WSL2, Rust stable, release build):

Operation Size Time
Map + free, plain any ~1.0 µs
Map + free, guarded any ~2.2 µs
Protect (read/write → read/execute → read/write) any ~0.32 µs
Write (memcpy into the region) 64 B ~1.2 ns
Write (memcpy into the region) 4 KiB ~20 ns
Write (memcpy into the region) 64 KiB ~0.52 µs

Map and protect costs are dominated by the kernel transition and are essentially independent of region size; write cost scales with the bytes copied. Numbers vary by CPU and environment — run the benches on your hardware for trends.

Configuration

Feature Flags

Feature Default Description
std on Links the standard library. Without it the crate is #![no_std] and needs only core; OS calls go through FFI either way, so behavior is identical.
serde off Derives Serialize / Deserialize for Protection and PagerError. A live Region is an OS resource, not a value, and is not serializable.
# no_std build:
pager-lang = { version = "0.2", default-features = false }

# with serialization of the value types:
pager-lang = { version = "0.2", features = ["serde"] }

Examples

Three runnable examples live in examples/:

# Assemble a function at run time, flip it to executable, and call it (x86-64).
cargo run --example jit_function

# Allocate a guard-flanked region and watch an overrun get rejected.
cargo run --example guard_pages

# Walk a region through the W^X lifecycle and watch the safe accessors track it.
cargo run --example protections

Testing

# Unit, integration (workflow), property, serde, and doc tests
cargo test --all-features

# Property tests only
cargo test --all-features --test properties

# Lints and formatting
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings

# no_std build
cargo build --no-default-features

# Benchmarks (Criterion)
cargo bench --bench bench --all-features

The integration suite in tests/workflow.rs walks the full lifecycle and, on x86-64, assembles a tiny function, flips the region to executable, and calls it through a transmuted function pointer — the same path a JIT takes. The property tests in tests/properties.rs check the size, bounds, and protection invariants across a wide range of inputs.

Cross-Platform Support

Regions are backed by mmap / mprotect / munmap on Unix and VirtualAlloc / VirtualProtect / VirtualFree on Windows, reached through direct FFI. Linux, macOS, and Windows (x86-64 and ARM64) are the supported targets and are exercised by the CI matrix on stable and the 1.85 MSRV.

Getting memory into an executable state is portable; running freshly written code is not entirely. On architectures with separate instruction and data caches — AArch64, for instance — code written into a region must have the instruction cache synchronized before it is executed. On x86 and x86-64 this is automatic. This crate manages the memory and its protections; instruction-cache synchronization for the architectures that need it is the caller's responsibility at the point of execution.

Safety

The crate uses unsafe where it must — FFI to the OS memory calls and the raw-pointer work behind the safe accessors. Every unsafe block carries a // SAFETY: justification, every unsafe fn documents its invariants, and the operations are covered by tests that map, write, protect, free, and execute real code on both Linux and Windows. The safe surface is designed so that no safe call can produce a dangling or wrongly-protected slice; the only inherently unsafe step left to the caller is transmuting a pointer to executable memory into a function and calling it.

Contributing

Engineering standards for this crate are the Rust Efficiency & Performance Standards; the current scope and plan are in dev/ROADMAP.md. Before a PR: cargo fmt --all, cargo clippy --all-targets --all-features -- -D warnings, and cargo test --all-features must be clean.