crates/guest-host/src/module.rs
//! **A loaded guest**: verify → digest → compile → tick, then bounded stores and
//! the call shapes, with a trap turned into the bound that caused it.
//!
//! Shape borrowed from holger's plugin host (which borrowed it from znippy's
//! loader): a fresh `Store` per call, `alloc`/`result_len` for the buffer
//! protocol, host functions on a `Linker` the *host* fills in. What is
//! deliberately kept from holger's version is the error handling: every
//! failure is a named `Err` all the way out — a missing export, a trap, a
//! refused allocation, a failed host import — because a host that answered
//! "nothing here" to a trapped module would be a silent empty answer over real
//! data.
//!
//! # The load order, and why it is this order
//!
//! 1. Validate the limits (a `0` is a refusal, not "unlimited").
//! 2. Read the bytes and **verify the signature over them** — BEFORE
//! compilation, because compiling an unverified module is already running
//! cranelift over attacker-supplied input.
//! 3. Digest the same bytes: the census name.
//! 4. Compile with both CPU bounds on at the engine level (`from_binary`, not
//! `from_file`, so a text module is never accepted as a binary one).
//! 5. Check the export set, naming the missing symbol.
//! 6. Start the epoch ticker.
//!
//! Nothing is instantiated at load. What a module may *import* is the host's
//! decision at instantiation, on the `Linker` it hands [`LoadedModule::instantiate`].
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use wasmtime::{Caller, Engine, Extern, Instance, Linker, Memory, Module, Store, Trap};
use crate::convention::{exports, pack, unpack};
use crate::limits::{Bound, BoundedLimiter, EpochTicker, LimitZero, PluginLimits};
use crate::verify::{ModuleTrust, TrustError};
/// What the host lends a guest for the length of one call, as this crate needs
/// to see it. A host's own state struct implements this over two fields.
pub trait GuestState: Send + 'static {
/// The memory/table ceiling for this call, and the record of whether it
/// was hit. Lives in the store data because that is the only thing a
/// `Store::limiter` closure can reach.
fn limiter(&mut self) -> &mut BoundedLimiter;
/// Every failure a host import hit, in order. An import can only signal
/// failure to wasm as a `0`, which a module is free to ignore — so the real
/// reason is kept here and surfaced after the call returns.
fn faults(&mut self) -> &mut Vec<String>;
}
/// Why a module did not load. `Display` names the module and what was wrong.
#[derive(Debug)]
pub enum LoadError {
/// A limit is zero.
Limits(LimitZero),
/// The file could not be read.
Unreadable { path: PathBuf, detail: String },
/// The trust policy refused it.
Trust(TrustError),
/// The bounded engine could not be built.
Engine { path: PathBuf, detail: String },
/// Not a loadable wasm binary.
NotWasm { path: PathBuf, detail: String },
/// One or more required exports are absent.
MissingExports { path: PathBuf, missing: Vec<String>, required: Vec<String> },
/// No exported linear `memory`, so the host cannot pass arguments.
NoMemory { path: PathBuf },
/// Imports from a namespace the host does not provide.
ForeignImports { path: PathBuf, allowed: String, imports: Vec<String> },
}
impl std::fmt::Display for LoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LoadError::Limits(e) => write!(f, "{e}"),
LoadError::Unreadable { path, detail } => write!(f, "{}: could not be read: {detail}", path.display()),
LoadError::Trust(e) => write!(f, "{e}"),
LoadError::Engine { path, detail } => write!(f, "{}: bounded wasm engine: {detail}", path.display()),
LoadError::NotWasm { path, detail } => write!(f, "{}: not a loadable wasm module: {detail}", path.display()),
LoadError::MissingExports { path, missing, required } => write!(
f,
"{}: not a guest of this kind — missing export(s): {}.\nA module must export all of: {}",
path.display(),
missing.join(", "),
required.join(", ")
),
LoadError::NoMemory { path } => {
write!(f, "{}: exports no linear `memory`; the host cannot pass arguments to it", path.display())
}
LoadError::ForeignImports { path, allowed, imports } => write!(
f,
"{}: imports from outside `{allowed}`: {}. A wasm32-unknown-unknown module has no \
syscalls to name; anything here is a runtime this host does not provide.",
path.display(),
imports.join(", ")
),
}
}
}
impl std::error::Error for LoadError {}
impl From<TrustError> for LoadError {
fn from(e: TrustError) -> Self {
LoadError::Trust(e)
}
}
impl From<LimitZero> for LoadError {
fn from(e: LimitZero) -> Self {
LoadError::Limits(e)
}
}
/// Why one call into a guest did not answer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CallError {
/// A resource bound fired. `refusal` is [`Bound::refusal`], already worded.
Bound { bound: Bound, refusal: String },
/// The module trapped for a reason that is not a bound.
Trap { path: PathBuf, export: String, detail: String },
/// An export has the wrong signature, or a pointer/length the module
/// returned points outside its memory, or instantiation failed.
Shape { path: PathBuf, detail: String },
/// The call returned, but a host import failed during it. A module that
/// swallowed the `0` and answered "absent" would otherwise turn a broken
/// host into an empty answer.
Faults { path: PathBuf, export: String, faults: Vec<String> },
}
impl std::fmt::Display for CallError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CallError::Bound { refusal, .. } => write!(f, "{refusal}"),
CallError::Trap { path, export, detail } => write!(f, "{}: {export} trapped: {detail}", path.display()),
CallError::Shape { path, detail } => write!(f, "{}: {detail}", path.display()),
CallError::Faults { path, export, faults } => {
write!(f, "{}: {export}: a host call failed: {}", path.display(), faults.join("; "))
}
}
}
}
impl std::error::Error for CallError {}
impl CallError {
/// The bound, when this is one.
pub fn bound(&self) -> Option<Bound> {
match self {
CallError::Bound { bound, .. } => Some(*bound),
_ => None,
}
}
}
/// A store, an instance and its memory: what one call runs in.
pub struct Instantiated<T: 'static> {
pub store: Store<T>,
pub instance: Instance,
pub memory: Memory,
}
/// A `.wasm` verified, digested, compiled and ticking — not yet instantiated.
///
/// `Engine` and `Module` are `Send + Sync`; a `Store` is not, so one is made
/// per call through [`LoadedModule::instantiate`]. A call therefore cannot
/// observe another call's linear memory, which is what makes a `&self` verb
/// sound on a shared host.
pub struct LoadedModule {
engine: Engine,
module: Module,
path: PathBuf,
limits: PluginLimits,
/// Keeps the engine's epoch advancing for as long as this module is
/// loaded. `Arc`, because [`LoadedModule::clone_shared`] mounts one
/// compiled module under N names and must not start N ticker threads.
ticker: Arc<EpochTicker>,
signer: Option<String>,
digest_hex: String,
/// Fuel burned by the most recent completed call, and the most any call
/// has burned since load. wasmtime knows exactly what a call consumed;
/// this is what lets a ceiling be a measurement rather than a guess.
fuel_last: AtomicU64,
fuel_peak: AtomicU64,
}
impl LoadedModule {
/// Load `path` under `limits` and `trust`, requiring `required_exports`.
pub fn load(
path: impl AsRef<Path>,
limits: &PluginLimits,
trust: &ModuleTrust,
required_exports: &[&str],
) -> Result<Self, LoadError> {
let path = path.as_ref().to_path_buf();
limits.validate()?;
let bytes = std::fs::read(&path).map_err(|e| LoadError::Unreadable { path: path.clone(), detail: e.to_string() })?;
Self::from_bytes(path, &bytes, limits, trust, required_exports)
}
/// [`LoadedModule::load`] over bytes already in hand. `path` is the label
/// every refusal names, and where the `.p7s` is looked for.
pub fn from_bytes(
path: PathBuf,
bytes: &[u8],
limits: &PluginLimits,
trust: &ModuleTrust,
required_exports: &[&str],
) -> Result<Self, LoadError> {
limits.validate()?;
// Provenance BEFORE compilation.
let signer = trust.verify(&path, bytes)?;
let digest_hex = crate::census::digest_hex(bytes);
let engine = Engine::new(&limits.engine_config())
.map_err(|e| LoadError::Engine { path: path.clone(), detail: e.to_string() })?;
let module = Module::from_binary(&engine, bytes)
.map_err(|e| LoadError::NotWasm { path: path.clone(), detail: e.to_string() })?;
let present: Vec<String> = module.exports().map(|e| e.name().to_string()).collect();
let missing: Vec<String> = required_exports
.iter()
.filter(|want| !present.iter().any(|p| p == *want))
.map(|s| s.to_string())
.collect();
if !missing.is_empty() {
return Err(LoadError::MissingExports {
path,
missing,
required: required_exports.iter().map(|s| s.to_string()).collect(),
});
}
if !present.iter().any(|p| p == exports::MEMORY) {
return Err(LoadError::NoMemory { path });
}
let (_, tick_period) = limits.epoch_schedule();
let ticker = Arc::new(EpochTicker::start(&engine, tick_period));
Ok(Self {
engine,
module,
path,
limits: limits.clone(),
ticker,
signer,
digest_hex,
fuel_last: AtomicU64::new(0),
fuel_peak: AtomicU64::new(0),
})
}
/// Refuse a module that imports from any namespace but `allowed`.
pub fn refuse_foreign_imports(&self, allowed: &str) -> Result<(), LoadError> {
let foreign: Vec<String> = self
.module
.imports()
.filter(|i| i.module() != allowed)
.map(|i| format!("{}::{}", i.module(), i.name()))
.collect();
if foreign.is_empty() {
Ok(())
} else {
Err(LoadError::ForeignImports { path: self.path.clone(), allowed: allowed.to_string(), imports: foreign })
}
}
/// The names this module imports from `namespace`, in module order.
pub fn imports_from(&self, namespace: &str) -> Vec<String> {
self.module.imports().filter(|i| i.module() == namespace).map(|i| i.name().to_string()).collect()
}
/// Every export name.
pub fn export_names(&self) -> Vec<String> {
self.module.exports().map(|e| e.name().to_string()).collect()
}
/// The same compiled module, sharing engine and ticker. Compiling is the
/// expensive part; this is free, and it starts no second ticker thread.
pub fn clone_shared(&self) -> Self {
Self {
engine: self.engine.clone(),
module: self.module.clone(),
path: self.path.clone(),
limits: self.limits.clone(),
ticker: Arc::clone(&self.ticker),
signer: self.signer.clone(),
digest_hex: self.digest_hex.clone(),
fuel_last: AtomicU64::new(self.fuel_last.load(Ordering::Relaxed)),
fuel_peak: AtomicU64::new(self.fuel_peak.load(Ordering::Relaxed)),
}
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn limits(&self) -> &PluginLimits {
&self.limits
}
pub fn engine(&self) -> &Engine {
&self.engine
}
pub fn module(&self) -> &Module {
&self.module
}
/// The verified signer's common name, or `None` when no provenance was
/// required. Never `None` because verification failed — that is a
/// [`LoadError::Trust`], and the module is not here.
pub fn signer(&self) -> Option<&str> {
self.signer.as_deref()
}
/// Hex blake3-256 of the module bytes, as the signature covers them.
pub fn digest_hex(&self) -> &str {
&self.digest_hex
}
/// Fuel burned by the most recent completed call.
pub fn last_call_fuel(&self) -> u64 {
self.fuel_last.load(Ordering::Relaxed)
}
/// The most fuel any one call has burned since load.
pub fn peak_call_fuel(&self) -> u64 {
self.fuel_peak.load(Ordering::Relaxed)
}
/// The fuel ceiling in force. What [`LoadedModule::peak_call_fuel`] is read against.
pub fn fuel_ceiling(&self) -> u64 {
self.limits.fuel
}
fn shape(&self, detail: String) -> CallError {
CallError::Shape { path: self.path.clone(), detail }
}
/// Instantiate with `state` and the imports on `linker`: a store with the
/// limiter, the fuel tank and the epoch deadline installed. There is no
/// constructor that skips them.
pub fn instantiate<T: GuestState>(&self, state: T, linker: &Linker<T>) -> Result<Instantiated<T>, CallError> {
let mut store = Store::new(&self.engine, state);
store.limiter(|s| s.limiter());
store.set_fuel(self.limits.fuel).map_err(|e| self.shape(format!("setting CPU fuel: {e}")))?;
let (ticks, _) = self.limits.epoch_schedule();
store.set_epoch_deadline(ticks);
let instance = linker
.instantiate(&mut store, &self.module)
.map_err(|e| self.shape(format!("instantiate failed: {e}")))?;
let memory = instance
.get_memory(&mut store, exports::MEMORY)
.ok_or_else(|| self.shape("exports no linear `memory`".to_string()))?;
Ok(Instantiated { store, instance, memory })
}
fn bound_hit<T: GuestState>(&self, store: &mut Store<T>, err: Option<&wasmtime::Error>) -> Option<Bound> {
if let Some(b) = store.data_mut().limiter().refused() {
return Some(b);
}
let err = err?;
match err.downcast_ref::<Trap>() {
Some(Trap::OutOfFuel) => Some(Bound::Fuel),
Some(Trap::Interrupt) => Some(Bound::Deadline),
_ => None,
}
}
fn bound_error(&self, bound: Bound, export: &str) -> CallError {
CallError::Bound { bound, refusal: bound.refusal(&self.path.display().to_string(), export, &self.limits) }
}
/// The error for a trapped call: the named bound if one fired, else the trap.
fn trap_error<T: GuestState>(&self, store: &mut Store<T>, export: &str, err: wasmtime::Error) -> CallError {
match self.bound_hit(store, Some(&err)) {
Some(bound) => self.bound_error(bound, export),
None => CallError::Trap { path: self.path.clone(), export: export.to_string(), detail: err.to_string() },
}
}
/// Record what this call burned. `get_fuel` answers what is LEFT, so the
/// consumption is the difference; an engine with fuel off answers `Err`,
/// and then nothing is recorded — an unknown consumption is not zero.
fn record_fuel<T: 'static>(&self, store: &Store<T>) {
let Ok(left) = store.get_fuel() else { return };
let used = self.limits.fuel.saturating_sub(left);
self.fuel_last.store(used, Ordering::Relaxed);
self.fuel_peak.fetch_max(used, Ordering::Relaxed);
}
/// After a call RETURNED: a bound that fired anyway (a module may swallow
/// the `-1` from a refused `memory.grow`), then any host-import fault.
fn settle<T: GuestState>(&self, store: &mut Store<T>, export: &str) -> Result<(), CallError> {
self.record_fuel(store);
if let Some(b) = self.bound_hit(store, None) {
return Err(self.bound_error(b, export));
}
let faults = std::mem::take(store.data_mut().faults());
if !faults.is_empty() {
return Err(CallError::Faults { path: self.path.clone(), export: export.to_string(), faults });
}
Ok(())
}
/// Copy `arg` into the guest through its own `alloc`; `0` for an empty arg.
fn push_arg<T: GuestState>(&self, inst: &mut Instantiated<T>, arg: &[u8]) -> Result<u32, CallError> {
if arg.is_empty() {
return Ok(0);
}
let alloc = inst
.instance
.get_typed_func::<u32, u32>(&mut inst.store, exports::ALLOC)
.map_err(|e| self.shape(format!("{} is not `(u32) -> u32`: {e}", exports::ALLOC)))?;
let ptr = alloc.call(&mut inst.store, arg.len() as u32).map_err(|e| self.trap_error(&mut inst.store, exports::ALLOC, e))?;
inst.memory.write(&mut inst.store, ptr as usize, arg).map_err(|e| {
self.shape(format!("{} returned ptr {ptr} that cannot hold {} bytes: {e}", exports::ALLOC, arg.len()))
})?;
Ok(ptr)
}
fn read_bytes<T: 'static>(&self, inst: &mut Instantiated<T>, ptr: u32, len: u32, export: &str) -> Result<Vec<u8>, CallError> {
// Slice first, copy second: a lying guest's len must never size a host allocation.
let data = inst.memory.data(&inst.store);
(ptr as usize)
.checked_add(len as usize)
.and_then(|end| data.get(ptr as usize..end))
.map(<[u8]>::to_vec)
.ok_or_else(|| {
self.shape(format!("{export} returned ptr {ptr}/len {len}, outside linear memory of {} bytes", data.len()))
})
}
/// `export() -> u64`. The `plugin_abi` shape.
pub fn call_u64<T: GuestState>(&self, inst: &mut Instantiated<T>, export: &str) -> Result<u64, CallError> {
let f = inst
.instance
.get_typed_func::<(), u64>(&mut inst.store, export)
.map_err(|e| self.shape(format!("{export} is not `() -> u64`: {e}")))?;
let out = f.call(&mut inst.store, ()).map_err(|e| self.trap_error(&mut inst.store, export, e))?;
self.settle(&mut inst.store, export)?;
Ok(out)
}
/// `export() -> ptr`, length from `result_len()`. The `plugin_manifest` shape.
pub fn call_ptr<T: GuestState>(&self, inst: &mut Instantiated<T>, export: &str) -> Result<Vec<u8>, CallError> {
let f = inst
.instance
.get_typed_func::<(), u32>(&mut inst.store, export)
.map_err(|e| self.shape(format!("{export} is not `() -> u32`: {e}")))?;
let ptr = f.call(&mut inst.store, ()).map_err(|e| self.trap_error(&mut inst.store, export, e))?;
let len = self.result_len(inst)?;
let out = self.read_bytes(inst, ptr, len, export)?;
self.settle(&mut inst.store, export)?;
Ok(out)
}
/// `export(ptr, len) -> ptr`, length from `result_len()`. The shape of a
/// per-verb export (holger's ABI v1).
pub fn call_with_ptr<T: GuestState>(&self, inst: &mut Instantiated<T>, export: &str, arg: &[u8]) -> Result<Vec<u8>, CallError> {
let arg_ptr = self.push_arg(inst, arg)?;
let f = inst
.instance
.get_typed_func::<(u32, u32), u32>(&mut inst.store, export)
.map_err(|e| self.shape(format!("{export} is not `(u32,u32) -> u32`: {e}")))?;
let ptr = f.call(&mut inst.store, (arg_ptr, arg.len() as u32)).map_err(|e| self.trap_error(&mut inst.store, export, e))?;
let len = self.result_len(inst)?;
let out = self.read_bytes(inst, ptr, len, export)?;
self.settle(&mut inst.store, export)?;
Ok(out)
}
/// `plugin_call(op, ptr, len) -> ptr << 32 | len`. THE verb shape.
pub fn call_op<T: GuestState>(&self, inst: &mut Instantiated<T>, op: u32, arg: &[u8]) -> Result<Vec<u8>, CallError> {
let export = exports::PLUGIN_CALL;
let arg_ptr = self.push_arg(inst, arg)?;
let f = inst
.instance
.get_typed_func::<(u32, u32, u32), u64>(&mut inst.store, export)
.map_err(|e| self.shape(format!("{export} is not `(u32,u32,u32) -> u64`: {e}")))?;
let packed = f
.call(&mut inst.store, (op, arg_ptr, arg.len() as u32))
.map_err(|e| self.trap_error(&mut inst.store, export, e))?;
let (ptr, len) = unpack(packed);
let out = self.read_bytes(inst, ptr, len, &format!("op {op}"))?;
self.settle(&mut inst.store, export)?;
Ok(out)
}
fn result_len<T: GuestState>(&self, inst: &mut Instantiated<T>) -> Result<u32, CallError> {
let f = inst
.instance
.get_typed_func::<(), u32>(&mut inst.store, exports::RESULT_LEN)
.map_err(|e| self.shape(format!("{} is not `() -> u32`: {e}", exports::RESULT_LEN)))?;
f.call(&mut inst.store, ()).map_err(|e| self.trap_error(&mut inst.store, exports::RESULT_LEN, e))
}
}
/// Prints WHERE the module came from, WHO signed it and WHAT it may spend —
/// never the engine or the module.
impl std::fmt::Debug for LoadedModule {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LoadedModule")
.field("path", &self.path)
.field("digest", &self.digest_hex)
.field("signer", &self.signer)
.field("limits", &self.limits)
.finish()
}
}
// ─── Host-import helpers: what an import closure needs to touch guest memory ──
/// Read a `(ptr, len)` pair out of the caller's memory.
pub fn read_mem<T>(caller: &mut Caller<'_, T>, ptr: u32, len: u32) -> Option<Vec<u8>> {
let memory = match caller.get_export(exports::MEMORY) {
Some(Extern::Memory(m)) => m,
_ => return None,
};
let end = (ptr as usize).checked_add(len as usize)?;
memory.data(&*caller).get(ptr as usize..end).map(<[u8]>::to_vec)
}
/// Read a UTF-8 argument, recording a fault (named `what`) if it is unreadable.
pub fn read_str<T: GuestState>(caller: &mut Caller<'_, T>, ptr: u32, len: u32, what: &str) -> Option<String> {
let Some(raw) = read_mem(caller, ptr, len) else {
caller.data_mut().faults().push(format!("{what}: argument outside linear memory"));
return None;
};
match String::from_utf8(raw) {
Ok(s) => Some(s),
Err(_) => {
caller.data_mut().faults().push(format!("{what}: argument is not UTF-8"));
None
}
}
}
/// Read a byte argument, recording a fault (named `what`) if it is unreadable.
pub fn read_arg<T: GuestState>(caller: &mut Caller<'_, T>, ptr: u32, len: u32, what: &str) -> Option<Vec<u8>> {
let out = read_mem(caller, ptr, len);
if out.is_none() {
caller.data_mut().faults().push(format!("{what}: argument outside linear memory"));
}
out
}
/// Copy `bytes` into the guest through its own `alloc` and return
/// `ptr << 32 | len`. **`0` means the host call failed**, and the reason is
/// in the faults — never "absent".
pub fn write_to_wasm<T: GuestState>(caller: &mut Caller<'_, T>, bytes: &[u8]) -> u64 {
let alloc = match caller.get_export(exports::ALLOC) {
Some(Extern::Func(f)) => match f.typed::<u32, u32>(&*caller) {
Ok(t) => t,
Err(_) => {
caller.data_mut().faults().push(format!("{} is not `(u32) -> u32`", exports::ALLOC));
return 0;
}
},
_ => {
caller.data_mut().faults().push(format!("module exports no `{}`", exports::ALLOC));
return 0;
}
};
let ptr = match alloc.call(&mut *caller, bytes.len() as u32) {
Ok(p) => p,
Err(e) => {
caller.data_mut().faults().push(format!("{} trapped: {e}", exports::ALLOC));
return 0;
}
};
let memory = match caller.get_export(exports::MEMORY) {
Some(Extern::Memory(m)) => m,
_ => {
caller.data_mut().faults().push(format!("module exports no `{}`", exports::MEMORY));
return 0;
}
};
if let Err(e) = memory.write(&mut *caller, ptr as usize, bytes) {
caller.data_mut().faults().push(format!("writing host answer into linear memory: {e}"));
return 0;
}
pack(ptr, bytes.len() as u32)
}
/// Record a host-import failure and answer the failure word.
pub fn fault<T: GuestState>(caller: &mut Caller<'_, T>, what: impl Into<String>) -> u64 {
caller.data_mut().faults().push(what.into());
0
}