Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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");
    }
}