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

nordisk/holger

plugin/host/src/limits.rs

//! **What a loaded module may spend before the host refuses it, by name.**
//!
//! The limits themselves — fuel, the epoch deadline, the memory and table
//! ceilings, and the rule that a configured `0` is a misconfiguration and never
//! "unlimited" — are `guest_host::limits` (edda) since 2026-09-22, shared with
//! monetize's product host. This file keeps what is holger's: the frozen
//! `holger-plugin-<n>` code on each refusal, and the narrowing of an operator's
//! limits to a guest KIND's own ceilings (?17).
//!
//! Every default is the SAFE one; see `guest_host::limits` for the numbers and
//! the two-CPU-bounds argument.

use holger_errcode as ec;

pub use guest_host::limits::{
    Bound, LimitZero, PluginLimits, DEFAULT_CALL_TIMEOUT, DEFAULT_FUEL, DEFAULT_MAX_MEMORY_BYTES,
    DEFAULT_MAX_TABLE_ELEMENTS,
};
pub use guest_host::limits::{BoundedLimiter, EpochTicker};

/// Refuse a zero limit, naming the field, under `holger-plugin-5`.
pub fn validate(limits: &PluginLimits) -> Result<(), String> {
    limits
        .validate()
        .map_err(|e| crate::named::limit_refusal(&e))
}

/// **Narrow limits to a guest KIND's own ceilings** — ?17, answered 2026-09-20.
///
/// The two CPU axes become the TIGHTER of what the operator configured and what
/// [`holger_plugin_abi::v2::KindSpec`] declares for the kind. Memory and tables
/// are untouched: they are about how much of the machine a module may hold,
/// which is a property of the box and not of the kind. It narrows and never
/// widens: nobody can raise a guest past the kind's ceiling by writing a bigger
/// number in a config file.
pub fn narrowed_to(limits: &PluginLimits, kind: holger_plugin_abi::v2::GuestKind) -> PluginLimits {
    limits.narrowed(
        kind.fuel(),
        std::time::Duration::from_millis(kind.call_timeout_ms()),
    )
}

/// The frozen code a [`Bound`] refusal is raised under.
pub fn bound_code(b: Bound) -> &'static ec::ErrCode {
    crate::named::bound_code(b)
}

/// `[code] <path>: <export>: <what and how to fix it>`, quoting the limit hit.
pub fn named_refusal(b: Bound, path: &str, export: &str, limits: &PluginLimits) -> String {
    bound_code(b).tag(b.refusal(path, export, limits))
}

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

    /// The defaults are the SAFE ones and validate clean under holger's code.
    #[test]
    fn the_defaults_are_bounded() {
        let l = PluginLimits::default();
        validate(&l).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 and under `holger-plugin-5`, for every field.
    #[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 = validate(&limits).expect_err("a zero limit was accepted");
            assert!(
                err.contains(ec::PLUGIN_LIMIT_MISCONFIGURED.code),
                "the refusal carries no code: {err}"
            );
            assert!(
                err.contains(field),
                "the refusal does not name the field {field}: {err}"
            );
        }
    }

    /// Every bound carries its own code — a caller cannot report one refusal as
    /// another — and the coded refusal names module, export and limit.
    #[test]
    fn every_bound_has_its_own_code() {
        let all = [Bound::Fuel, Bound::Deadline, Bound::Memory, Bound::Table];
        let mut codes: Vec<&str> = all.iter().map(|b| bound_code(*b).code).collect();
        codes.sort_unstable();
        codes.dedup();
        assert_eq!(codes.len(), all.len(), "two bounds share a code");
        let limits = PluginLimits::default();
        for b in all {
            let msg = named_refusal(b, "/plugins/x.wasm", "plugin_fetch", &limits);
            assert!(msg.contains(bound_code(b).code), "{msg}");
            assert!(
                msg.contains("plugin_fetch") && msg.contains("/plugins/x.wasm"),
                "{msg}"
            );
        }
    }

    /// The kind's ceiling narrows an operator's numbers and never widens them.
    #[test]
    fn narrowing_to_a_kind_never_widens() {
        use holger_plugin_abi::v2::GuestKind;
        let generous = PluginLimits {
            fuel: u64::MAX,
            ..Default::default()
        };
        assert_eq!(
            narrowed_to(&generous, GuestKind::Meter).fuel,
            GuestKind::Meter.fuel()
        );
        let tight = PluginLimits {
            fuel: 1_000,
            ..Default::default()
        };
        assert_eq!(narrowed_to(&tight, GuestKind::Meter).fuel, 1_000);
    }
}