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

nordisk/holger

holger-build-thing/tests/loaded_as_a_plugin.rs

//! **The acceptance proof: a forge that has never heard of holger.**
//!
//! This test loads `holger_build_thing.wasm` through the generic
//! [`nornir_build_thing::plugin_host`] loader and asks it everything the forge asks —
//! menu, validate, plan, provenance, artifact judgement — with:
//!
//! * **no holger source tree** consulted (the module is bytes; the test never reads
//!   holger's repo);
//! * **no capability granted** — the sandbox answers three questions and offers
//!   nothing else, and a module reaching for anything more is refused at load;
//! * **nothing executed** but the plugin. No cargo, no artifact, no subprocess.
//!
//! It deliberately calls the loader by its generic name and never mentions the holger
//! *crate*: everything below would be identical for a build thing this test had never
//! seen. That is the property being proved — the forge does not link the app.
//!
//! The module is built by `traits::test_support::wasm_guest` rather than being
//! checked in, so the wasm under test is always this source, never a stale copy.
//! A missing wasm32 target or a failed build is red, never a skip.

use holger_build_thing::HolgerBuildThing;
use nornir_build_thing::plugin::{ArtifactFacts, BuildRequest, BuildThing, Role};
use nornir_build_thing::plugin_host::{HostFacts, Limits, PluginHandle};

/// The facts the forge is willing to answer. This is the ENTIRE world the plugin can
/// see: one revision, and whatever manifests the host chose to offer. No filesystem,
/// no network, no clock.
fn facts(rev: &str) -> HostFacts {
    HostFacts::at_rev(rev, false)
}

