Skip to content

The Rust Engineering Handbook / Chapter 95

WebAssembly, Kernels, Drivers, and Constrained Runtimes

Audit memory, ABI, privilege, reentrancy, and ownership contracts when Rust runs outside a conventional native user-space process.

An imported function receives two integers from a WebAssembly guest: offset = 65_532 and length = 16. The guest’s current linear memory is one 64 KiB WebAssembly page. A native host wrapper turns the integers into a pointer and slice, then passes the slice to safe Rust.

The arithmetic looks harmless. The range is not. Four bytes exist after the offset, not sixteen. If the host computes offset + length without checked arithmetic, trusts a stale memory view, or creates a reference before validating the complete range, the boundary has converted untrusted guest values into host authority.

Now change the setting. A network driver receives a completion interrupt for descriptor 31. The CPU prepared the corresponding buffer, the device may have written into it, and the handler wants to parse the header immediately. The same questions return with harder machinery: which agent owns the bytes, which address space names them, what synchronization makes device writes visible, and which context may touch the descriptor?

Outside a conventional native user-space process, familiar services stop being defaults. A WebAssembly engine mediates capabilities and memory. A kernel supplies its own allocation, scheduling, panic, and synchronization rules. A driver shares authority with an independently executing device. Rust still enforces the contracts expressible in its type system, but the platform boundary decides what addresses mean, what calls may re-enter, how bytes cross, and which evidence can establish correctness.

The governing rule is: validate capabilities and representation before constructing Rust authority, and transfer ownership explicitly before another execution agent can access the resource.

A target name does not describe a host

wasm32-unknown-unknown, wasm32-wasip1, and wasm32-wasip2 are not interchangeable spellings for “WebAssembly.” They select different assumptions about the environment and artifact.

The Rust target documentation describes wasm32-unknown-unknown as a minimal host-assumption target. It has core and alloc, and a Rust standard library is present, but operating-system-dependent facilities may be absent, inert, or fail. The target documentation explicitly notes that filesystem operations fail, println! produces no output, and spawning a standard thread panics. It also warns that cfg(target_family = "wasm") cannot tell you whether execution occurs in a browser.

WASI targets select a system interface. Preview 1 and Preview 2 differ at the boundary, and wasm32-wasip2 emits a component rather than only a core module. The engine must support the selected interface and component requirements. A custom embedded engine may support neither. A browser supplies JavaScript integration rather than an implicit POSIX process. A plugin host may expose only five application-specific imports.

Record a capability manifest beside the artifact:

Dimension Questions the release must answer
Artifact Core module or component? Which Wasm features may appear?
Host calls Which imports exist, with what parameter, error, blocking, and reentrancy contracts?
Memory Who owns each memory, can it grow, and how are ranges validated after growth?
Time and randomness Which clock and entropy capabilities exist, and are they deterministic in tests?
Concurrency Are threads, shared memory, atomics, async host calls, or only single-threaded calls supported?
Failure What does a trap, Rust panic, host error, cancellation, or fuel exhaustion mean to the caller?
Resource limits What bounds memory, tables, instances, call depth, execution fuel, handles, and output?
Distribution Which engines, browsers, interface versions, and CPU architectures are supported?

Treat feature availability as version-sensitive. A Wasm proposal can be standardized without being enabled in every engine or in the Rust target’s default feature set. Validate the final binary against the oldest supported engine; do not assume unused instructions are harmless merely because a code path will not execute. Wasm validation rejects instructions the engine does not understand before that application argument matters.

Kernel and driver targets need a similar manifest: architecture, kernel tree and configuration, Rust toolchain selected by that tree, allocation contexts, preemption model, interrupt classes, locking rules, available atomics, DMA mask, cache coherence, IOMMU configuration, device revision, platform ABI, and load/unload policy. “Linux” or “ARM” is not a sufficient target contract.

Imports and exports are authority, not plumbing

A Wasm import grants the guest a host operation. The import name and function type describe only part of the contract. The host must also define which resources the operation can reach, whether it blocks, whether it may call back into the instance, whether cancellation stops the external effect, how handles expire, and which failures trap rather than return a typed error.

