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

nordisk/edda

crates/guest-host/src/limits.rs

//! **What a loaded module may spend before the host refuses it, by name.**
//!
//! # Two CPU bounds, because they measure different things
//!
//! | bound | what it counts | why it is here |
//! |---|---|---|
//! | **fuel** (`Config::consume_fuel`) | wasm instructions executed | DETERMINISTIC. The same module on the same input refuses at the same instruction on a loaded machine and an idle one, so a runaway is reproducible instead of a flaky timeout. |
//! | **epoch** (`Config::epoch_interruption`) | wall-clock ticks | The BACKSTOP. Fuel does not tick while the module is parked inside a host import waiting on a slow disk or a slow HTTP peer. Only the clock catches that. |
//!
//! Neither replaces the other.
//!
//! # Zero is a misconfiguration, never "unlimited"
//!
//! [`PluginLimits::validate`] REFUSES a zero. In every config language a host
//! reads, an unset number arrives as `0`, and the single most likely way to
//! silently reinstate an unbounded host is to let `0` mean "no ceiling". So it
//! is named and refused at boot rather than interpreted.
//!
//! # Without the `wasmtime` feature
//!
//! The vocabulary — [`PluginLimits`], [`Bound`], the defaults, `validate` —
//! compiles everywhere, so a config validator or a signer can name a limit
//! without carrying a runtime. The engine config, the limiter and the ticker
//! need the runtime and are behind the feature.

use std::time::Duration;

/// Default CPU fuel for ONE call into a module: a billion instructions.
///
/// Four orders of magnitude of headroom over honest work (holger's package
/// handlers spend six figures on a real fetch) and still under a second of pure
/// compute for a `loop {}`. A kind of guest that does less narrows it.
pub const DEFAULT_FUEL: u64 = 1_000_000_000;

/// Default linear-memory ceiling for one store: 256 MiB. Sized against what a
/// guest legitimately holds, not against what the machine has.
pub const DEFAULT_MAX_MEMORY_BYTES: usize = 256 * 1024 * 1024;

/// Default table-element ceiling. Six figures is far past any real guest and
/// far short of a table grown as an allocation channel.
pub const DEFAULT_MAX_TABLE_ELEMENTS: usize = 100_000;

/// Default wall-clock deadline for one call into a module.
pub const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(30);

/// How many epoch ticks make up one call deadline. The ticker fires
/// `call_timeout / TICKS_PER_DEADLINE`, so the deadline is accurate to one
/// tick; eight is close enough that a 30 s deadline is never a 60 s hang.
pub const TICKS_PER_DEADLINE: u64 = 8;

/// Everything one loaded module is allowed to spend.
///
/// [`Default`] is the SAFE answer on purpose: a caller that never configures
/// anything gets bounded execution, because the failure being closed here is a
/// host that was unbounded because nobody said otherwise.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginLimits {
    /// Wasm instructions one call may execute.
    pub fuel: u64,
    /// Bytes of linear memory one store may hold.
    pub max_memory_bytes: usize,
    /// Elements one store's tables may hold.
    pub max_table_elements: usize,
    /// Wall-clock ceiling on one call, including time spent in host imports.
    pub call_timeout: Duration,
}

impl Default for PluginLimits {
    fn default() -> Self {
        Self {
            fuel: DEFAULT_FUEL,
            max_memory_bytes: DEFAULT_MAX_MEMORY_BYTES,
            max_table_elements: DEFAULT_MAX_TABLE_ELEMENTS,
            call_timeout: DEFAULT_CALL_TIMEOUT,
        }
    }
}

/// A limit that is zero. `Display` says which and how to fix it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LimitZero {
    /// `fuel` | `max_memory_bytes` | `max_table_elements` | `call_timeout`.
    pub field: &'static str,
}

impl std::fmt::Display for LimitZero {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "wasm guest limit `{}` is 0. Zero is NOT \"unlimited\" here — it is refused, because an \
             unset config value arrives as 0 and reading it as \"no ceiling\" is exactly the \
             unbounded host these limits exist to close. Set a real number, or leave the whole \
             limits block out to get the safe defaults (fuel {DEFAULT_FUEL}, memory \
             {DEFAULT_MAX_MEMORY_BYTES} bytes, {DEFAULT_MAX_TABLE_ELEMENTS} table elements, {}s \
             deadline).",
            self.field,
            DEFAULT_CALL_TIMEOUT.as_secs()
        )
    }
}

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

