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

nordisk/heimdall

crates/heimdall-wasm/src/host.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! The engine, the signature gate before load, and one bounded run.

use std::fmt;
use std::io::Read;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::Instant;

use guest_host::verify::{ModuleTrust, TrustError};
use heimdall_core::{GuestRef, Params};
use wasmtime::component::{
    Component, ComponentType, InstancePre, Lift, Linker, Lower, WasmList, WasmStr,
};
use wasmtime::{Config, Engine, ResourceLimiter, Store, StoreContextMut, Trap};

use crate::codes;
use crate::limits::{HostConfig, RunLimits};
use crate::names::check_file_name;
use crate::outcome::{EmittedFile, GuestOutcome, Verdict};
use crate::split::{ChunkAnswer, ChunkIn, ChunkRefLifted, DState, Decoder};

/// The host could not be built.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HostError {
    /// No trust roots: Heimdall has no unsigned mode.
    NoTrustRoots,
    /// wasmtime refused the engine configuration.
    Engine(String),
}

impl fmt::Display for HostError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            HostError::NoTrustRoots => write!(
                f,
                "no trust roots configured: every guest must be signed, there is no unsigned mode"
            ),
            HostError::Engine(e) => write!(f, "the wasm engine could not be configured: {e}"),
        }
    }
}

impl std::error::Error for HostError {}

/// Which part of the signature check failed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrustFailure {
    /// No `.wasm.p7s` beside the module.
    Unsigned,
    /// The signature is not over these bytes, or chains to no trusted root.
    SignatureInvalid,
    /// A trust root could not be read or parsed.
    RootsUnreadable,
    /// This build has no verifier (never the case for heimdall-wasm).
    VerifierUnavailable,
}

/// Why a guest was not loaded. Nothing of the guest ran.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoadRefusal {
    Unreadable {
        path: PathBuf,
        detail: String,
    },
    TooLarge {
        bytes: u64,
        max: u64,
    },
    DigestMissing {
        sidecar: PathBuf,
    },
    DigestMismatch {
        pin: &'static str,
        expected: String,
        actual: String,
    },
    Untrusted {
        failure: TrustFailure,
        detail: String,
    },
    NotAComponent,
    Invalid {
        detail: String,
    },
    ImportRefused {
        imports: Vec<String>,
    },
    MissingExport {
        name: String,
    },
    Link {
        detail: String,
    },
}

impl fmt::Display for LoadRefusal {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "refused: guest not loaded (")?;
        match self {
            LoadRefusal::Unreadable { path, detail } => {
                write!(f, "{} could not be read: {detail}", path.display())
            }
            LoadRefusal::TooLarge { bytes, max } => {
                write!(f, "module is {bytes} bytes, more than {max}")
            }
            LoadRefusal::DigestMissing { sidecar } => {
                write!(f, "no blake3 pin at {}", sidecar.display())
            }
            LoadRefusal::DigestMismatch {
                pin,
                expected,
                actual,
            } => {
                write!(f, "blake3 {actual} does not match the {pin} {expected}")
            }
            LoadRefusal::Untrusted { failure, detail } => {
                write!(f, "signature check failed ({failure:?}): {detail}")
            }
            LoadRefusal::NotAComponent => write!(
                f,
                "a core module, not a component implementing heimdall:guest/job"
            ),
            LoadRefusal::Invalid { detail } => write!(f, "not a valid component: {detail}"),
            LoadRefusal::ImportRefused { imports } => {
                write!(
                    f,
                    "imports {} not provided; the host links only emit-file, and \
                     decode-chunks + digest-members for a verifier loaded with its \
                     decoder (no WASI)",
                    imports.join(", ")
                )
            }
            LoadRefusal::MissingExport { name } => write!(f, "no export {name:?}"),
            LoadRefusal::Link { detail } => write!(f, "link failed: {detail}"),
        }?;
        write!(f, ") [{}]", self.code())
    }
}

impl std::error::Error for LoadRefusal {}

impl LoadRefusal {
    /// The stable `heimdall-wasm-<n>` code of this refusal.
    pub fn code(&self) -> &'static str {
        match self {
            LoadRefusal::Unreadable { .. } => codes::LOAD_UNREADABLE,
            LoadRefusal::TooLarge { .. } => codes::LOAD_TOO_LARGE,
            LoadRefusal::DigestMissing { .. } => codes::LOAD_DIGEST_MISSING,
            LoadRefusal::DigestMismatch { .. } => codes::LOAD_DIGEST_MISMATCH,
            LoadRefusal::Untrusted { .. } => codes::LOAD_UNTRUSTED,
            LoadRefusal::NotAComponent => codes::LOAD_NOT_A_COMPONENT,
            LoadRefusal::Invalid { .. } => codes::LOAD_INVALID,
            LoadRefusal::ImportRefused { .. } => codes::LOAD_IMPORT_REFUSED,
            LoadRefusal::MissingExport { .. } => codes::LOAD_MISSING_EXPORT,
            LoadRefusal::Link { .. } => codes::LOAD_LINK,
        }
    }
}