Prefer capability-shaped imports. An import that writes to one pre-opened output stream is easier to constrain than a generic open(path) operation. A database handle scoped to one tenant is easier to review than ambient network access plus credentials in guest memory. The host should reject unknown or excessive imports before instantiation, not discover them after the plugin starts.

Exports reverse the call direction but not the obligation. The host supplies parameters using an agreed ABI, enters guest code, handles a return or trap, and releases any borrowed host resource. If the guest can invoke host callbacks during the exported call, the host is reentrant. A &mut InstanceState held across the call is then suspicious: the callback may try to access the same state while the exclusive reference is live.

Three boundary designs are common:

  1. A narrow core-module ABI exposes integers, offsets, lengths, and handles. It is compact and explicit, but the application owns lifting, lowering, memory validation, strings, resource tables, and versioning.
  2. Generated browser bindings translate between JavaScript and Rust-facing types. They improve ergonomics but add generated glue, JavaScript object lifetime, exception, promise, bundling, and copy behavior that must be inspected.
  3. A component interface describes records, variants, strings, lists, and resources. The canonical ABI standardizes how component values are lowered to and lifted from core Wasm representations. It reduces bespoke ABI invention; it does not remove allocation, copying, resource-lifetime, host-capability, or version-compatibility decisions.

Choose the richest boundary whose runtime support and maintenance cost the product can defend. Do not hand-design a pointer-heavy ABI merely to avoid generated code, and do not adopt a component stack merely because it is newer.

Linear memory needs range proofs and copy budgets

WebAssembly linear memory is an indexed byte array. A guest pointer is ordinarily an integer offset into that memory, not a native pointer that the host may dereference. The host must establish all of the following before reading a record:

  • the integer conversion to host usize is valid;
  • offset + header_size and payload_start + payload_length do not overflow;
  • the complete range lies within the current memory extent;
  • alignment and representation requirements are met if typed values are decoded;
  • the guest cannot grow or replace the memory in a way that invalidates a cached host view during use;
  • the length is below an application copy and processing budget;
  • text, discriminants, handles, and nested lengths are validated before becoming typed host values.

The fixture deliberately copies a record into host-owned storage:

pub fn copy_record<'out>(
    &self,
    offset: usize,
    host_output: &'out mut [u8],
) -> Result<&'out [u8], GuestReadError> {
    let header_end = offset
        .checked_add(4)
        .ok_or(GuestReadError::HeaderOutsideMemory)?;
    let header = self.bytes.get(offset..header_end)
        .ok_or(GuestReadError::HeaderOutsideMemory)?;
    let length = u32::from_le_bytes(header.try_into().expect("four-byte range")) as usize;

    if length > self.max_copy_bytes || length > host_output.len() {
        return Err(GuestReadError::CopyBudgetExceeded);
    }
    let payload_end = header_end
        .checked_add(length)
        .ok_or(GuestReadError::PayloadOutsideMemory)?;
    let payload = self.bytes.get(header_end..payload_end)
        .ok_or(GuestReadError::PayloadOutsideMemory)?;
    host_output[..length].copy_from_slice(payload);
    Ok(&host_output[..length])
}

The complete source avoids the excerpt’s infallible array conversion and is verified in examples/rust-engineering-handbook/part-15/platform-boundaries. The important result is ownership: the returned slice aliases the host buffer, not mutable guest memory. The host pays one bounded copy and may retain the result after the call.

A borrowed guest view can be faster for large payloads. Its lifetime must end before anything can grow memory or re-enter guest code, and no guest or concurrent agent may mutate the range while safe Rust treats it as shared. Many hosts therefore expose scoped accessors rather than a long-lived &[u8]. For streaming data, bounded chunks often beat one giant copy while keeping cancellation and memory pressure visible.