impl PluginLimits {
    /// Refuse a limit that is zero, naming the field. See the module docs.
    pub fn validate(&self) -> Result<(), LimitZero> {
        if self.fuel == 0 {
            return Err(LimitZero { field: "fuel" });
        }
        if self.max_memory_bytes == 0 {
            return Err(LimitZero { field: "max_memory_bytes" });
        }
        if self.max_table_elements == 0 {
            return Err(LimitZero { field: "max_table_elements" });
        }
        if self.call_timeout.is_zero() {
            return Err(LimitZero { field: "call_timeout" });
        }
        Ok(())
    }

    /// **The tighter of these limits and a ceiling** on the two CPU axes.
    ///
    /// A guest KIND brings its own ceiling (holger's meter runs on the push
    /// path with a tank a hundred times its measured worst call; a monetize
    /// product answers a five-minute poll). This narrows and never widens: an
    /// operator may bring a guest further down, never up past the kind's
    /// ceiling by writing a bigger number in a config file. Memory and tables
    /// are untouched — they are about how much of the machine a module may
    /// hold, a property of the box and not of the kind.
    pub fn narrowed(&self, fuel_ceiling: u64, call_timeout_ceiling: Duration) -> Self {
        Self {
            fuel: self.fuel.min(fuel_ceiling),
            call_timeout: self.call_timeout.min(call_timeout_ceiling),
            max_memory_bytes: self.max_memory_bytes,
            max_table_elements: self.max_table_elements,
        }
    }

    /// Epoch ticks one call is given, and the ticker period that makes them
    /// add up to [`PluginLimits::call_timeout`].
    pub fn epoch_schedule(&self) -> (u64, Duration) {
        let period = self.call_timeout / TICKS_PER_DEADLINE as u32;
        // A sub-millisecond ticker would spin a core for no extra accuracy.
        (TICKS_PER_DEADLINE, period.max(Duration::from_millis(1)))
    }
}

/// Which ceiling a call hit. Carried instead of a bare string so a host can put
/// its own code on the refusal, and so an operator reading a log can tell a
/// runaway from an over-large guest without guessing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Bound {
    /// Burned its whole fuel allowance.
    Fuel,
    /// Passed its wall-clock deadline.
    Deadline,
    /// Asked for linear memory past the ceiling.
    Memory,
    /// Asked for table elements past the ceiling.
    Table,
}

impl Bound {
    /// `fuel` | `deadline` | `memory` | `table`.
    pub fn name(self) -> &'static str {
        match self {
            Bound::Fuel => "fuel",
            Bound::Deadline => "deadline",
            Bound::Memory => "memory",
            Bound::Table => "table",
        }
    }

    /// The refusal: `<path>: <export>: <what and how to fix it>`, quoting the
    /// limit that was hit — the operator's next move is to raise it or throw
    /// the module out, and they cannot do either without knowing what it was.
    pub fn refusal(self, path: &str, export: &str, limits: &PluginLimits) -> String {
        match self {
            Bound::Fuel => format!(
                "{path}: {export} burned its whole CPU fuel allowance ({} instructions) and was \
                 cut off. An honest guest spends a tiny fraction of that, so this is a runaway \
                 or an infinite loop, not a busy one. The module is NOT unloaded; the next call \
                 starts with a full tank.",
                limits.fuel
            ),
            Bound::Deadline => format!(
                "{path}: {export} passed its wall-clock deadline ({:?}) and was interrupted. \
                 Unlike the fuel bound this also counts time spent inside a host import, so a \
                 module blocked on storage or on a slow peer lands here rather than hanging \
                 the caller.",
                limits.call_timeout
            ),
            Bound::Memory => format!(
                "{path}: {export} asked to grow its linear memory past the ceiling ({} bytes). \
                 Refused by name rather than left as the -1 the guest sees from memory.grow — \
                 a module that turned a refused allocation into \"nothing here\" would make a \
                 memory ceiling look like an empty answer.",
                limits.max_memory_bytes
            ),
            Bound::Table => format!(
                "{path}: {export} asked to grow a table past the ceiling ({} elements).",
                limits.max_table_elements
            ),
        }
    }
}