/// This process has a finite `RLIMIT_AS` (its address space is bounded).
pub(crate) fn address_space_bounded() -> bool {
    let mut r = libc::rlimit {
        rlim_cur: 0,
        rlim_max: 0,
    };
    // SAFETY: getrlimit writes one rlimit through a valid pointer.
    let ok = unsafe { libc::getrlimit(libc::RLIMIT_AS, &mut r) } == 0;
    ok && r.rlim_cur != libc::RLIM_INFINITY
}

/// The wasm host: one engine, one linker that offers only `emit-file`.
pub struct Host {
    cfg: HostConfig,
    engine: Engine,
    linker: Arc<Linker<State>>,
    /// A decoder imports nothing: this linker defines nothing.
    dlinker: Linker<DState>,
    trust: ModuleTrust,
    ticker: Arc<Ticker>,
}

impl Host {
    /// Build the host. Refused without trust roots: there is no unsigned mode.
    pub fn new(cfg: HostConfig) -> Result<Host, HostError> {
        if cfg.trust_roots.is_empty() {
            return Err(HostError::NoTrustRoots);
        }
        let mut c = Config::new();
        c.wasm_component_model(true)
            .consume_fuel(true)
            .epoch_interruption(true)
            .max_wasm_stack(cfg.stack_bytes)
            // No compile pool: a guest compiles on the calling thread. wasmtime's
            // parallel compilation starts rayon's global pool, one thread per host
            // CPU, whatever the job's limits say. Inside Holger's scan sidecar
            // (RLIMIT_AS = the job's memory + 64 MiB) the pool's stacks and malloc
            // arenas did not fit and it could not start (EAGAIN), and its threads
            // spend the job's RLIMIT_CPU many times faster than its wall clock. The
            // feature stays on (another crate may unify it on), so it is switched
            // off here, always.
            .parallel_compilation(false)
            // No memfd_create: copy-on-write memory images are backed by a
            // memfd on Linux, and Heimdall's own container profile refuses
            // memfd_create (a guest would trap "cannot create a memfd").
            // Linear memories are initialised by copying the data segments.
            .memory_init_cow(false)
            .force_memory_init_memfd(false)
            // Determinism: the same guest and input give the same bytes out.
            .cranelift_nan_canonicalization(true);
        if address_space_bounded() {
            // No virtual reservation beyond what a memory uses (plus 64 MiB room to
            // grow in place) in a process with an RLIMIT_AS: wasmtime's 64-bit
            // default reserves 4 GiB + 32 MiB guards per linear memory, which an
            // address-space-bounded job (Holger's scan sidecar: RLIMIT_AS = the
            // job's memory + 64 MiB) cannot map ("mmap failed to reserve
            // 0x104000000 bytes"). Bounds are then checked in code instead of by
            // guard pages, and a memory that outgrows its room moves. Without an
            // RLIMIT_AS the default stays: its guard pages make the guest faster.
            c.memory_reservation(0)
                .memory_guard_size(0)
                .guard_before_linear_memory(false)
                .memory_reservation_for_growth(64 << 20)
                .memory_may_move(true);
        }
        let engine = Engine::new(&c).map_err(|e| HostError::Engine(format!("{e:#}")))?;
        let mut linker: Linker<State> = Linker::new(&engine);
        linker
            .root()
            .func_wrap("emit-file", emit_file)
            .map_err(|e| HostError::Engine(format!("{e:#}")))?;
        // Offered only to a verifier loaded WITH a decoder (Host::load_split):
        // every other guest that imports it is refused at load.
        linker
            .root()
            .func_wrap("decode-chunks", decode_chunks)
            .map_err(|e| HostError::Engine(format!("{e:#}")))?;
        linker
            .root()
            .func_wrap("digest-members", digest_members)
            .map_err(|e| HostError::Engine(format!("{e:#}")))?;
        let dlinker: Linker<DState> = Linker::new(&engine);
        let ticker = Arc::new(Ticker::start(engine.clone(), cfg.epoch_tick));
        let trust = ModuleTrust::required(cfg.trust_roots.clone());
        Ok(Host {
            cfg,
            engine,
            linker: Arc::new(linker),
            dlinker,
            trust,
            ticker,
        })
    }

    /// The host's configuration.
    pub fn config(&self) -> &HostConfig {
        &self.cfg
    }

    /// Read, verify (blake3 pin + signature), compile and link a guest.
    ///
    /// The module file is read ONCE; the bytes that are verified are the bytes
    /// that are compiled. Nothing of the guest runs here.
    pub fn load(&self, module: &Path) -> Result<Guest, LoadRefusal> {
        self.load_inner(module, None)
    }