“Zero-copy” is too vague for this boundary. Identify each transition: guest producer to linear memory, linear memory to host view, host decoder to domain value, and domain value to downstream I/O. A component adapter may copy strings while passing scalar values directly. A browser may copy between Wasm memory and a JavaScript-owned buffer. Measure the real path and keep the safe ownership model unless evidence justifies a more fragile view.

Compare the two halves of Figure 95-1. The mechanisms differ, but the safe moment is the same: external integers or completion signals do not become Rust authority until range, budget, synchronization, and ownership conditions have all been established.

Paired boundary model. The top half validates guest linear-memory offset and length, enforces a copy budget, and creates a bounded copy in a host-owned buffer across the guest-host boundary. The bottom half moves a DMA buffer from CPU-owned through preparation and synchronization to device-owned, forbids CPU reads and writes during device ownership, then completes and synchronizes before returning CPU ownership.
Whether bytes come from a Wasm guest or a DMA device, safe Rust access begins only after the boundary has validated representation and returned exclusive authority to the CPU or host.

Sandboxing is a host property with residual attack surface

Wasm validation and runtime isolation can prevent a guest from directly addressing arbitrary host memory through ordinary core Wasm instructions. That is valuable, but “runs in Wasm” is not a complete security claim.

The host imports define authority. A path-opening import can permit traversal or symlink attacks if its policy is weak. A network import can reach internal services. A logging import can leak secrets or allocate unbounded output. A resource handle table can permit use-after-close at the application level even when memory remains safe. Engine defects, native extensions, JIT executable-memory policy, generated bindings, and unsafe host adapters remain in the trusted computing base.

Bound CPU as well as memory. A guest can enter an infinite loop, recurse, allocate until a limit, create many resources, emit excessive logs, or submit small calls that amplify host work. Use engine mechanisms such as fuel, epoch interruption, deadlines, stack limits, instance limits, and resource quotas where available, and test their exact semantics. A deadline that returns control while a host operation continues is not cancellation.

Separate tenants when a process-level failure or side channel is unacceptable. A Wasm instance boundary may be appropriate for extensibility within one trust domain; a process, virtual machine, or separate host may be necessary for stronger fault containment. The right boundary follows the threat model, not the artifact suffix.

Kernel Rust inherits kernel law

Kernel code does not gain a conventional Rust process merely because it is written in Rust. It uses the kernel’s allocator interfaces, synchronization primitives, object lifetimes, error conventions, logging, module model, and scheduler. The exact Rust abstractions are tied to the kernel tree and configuration, and many remain under active development. Label maturity at the API and version level.

Allocation can fail and may be forbidden or dangerous in particular contexts. An interrupt or spinlock-held path cannot casually invoke an allocator that may sleep. Preallocate on a setup path, use the kernel’s fallible allocation interfaces, and surface capacity exhaustion as part of driver behavior. A Vec type does not prove that growth is permitted in the current execution context.

Panic policy is also owned by the environment. Unwinding through kernel frames is not a general recovery mechanism. Driver code must use typed errors for expected device and resource failures, keep invariants valid at every return, and follow the kernel tree’s panic policy for defects. An expect justified by “the device always responds” converts an external failure into a kernel-wide risk.

Kernel object lifetimes combine Rust ownership with subsystem reference counts, callbacks, work queues, interrupts, and teardown. A registration call may make an object reachable by callbacks before the constructor returns. Unregistration may stop new callbacks but still require a grace period or completion wait. Drop is useful only when its execution context and ordering satisfy the subsystem contract. Model registration, quiescing, cancellation, and final release as explicit states.

ABI stability differs by boundary. Rust has no general stable Rust ABI for arbitrary types. In-tree kernel Rust calls version-matched abstractions and is rebuilt with the kernel. A user/kernel interface needs the kernel subsystem’s stable UAPI rules: fixed-width representations, padding initialization, compat behavior, extensible flags, copy validation, and versioning. A device’s register and descriptor formats follow the hardware specification, not repr(Rust).

DMA is a transfer of authority

Direct memory access lets a device access memory without the CPU copying each byte. It does not let both agents treat the same bytes as ordinary Rust data simultaneously.