#[cfg(feature = "wasmtime")]
pub use runtime::{BoundedLimiter, EpochTicker};

#[cfg(feature = "wasmtime")]
mod runtime {
    use super::*;
    use std::sync::atomic::{AtomicBool, Ordering};
    use std::sync::Arc;
    use wasmtime::{Engine, ResourceLimiter, StoreLimits, StoreLimitsBuilder};

    impl PluginLimits {
        /// The wasmtime `Config` these limits require. Both CPU bounds are
        /// switched on at the *engine* level — they cannot be added to a
        /// `Store` later — which is why a host built on this crate can never
        /// be built from `Engine::default()`.
        pub fn engine_config(&self) -> wasmtime::Config {
            let mut config = wasmtime::Config::new();
            config.consume_fuel(true);
            config.epoch_interruption(true);
            config
        }

        /// The per-store resource limiter for these limits.
        pub fn limiter(&self) -> BoundedLimiter {
            BoundedLimiter {
                inner: StoreLimitsBuilder::new()
                    .memory_size(self.max_memory_bytes)
                    .table_elements(self.max_table_elements)
                    .build(),
                refused: None,
                max_memory_bytes: self.max_memory_bytes,
                max_table_elements: self.max_table_elements,
            }
        }
    }

    /// `StoreLimits` that REMEMBERS the refusal.
    ///
    /// `StoreLimits` on its own answers `memory.grow` with `false`, which the
    /// guest reads as `-1` and typically turns into its own `unreachable` — so
    /// the host would see "the module trapped" and have nothing to say about
    /// why. Recording the refusal here is what lets a host name the ceiling
    /// afterwards instead of reporting a generic trap.
    pub struct BoundedLimiter {
        inner: StoreLimits,
        refused: Option<Bound>,
        max_memory_bytes: usize,
        max_table_elements: usize,
    }

    impl BoundedLimiter {
        /// The ceiling this store hit, if it hit one. Read after a call returns.
        pub fn refused(&self) -> Option<Bound> {
            self.refused
        }
    }

    impl ResourceLimiter for BoundedLimiter {
        fn memory_growing(
            &mut self,
            current: usize,
            desired: usize,
            maximum: Option<usize>,
        ) -> wasmtime::Result<bool> {
            let allowed = self.inner.memory_growing(current, desired, maximum)?;
            if !allowed || desired > self.max_memory_bytes {
                self.refused = Some(Bound::Memory);
            }
            Ok(allowed)
        }

        fn table_growing(
            &mut self,
            current: usize,
            desired: usize,
            maximum: Option<usize>,
        ) -> wasmtime::Result<bool> {
            let allowed = self.inner.table_growing(current, desired, maximum)?;
            if !allowed || desired > self.max_table_elements {
                self.refused = Some(Bound::Table);
            }
            Ok(allowed)
        }
    }

    /// The thread that advances an `Engine`'s epoch, so a wall-clock deadline
    /// is something other than a promise.
    ///
    /// `epoch_interruption(true)` alone interrupts nothing: wasmtime checks the
    /// engine's epoch counter against the store's deadline, and the counter
    /// only moves when somebody moves it. One ticker per loaded module; a
    /// module mounted under N names shares one ticker (`Arc`).
    pub struct EpochTicker {
        stop: Arc<AtomicBool>,
        handle: Option<std::thread::JoinHandle<()>>,
    }

    impl EpochTicker {
        /// Start ticking `engine` every `period`.
        pub fn start(engine: &Engine, period: Duration) -> Self {
            let stop = Arc::new(AtomicBool::new(false));
            let engine = engine.clone();
            let flag = Arc::clone(&stop);
            // A32-14, stays a thread: the wasm epoch ticker that makes the wall-clock ceiling bite; stopped by its flag on drop.
            let handle = std::thread::Builder::new()
                .name("guest-host-epoch".to_string())
                .spawn(move || {
                    while !flag.load(Ordering::Relaxed) {
                        std::thread::sleep(period);
                        engine.increment_epoch();
                    }
                })
                .ok();
            Self { stop, handle }
        }
    }