#[test]
fn a_forge_that_never_heard_of_holger_lists_validates_and_plans_it() {
    let wasm = traits::test_support::wasm_guest("holger-build-thing");

    // Copy the module somewhere with NO source tree beside it, to make the "no
    // checkout needed" claim physical rather than rhetorical.
    let elsewhere = tempfile::tempdir().unwrap();
    let isolated = elsewhere.path().join("unknown-app.wasm");
    std::fs::copy(&wasm, &isolated).unwrap();
    assert!(
        !elsewhere.path().join("Cargo.toml").exists() && !elsewhere.path().join(".nornir").exists(),
        "the module sits alone — no manifest, no .nornir, no repo"
    );

    let rev = "c0ffee".repeat(6) + "abcd";
    let mut p = PluginHandle::load_file_with(&isolated, Limits::default(), facts(&rev))
        .expect("a build thing loads from bytes alone");

    // ── who is this? ─────────────────────────────────────────────────────────
    let m = p.manifest().clone();
    assert_eq!(
        m.repo, "holger",
        "the module says what it is; the host did not know"
    );
    assert_eq!(m.source_crate, "holger-build-thing");

    // ── the MENU: the SAME declaration the source declares ───────────────────
    //
    // ⚠ THE ROSTER IS THE POINT, and a COUNT IS NOT THE ROSTER. holger's on-disk
    // `.nornir/build-variants.toml` twice grew a variant the plugin did not declare
    // (`henriks-mini-holger` 2026-07-23; five more by 2026-08-01), and once RENAMED
    // two it did declare (`henriks-{mini,mikro}-holger` → `ui-{mini,mikro}`) plus
    // every `holger-ui*` bin to `holger-server-ui*`. A rename keeps the count and
    // breaks the build, so what is asserted here — through the LOADED MODULE, which
    // is what the forge reads — is every name, per role, and the artifact each
    // cargo row plans.
    //
    // The expectation is taken from the declaration ITSELF (`holger_recipe::declared`
    // and the native `HolgerBuildThing`), not from a literal list: the literal list
    // that stood here went stale when the egui rungs left (2026-09-16) and nothing but
    // this test noticed. What is proved is unchanged — the WASM answers the same
    // declaration the source declares, not a stale compiled-in copy. The literal
    // roster, reviewable as a diff, lives in `holger-recipe/src/rules.rs`
    // (`the_declared_builds_are_holgers_real_ones`).
    let declared = holger_recipe::declared();
    let native = HolgerBuildThing.menu();
    let menu = p.menu().expect("menu");
    assert_eq!(menu.repo, "holger");
    for role in [Role::Server, Role::Client] {
        assert_eq!(
            menu.in_role(role),
            native.in_role(role),
            "{role:?}: the module and the source disagree"
        );
    }
    // Coverage both ways: every declared variant is on the loaded menu, and nothing
    // undeclared is.
    let mut on_menu: Vec<&str> = menu.entries.iter().map(|e| e.id.as_str()).collect();
    on_menu.sort_unstable();
    let mut names: Vec<&str> = declared.variants.iter().map(|v| v.name.as_str()).collect();
    names.sort_unstable();
    assert_eq!(
        on_menu, names,
        "the forge sees exactly the declared variants"
    );
    // The media the owner asked for are on the menu the forge reads.
    for medium in [
        "holger-installer",
        "holger-installer-metered",
        "holger-appliance-iso",
    ] {
        assert_eq!(menu.role_of(medium), Some(Role::Server), "{medium}");
    }
    // Linkage, per entry, as the source declares it — asserted through the LOADED
    // MODULE, because that is what the forge reads.
    for e in &menu.entries {
        let v = declared.get(&e.id).expect("a declared variant");
        assert_eq!(
            e.linkage,
            nornir_build_thing::plugin::Linkage::from(v.shape),
            "{}",
            e.id
        );
    }
    assert!(
        menu.entries
            .iter()
            .any(|e| e.linkage == nornir_build_thing::plugin::Linkage::Thin),
        "the clients are thin (the command-line tool and the console)"
    );
    assert!(
        menu.entries.iter().all(|e| e.ecosystem.as_str() == "cargo"),
        "every entry names the build system that builds it"
    );

    // ── the rev PIN comes from the host, through the import ──────────────────
    let prov = p.provenance().expect("provenance");
    assert_eq!(
        prov.rev, rev,
        "the plugin learned the revision by ASKING the host — it cannot run git"
    );
    assert!(!prov.check(&rev).refuse_rebuild());
    let mismatch = prov.check(&"d".repeat(40));
    assert!(mismatch.refuse_rebuild(), "a different tree is refused");
    assert!(
        mismatch.line().contains("REFUSING to rebuild"),
        "{}",
        mismatch.line()
    );

    // ── VALIDATION is the app's logic, executed in the sandbox ───────────────
    assert!(p.validate(&BuildRequest::variant("holger")).unwrap().ok);
    let v = p.validate(&BuildRequest::variant("nope")).unwrap();
    assert!(!v.ok && v.reasons[0].contains("no variant"), "{v:?}");
    // The rule the declaration alone would permit — holger's own.
    let v = p
        .validate(&BuildRequest::features(["downstream", "served"]))
        .unwrap();
    assert!(
        !v.ok,
        "holger refuses an airgap build that asks for an egress feature"
    );
    assert!(
        v.line().contains("provably unable to reach the network"),
        "{}",
        v.line()
    );

    // ── the PLAN is data; this test executes none of it ──────────────────────
    let plan = p
        .plan("holger-downstream")
        .unwrap()
        .expect("a plan for a declared variant");
    assert_eq!(plan.variant, "holger-downstream");
    assert!(
        plan.steps.iter().all(|s| s.program == "cargo"),
        "no shell in a plan"
    );
    assert_eq!(
        plan.expected_artifacts,
        vec!["target/release/holger-downstream".to_string()]
    );
    // The bin names, asserted where they bite: the artifact path the forge waits
    // for, for EVERY declared variant, equal to what the source plans. (The plugin
    // once planned `target/release/holger-ui-mini`, which cargo had stopped
    // producing; the egui rungs have since left for holger-egui.)
    for v in &holger_recipe::declared().variants {
        let wasm_plan = p.plan(&v.name).unwrap().map(|x| x.expected_artifacts);
        let native_plan = HolgerBuildThing.plan(&v.name).map(|x| x.expected_artifacts);
        assert_eq!(
            wasm_plan, native_plan,
            "{}: the module plans another artifact than the source",
            v.name
        );
    }
    let paid = p
        .plan("mvp-server-musl-metered")
        .unwrap()
        .expect("the metered payload plans");
    assert!(
        paid.expected_artifacts.len() == 1
            && paid.expected_artifacts[0].ends_with("/holger-mvp-metered"),
        "the paid payload is the holger-mvp-metered bin: {:?}",
        paid.expected_artifacts
    );
    assert!(plan.preview().contains("cargo build"), "{}", plan.preview());
    // The plugin logged through the one import that lets it speak.
    assert!(
        p.logged().iter().any(|l| l.contains("planned")),
        "the plugin's log arrived over `nordisk::log`: {:?}",
        p.logged()
    );

    // ── the ARTIFACT judgement, both directions ──────────────────────────────
    let judge = |variant: &str, linked: &[&str]| ArtifactFacts {
        variant: Some(variant.to_string()),
        file_name: format!("holger-{variant}"),
        bytes: 26_700_000,
        sha256: "0".repeat(64),
        linked_crates: linked.iter().map(|s| s.to_string()).collect(),
    };
    let v = p
        .judge_artifact(&judge("holger", &["serde", "hyper", "tonic"]))
        .unwrap();
    assert!(
        v.ok && v.line().contains("cannot phone home"),
        "{}",
        v.line()
    );
    let v = p.judge_artifact(&judge("holger", &["reqwest"])).unwrap();
    assert!(
        !v.ok && v.line().contains("must not be promoted"),
        "{}",
        v.line()
    );
    // The sold box fetches its own advisories: an HTTP client there is not refused.
    let v = p
        .judge_artifact(&judge("mvp-server-metered", &["reqwest", "ureq"]))
        .unwrap();
    assert!(v.ok && v.line().contains("not constrained"), "{}", v.line());
}