    /// [`Host::load`] that also demands the module's blake3 equals `blake3_hex`.
    pub fn load_pinned(&self, module: &Path, blake3_hex: &str) -> Result<Guest, LoadRefusal> {
        self.load_inner(module, Some(blake3_hex))
    }

    /// [`Host::load_pinned`] for a heimdall-core [`GuestRef`] (its `blake3` is the caller pin).
    pub fn load_ref(&self, guest: &GuestRef) -> Result<Guest, LoadRefusal> {
        self.load_pinned(&guest.path, &guest.blake3)
    }

    /// Read, verify (blake3 pin + signature), compile and link a DECODER
    /// (world `decoder`: it must import nothing at all and export
    /// `decode-chunks`). Nothing of it runs here.
    pub fn load_decoder(&self, module: &Path) -> Result<Decoder, LoadRefusal> {
        self.load_decoder_inner(module, None)
    }

    /// [`Host::load_decoder`] that also demands the module's blake3.
    pub fn load_decoder_pinned(
        &self,
        module: &Path,
        blake3_hex: &str,
    ) -> Result<Decoder, LoadRefusal> {
        self.load_decoder_inner(module, Some(blake3_hex))
    }

    /// Load a split guest: the VERIFIER at `module` (world `verifier`, it may
    /// import `decode-chunks` besides `emit-file`) with `decoder` behind its
    /// `decode-chunks`. Each run then drives the decoder instances with
    /// gatling over [`RunLimits::decoders`] workers.
    pub fn load_split(&self, module: &Path, decoder: Decoder) -> Result<Guest, LoadRefusal> {
        let (component, digest, signer) = self.verified_component(module, None)?;
        self.link_guest(component, digest, signer, Some(Arc::new(decoder)))
    }

    /// [`Host::load_split`] with the verifier's blake3 pinned by the caller.
    pub fn load_split_pinned(
        &self,
        module: &Path,
        blake3_hex: &str,
        decoder: Decoder,
    ) -> Result<Guest, LoadRefusal> {
        let (component, digest, signer) = self.verified_component(module, Some(blake3_hex))?;
        self.link_guest(component, digest, signer, Some(Arc::new(decoder)))
    }

    fn load_inner(&self, module: &Path, caller_pin: Option<&str>) -> Result<Guest, LoadRefusal> {
        let (component, digest, signer) = self.verified_component(module, caller_pin)?;
        self.link_guest(component, digest, signer, None)
    }

    fn load_decoder_inner(
        &self,
        module: &Path,
        caller_pin: Option<&str>,
    ) -> Result<Decoder, LoadRefusal> {
        let (component, digest, signer) = self.verified_component(module, caller_pin)?;
        let ty = component.component_type();
        let foreign: Vec<String> = ty
            .imports(&self.engine)
            .map(|(name, _)| name.to_string())
            .collect();
        if !foreign.is_empty() {
            return Err(LoadRefusal::ImportRefused { imports: foreign });
        }
        if !ty
            .exports(&self.engine)
            .any(|(name, _)| name == "decode-chunks")
        {
            return Err(LoadRefusal::MissingExport {
                name: "decode-chunks".into(),
            });
        }
        let pre = self
            .dlinker
            .instantiate_pre(&component)
            .map_err(|e| LoadRefusal::Link {
                detail: one_line(&format!("{e:#}")),
            })?;
        Ok(Decoder {
            pre,
            digest_hex: digest,
            signer,
            stack_bytes: self.cfg.stack_bytes,
            epoch_tick: self.cfg.epoch_tick,
            _ticker: self.ticker.clone(),
        })
    }

    /// The gate every module passes: read once, pins, signature, compiled
    /// as a component. Returns the component, its blake3 and its signer.
    fn verified_component(
        &self,
        module: &Path,
        caller_pin: Option<&str>,
    ) -> Result<(Component, String, String), LoadRefusal> {
        let bytes = read_capped(module, self.cfg.max_module_bytes)?;
        let digest = blake3::hash(&bytes).to_hex().to_string();

        // 1. The `.wasm.blake3` pin beside the module, then the caller's pin.
        let sidecar = sidecar_path(module, "blake3");
        let pinned = read_pin(&sidecar)?;
        check_pin("sidecar .wasm.blake3", &pinned, &digest)?;
        if let Some(pin) = caller_pin {
            check_pin("caller pin", pin, &digest)?;
        }

        // 2. The signature (`.wasm.p7s`, CMS over the blake3), before compiling.
        let signer = match self.trust.verify(module, &bytes) {
            Ok(Some(cn)) => cn,
            Ok(None) => {
                return Err(LoadRefusal::Untrusted {
                    failure: TrustFailure::VerifierUnavailable,
                    detail: "no trust policy in force".into(),
                })
            }
            Err(e) => return Err(untrusted(e)),
        };

        // 3. A component, compiled; the callers check imports and exports.
        if bytes.len() >= 8 && bytes[..4] == *b"\0asm" && bytes[4..8] == [1, 0, 0, 0] {
            return Err(LoadRefusal::NotAComponent);
        }
        let component = Component::new(&self.engine, &bytes).map_err(|e| LoadRefusal::Invalid {
            detail: one_line(&format!("{e:#}")),
        })?;
        Ok((component, digest, signer))
    }