For a transmit buffer, a defensible sequence is:

  1. CPU code exclusively owns the buffer and writes a valid descriptor and payload.
  2. The driver performs the platform’s required mapping and cache synchronization.
  3. The driver publishes descriptors with the required ordering, then notifies the device.
  4. Device ownership begins. Safe CPU code does not read, write, move, free, or remap the buffer.
  5. The device completes, and the interrupt or polling path acknowledges completion using the device protocol.
  6. The driver performs required completion barriers, unmapping, or cache synchronization.
  7. CPU ownership resumes; only then may safe code inspect or reuse the bytes.

Receive buffers add validity work. Device-written bytes are initialized only to the extent the descriptor reports; packet lengths, checksums, flags, and nested headers remain untrusted input. Do not create a slice using a device length until it has been checked against the allocation, descriptor capacity, and protocol minimums.

The fixture represents the authority cycle with typestates:

let mut buffer = DmaBuffer::<2048, CpuOwned>::new();
buffer.stage(packet)?;

let buffer = buffer.prepare_for_device();
// No safe `as_slice` method exists for DeviceOwned.
let buffer = buffer.report_completion();
let buffer = buffer.synchronize_for_cpu();
consume(buffer.as_slice());

This model proves only an API property: safe fixture code cannot inspect a DeviceOwned buffer. It does not execute cache maintenance, IOMMU mapping, register writes, memory barriers, or interrupt acknowledgement. A production wrapper must place those actions at the typestate transitions and justify every unsafe operation against the architecture, kernel DMA API, and device manual.

Coherent DMA memory simplifies some cache synchronization but does not erase ownership, ordering, device completion, or lifetime. Streaming mappings impose direction and synchronization rules. An IOMMU constrains device-visible addresses when correctly configured; it does not validate descriptor contents or fix early buffer reuse. Volatile access may be required for doorbells or status registers, but it does not order ordinary memory or complete the DMA handoff by itself.

Interrupts and callbacks make reentrancy explicit

An interrupt handler, completion callback, browser callback, or host import can run in a context with different allocation, blocking, and locking permissions. Define for every entry point:

  • which thread, CPU, priority, or executor invokes it;
  • whether calls can overlap or re-enter;
  • which locks or critical sections may already be held;
  • whether allocation, blocking, logging, or host calls are permitted;
  • which state is exclusively owned, synchronized, or deferred;
  • how teardown prevents or drains later calls;
  • what happens when the handoff queue is full.

Keep fast paths narrow. A driver interrupt can acknowledge status and enqueue a bounded completion token, leaving parsing and recovery to a permitted context. A Wasm host callback can copy a bounded message into host storage and schedule later work, avoiding re-entry into a lock-heavy application core. Deferral does not remove overload: the queue needs a capacity, saturation policy, and signal.

Avoid calling unknown code while holding a lock or live exclusive reference. Guest exports, plugin callbacks, kernel subsystem hooks, and device completion handlers are unknown code from the caller’s perspective. Split state, take an owned work item, release the guard, make the call, and reconcile the result under a new state transition.

Portability belongs above small platform kernels

The reusable layer should express capabilities and owned values: read a record from a bounded source, submit an owned frame, schedule a deadline, or report a typed completion. Platform adapters supply Wasm imports, kernel allocation, DMA mappings, browser promises, or RTOS queues.

Do not flatten meaningful differences into a lowest-common-denominator trait. A synchronous fn read(&mut self) trait hides whether an implementation may block an OS thread, trap into a host, wait for an interrupt, or require an async runtime. Separate interfaces when execution or cancellation contracts differ. Likewise, copying and borrowed-view APIs should not share a name if one permits retention and the other does not.

Use conditional compilation for concrete platform implementations, not as an invisible replacement for product architecture. Build each supported target and feature combination. Keep target-specific modules small enough that their unsafe and ABI obligations can be audited. When platforms diverge materially, two clear adapters are better than one maze of cfg branches.

Match evidence to the boundary

