plugin/host/src/wasm.rs
//! The wasmtime host for holger's ABI v1 package handlers: load a `.wasm`, and
//! answer holger's verbs through it.
//!
//! The mechanics — verify before compile, the census digest, both CPU bounds
//! at the engine level, a fresh bounded `Store` per call, `alloc`/`result_len`
//! for the buffer protocol, a trap turned into the bound that caused it — are
//! `guest_host::module::LoadedModule` (edda) since 2026-09-22, shared with the
//! meter host below and with monetize's product host. What stays here is
//! holger's: the v1 export set, the manifest and its ABI-version check, the
//! four `host_store_*` imports over a [`SharedStore`], and the frozen
//! `holger-plugin-<n>` code on every refusal (`named.rs`).
//!
//! Error handling is the point, as it always was: a failure is a named `Err`
//! all the way out, because a repository backend that answers "no such
//! artifact" to a trapped module would be a silent 404 over real bytes.
use std::path::Path;
use std::sync::Arc;
use guest_host::module::{
read_mem, read_str, write_to_wasm, GuestState, Instantiated, LoadedModule,
};
use guest_host::BoundedLimiter;
use holger_plugin_abi::{
exports, imports, wire, WireArtifactEntry, WireArtifactId, WireHttpRequest, WireHttpResponse,
WireManifest, ABI_VERSION,
};
use wasmtime::{Caller, Linker};
use crate::backend::{HandlerRuntime, SharedStore};
use crate::limits::PluginLimits;
use crate::named::{call_refusal, load_refusal};
use crate::PluginPolicy;
/// What the host lends a module for the length of one call.
struct HostState {
store: SharedStore,
/// 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 by the host after the call returns.
faults: Vec<String>,
/// The memory/table ceiling for this call, and whether it was hit.
limits: BoundedLimiter,
}
impl GuestState for HostState {
fn limiter(&mut self) -> &mut BoundedLimiter {
&mut self.limits
}
fn faults(&mut self) -> &mut Vec<String> {
&mut self.faults
}
}
/// A `.wasm` package handler, loaded and ready to answer.
///
/// One `Store` per call (`LoadedModule::instantiate`), so a call cannot observe
/// another call's linear memory — which is what makes a `&self` verb sound on
/// a shared backend.
pub struct WasmHandler {
inner: LoadedModule,
store: SharedStore,
manifest: WireManifest,
}
impl WasmHandler {
/// Load a module and read its manifest.
///
/// Every failure names the module path and what was wrong with it. In
/// particular a **missing export** is named individually: a module that is
/// half-built is otherwise indistinguishable from one that simply answers
/// nothing.
pub fn load(path: impl AsRef<Path>, store: SharedStore) -> Result<Self, String> {
Self::load_with(path, store, &PluginPolicy::default())
}
/// [`WasmHandler::load`] under an explicit policy: what the module must prove
/// before it is compiled, and what it may spend once it runs.
///
/// **Verification happens before compilation**, over the bytes on disk —
/// compiling an unverified module is already running the cranelift frontend
/// over attacker-supplied input.
pub fn load_with(
path: impl AsRef<Path>,
store: SharedStore,
policy: &PluginPolicy,
) -> Result<Self, String> {
let path = path.as_ref().to_path_buf();
let limits = policy.limits.clone();
crate::limits::validate(&limits)?;
let inner = LoadedModule::load(&path, &limits, &policy.trust, exports::ALL)
.map_err(load_refusal)?;
let mut handler = Self {
inner,
store,
// Placeholder: replaced immediately below. Never observable —
// `read_manifest` does not consult it.
manifest: WireManifest {
abi_version: 0,
handler: String::new(),
format: String::new(),
writable: false,
identity: None,
},
};
handler.manifest = handler.read_manifest()?;
if handler.manifest.abi_version != ABI_VERSION {
return Err(format!(
"{}: plugin ABI version {} does not match this holger's {}. \
A mismatched ABI decodes one field's bytes as another's, so the module \
is refused rather than loaded. Rebuild the plugin against \
holger-plugin-abi {}.",
path.display(),
handler.manifest.abi_version,
ABI_VERSION,
ABI_VERSION
));
}
if handler.manifest.handler.trim().is_empty() {
return Err(format!(
"{}: plugin manifest declares an empty handler name",
path.display()
));
}
Ok(handler)
}
pub fn manifest(&self) -> &WireManifest {
&self.manifest
}
pub fn path(&self) -> &Path {
self.inner.path()
}
fn read_manifest(&self) -> Result<WireManifest, String> {
let raw = self.call_no_arg(exports::MANIFEST)?;
WireManifest::from_bytes(&raw).map_err(|e| {
format!(
"{}: {} returned undecodable bytes: {e}",
self.path().display(),
exports::MANIFEST
)
})
}
/// A bounded store with the four store imports defined.
fn instantiate(&self) -> Result<Instantiated<HostState>, String> {
let mut linker = Linker::new(self.inner.engine());
register_host_functions(&mut linker)
.map_err(|e| format!("{}: registering host functions: {e}", self.path().display()))?;
let state = HostState {
store: Arc::clone(&self.store),
faults: Vec::new(),
limits: self.inner.limits().limiter(),
};
self.inner.instantiate(state, &linker).map_err(call_refusal)
}
fn call_no_arg(&self, export: &str) -> Result<Vec<u8>, String> {
let mut inst = self.instantiate()?;
self.inner.call_ptr(&mut inst, export).map_err(call_refusal)
}
/// Call `export(ptr, len) -> ptr` with `arg` copied into linear memory.
fn call_with(&self, export: &str, arg: &[u8]) -> Result<Vec<u8>, String> {
let mut inst = self.instantiate()?;
self.inner
.call_with_ptr(&mut inst, export, arg)
.map_err(call_refusal)
}
}
/// 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 WasmHandler {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WasmHandler")
.field("path", &self.inner.path())
.field("handler", &self.manifest.handler)
.field("signer", &self.inner.signer())
.field("limits", self.inner.limits())
.finish()
}
}
impl HandlerRuntime for WasmHandler {
fn manifest(&self) -> &WireManifest {
&self.manifest
}
fn fetch(&self, id: &WireArtifactId) -> Result<Option<Vec<u8>>, String> {
let raw = self.call_with(exports::FETCH, &id.to_bytes())?;
wire::decode_fetch_response(&raw)
.map_err(|e| format!("{}: {}: {e}", self.path().display(), exports::FETCH))?
}
fn put(&self, id: &WireArtifactId, data: &[u8]) -> Result<(), String> {
let req = wire::WirePutRequest {
id: id.clone(),
data: data.to_vec(),
};
let raw = self.call_with(exports::PUT, &req.to_bytes())?;
wire::decode_unit_response(&raw)
.map_err(|e| format!("{}: {}: {e}", self.path().display(), exports::PUT))?
}
fn list(
&self,
name_filter: Option<&str>,
limit: usize,
) -> Result<Vec<WireArtifactEntry>, String> {
let req = wire::WireListRequest {
name_filter: name_filter.map(str::to_string),
limit: limit.min(u32::MAX as usize) as u32,
};
let raw = self.call_with(exports::LIST, &req.to_bytes())?;
wire::decode_list_response(&raw)
.map_err(|e| format!("{}: {}: {e}", self.path().display(), exports::LIST))?
}
fn coordinate_for_path(&self, suburl: &str) -> Option<WireArtifactId> {
// The trait returns `Option`, so a module failure cannot be reported
// here. That is safe in exactly one direction: this is the serve-time
// quarantine gate's lookup, and `None` means "not gated by coordinate",
// which is the trait's own documented default. A failure therefore loses
// a gate check, never serves bytes it should not have.
let mut w = holger_plugin_abi::Writer::new();
w.str(suburl);
let raw = self
.call_with(exports::COORDINATE_FOR_PATH, &w.finish())
.ok()?;
wire::decode_coordinate_response(&raw).ok().flatten()
}
fn http(&self, req: &WireHttpRequest) -> Result<WireHttpResponse, String> {
let raw = self.call_with(exports::HTTP, &req.to_bytes())?;
wire::decode_http_response(&raw)
.map_err(|e| format!("{}: {}: {e}", self.path().display(), exports::HTTP))?
}
}
// ─── Host imports ────────────────────────────────────────────────────
fn register_host_functions(linker: &mut Linker<HostState>) -> anyhow::Result<()> {
linker.func_wrap(
imports::MODULE,
imports::STORE_GET,
|mut caller: Caller<'_, HostState>, ptr: u32, len: u32| -> u64 {
let Some(key) = read_str(&mut caller, ptr, len, imports::STORE_GET) else {
return 0;
};
match caller.data().store.get(&key) {
Ok(v) => {
let bytes = wire::encode_store_get(&v);
write_to_wasm(&mut caller, &bytes)
}
Err(e) => {
// A store failure is recorded, and 0 is returned. It is NOT
// encoded as `None`: a module that read "absent" would turn
// an unreadable disk into a clean 404.
caller.data_mut().faults.push(format!("get({key}): {e}"));
0
}
}
},
)?;
linker.func_wrap(
imports::MODULE,
imports::STORE_PUT,
|mut caller: Caller<'_, HostState>,
key_ptr: u32,
key_len: u32,
data_ptr: u32,
data_len: u32|
-> u64 {
let Some(key) = read_str(&mut caller, key_ptr, key_len, imports::STORE_PUT) else {
return 0;
};
let Some(data) = read_mem(&mut caller, data_ptr, data_len) else {
caller.data_mut().faults.push(format!(
"{}: body outside linear memory",
imports::STORE_PUT
));
return 0;
};
let res = caller.data().store.put(&key, &data);
if let Err(e) = &res {
caller.data_mut().faults.push(format!("put({key}): {e}"));
}
let bytes = wire::encode_unit_response(&res);
write_to_wasm(&mut caller, &bytes)
},
)?;
linker.func_wrap(
imports::MODULE,
imports::STORE_LIST,
|mut caller: Caller<'_, HostState>, ptr: u32, len: u32| -> u64 {
let Some(prefix) = read_str(&mut caller, ptr, len, imports::STORE_LIST) else {
return 0;
};
match caller.data().store.list(&prefix) {
Ok(rows) => {
let bytes = wire::encode_store_listing(&rows);
write_to_wasm(&mut caller, &bytes)
}
Err(e) => {
caller
.data_mut()
.faults
.push(format!("list({prefix}): {e}"));
0
}
}
},
)?;
linker.func_wrap(
imports::MODULE,
imports::STORE_SIZE,
|mut caller: Caller<'_, HostState>, ptr: u32, len: u32| -> u64 {
let Some(key) = read_str(&mut caller, ptr, len, imports::STORE_SIZE) else {
return 0;
};
match caller.data().store.size(&key) {
Ok(v) => {
// Carried as `Option<Vec<u8>>` holding 8 little-endian bytes,
// so "absent" and "zero bytes" stay distinguishable.
let payload = v.map(|n| n.to_le_bytes().to_vec());
let bytes = wire::encode_store_get(&payload);
write_to_wasm(&mut caller, &bytes)
}
Err(e) => {
caller.data_mut().faults.push(format!("size({key}): {e}"));
0
}
}
},
)?;
Ok(())
}
impl WasmHandler {
/// Trait-object-safe helper so `WasmHandler` can be boxed into a
/// [`crate::backend::HandlerBackend`] without the caller naming its type.
pub fn into_runtime(self) -> Box<dyn HandlerRuntime> {
Box::new(self)
}
/// The same compiled module, bound to a different store.
///
/// Compiling is the expensive part of [`WasmHandler::load`]; binding a store
/// is free. A catalog reads a plugin directory ONCE at boot and mounts the
/// same handler under however many repositories name it — so this must not
/// recompile, must not start a second epoch ticker, and must not re-read the
/// manifest (a module whose manifest answered differently on a second read
/// would be registered under one name and serve as another).
pub fn with_store(&self, store: SharedStore) -> Self {
Self {
inner: self.inner.clone_shared(),
store,
manifest: self.manifest.clone(),
}
}
/// The bounds this handler runs under.
pub fn limits(&self) -> &PluginLimits {
self.inner.limits()
}
/// The verified signer's common name, or `None` when no provenance was
/// required. Never `None` because verification failed — that is an `Err` out
/// of [`WasmHandler::load_with`], and the module is not here.
pub fn signer(&self) -> Option<&str> {
self.inner.signer()
}
/// Hex blake3-256 of the module bytes, as the signature covers them.
pub fn digest_hex(&self) -> &str {
self.inner.digest_hex()
}
}