/// The permission list, proved from the module side: holger's real plugin imports
/// **only** the three the host grants. If a build thing ever grew a WASI import, this
/// is where it would be caught — and the loader would refuse it by name.
#[test]
fn the_plugin_imports_only_what_the_host_grants() {
    let wasm = traits::test_support::wasm_guest("holger-build-thing");
    let bytes = std::fs::read(&wasm).unwrap();

    // Parse the module's import section without running anything.
    let mut imports: Vec<String> = Vec::new();
    for payload in wasmparser::Parser::new(0).parse_all(&bytes) {
        if let wasmparser::Payload::ImportSection(section) = payload.unwrap() {
            // wasmparser 0.261 (2026-10-02) groups imports (compact encoding);
            // `into_imports` flattens the groups back to one entry per import.
            for imp in section.into_imports() {
                let imp = imp.unwrap();
                imports.push(format!("{}::{}", imp.module, imp.name));
            }
        }
    }
    let granted = nornir_build_thing::plugin::host::IMPORTS;
    for i in &imports {
        let (m, f) = i.split_once("::").unwrap();
        assert_eq!(
            m, "nordisk",
            "the only namespace a build thing may import from: {i}"
        );
        assert!(
            granted.contains(&f),
            "'{i}' is not on the permission list {granted:?}"
        );
    }
    assert!(
        imports.iter().any(|i| i.ends_with("::git_rev")),
        "the rev pin really does come over an import, not out of the plugin's own head: {imports:?}"
    );
}

/// The section path: a build thing embedded in an artifact is read with an object
/// parser and **never** by executing the artifact — which is what makes it work for a
/// binary this host cannot run at all.
#[test]
fn a_build_thing_is_read_out_of_a_binary_without_executing_it() {
    let wasm = traits::test_support::wasm_guest("holger-build-thing");
    let module = std::fs::read(&wasm).unwrap();

    // Forge an ELF for a machine this host cannot execute: aarch64. If interrogation
    // required RUNNING the artifact, this file would be unreadable here — which is
    // exactly the case that disqualifies `--describe-build` as the general mechanism.
    let dir = tempfile::tempdir().unwrap();
    let artifact = dir.path().join("holger-downstream.aarch64");
    std::fs::write(&artifact, fake_aarch64_elf_with_section(&module)).unwrap();

    let got = nornir_build_thing::plugin_host::read_section(&artifact)
        .expect("parsing an object file is not running it")
        .expect("the artifact carries its build thing");
    assert_eq!(
        got, module,
        "the section round-trips the module byte for byte"
    );

    // And the extracted module still loads and answers — the pulled-artifact flow.
    let mut p = PluginHandle::load(&got, Limits::default()).expect("load from the section");
    assert_eq!(p.manifest().repo, "holger");
    assert_eq!(
        p.menu().unwrap().in_role(Role::Server),
        HolgerBuildThing.menu().in_role(Role::Server)
    );

    // An artifact with no build thing is a REPORTED state, not an error and not a
    // silent empty: `Ok(None)` is a different answer from `Err`.
    let bare = dir.path().join("bare.aarch64");
    std::fs::write(&bare, fake_aarch64_elf_with_section(&[])).unwrap();
    assert!(
        nornir_build_thing::plugin_host::read_section(&bare)
            .unwrap()
            .is_none(),
        "no section ⇒ NoBuildThing, which the caller renders — never an absent row"
    );
}