    fn link_guest(
        &self,
        component: Component,
        digest: String,
        signer: String,
        decoder: Option<Arc<Decoder>>,
    ) -> Result<Guest, LoadRefusal> {
        let ty = component.component_type();
        let allowed = |name: &str| {
            name == "emit-file"
                || (decoder.is_some() && (name == "decode-chunks" || name == "digest-members"))
        };
        let foreign: Vec<String> = ty
            .imports(&self.engine)
            .map(|(name, _)| name.to_string())
            .filter(|name| !allowed(name))
            .collect();
        if !foreign.is_empty() {
            return Err(LoadRefusal::ImportRefused { imports: foreign });
        }
        if !ty.exports(&self.engine).any(|(name, _)| name == "run") {
            return Err(LoadRefusal::MissingExport { name: "run".into() });
        }
        let pre = self
            .linker
            .instantiate_pre(&component)
            .map_err(|e| LoadRefusal::Link {
                detail: one_line(&format!("{e:#}")),
            })?;
        Ok(Guest {
            pre,
            digest_hex: digest,
            signer,
            decoder,
            stack_bytes: self.cfg.stack_bytes,
            epoch_tick: self.cfg.epoch_tick,
            _ticker: self.ticker.clone(),
        })
    }
}

/// A verified, compiled, linked guest. Each run gets a fresh store.
pub struct Guest {
    pre: InstancePre<State>,
    digest_hex: String,
    signer: String,
    /// The decoder behind `decode-chunks` (a split guest), if any.
    decoder: Option<Arc<Decoder>>,
    stack_bytes: usize,
    epoch_tick: std::time::Duration,
    _ticker: Arc<Ticker>,
}

impl Guest {
    /// Lowercase hex BLAKE3-256 of the module bytes that were verified.
    pub fn digest_hex(&self) -> &str {
        &self.digest_hex
    }

    /// The common name of the certificate that signed the module.
    pub fn signer(&self) -> &str {
        &self.signer
    }

    /// The decoder of a split guest ([`Host::load_split`]).
    pub fn decoder(&self) -> Option<&Decoder> {
        self.decoder.as_deref()
    }

    /// Run the guest once on `input` with `params`, under `limits`.
    ///
    /// Always returns an outcome: a trap, a limit or even a panic inside the
    /// host's own glue becomes a refusal, never an unwind into the caller.
    pub fn run(&self, input: &[u8], params: &Params, limits: &RunLimits) -> GuestOutcome {
        match catch_unwind(AssertUnwindSafe(|| self.run_inner(input, params, limits))) {
            Ok(o) => o,
            Err(p) => {
                let what = p
                    .downcast_ref::<&str>()
                    .map(|s| s.to_string())
                    .or_else(|| p.downcast_ref::<String>().cloned())
                    .unwrap_or_else(|| "panic".into());
                GuestOutcome::refused(codes::HOST_FAULT, format!("host fault ({what})"), 0)
            }
        }
    }

