Gunnar

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

/// 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 (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,
        }
    }
}

/// The wasm host: one engine, one linker that offers only `emit-file`.
pub struct Host {
    cfg: HostConfig,
    engine: Engine,
    linker: Arc<Linker<State>>,
    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)
            // Determinism: the same guest and input give the same bytes out.
            .cranelift_nan_canonicalization(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:#}")))?;
        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),
            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)
    }

    fn load_inner(&self, module: &Path, caller_pin: Option<&str>) -> Result<Guest, 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; only then the import/export check.
        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:#}")),
        })?;
        let ty = component.component_type();
        let foreign: Vec<String> = ty
            .imports(&self.engine)
            .map(|(name, _)| name.to_string())
            .filter(|name| name != "emit-file")
            .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,
            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,
    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
    }

    /// 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 store = Store::new(self.pre.engine(), State::new(limits.clone()));
        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();

        let used = |store: &Store<State>| limits.fuel.saturating_sub(store.get_fuel().unwrap_or(0));

        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 (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(r) = &state.refusal {
            return (codes::EMIT_REFUSED, r.clone());
        }
        if let Some((code, h)) = &state.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 {} bytes", self.stack_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>,
}

impl State {
    fn new(limits: RunLimits) -> State {
        State {
            limiter: Limiter::new(&limits),
            limits,
            files: Vec::new(),
            emitted_bytes: 0,
            refusal: 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(())
}

/// 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.
struct Limiter {
    memory_bytes: usize,
    table_elements: usize,
    instances: usize,
    tables: usize,
    memories: usize,
    memory_total: usize,
    table_total: usize,
    hit: Option<(&'static str, String)>,
}

impl Limiter {
    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.
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.
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);
    }
}