A host test can verify checked arithmetic, copy limits, typestate transitions, and policy tables. It cannot validate an engine’s import behavior, a browser’s promise integration, a kernel callback race, an IOMMU mapping, a cache-maintenance sequence, or a physical device.

Use an evidence ladder:

  • pure host tests for parsers, state machines, quotas, and ownership transitions;
  • compile and link checks for every target and supported feature set;
  • ABI conformance tests generated from interface definitions or headers;
  • engine tests against the oldest and newest supported Wasm runtimes;
  • browser automation across the supported browser matrix where the web is a target;
  • kernel build and subsystem tests against the pinned tree/configuration;
  • emulation for instruction, trap, and modeled-device behavior;
  • hardware-in-the-loop for interrupts, DMA, cache coherence, reset, timing, and device faults;
  • fuzzing across host/guest parsers, ioctl or UAPI decoders, descriptors, and completion paths;
  • teardown and reentrancy tests that race calls with cancellation, unload, memory growth, and device reset.

Keep a maturity label with each adapter: prototype, bounded pilot, supported, or critical. The label should name evidence and exclusions. A supported Wasm adapter might have interface compatibility tests and a two-engine matrix but no browser support. A critical driver should add hardware revisions, fault injection, long-duration load, independent unsafe review, and operational recovery drills. “Rust implementation exists” is not a maturity label.

Boundary design hearing

Choose one proposal and produce a two-page decision record.

Wasm plugin: an untrusted customer plugin transforms messages up to 256 KiB. It needs a tenant-scoped key/value capability, must finish within 50 ms, and may run in two supported engines. Decide between a bespoke core-module ABI and a component interface. Specify imports/exports, memory ownership, maximum copies, resource handles, cancellation, reentrancy, compatibility, engine limits, and evidence.

Device driver: a network device has 256 receive descriptors, non-coherent DMA, two queues, and interrupt moderation. Specify buffer states, descriptor and payload ownership, mapping and synchronization transitions, interrupt/deferred-work split, queue-full behavior, reset/unload ordering, unsafe kernel, and hardware tests.

Evaluate the record using five questions:

  1. Can every integer from outside Rust be traced through range, representation, and resource validation before it creates authority?
  2. Is there exactly one named owner for memory at each stage, including cancellation, reset, and teardown?
  3. Are reentrant and interrupt contexts explicit about blocking, allocation, locks, and overload?
  4. Does the portability layer preserve important platform differences instead of hiding them?
  5. Does each claim name evidence that can actually observe the relevant host, engine, kernel, or device behavior?

The audit is complete when target, capability, representation, ownership, and evidence agree. That still leaves a product question: the safest defaults for a plugin host may be wasteful for a batch compute tool, while a service’s lifecycle machinery may damage a latency-critical data plane. The next decision is not another platform port. It is choosing defaults by application shape.

Sources and version notes

  • The Rust wasm32-unknown-unknown target documentation defines current target assumptions, standard-library limitations, default feature considerations, testing status, and panic behavior. Recheck it for the selected toolchain.
  • The Rust wasm32-wasip2 target documentation describes its component artifact and runtime requirements. Its maturity wording is version-sensitive.
  • The WebAssembly core specification defines modules, imports, exports, memories, validation, and execution. Host capabilities and application ABIs come from environment-specific contracts.
  • The WebAssembly Component Model canonical ABI guide explains lifting and lowering component values. Consult the canonical ABI specification for normative details.
  • The Linux kernel DMA API documentation and current Rust-for-Linux DMA module documentation describe kernel-specific mapping and ownership abstractions. They are tied to kernel version and configuration, not a universal Rust API.
  • The executable examples/rust-engineering-handbook/part-15/platform-boundaries fixture uses Rust 2024, declares MSRV 1.85, and has no third-party dependencies. Host formatting, locked/offline checks, six tests, doctests, Clippy, and a release build passed on Rust/Cargo 1.97.0; locked/offline checks, the same tests, doctests, and a release build also passed on Rust/Cargo 1.85.0. These host passes are not Wasm-target, kernel, device, cache-coherence, or hardware proof.