    fn run_inner(&self, input: &[u8], params: &Params, limits: &RunLimits) -> GuestOutcome {
        let mut state = State::new(limits.clone());
        state.decoder = self.decoder.clone();
        let mut store = Store::new(self.pre.engine(), state);
        store.limiter(|s| &mut s.limiter);
        if let Err(e) = store.set_fuel(limits.fuel) {
            return GuestOutcome::refused(codes::HOST_FAULT, format!("host fault (fuel: {e})"), 0);
        }
        let tick = self.epoch_tick.as_nanos().max(1);
        let ticks = limits.timeout.as_nanos().div_ceil(tick).max(1);
        store.set_epoch_deadline(u64::try_from(ticks).unwrap_or(u64::MAX));
        store.epoch_deadline_trap();

        // The verifier's fuel plus every decoder instance's that counted.
        let used = |store: &Store<State>| {
            limits
                .fuel
                .saturating_sub(store.get_fuel().unwrap_or(0))
                .saturating_add(store.data().decoder_fuel)
        };

        let instance = match self.pre.instantiate(&mut store) {
            Ok(i) => i,
            Err(e) => {
                let (code, why) = self.classify(&e, store.data(), limits);
                let fuel = used(&store);
                return GuestOutcome::trapped(code, why, fuel);
            }
        };
        type Run<'a> = (&'a [u8], &'a [Param]);
        type Ret = (Result<Output, Refusal>,);
        let func = match instance.get_typed_func::<Run<'_>, Ret>(&mut store, "run") {
            Ok(f) => f,
            Err(e) => {
                let fuel = used(&store);
                return GuestOutcome::refused(
                    codes::INTERFACE_MISMATCH,
                    format!("guest interface mismatch ({})", one_line(&format!("{e:#}"))),
                    fuel,
                );
            }
        };
        let params: Vec<Param> = params
            .iter()
            .map(|(k, v)| Param {
                key: k.clone(),
                value: v.clone(),
            })
            .collect();
        let result = func.call(&mut store, (input, &params));
        let result = match result {
            Ok((r,)) => r,
            Err(e) => {
                let (code, why) = self.classify(&e, store.data(), limits);
                let fuel = used(&store);
                return GuestOutcome::trapped(code, why, fuel);
            }
        };
        let fuel = used(&store);
        let max = limits.max_text_bytes;
        let files = std::mem::take(&mut store.data_mut().files);
        match result {
            Ok(Output { note }) if note.len() > max => GuestOutcome::trapped(
                codes::OUTPUT_TOO_LARGE,
                format!("output note is {} bytes, more than {max}", note.len()),
                fuel,
            ),
            Err(Refusal { reason }) if reason.len() > max => GuestOutcome::trapped(
                codes::OUTPUT_TOO_LARGE,
                format!("refusal reason is {} bytes, more than {max}", reason.len()),
                fuel,
            ),
            Ok(Output { note }) => GuestOutcome {
                verdict: Verdict::Accepted { note },
                files,
                fuel_used: fuel,
            },
            Err(Refusal { reason }) => GuestOutcome {
                verdict: Verdict::Refused {
                    code: codes::GUEST_REFUSED,
                    reason,
                },
                files,
                fuel_used: fuel,
            },
        }
    }

    /// Name the reason a call ended in an error. A refusal the host recorded
    /// itself (a decoder unit, emit-file, a limit) wins over wasmtime's
    /// generic message.
    fn classify(
        &self,
        e: &wasmtime::Error,
        state: &State,
        limits: &RunLimits,
    ) -> (&'static str, String) {
        if let Some((code, why)) = &state.decoder_failure {
            return (code, why.clone());
        }
        classify_error(
            e,
            state.refusal.as_ref(),
            &state.limiter,
            limits,
            self.stack_bytes,
        )
    }
}

/// Name the reason a call into a guest (or a decoder instance) ended in an
/// error: a host-recorded refusal first, then the limiter, then the trap.
pub(crate) fn classify_error(
    e: &wasmtime::Error,
    refusal: Option<&String>,
    limiter: &Limiter,
    limits: &RunLimits,
    stack_bytes: usize,
) -> (&'static str, String) {
    if let Some(r) = refusal {
        return (codes::EMIT_REFUSED, r.clone());
    }
    if let Some((code, h)) = &limiter.hit {
        return (code, h.clone());
    }
    if let Some(t) = e.downcast_ref::<Trap>() {
        return match t {
            Trap::OutOfFuel => (
                codes::OUT_OF_FUEL,
                format!("out of fuel: limit {} units", limits.fuel),
            ),
            Trap::Interrupt => (
                codes::TIMEOUT,
                format!("wall-clock timeout after {:?}", limits.timeout),
            ),
            Trap::StackOverflow => (
                codes::STACK_OVERFLOW,
                format!("stack overflow: limit {stack_bytes} bytes"),
            ),
            Trap::UnreachableCodeReached => (
                codes::TRAPPED,
                "unreachable instruction (the guest aborted)".into(),
            ),
            other => (codes::TRAPPED, other.to_string()),
        };
    }
    // Lifting/lowering errors (bounds, alignment, UTF-8, discriminants)
    // and instantiation limits come as plain errors; keep the cause.
    let text = one_line(&format!("{e:#}"));
    if text.contains("resource limit exceeded") {
        (codes::INSTANTIATION_LIMIT, text)
    } else if text.contains("utf-8") {
        (codes::TRAPPED, format!("not valid UTF-8: {text}"))
    } else {
        (codes::TRAPPED, text)
    }
}

/// The record types of `wit/heimdall.wit`.
#[derive(ComponentType, Lower)]
#[component(record)]
struct Param {
    key: String,
    value: String,
}

#[derive(ComponentType, Lift)]
#[component(record)]
struct Output {
    note: String,
}

#[derive(ComponentType, Lift)]
#[component(record)]
struct Refusal {
    reason: String,
}