    impl Drop for EpochTicker {
        fn drop(&mut self) {
            self.stop.store(true, Ordering::Relaxed);
            // Not joined: the thread is asleep for up to one period and joining
            // would stall an unload for that long. The flag is the contract; the
            // `Engine` clone it holds keeps the engine alive until it notices.
            let _ = self.handle.take();
        }
    }
}

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

    /// The defaults are the SAFE ones. A host whose defaults are unbounded is
    /// a host that is unbounded, because nobody configures what already works.
    #[test]
    fn the_defaults_are_bounded() {
        let l = PluginLimits::default();
        l.validate().expect("the defaults must be a valid configuration");
        assert!(l.fuel > 0 && l.max_memory_bytes > 0 && l.max_table_elements > 0);
        assert!(!l.call_timeout.is_zero());
    }

    /// Zero is refused, by name, for every field — not read as "unlimited".
    #[test]
    fn a_zero_limit_is_refused_by_name_and_never_means_unlimited() {
        for (field, limits) in [
            ("fuel", PluginLimits { fuel: 0, ..Default::default() }),
            ("max_memory_bytes", PluginLimits { max_memory_bytes: 0, ..Default::default() }),
            ("max_table_elements", PluginLimits { max_table_elements: 0, ..Default::default() }),
            ("call_timeout", PluginLimits { call_timeout: Duration::ZERO, ..Default::default() }),
        ] {
            let err = limits.validate().expect_err("a zero limit was accepted");
            assert_eq!(err.field, field);
            assert!(err.to_string().contains(field), "the refusal does not name the field: {err}");
        }
    }

    /// The deadline is divided into ticks that add up to it.
    #[test]
    fn the_epoch_schedule_adds_up_to_the_deadline() {
        let limits = PluginLimits { call_timeout: Duration::from_secs(8), ..Default::default() };
        let (ticks, period) = limits.epoch_schedule();
        assert_eq!(period * ticks as u32, limits.call_timeout);
    }

    /// Narrowing takes the tighter number on each CPU axis and leaves memory alone.
    #[test]
    fn narrowing_never_widens() {
        let generous = PluginLimits { fuel: u64::MAX, call_timeout: Duration::from_secs(600), ..Default::default() };
        let n = generous.narrowed(1_000, Duration::from_secs(5));
        assert_eq!((n.fuel, n.call_timeout), (1_000, Duration::from_secs(5)));
        let tight = PluginLimits { fuel: 10, call_timeout: Duration::from_millis(50), ..Default::default() };
        let n = tight.narrowed(1_000, Duration::from_secs(5));
        assert_eq!((n.fuel, n.call_timeout), (10, Duration::from_millis(50)));
        assert_eq!(n.max_memory_bytes, tight.max_memory_bytes);
    }

    /// Every bound names itself, the export, the module and the limit hit.
    #[test]
    fn every_refusal_names_module_export_and_limit() {
        let limits = PluginLimits::default();
        let mut names = Vec::new();
        for b in [Bound::Fuel, Bound::Deadline, Bound::Memory, Bound::Table] {
            names.push(b.name());
            let msg = b.refusal("/plugins/x.wasm", "plugin_call", &limits);
            assert!(msg.contains("plugin_call") && msg.contains("/plugins/x.wasm"), "{msg}");
        }
        names.sort_unstable();
        names.dedup();
        assert_eq!(names.len(), 4, "two bounds share a name");
        assert!(Bound::Fuel.refusal("m", "e", &limits).contains(&DEFAULT_FUEL.to_string()));
        assert!(Bound::Memory.refusal("m", "e", &limits).contains(&DEFAULT_MAX_MEMORY_BYTES.to_string()));
    }

    /// The engine config actually switches both CPU bounds on.
    #[cfg(feature = "wasmtime")]
    #[test]
    fn the_engine_config_enables_both_cpu_bounds() {
        let limits = PluginLimits::default();
        let engine = wasmtime::Engine::new(&limits.engine_config()).expect("engine");
        let mut store = wasmtime::Store::new(&engine, ());
        store.set_fuel(limits.fuel).expect("fuel is not enabled on this engine");
        store.set_epoch_deadline(1);
    }
}