/// Assemble a minimal but genuine little-endian aarch64 ELF64 carrying `payload` in a
/// `.nordisk.buildthing` section. Hand-built rather than cross-compiled so the test
/// needs no aarch64 toolchain — the point is only that the *object format* is one this
/// host cannot execute.
fn fake_aarch64_elf_with_section(payload: &[u8]) -> Vec<u8> {
    const EHSIZE: u64 = 64;
    const SHENTSIZE: u64 = 64;
    let shnum: u64 = if payload.is_empty() { 2 } else { 3 };

    // .shstrtab contents.
    let mut names = vec![0u8];
    let name_shstrtab = names.len() as u32;
    names.extend_from_slice(b".shstrtab\0");
    let name_section = names.len() as u32;
    names.extend_from_slice(b".nordisk.buildthing\0");

    let payload_off = EHSIZE;
    let names_off = payload_off + payload.len() as u64;
    let sh_off = names_off + names.len() as u64;

    let mut out = Vec::new();
    // ── ELF header ──
    out.extend_from_slice(&[0x7f, b'E', b'L', b'F', 2, 1, 1, 0]); // magic, 64-bit, LE, v1
    out.extend_from_slice(&[0u8; 8]);
    out.extend_from_slice(&2u16.to_le_bytes()); // ET_EXEC
    out.extend_from_slice(&183u16.to_le_bytes()); // EM_AARCH64 — not this host
    out.extend_from_slice(&1u32.to_le_bytes()); // version
    out.extend_from_slice(&0u64.to_le_bytes()); // entry
    out.extend_from_slice(&0u64.to_le_bytes()); // phoff
    out.extend_from_slice(&sh_off.to_le_bytes()); // shoff
    out.extend_from_slice(&0u32.to_le_bytes()); // flags
    out.extend_from_slice(&(EHSIZE as u16).to_le_bytes());
    out.extend_from_slice(&0u16.to_le_bytes()); // phentsize
    out.extend_from_slice(&0u16.to_le_bytes()); // phnum
    out.extend_from_slice(&(SHENTSIZE as u16).to_le_bytes());
    out.extend_from_slice(&(shnum as u16).to_le_bytes());
    out.extend_from_slice(&1u16.to_le_bytes()); // shstrndx
    debug_assert_eq!(out.len() as u64, EHSIZE);

    out.extend_from_slice(payload);
    out.extend_from_slice(&names);

    let mut section = |name: u32, kind: u32, off: u64, size: u64| {
        out.extend_from_slice(&name.to_le_bytes());
        out.extend_from_slice(&kind.to_le_bytes());
        out.extend_from_slice(&0u64.to_le_bytes()); // flags
        out.extend_from_slice(&0u64.to_le_bytes()); // addr
        out.extend_from_slice(&off.to_le_bytes());
        out.extend_from_slice(&size.to_le_bytes());
        out.extend_from_slice(&0u32.to_le_bytes()); // link
        out.extend_from_slice(&0u32.to_le_bytes()); // info
        out.extend_from_slice(&1u64.to_le_bytes()); // addralign
        out.extend_from_slice(&0u64.to_le_bytes()); // entsize
    };
    section(0, 0, 0, 0); // SHT_NULL
    section(name_shstrtab, 3, names_off, names.len() as u64); // .shstrtab
    if !payload.is_empty() {
        section(name_section, 1, payload_off, payload.len() as u64); // PROGBITS
    }
    out
}

/// A path that is not a build thing must be refused with a reason — a forge pulling
/// from a registry meets garbage sooner or later.
#[test]
fn a_file_that_is_not_a_build_thing_is_refused_with_a_reason() {
    let dir = tempfile::tempdir().unwrap();
    let junk = dir.path().join("not-wasm");
    std::fs::write(&junk, b"this is not a wasm module").unwrap();
    let err = PluginHandle::load_file(&junk, Limits::default()).unwrap_err();
    let msg = format!("{err:#}");
    assert!(
        msg.contains("core wasm32 module") || msg.contains("parse"),
        "{msg}"
    );
}