/// Per-run store data.
struct State {
    limiter: Limiter,
    limits: RunLimits,
    files: Vec<EmittedFile>,
    emitted_bytes: usize,
    /// Set when the host itself refused the guest (emit-file); ends the run.
    refusal: Option<String>,
    /// A split guest's decoder, its run's deadline, the fuel its instances
    /// burnt and the failure that ended the run, if one did.
    decoder: Option<Arc<Decoder>>,
    deadline: Instant,
    decoder_fuel: u64,
    decoder_failure: Option<(&'static str, String)>,
}

impl State {
    fn new(limits: RunLimits) -> State {
        let deadline = Instant::now() + limits.timeout;
        State {
            limiter: Limiter::new(&limits),
            limits,
            files: Vec::new(),
            emitted_bytes: 0,
            refusal: None,
            decoder: None,
            deadline,
            decoder_fuel: 0,
            decoder_failure: None,
        }
    }

    fn refuse(&mut self, why: String) -> wasmtime::Error {
        let r = format!("emit-file refused: {why}");
        self.refusal = Some(r.clone());
        wasmtime::Error::msg(r)
    }
}

/// The host's only import: hand one file out. Every check runs BEFORE the
/// bytes are copied; a refusal ends the run (and the run hands on nothing).
fn emit_file(
    mut cx: StoreContextMut<'_, State>,
    (name, bytes): (WasmStr, WasmList<u8>),
) -> wasmtime::Result<()> {
    let limits = cx.data().limits.clone();
    let count = cx.data().files.len();
    if count >= limits.max_files {
        return Err(cx
            .data_mut()
            .refuse(format!("too many files: limit {}", limits.max_files)));
    }
    let len = bytes.len();
    if len > limits.max_file_bytes {
        return Err(cx.data_mut().refuse(format!(
            "file too large: {len} bytes, limit {}",
            limits.max_file_bytes
        )));
    }
    let total = cx.data().emitted_bytes.saturating_add(len);
    if total > limits.max_emit_bytes {
        return Err(cx.data_mut().refuse(format!(
            "total output {total} bytes is over the limit {}",
            limits.max_emit_bytes
        )));
    }
    let name = match name.to_str(&cx) {
        Ok(n) => n.into_owned(),
        Err(_) => return Err(cx.data_mut().refuse("the name is not valid UTF-8".into())),
    };
    if let Err(why) = check_file_name(&name) {
        let shown: String = name.chars().take(64).collect();
        return Err(cx.data_mut().refuse(format!("bad name {shown:?}: {why}")));
    }
    if cx.data().files.iter().any(|f| f.name == name) {
        return Err(cx
            .data_mut()
            .refuse(format!("{name:?} was already emitted")));
    }
    let data = bytes.as_le_slice(&cx).to_vec();
    let sha256 = heimdall_core::Sha256Digest::of(&data);
    let st = cx.data_mut();
    st.emitted_bytes = total;
    st.files.push(EmittedFile {
        name,
        bytes: data,
        sha256,
    });
    Ok(())
}

/// The split guest's import: decode chunks in isolated decoder instances
/// (crate::split). The frames are lent from the verifier's memory to the
/// decoder instances; nothing is copied on the host side. A failure ends the
/// run with the failing unit's code.
fn decode_chunks(
    mut cx: StoreContextMut<'_, State>,
    (chunks,): (WasmList<ChunkRefLifted>,),
) -> wasmtime::Result<(Vec<ChunkAnswer>,)> {
    let Some(decoder) = cx.data().decoder.clone() else {
        // Unreachable: load() refuses a guest that imports decode-chunks.
        let why = "decode-chunks called without a decoder".to_string();
        cx.data_mut().decoder_failure = Some((codes::HOST_FAULT, why.clone()));
        return Err(wasmtime::Error::msg(why));
    };
    let limits = cx.data().limits.clone();
    let deadline = cx.data().deadline;
    let mut refs = Vec::with_capacity(chunks.len());
    for i in 0..chunks.len() {
        match chunks.get(&mut cx, i) {
            Some(r) => refs.push(r?),
            None => break,
        }
    }
    // Every byte that comes back lands in the verifier's memory: refuse a
    // call that could not fit BEFORE any decoder runs.
    let back: u64 = refs
        .iter()
        .filter(|r| r.want_bytes)
        .fold(0u64, |a, r| a.saturating_add(r.size));
    if back > limits.memory_bytes as u64 {
        let why = format!(
            "memory limit: decode-chunks asks for {back} decoded bytes, the verifier's limit is {}",
            limits.memory_bytes
        );
        cx.data_mut().decoder_failure = Some((codes::MEMORY_LIMIT, why.clone()));
        return Err(wasmtime::Error::msg(why));
    }
    let run = {
        let view = &cx;
        let ins: Vec<ChunkIn<'_>> = refs
            .iter()
            .map(|r| ChunkIn::new(r.frame.as_le_slice(view), r.size))
            .collect();
        let wants: Vec<bool> = refs.iter().map(|r| r.want_bytes).collect();
        decoder.run(&ins, &wants, &limits, deadline)
    };
    let st = cx.data_mut();
    st.decoder_fuel = st.decoder_fuel.saturating_add(run.fuel_used);
    match run.result {
        Ok(answers) => Ok((answers,)),
        Err(f) => {
            st.decoder_failure = Some((f.code, f.reason.clone()));
            Err(wasmtime::Error::msg(f.reason))
        }
    }
}

/// The split guest's second import: SHA-256 and BLAKE3-256 of every blob the
/// verifier lends, natively, over [`RunLimits::decoders`] gatling workers.
fn digest_members(
    mut cx: StoreContextMut<'_, State>,
    (blobs,): (WasmList<WasmList<u8>>,),
) -> wasmtime::Result<(Vec<MemberDigestOut>,)> {
    if cx.data().decoder.is_none() {
        let why = "digest-members called without a decoder".to_string();
        cx.data_mut().decoder_failure = Some((codes::HOST_FAULT, why.clone()));
        return Err(wasmtime::Error::msg(why));
    }
    let workers = cx.data().limits.decoders.max(1);
    let mut lists = Vec::with_capacity(blobs.len());
    for i in 0..blobs.len() {
        match blobs.get(&mut cx, i) {
            Some(b) => lists.push(b?),
            None => break,
        }
    }
    let view = &cx;
    let slices: Vec<&[u8]> = lists.iter().map(|b| b.as_le_slice(view)).collect();
    let out = gatling::gatling_forkjoin::gatling_for_each(slices.len(), workers, |i| {
        let b = slices[i];
        MemberDigestOut {
            sha256: heimdall_core::Sha256Digest::of(b).0.to_vec(),
            blake3: blake3::hash(b).as_bytes().to_vec(),
        }
    });
    Ok((out,))
}

#[derive(ComponentType, Lower)]
#[component(record)]
struct MemberDigestOut {
    sha256: Vec<u8>,
    blake3: Vec<u8>,
}

/// Memory/table/instance limits that refuse BY NAME: a growth past the limit
/// is a trap that records which limit fired (never a quiet -1 the guest could
/// retry around). Memory and table sizes count over ALL of the guest's
/// memories and tables together (docs/POLICY.md), not per memory.
pub(crate) struct Limiter {
    memory_bytes: usize,
    table_elements: usize,
    instances: usize,
    tables: usize,
    memories: usize,
    memory_total: usize,
    table_total: usize,
    pub(crate) hit: Option<(&'static str, String)>,
}

impl Limiter {
    pub(crate) fn new(l: &RunLimits) -> Limiter {
        Limiter {
            memory_bytes: l.memory_bytes,
            table_elements: l.table_elements,
            instances: l.instances,
            tables: l.tables,
            memories: l.memories,
            memory_total: 0,
            table_total: 0,
            hit: None,
        }
    }
}

impl ResourceLimiter for Limiter {
    fn memory_growing(
        &mut self,
        current: usize,
        desired: usize,
        _max: Option<usize>,
    ) -> wasmtime::Result<bool> {
        let total = self
            .memory_total
            .saturating_sub(current)
            .saturating_add(desired);
        if total > self.memory_bytes {
            let why = format!(
                "memory limit: asked to grow from {current} to {desired} bytes ({total} in all), limit {}",
                self.memory_bytes
            );
            self.hit = Some((codes::MEMORY_LIMIT, why.clone()));
            return Err(wasmtime::Error::msg(why));
        }
        self.memory_total = total;
        Ok(true)
    }

