crates/nornir-build-thing/src/plugin_host.rs
//! **Loading and calling an app's build thing** — the host half of [`crate::plugin`].
//!
//! The forge holds no dependency on any app and none on any buildspec crate. It loads
//! a `.wasm` module and calls it, so a new app — or an app growing a variant — needs
//! **zero forge rebuild**.
//!
//! # The sandbox is granted nothing
//!
//! The module is a core `wasm32-unknown-unknown` module. It has no imports: there is
//! no filesystem, no socket, no clock, no environment, and no way to name one. This
//! host builds a [`wasmtime::Linker`] and **defines nothing on it**, so an import
//! would fail instantiation loudly rather than being silently satisfied.
//!
//! On top of that, [`Limits`] caps memory, fuel (so an infinite loop terminates rather
//! than hanging the forge), and reply size. A build thing pulled out of an artifact
//! from a registry is untrusted input, and it is treated as such.
//!
//! # What it is NOT
//!
//! It is not a way to run the artifact. dwarves executes the artifact's *build thing*,
//! never the artifact — which is why this works for an aarch64 or musl binary on an
//! x86 forge, and why a hostile artifact gets a capability-free wasm sandbox instead
//! of the process that holds the SSH deploy keys.
use anyhow::{anyhow, bail, Context, Result};
use serde::de::DeserializeOwned;
use crate::plugin::{
abi, host, ArtifactFacts, BuildGraph, BuildMenu, BuildPlan, BuildRequest, Manifest,
OptionsQuery, RebuildRecipe, SpecProvenance, Verdict, ABI_VERSION,
};
use crate::variant::BuildVariantSet;
/// What a loaded build thing is allowed to consume. Defaults are generous for a
/// declaration-and-validation plugin and small for anything else.
#[derive(Debug, Clone, Copy)]
pub struct Limits {
/// Maximum linear memory, in 64 KiB wasm pages.
pub max_pages: usize,
/// Fuel per call — the cap that makes an infinite loop terminate instead of
/// hanging the forge. A declaration plugin uses a tiny fraction of this.
pub fuel_per_call: u64,
/// Maximum reply size in bytes, so a module cannot answer with a gigabyte.
pub max_reply: usize,
}
impl Default for Limits {
fn default() -> Self {
Limits { max_pages: 256, fuel_per_call: 200_000_000, max_reply: 8 * 1024 * 1024 }
}
}
/// **How the host fulfils `nordisk::fetch`.** The CONTRACT: the reference
/// fulfilment is a container — the coordinate is resolved inside the declared
/// toolchain image ([`crate::cargo_container::CARGO_TOOLCHAIN_IMAGE`]) by a
/// runner the forge binds (dwarves, over draupnir's `backend-oci` /
/// skidbladnir); this crate defines the seam and ships no runner. A host-side
/// fulfilment is a NAMED exception: it must carry a reason, it is answered as
/// [`host::FETCH_OK_HOST_EXCEPTION`] on the wire, and it is written to the
/// plugin's log — so it can never pass as the container one. No fetcher bound
/// ⇒ every fetch is refused with a reason.
pub trait Fetcher: Send + Sync {
/// The bytes behind `coordinate`, or a refusal reason.
fn fetch(&self, coordinate: &str) -> Result<host::Fetched, String>;
/// One line naming the fulfilment (for the refusal text and the log).
fn describe(&self) -> String;
}
/// **The seam a container runner fills** — dwarves implements this over the
/// draupnir `backend-oci` engine; edda ships no implementation on purpose (the
/// container is the forge's business, the contract is edda's).
pub trait ContainerFetchRunner: Send + Sync {
/// Resolve `coordinate` inside `image` and return the artifact bytes.
fn fetch_in_container(&self, image: &str, coordinate: &str) -> Result<Vec<u8>, String>;
}
/// **The reference fulfilment**: a coordinate resolved inside the declared
/// toolchain container. Answers [`host::FetchOrigin::Container`].
pub struct ContainerFetcher {
/// The toolchain image the coordinate is resolved in.
pub image: String,
/// The runner the forge bound.
pub runner: Box<dyn ContainerFetchRunner>,
}
impl ContainerFetcher {
/// A container fetcher over the constellation's declared toolchain image.
pub fn new(runner: Box<dyn ContainerFetchRunner>) -> Self {
ContainerFetcher { image: crate::cargo_container::CARGO_TOOLCHAIN_IMAGE.to_string(), runner }
}
}
impl Fetcher for ContainerFetcher {
fn fetch(&self, coordinate: &str) -> Result<host::Fetched, String> {
let bytes = self.runner.fetch_in_container(&self.image, coordinate)?;
Ok(host::Fetched { bytes, origin: host::FetchOrigin::Container })
}
fn describe(&self) -> String {
format!("container fetch in {}", self.image)
}
}
/// **The named exception**: bytes read from HOST paths the operator chose,
/// with a reason that must be given. Answers [`host::FetchOrigin::HostException`]
/// and is logged on every fulfilment.
pub struct HostPathFetcher {
/// WHY the host is fulfilling this instead of the container — required,
/// non-empty, and written into the plugin's log on each fetch.
pub exception: String,
/// coordinate → host path.
pub paths: std::collections::BTreeMap<String, std::path::PathBuf>,
}
impl HostPathFetcher {
/// A host-path fetcher. Refuses (returns `Err`) an empty exception: an
/// exception without a reason is the silent host fulfilment the contract forbids.
pub fn named(exception: impl Into<String>) -> Result<Self> {
let exception = exception.into();
if exception.trim().is_empty() {
bail!("a host-path fetch is a NAMED exception — give the reason");
}
Ok(HostPathFetcher { exception, paths: Default::default() })
}
/// Offer one coordinate from a host path.
pub fn with_path(mut self, coordinate: impl Into<String>, path: impl Into<std::path::PathBuf>) -> Self {
self.paths.insert(coordinate.into(), path.into());
self
}
}
impl Fetcher for HostPathFetcher {
fn fetch(&self, coordinate: &str) -> Result<host::Fetched, String> {
let path = self
.paths
.get(coordinate)
.ok_or_else(|| format!("no host path offered for '{coordinate}' (exception: {})", self.exception))?;
let bytes = std::fs::read(path).map_err(|e| format!("host path {} for '{coordinate}': {e}", path.display()))?;
Ok(host::Fetched { bytes, origin: host::FetchOrigin::HostException })
}
fn describe(&self) -> String {
format!("HOST-PATH fetch by exception: {}", self.exception)
}
}
/// **The facts the host is willing to answer.** A build thing has no filesystem, no
/// socket and no clock; everything it can learn about the world comes from here,
/// because dwarves decided to answer. Anything absent is simply not knowable to the
/// plugin — which is the point.
#[derive(Clone, Default)]
pub struct HostFacts {
/// The revision of the tree the host is looking at, and whether it is dirty.
pub rev: String,
/// Whether that tree is dirty.
pub dirty: bool,
/// Manifests the host will read, by repo-relative path. A path not in here is
/// declined — the plugin never gets a path handle, so there is no traversal to
/// defend against, only a lookup that misses.
pub manifests: std::collections::BTreeMap<String, String>,
/// The `nordisk::fetch` fulfilment — a [`ContainerFetcher`] (the reference)
/// or a [`HostPathFetcher`] (the named exception). `None` ⇒ every fetch is
/// REFUSED with a reason the plugin sees by name (`host::FetchRefused`) —
/// never an empty reply.
pub fetcher: Option<std::sync::Arc<dyn Fetcher>>,
/// In-memory FIXTURE bytes by coordinate — a test's host exception,
/// answered as such. Consulted only when no `fetcher` is bound.
pub fetchables: std::collections::BTreeMap<String, Vec<u8>>,
/// Lines the plugin logged, newest last.
pub log: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
}
impl std::fmt::Debug for HostFacts {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HostFacts")
.field("rev", &self.rev)
.field("dirty", &self.dirty)
.field("manifests", &self.manifests.keys().collect::<Vec<_>>())
.field("fetcher", &self.fetcher.as_ref().map(|x| x.describe()))
.field("fetchables", &self.fetchables.keys().collect::<Vec<_>>())
.finish_non_exhaustive()
}
}
impl HostFacts {
/// Facts for a checkout: its revision + dirty flag.
pub fn at_rev(rev: impl Into<String>, dirty: bool) -> Self {
HostFacts { rev: rev.into(), dirty, ..Default::default() }
}
/// Offer one manifest to the plugin.
pub fn with_manifest(mut self, path: impl Into<String>, text: impl Into<String>) -> Self {
self.manifests.insert(path.into(), text.into());
self
}
/// Offer FIXTURE bytes behind one coordinate to the plugin's `fetch` — a
/// host exception (tests); a forge binds [`with_fetcher`](Self::with_fetcher).
pub fn with_fetchable(mut self, coordinate: impl Into<String>, bytes: Vec<u8>) -> Self {
self.fetchables.insert(coordinate.into(), bytes);
self
}
/// Bind the `fetch` fulfilment — the [`ContainerFetcher`] by default, a
/// [`HostPathFetcher`] as the named exception.
pub fn with_fetcher(mut self, fetcher: std::sync::Arc<dyn Fetcher>) -> Self {
self.fetcher = Some(fetcher);
self
}
/// Answer one fetch — the ONE place the policy lives: bound fetcher, else
/// fixture (host exception), else refusal with what would have answered.
pub fn answer_fetch(&self, coordinate: &str) -> Result<host::Fetched, String> {
if coordinate.is_empty() {
return Err("an empty coordinate".to_string());
}
let fetched = if let Some(f) = &self.fetcher {
f.fetch(coordinate)?
} else if let Some(bytes) = self.fetchables.get(coordinate) {
host::Fetched { bytes: bytes.clone(), origin: host::FetchOrigin::HostException }
} else {
return Err(format!(
"the host holds nothing under '{coordinate}' — no fetcher is bound (the reference is a \
ContainerFetcher over the declared toolchain image; a HostPathFetcher is the named \
exception){}",
if self.fetchables.is_empty() {
String::new()
} else {
format!("; fixtures offered: {}", self.fetchables.keys().cloned().collect::<Vec<_>>().join(", "))
}
));
};
if fetched.origin == host::FetchOrigin::HostException {
let why = self.fetcher.as_ref().map(|f| f.describe()).unwrap_or_else(|| "in-memory fixture".to_string());
if let Ok(mut g) = self.log.lock() {
g.push(format!("fetch('{coordinate}') fulfilled on the HOST by exception: {why}"));
}
}
Ok(fetched)
}
/// What the plugin logged.
pub fn logged(&self) -> Vec<String> {
self.log.lock().map(|g| g.clone()).unwrap_or_default()
}
}
/// A loaded, instantiated build thing.
pub struct PluginHandle {
store: wasmtime::Store<HostFacts>,
instance: wasmtime::Instance,
memory: wasmtime::Memory,
limits: Limits,
manifest: Manifest,
}
impl std::fmt::Debug for PluginHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PluginHandle").field("manifest", &self.manifest).finish_non_exhaustive()
}
}
impl PluginHandle {
/// Load a build thing from wasm bytes.
///
/// Fails — rather than guessing — when the module reports a different
/// [`ABI_VERSION`], when a required export is missing, or when it declares an
/// import (which, since this host defines nothing, means it wants a capability
/// that is not on offer).
pub fn load(wasm: &[u8], limits: Limits) -> Result<PluginHandle> {
Self::load_with(wasm, limits, HostFacts::default())
}
/// Load a build thing, answering its questions from `facts`.
pub fn load_with(wasm: &[u8], limits: Limits, facts: HostFacts) -> Result<PluginHandle> {
let mut config = wasmtime::Config::new();
// Fuel is what turns "a module with a loop" from a hung forge into an error.
config.consume_fuel(true);
let engine = wasmtime::Engine::new(&config).map_err(|e| anyhow!("wasmtime engine: {e}"))?;
let module = wasmtime::Module::new(&engine, wasm).map_err(|e| {
anyhow!("parse build-thing wasm (is this a core wasm32 module?): {e}")
})?;
// The capability check, made explicit rather than implied: a core module for
// `wasm32-unknown-unknown` imports nothing. If this one does, it is asking for
// something, and the answer is no — with the name in the error.
let imports: Vec<String> = module
.imports()
.map(|i| format!("{}::{}", i.module(), i.name()))
.collect();
// **The import list IS the permission list.** A build thing may ask the host
// four questions (`nordisk::{git_rev,read_manifest,log,fetch}`) and nothing else.
// Anything outside that namespace — a WASI call, a socket, a clock — is
// refused at LOAD, by name, rather than being silently stubbed out. There is
// no filesystem to sandbox and no process to deny: the plugin cannot name one.
let ungranted: Vec<&String> = imports
.iter()
.filter(|i| {
let Some((m, f)) = i.split_once("::") else { return true };
m != host::NAMESPACE || !host::IMPORTS.contains(&f)
})
.collect();
if !ungranted.is_empty() {
bail!(
"build-thing wasm requests host capabilities that are NOT granted: {}. \
The permission list is exactly `{}::{{{}}}` — a build thing asks the forge \
questions; it does not open files, sockets or clocks.",
ungranted.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "),
host::NAMESPACE,
host::IMPORTS.join(", ")
);
}
let mut store = wasmtime::Store::new(&engine, facts);
store.set_fuel(limits.fuel_per_call).map_err(|e| anyhow!("set fuel: {e}"))?;
let mut linker: wasmtime::Linker<HostFacts> = wasmtime::Linker::new(&engine);
define_imports(&mut linker)?;
let instance = linker
.instantiate(&mut store, &module)
.map_err(|e| anyhow!("instantiate build-thing wasm: {e}"))?;
let memory = instance
.get_memory(&mut store, abi::EXPORT_MEMORY)
.ok_or_else(|| anyhow!("build-thing wasm exports no `{}`", abi::EXPORT_MEMORY))?;
let mut h = PluginHandle {
store,
instance,
memory,
limits,
manifest: Manifest {
abi: 0,
repo: String::new(),
source_crate: String::new(),
summary: String::new(),
},
};
let reported = h.abi_version()?;
if reported != ABI_VERSION {
bail!(
"build-thing wasm reports ABI {reported}, this host speaks {ABI_VERSION} — \
refusing rather than reading its replies as if they were the shape we expect"
);
}
h.manifest = h.call_json::<Manifest>(abi::OP_MANIFEST, "")?;
Ok(h)
}
/// Load from a `.wasm` file on disk.
pub fn load_file(path: &std::path::Path, limits: Limits) -> Result<PluginHandle> {
Self::load_file_with(path, limits, HostFacts::default())
}
/// Load from a `.wasm` file, answering its questions from `facts`.
pub fn load_file_with(
path: &std::path::Path,
limits: Limits,
facts: HostFacts,
) -> Result<PluginHandle> {
let bytes = std::fs::read(path)
.with_context(|| format!("read build-thing wasm {}", path.display()))?;
Self::load_with(&bytes, limits, facts)
.with_context(|| format!("load build-thing wasm {}", path.display()))
}
/// What the plugin logged, in order.
pub fn logged(&self) -> Vec<String> {
self.store.data().logged()
}
/// Who this build thing belongs to (read at load).
pub fn manifest(&self) -> &Manifest {
&self.manifest
}
/// The declared build space.
pub fn variants(&mut self) -> Result<BuildVariantSet> {
self.call_json(abi::OP_VARIANTS, "")
}
/// **The app's own judgement**, computed by the app's code inside the sandbox.
pub fn validate(&mut self, req: &BuildRequest) -> Result<Verdict> {
let arg = serde_json::to_string(req)?;
self.call_json(abi::OP_VALIDATE, &arg)
}
/// The app's judgement on a built artifact.
pub fn judge_artifact(&mut self, facts: &ArtifactFacts) -> Result<Verdict> {
let arg = serde_json::to_string(facts)?;
self.call_json(abi::OP_JUDGE_ARTIFACT, &arg)
}
/// How to rebuild a declared variant.
pub fn rebuild_recipe(&mut self, variant: &str) -> Result<Option<RebuildRecipe>> {
let arg = serde_json::to_string(variant)?;
self.call_json(abi::OP_REBUILD_RECIPE, &arg)
}
/// The human describe text.
pub fn describe(&mut self) -> Result<String> {
self.call_json(abi::OP_DESCRIBE, "")
}
/// **The role-grouped menu** — 3 servers and 2 clients, not a flat list of five.
pub fn menu(&mut self) -> Result<BuildMenu> {
self.call_json(abi::OP_MENU, "")
}
/// The `(repo, rev)` pin this build thing is true for.
pub fn provenance(&mut self) -> Result<SpecProvenance> {
self.call_json(abi::OP_PROVENANCE, "")
}
/// **The plan for a variant, as DATA.** The plugin performs nothing; the caller
/// decides whether and where to execute the returned steps.
pub fn plan(&mut self, variant: &str) -> Result<Option<BuildPlan>> {
let arg = serde_json::to_string(variant)?;
self.call_json(abi::OP_PLAN, &arg)
}
/// **What building a variant IS, as a graph** — variant → steps → artifacts plus
/// the embeds/reaches edges. Data: the renderer lays it out and paints it, and a
/// clicked node carries the variant it selects.
pub fn graph(&mut self, variant: &str) -> Result<BuildGraph> {
let arg = serde_json::to_string(variant)?;
self.call_json(abi::OP_GRAPH, &arg)
}
/// **The settings tree** of `variant` from `path` down (`""` = whole).
pub fn options(&mut self, variant: &str, path: &str) -> Result<Option<crate::tree::SettingsTree>> {
let arg = serde_json::to_string(&OptionsQuery { variant: variant.to_string(), path: path.to_string() })?;
self.call_json(abi::OP_OPTIONS, &arg)
}
/// **The judge**: may this feature toggle, and what follows.
pub fn try_toggle(&mut self, req: &crate::tree::ToggleRequest) -> Result<crate::tree::ToggleDecision> {
let arg = serde_json::to_string(req)?;
self.call_json(abi::OP_TRY_TOGGLE, &arg)
}
/// **The verbs** a typ bears, as this build thing says them.
pub fn actions(&mut self, kind: crate::variant::ArtifactKind) -> Result<Vec<crate::verbs::Action>> {
let arg = serde_json::to_string(&kind)?;
self.call_json(abi::OP_ACTIONS, &arg)
}
// ── the call convention ──────────────────────────────────────────────────
fn abi_version(&mut self) -> Result<u32> {
let f = self
.instance
.get_typed_func::<(), i32>(&mut self.store, abi::EXPORT_ABI_VERSION)
.map_err(|e| anyhow!("export `{}`: {e}", abi::EXPORT_ABI_VERSION))?;
Ok(f.call(&mut self.store, ()).map_err(|e| anyhow!("bt_abi_version: {e}"))? as u32)
}
fn call_json<T: DeserializeOwned>(&mut self, op: u32, arg: &str) -> Result<T> {
let json = self.call_raw(op, arg)?;
serde_json::from_str(&json).with_context(|| {
format!("build-thing op {op} replied with JSON this host cannot read: {json:.400}")
})
}
fn call_raw(&mut self, op: u32, arg: &str) -> Result<String> {
// Every call gets its own fuel budget, so one slow call cannot starve the next.
self.store.set_fuel(self.limits.fuel_per_call).map_err(|e| anyhow!("refuel: {e}"))?;
let alloc = self
.instance
.get_typed_func::<i32, i32>(&mut self.store, abi::EXPORT_ALLOC)
.map_err(|e| anyhow!("export `{}`: {e}", abi::EXPORT_ALLOC))?;
let dealloc = self
.instance
.get_typed_func::<(i32, i32), ()>(&mut self.store, abi::EXPORT_DEALLOC)
.map_err(|e| anyhow!("export `{}`: {e}", abi::EXPORT_DEALLOC))?;
let call = self
.instance
.get_typed_func::<(i32, i32, i32), i64>(&mut self.store, abi::EXPORT_CALL)
.map_err(|e| anyhow!("export `{}`: {e}", abi::EXPORT_CALL))?;
// Copy the argument in.
let (ptr, len) = if arg.is_empty() {
(0i32, 0i32)
} else {
let len = arg.len() as i32;
let ptr = alloc.call(&mut self.store, len).map_err(|e| anyhow!("plugin alloc: {e}"))?;
if ptr == 0 {
bail!("build-thing wasm refused to allocate {len} bytes");
}
self.memory
.write(&mut self.store, ptr as usize, arg.as_bytes())
.map_err(|e| anyhow!("write plugin argument: {e}"))?;
(ptr, len)
};
let packed = call.call(&mut self.store, (op as i32, ptr, len)).map_err(|e| {
anyhow!(
"build-thing op {op} trapped (fuel exhausted, or the plugin panicked — \
either way the forge is unharmed): {e}"
)
})?;
if ptr != 0 {
let _ = dealloc.call(&mut self.store, (ptr, len));
}
let (out_ptr, out_len) = abi::unpack(packed);
if out_len as usize > self.limits.max_reply {
bail!(
"build-thing op {op} replied with {out_len} bytes, over the {} byte cap",
self.limits.max_reply
);
}
let mut buf = vec![0u8; out_len as usize];
self.memory
.read(&mut self.store, out_ptr as usize, &mut buf)
.map_err(|e| anyhow!("read plugin reply: {e}"))?;
let _ = dealloc.call(&mut self.store, (out_ptr as i32, out_len as i32));
String::from_utf8(buf).context("plugin reply is not UTF-8")
}
}
/// **Bind the four host functions — the whole permission list.**
///
/// Each returns its answer by allocating inside the *guest's* memory through the
/// guest's own `bt_alloc`, so the host never hands a plugin a pointer to host memory
/// and the plugin never gets a handle it could walk. `read_manifest` is a MAP LOOKUP,
/// not a file open: a path the host did not choose to offer simply misses, so there is
/// no traversal to defend against.
fn define_imports(linker: &mut wasmtime::Linker<HostFacts>) -> Result<()> {
// nordisk::git_rev() -> i64 (packed ptr/len of "<rev>\u{1}<0|1>")
linker
.func_wrap(host::NAMESPACE, host::IMPORT_GIT_REV, |mut caller: wasmtime::Caller<'_, HostFacts>| {
let facts = caller.data().clone();
let payload = format!("{}\u{1}{}", facts.rev, if facts.dirty { "1" } else { "0" });
write_into_guest(&mut caller, payload.as_bytes())
})
.map_err(|e| anyhow!("bind {}::{}: {e}", host::NAMESPACE, host::IMPORT_GIT_REV))?;
// nordisk::read_manifest(ptr, len) -> i64
linker
.func_wrap(
host::NAMESPACE,
host::IMPORT_READ_MANIFEST,
|mut caller: wasmtime::Caller<'_, HostFacts>, ptr: i32, len: i32| {
let want = read_from_guest(&mut caller, ptr, len);
let text = caller.data().manifests.get(&want).cloned().unwrap_or_default();
write_into_guest(&mut caller, text.as_bytes())
},
)
.map_err(|e| anyhow!("bind {}::{}: {e}", host::NAMESPACE, host::IMPORT_READ_MANIFEST))?;
// nordisk::log(ptr, len)
linker
.func_wrap(
host::NAMESPACE,
host::IMPORT_LOG,
|mut caller: wasmtime::Caller<'_, HostFacts>, ptr: i32, len: i32| {
let msg = read_from_guest(&mut caller, ptr, len);
if let Ok(mut g) = caller.data().log.lock() {
g.push(msg);
}
},
)
.map_err(|e| anyhow!("bind {}::{}: {e}", host::NAMESPACE, host::IMPORT_LOG))?;
// nordisk::fetch(ptr, len) -> i64 (packed ptr/len of <origin byte><payload>)
// The policy is `HostFacts::answer_fetch`; the wire says WHERE the bytes came
// from, so a host exception can never pass as the container fulfilment.
linker
.func_wrap(
host::NAMESPACE,
host::IMPORT_FETCH,
|mut caller: wasmtime::Caller<'_, HostFacts>, ptr: i32, len: i32| {
let coordinate = read_from_guest(&mut caller, ptr, len);
let payload: Vec<u8> = match caller.data().answer_fetch(&coordinate) {
Ok(f) => {
let tag = match f.origin {
host::FetchOrigin::Container => host::FETCH_OK,
host::FetchOrigin::HostException => host::FETCH_OK_HOST_EXCEPTION,
};
std::iter::once(tag).chain(f.bytes).collect()
}
Err(reason) => std::iter::once(host::FETCH_REFUSED).chain(reason.into_bytes()).collect(),
};
write_into_guest(&mut caller, &payload)
},
)
.map_err(|e| anyhow!("bind {}::{}: {e}", host::NAMESPACE, host::IMPORT_FETCH))?;
Ok(())
}
fn guest_memory(caller: &mut wasmtime::Caller<'_, HostFacts>) -> Option<wasmtime::Memory> {
match caller.get_export(crate::plugin::abi::EXPORT_MEMORY) {
Some(wasmtime::Extern::Memory(m)) => Some(m),
_ => None,
}
}
fn read_from_guest(caller: &mut wasmtime::Caller<'_, HostFacts>, ptr: i32, len: i32) -> String {
if ptr <= 0 || len <= 0 {
return String::new();
}
let Some(mem) = guest_memory(caller) else { return String::new() };
let mut buf = vec![0u8; len as usize];
if mem.read(&mut *caller, ptr as usize, &mut buf).is_err() {
return String::new();
}
String::from_utf8_lossy(&buf).into_owned()
}
/// Allocate inside the GUEST and copy `bytes` there; returns the packed `(ptr, len)`.
/// Failure answers `(0, 0)` — an empty reply the plugin already has to handle — rather
/// than trapping, because a host that cannot answer is a fact, not a crash.
fn write_into_guest(caller: &mut wasmtime::Caller<'_, HostFacts>, bytes: &[u8]) -> i64 {
if bytes.is_empty() {
return crate::plugin::abi::pack(0, 0);
}
let Some(alloc) = caller
.get_export(crate::plugin::abi::EXPORT_ALLOC)
.and_then(|e| e.into_func())
.and_then(|f| f.typed::<i32, i32>(&*caller).ok())
else {
return crate::plugin::abi::pack(0, 0);
};
let Ok(ptr) = alloc.call(&mut *caller, bytes.len() as i32) else {
return crate::plugin::abi::pack(0, 0);
};
let Some(mem) = guest_memory(caller) else { return crate::plugin::abi::pack(0, 0) };
if mem.write(&mut *caller, ptr as usize, bytes).is_err() {
return crate::plugin::abi::pack(0, 0);
}
crate::plugin::abi::pack(ptr as u32, bytes.len() as u32)
}
/// Extract a build thing's wasm module from an artifact's [`crate::plugin::SECTION`]
/// linker section — **without executing the artifact**.
///
/// This is the whole point of the section: it works for a binary this host could never
/// run (aarch64, musl, another OS), it works on a stripped binary, and it works on a
/// hostile one, because parsing an object file is not running it.
///
/// The parse itself lives in [`crate::receipt::read_section_named`] — this is a
/// name for one section, not a second reader. Two copies of the same object-file
/// walk is exactly how the "parsing is not running" property ends up holding on
/// one path and not the other.
#[cfg(feature = "artifact-section")]
pub fn read_section(artifact: &std::path::Path) -> Result<Option<Vec<u8>>> {
crate::receipt::read_section_named(artifact, crate::plugin::SECTION)
}
#[cfg(test)]
mod tests {
use super::*;
/// A module that imports something must be refused with the import NAMED — the
/// capability question answered out loud rather than by silently linking a stub.
#[test]
fn a_module_asking_for_a_capability_is_refused_by_name() {
// (module (import "wasi_snapshot_preview1" "fd_write" (func (param i32))))
let wat = r#"(module (import "wasi_snapshot_preview1" "fd_write" (func $fd (param i32))))"#;
let wasm = wat::parse_str(wat).expect("assemble wat");
let err = PluginHandle::load(&wasm, Limits::default()).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("NOT granted"), "{msg}");
assert!(msg.contains("wasi_snapshot_preview1::fd_write"), "the ask is named: {msg}");
}
/// An ABI mismatch is refused, not guessed at.
#[test]
fn a_module_speaking_another_abi_is_refused() {
let wat = r#"
(module
(memory (export "memory") 1)
(func (export "bt_abi_version") (result i32) i32.const 99)
(func (export "bt_alloc") (param i32) (result i32) i32.const 0)
(func (export "bt_dealloc") (param i32 i32))
(func (export "bt_call") (param i32 i32 i32) (result i64) i64.const 0))
"#;
let wasm = wat::parse_str(wat).expect("assemble wat");
let err = PluginHandle::load(&wasm, Limits::default()).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("reports ABI 99"), "{msg}");
assert!(msg.contains("refusing"), "{msg}");
}
/// A module missing `memory` cannot be talked to, and says so rather than
/// half-loading.
#[test]
fn a_module_without_memory_is_refused() {
let wat = r#"(module (func (export "bt_abi_version") (result i32) i32.const 1))"#;
let wasm = wat::parse_str(wat).expect("assemble wat");
let err = PluginHandle::load(&wasm, Limits::default()).unwrap_err();
assert!(format!("{err:#}").contains("exports no `memory`"), "{err:#}");
}
/// **Fuel is what makes a hostile plugin a failed call rather than a hung forge.**
#[test]
fn an_infinite_loop_exhausts_fuel_instead_of_hanging_the_host() {
let wat = format!(
r#"
(module
(memory (export "memory") 1)
(func (export "bt_abi_version") (result i32) i32.const {abi})
(func (export "bt_alloc") (param i32) (result i32) i32.const 16)
(func (export "bt_dealloc") (param i32 i32))
(func (export "bt_call") (param i32 i32 i32) (result i64)
(loop $spin (br $spin))
i64.const 0))
"#,
abi = ABI_VERSION
);
let wasm = wat::parse_str(&wat).expect("assemble wat");
// Load itself calls OP_MANIFEST, so the spin is hit immediately — with a small
// budget so the test is fast.
let err = PluginHandle::load(
&wasm,
Limits { fuel_per_call: 100_000, ..Limits::default() },
)
.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("trapped") || msg.to_lowercase().contains("fuel"),
"the call must END: {msg}"
);
}
/// **`fetch` through the boundary.** A module that asks for a coordinate the
/// host did not offer gets a REFUSAL it can name — never an empty artifact —
/// and one the host offered arrives byte-identical.
#[test]
fn a_fetch_the_host_declines_is_refused_by_name_and_a_granted_one_arrives_whole() {
let manifest = r#"{"abi":1,"repo":"fetcher","source_crate":"fetcher-build-thing","summary":"asks the host for bytes"}"#;
let wat = format!(
r#"
(module
(import "nordisk" "fetch" (func $fetch (param i32 i32) (result i64)))
(memory (export "memory") 1)
(global $next (mut i32) (i32.const 1024))
(data (i32.const 0) "{escaped}")
(func (export "bt_abi_version") (result i32) i32.const {abi})
(func (export "bt_alloc") (param $len i32) (result i32)
(local $p i32)
(local.set $p (global.get $next))
(global.set $next (i32.add (global.get $next) (local.get $len)))
(local.get $p))
(func (export "bt_dealloc") (param i32 i32))
(func (export "bt_call") (param $op i32) (param $ptr i32) (param $len i32) (result i64)
(if (result i64) (i32.eq (local.get $op) (i32.const 1))
(then (i64.const {mlen}))
(else (call $fetch (local.get $ptr) (local.get $len))))))
"#,
abi = ABI_VERSION,
escaped = manifest.replace('"', "\\\""),
mlen = manifest.len(),
);
let wasm = wat::parse_str(&wat).expect("assemble wat");
let facts = HostFacts::at_rev("r", false).with_fetchable("tunnr/tunnr-kernel", b"bzImage".to_vec());
let mut h = PluginHandle::load_with(&wasm, Limits::default(), facts).expect("fetch is on the permission list");
assert_eq!(h.manifest().repo, "fetcher");
let granted = h.call_raw(99, "tunnr/tunnr-kernel").unwrap();
assert_eq!(granted.as_bytes()[0], host::FETCH_OK_HOST_EXCEPTION, "a fixture is a HOST exception on the wire");
assert_eq!(&granted.as_bytes()[1..], b"bzImage");
assert!(h.logged().iter().any(|l| l.contains("fulfilled on the HOST by exception")), "{:?}", h.logged());
let refused = h.call_raw(99, "korp/korp-kernel").unwrap();
assert_eq!(refused.as_bytes()[0], host::FETCH_REFUSED, "a miss is a refusal, not an empty reply: {refused:?}");
assert!(refused.contains("holds nothing under 'korp/korp-kernel'"), "{refused}");
assert!(refused.contains("tunnr/tunnr-kernel"), "the refusal lists what IS offered: {refused}");
assert!(refused.contains("ContainerFetcher"), "the refusal names the reference fulfilment: {refused}");
// The reference: a container runner answers as FETCH_OK, unlogged as an exception.
struct FakeRunner;
impl ContainerFetchRunner for FakeRunner {
fn fetch_in_container(&self, image: &str, coordinate: &str) -> Result<Vec<u8>, String> {
Ok(format!("{image}:{coordinate}").into_bytes())
}
}
let facts = HostFacts::at_rev("r", false)
.with_fetcher(std::sync::Arc::new(ContainerFetcher::new(Box::new(FakeRunner))));
let mut h = PluginHandle::load_with(&wasm, Limits::default(), facts).unwrap();
let granted = h.call_raw(99, "tunnr/tunnr-kernel").unwrap();
assert_eq!(granted.as_bytes()[0], host::FETCH_OK);
assert!(granted.contains(crate::cargo_container::CARGO_TOOLCHAIN_IMAGE), "{granted}");
assert!(h.logged().is_empty(), "the container fulfilment is the norm, not an exception");
// The named exception cannot be unnamed.
assert!(HostPathFetcher::named(" ").is_err());
let hp = HostPathFetcher::named("bench box: kernel source pre-staged").unwrap();
assert_eq!(hp.describe(), "HOST-PATH fetch by exception: bench box: kernel source pre-staged");
}
}