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

nordisk/holger

plugin/host/tests/the_grant_is_the_linker.rs

//! **An ungranted capability is not refused — it is not linkable.**
//!
//! This is the running-process replacement for gunnar's symbol-table proof. A
//! cargo feature's absence is provable in the symbol table; an uninstalled
//! module's absence is only "a file that is not there", which is weaker. The
//! capability grant is where the strength is recovered, because the linker
//! defines only what was granted, so a module reaching for anything else fails
//! INSTANTIATION.
//!
//! Every assertion here runs against the REAL `holger_plugin_allocation_wasm.wasm`
//! built by `cargo xtask build-wasm-plugins`. Nothing is mocked and nothing
//! skips: a test that quietly passed because there was no module to load would
//! be worth nothing.

mod common;

use std::sync::Arc;

use holger_plugin_abi::v2::{
    imports, Capability, ClockSource, Enforcement, Meter, MeterHost, MeterScope, ReadingValue,
    WireAuditRecord, WireCallContext, WireInstant, WireMeterReading, WireVerb, WireVerdict,
};
use holger_plugin_allocation::AllocationMeter;
use holger_plugin_host::backend::SharedStore;
use holger_plugin_host::meter::{GrantedHost, LinkedMeter, MeterHostServices, MeterRuntime};
use holger_plugin_host::meter_wasm::WasmMeter;
use holger_plugin_host::{DirStore, PluginPolicy};

const MODULE: &str = "holger_plugin_allocation_wasm.wasm";

/// A host that counts, so the two runtimes are compared over the same numbers.
struct Counting;

fn reading(meter: &str, v: u64) -> WireMeterReading {
    WireMeterReading {
        meter: meter.into(),
        scope: MeterScope::Product,
        enforcement: Enforcement::Measured,
        value: ReadingValue::Counted(v),
    }
}

impl MeterHostServices for Counting {
    fn usage_read(&self, repository: &str) -> Result<Vec<WireMeterReading>, String> {
        use holger_plugin_abi::v2::meters;
        if repository.is_empty() {
            Ok(vec![
                reading(meters::STORAGE_TOTAL_BYTES, 4096),
                reading(meters::STORAGE_SERVABLE_BYTES, 3000),
                reading(meters::STORAGE_USED_BYTES, 1096),
            ])
        } else {
            Ok(vec![
                reading(meters::ARTIFACT_BYTES, 17),
                WireMeterReading {
                    meter: meters::ARTIFACTS.into(),
                    scope: MeterScope::Tenant,
                    enforcement: Enforcement::Measured,
                    // The whole reason `ReadingValue` exists: this backend cannot
                    // enumerate, and says so instead of answering zero.
                    value: ReadingValue::Refused {
                        reason: "znippy-go cannot enumerate".into(),
                    },
                },
            ])
        }
    }
    fn audit_write(&self, _record: &WireAuditRecord) -> Result<(), String> {
        Ok(())
    }
    fn clock(&self) -> Result<WireInstant, String> {
        Ok(WireInstant {
            unix_nanos: 1,
            source: ClockSource::MonotonicSinceBoot,
            trusted_until_unix_nanos: 0,
        })
    }
}

fn store_with(files: &[(&str, usize)]) -> (tempdirish::Scratch, SharedStore) {
    let dir = tempdirish::Scratch::new("meter-store");
    for (name, size) in files {
        std::fs::write(dir.path().join(name), vec![7u8; *size]).expect("write probe file");
    }
    let store: SharedStore = Arc::new(DirStore::new(dir.path()));
    (dir, store)
}

/// A scratch directory that removes itself. Local because the crate has no
/// tempfile dependency and this test must not add one.
mod tempdirish {
    use std::path::{Path, PathBuf};

    pub struct Scratch(PathBuf);

    impl Scratch {
        pub fn new(name: &str) -> Self {
            let p = std::env::temp_dir().join(format!(
                "holger-{name}-{}-{:?}",
                std::process::id(),
                std::thread::current().id()
            ));
            let _ = std::fs::remove_dir_all(&p);
            std::fs::create_dir_all(&p).expect("scratch dir");
            Self(p)
        }
        pub fn path(&self) -> &Path {
            &self.0
        }
    }

    impl Drop for Scratch {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.0);
        }
    }
}

fn load(
    granted: &[Capability],
    store: SharedStore,
) -> Result<WasmMeter, holger_plugin_host::meter_wasm::MeterLoadError> {
    let dir = common::plugin_dir_with_modules(&[MODULE]);
    WasmMeter::load_with(
        dir.join(MODULE),
        store,
        Arc::new(Counting),
        &PluginPolicy::default(),
        granted,
    )
}

const GRANT: &[Capability] = &[Capability::UsageRead, Capability::StoreList];

#[test]
fn the_linker_defines_exactly_the_granted_imports_and_nothing_else() {
    let (_d, store) = store_with(&[]);
    let m = load(GRANT, store).expect("the guest loads under its own grant");
    let defined = m.linker_defines();
    assert_eq!(defined.len(), 2, "defined: {defined:?}");
    assert!(defined.contains(&imports::USAGE_READ));
    assert!(defined.contains(&imports::STORE_LIST));
    // The four that make a metering plugin dangerous are simply not there.
    for absent in [
        imports::AUDIT_WRITE,
        imports::CLOCK,
        imports::STORE_PUT,
        imports::STORE_GET,
        imports::STORE_SIZE,
    ] {
        assert!(
            !defined.contains(&absent),
            "{absent} is defined on this boot's linker"
        );
    }
}