    fn table_growing(
        &mut self,
        current: usize,
        desired: usize,
        _max: Option<usize>,
    ) -> wasmtime::Result<bool> {
        let total = self
            .table_total
            .saturating_sub(current)
            .saturating_add(desired);
        if total > self.table_elements {
            let why = format!(
                "table limit: asked to grow from {current} to {desired} elements ({total} in all), limit {}",
                self.table_elements
            );
            self.hit = Some((codes::TABLE_LIMIT, why.clone()));
            return Err(wasmtime::Error::msg(why));
        }
        self.table_total = total;
        Ok(true)
    }

    fn instances(&self) -> usize {
        self.instances
    }

    fn tables(&self) -> usize {
        self.tables
    }

    fn memories(&self) -> usize {
        self.memories
    }
}

/// Bumps the engine epoch every tick; a store's deadline is counted in ticks.
/// Stops when the host and every guest from it are dropped.
pub(crate) struct Ticker {
    stop: Arc<AtomicBool>,
    handle: Option<JoinHandle<()>>,
}

impl Ticker {
    fn start(engine: Engine, tick: std::time::Duration) -> Ticker {
        let stop = Arc::new(AtomicBool::new(false));
        let s = stop.clone();
        let handle = std::thread::Builder::new()
            .name("heimdall-wasm-epoch".into())
            .spawn(move || {
                while !s.load(Ordering::Relaxed) {
                    std::thread::sleep(tick);
                    engine.increment_epoch();
                }
            })
            .expect("the epoch thread could not be started");
        Ticker {
            stop,
            handle: Some(handle),
        }
    }
}

impl Drop for Ticker {
    fn drop(&mut self) {
        self.stop.store(true, Ordering::Relaxed);
        if let Some(h) = self.handle.take() {
            let _ = h.join();
        }
    }
}

/// Whitespace collapsed and capped, so a refusal stays one readable line.
pub(crate) fn one_line(s: &str) -> String {
    let flat = s.split_whitespace().collect::<Vec<_>>().join(" ");
    if flat.len() <= 400 {
        return flat;
    }
    let mut cut = 400;
    while !flat.is_char_boundary(cut) {
        cut -= 1;
    }
    format!("{}…", &flat[..cut])
}

fn sidecar_path(module: &Path, ext: &str) -> PathBuf {
    let mut n = module.file_name().unwrap_or_default().to_os_string();
    n.push(".");
    n.push(ext);
    module.with_file_name(n)
}

/// Read at most `max` bytes; a larger file is refused without reading it all.
fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>, LoadRefusal> {
    let unreadable = |e: std::io::Error| LoadRefusal::Unreadable {
        path: path.to_path_buf(),
        detail: e.to_string(),
    };
    let f = std::fs::File::open(path).map_err(unreadable)?;
    let mut bytes = Vec::new();
    f.take(max.saturating_add(1))
        .read_to_end(&mut bytes)
        .map_err(unreadable)?;
    if bytes.len() as u64 > max {
        let total = std::fs::metadata(path)
            .map(|m| m.len())
            .unwrap_or(bytes.len() as u64);
        return Err(LoadRefusal::TooLarge { bytes: total, max });
    }
    Ok(bytes)
}

fn read_pin(sidecar: &Path) -> Result<String, LoadRefusal> {
    let missing = || LoadRefusal::DigestMissing {
        sidecar: sidecar.to_path_buf(),
    };
    let f = std::fs::File::open(sidecar).map_err(|_| missing())?;
    let mut text = String::new();
    f.take(256)
        .read_to_string(&mut text)
        .map_err(|_| missing())?;
    Ok(text)
}

fn check_pin(pin: &'static str, expected: &str, actual: &str) -> Result<(), LoadRefusal> {
    let e = expected.trim().to_ascii_lowercase();
    let shown: String = e.chars().take(64).collect();
    if e.len() == 64 && e.bytes().all(|b| b.is_ascii_hexdigit()) && e == actual {
        Ok(())
    } else {
        Err(LoadRefusal::DigestMismatch {
            pin,
            expected: shown,
            actual: actual.to_string(),
        })
    }
}

fn untrusted(e: TrustError) -> LoadRefusal {
    let failure = match &e {
        TrustError::Unsigned { .. } => TrustFailure::Unsigned,
        TrustError::SignatureInvalid { .. } => TrustFailure::SignatureInvalid,
        TrustError::RootsUnreadable { .. } => TrustFailure::RootsUnreadable,
        TrustError::VerifierUnavailable { .. } => TrustFailure::VerifierUnavailable,
    };
    LoadRefusal::Untrusted {
        failure,
        detail: e.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn memory_is_counted_over_all_memories_together() {
        let mut l = Limiter::new(&RunLimits {
            memory_bytes: 15,
            ..RunLimits::default()
        });
        assert!(l.memory_growing(0, 10, None).unwrap());
        assert!(
            l.memory_growing(0, 10, None).is_err(),
            "two memories of 10 passed a limit of 15"
        );
        let (code, why) = l.hit.clone().unwrap();
        assert_eq!(code, codes::MEMORY_LIMIT);
        assert!(why.contains("20 in all"));
    }

    #[test]
    fn tables_are_counted_over_all_tables_together() {
        let mut l = Limiter::new(&RunLimits {
            table_elements: 100,
            ..RunLimits::default()
        });
        assert!(l.table_growing(0, 60, None).unwrap());
        assert!(
            l.table_growing(60, 70, None).unwrap(),
            "growing the same table is not double counted"
        );
        assert!(l.table_growing(0, 40, None).is_err());
    }

    #[test]
    fn a_pin_must_be_64_hex_digits() {
        let d = blake3::hash(b"x").to_hex().to_string();
        assert!(check_pin("p", &d, &d).is_ok());
        assert!(check_pin("p", &format!(" {}\n", d.to_uppercase()), &d).is_ok());
        assert!(check_pin("p", &d[..63], &d).is_err());
        assert!(check_pin("p", "", &d).is_err());
    }

    #[test]
    fn a_long_error_is_cut_to_one_line() {
        let s = one_line(&format!("a\n  b\t{}", "é".repeat(500)));
        assert!(s.starts_with("a b "));
        assert!(!s.contains('\n'));
        assert!(s.len() <= 404);
    }
}