#[test]
fn a_grant_that_stops_short_refuses_the_module_by_name_before_it_is_instantiated() {
    let (_d, store) = store_with(&[]);
    let err = load(&[Capability::UsageRead], store)
        .expect_err("store.list was not granted")
        .to_string();
    assert!(err.contains("store.list"), "{err}");
    assert!(err.contains("host_store_list"), "{err}");
    assert!(err.contains("NOT DEFINED"), "{err}");
}

#[test]
fn granting_more_than_the_module_asks_for_still_defines_only_what_it_uses() {
    // An operator who over-grants does not widen the module's reach: the linker
    // is built from what the module imports, intersected with the grant.
    let (_d, store) = store_with(&[]);
    let m = load(
        &[
            Capability::UsageRead,
            Capability::StoreList,
            Capability::AuditWrite,
            Capability::ClockRead,
            Capability::StoreWrite,
        ],
        store,
    )
    .expect("an over-grant still loads");
    assert_eq!(m.linker_defines().len(), 2, "{:?}", m.linker_defines());
    assert!(!m.linker_defines().contains(&imports::AUDIT_WRITE));
}

#[test]
fn the_module_declares_the_two_capabilities_it_imports_and_no_others() {
    let (_d, store) = store_with(&[]);
    let m = load(GRANT, store).unwrap();
    assert_eq!(m.manifest().name, "allocation");
    assert_eq!(
        m.manifest().requires,
        vec![Capability::UsageRead, Capability::StoreList]
    );
    assert_eq!(m.abi(), 2);
    assert!(!m.manifest().meter.as_ref().unwrap().accepts_facts);
}

#[test]
fn native_and_wasm_answer_identically() {
    let (_d, store) = store_with(&[("one", 100), ("two", 300)]);

    let linked = match LinkedMeter::new(
        "allocation",
        AllocationMeter,
        Arc::clone(&store),
        Arc::new(Counting),
        GRANT,
    ) {
        Ok(m) => m,
        Err(e) => panic!("{e}"),
    };
    let loaded = load(GRANT, Arc::clone(&store)).expect("the module loads");

    assert_eq!(linked.meters().unwrap(), loaded.meters().unwrap());
    assert_eq!(linked.capacity().unwrap(), loaded.capacity().unwrap());
    assert_eq!(
        linked.read("releases").unwrap(),
        loaded.read("releases").unwrap()
    );

    let a = linked.selftest().unwrap();
    let b = loaded.selftest().unwrap();
    assert_eq!(a, b);
    assert!(a.passed, "{}", a.detail);
    // The selftest measured the store for itself: 400 bytes over two keys.
    assert!(a.detail.contains("400 bytes"), "{}", a.detail);
    assert!(a.detail.contains("2 stored key"), "{}", a.detail);

    let ctx = WireCallContext {
        abi: 2,
        repository: "releases".into(),
        verb: WireVerb::Put,
        principal: None,
        artifact: None,
        now: None,
    };
    assert_eq!(linked.evaluate(&ctx).unwrap(), WireVerdict::Allow);
    assert_eq!(loaded.evaluate(&ctx).unwrap(), WireVerdict::Allow);

    // Both decline a signed fact the same way, so nothing has to infer support
    // from an error string.
    assert_eq!(linked.accept_fact(b"x").unwrap_err(), "UNSUPPORTED");
    assert_eq!(loaded.accept_fact(b"x").unwrap_err(), "UNSUPPORTED");
}

#[test]
fn an_uncounted_meter_survives_the_wasm_round_trip_as_a_refusal() {
    let (_d, store) = store_with(&[]);
    let loaded = load(GRANT, store).unwrap();
    let rows = loaded.read("releases").unwrap();
    let artifacts = rows
        .iter()
        .find(|r| r.meter == "artifacts")
        .expect("the artifacts meter");
    match &artifacts.value {
        ReadingValue::Refused { reason } => {
            assert!(reason.contains("cannot enumerate"), "{reason}")
        }
        other => panic!("a refusal became {other:?} across the ABI"),
    }
    assert_eq!(artifacts.value.counted(), None);
}

#[test]
fn the_host_bridge_refuses_an_ungranted_capability_on_the_native_path_too() {
    // The wasm path is protected by the linker. The native path has no linker,
    // so the same grant is enforced on every method of the bridge.
    let (_d, store) = store_with(&[]);
    let h = GrantedHost::new(store, Arc::new(Counting), GRANT);
    assert!(h.usage_read("releases").is_ok());
    assert!(h.store_list("").is_ok());
    assert!(h
        .audit_write(&WireAuditRecord::default())
        .unwrap_err()
        .contains("audit.write"));
    assert!(h.clock().unwrap_err().contains("clock.read"));
    assert!(h.store_get("k").unwrap_err().contains("store.read"));
    assert!(h.store_put("k", b"v").unwrap_err().contains("store.write"));
}

#[test]
fn the_guest_never_learns_a_capability_it_was_not_granted_even_natively() {
    // Belt and braces: the native guest, handed a bridge with nothing granted,
    // still answers `evaluate` — and still cannot read anything.
    struct Nothing;
    impl MeterHost for Nothing {}
    let ctx = WireCallContext {
        abi: 2,
        repository: "releases".into(),
        verb: WireVerb::Delete,
        principal: None,
        artifact: None,
        now: None,
    };
    assert_eq!(
        AllocationMeter.evaluate(&Nothing, &ctx).unwrap(),
        WireVerdict::Allow
    );
    assert!(AllocationMeter.capacity(&Nothing).is_err());
}