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/config_names_the_reader.rs

//! **The bar: `ron_handler = "skidbladnir"` must mean the same thing whether
//! that handler is linked into holger or loaded from a `.wasm` in a directory.**
//!
//! That indistinguishability is the whole design. A repository config names the
//! *container* (`ron_repo_type` / `ron_upstream_format` → `ArtifactFormat`) and
//! now names the *reader* (`ron_handler`); the payoff is that a new reader is a
//! string in the config plus a module in the plugin directory, with **no holger
//! rebuild**. A test that only exercised the linked path would prove half of it
//! and leave the half that carries the payoff unasserted.
//!
//! So every assertion below is made against BOTH, in a loop, with no branch on
//! which kind is answering — and the two are wired from the same
//! `holger_server_lib::repository::Repository`, through
//! `backend_from_config_with`, the way boot does it.
//!
//! `server/lib/src/repository.rs` carries the linked-only half (the red/green
//! against the generic znippy backend, the typo, and the absent field); this
//! file is the module half plus the two-together claim.

// Loads real modules through the wasm host: without `wasm-plugins` this file
// did not compile under `cargo test -p holger-plugin-host` and only ran in a
// workspace build through feature unification. Its declared arm turns it on.

use std::path::{Path, PathBuf};

use holger_plugin_host::{HandlerCatalog, Origin};
use holger_server_lib::handlers::builtin_catalog;
use holger_server_lib::repository::Repository;
use traits::{ArtifactFormat, ArtifactId};

mod common;
use common::{plugin_dir_with_modules, scratch};

const SKID_MODULE: &str = "holger_handler_skidbladnir_wasm.wasm";

/// The bytes a bundle is served as. A NUL, a comma, a colon and a `0xff` —
/// nothing that survives a lossy round trip by accident.
const BUNDLE: &[u8] = b"\x00sealed,bundle:bytes\xff";

/// A repository config that names a reader for its container. Written field by
/// field rather than parsed from RON so the compiler catches a renamed field —
/// but it is the same struct `read_ron_config` deserializes into.
fn repo_named(name: &str, data_dir: &Path, handler: Option<&str>) -> Repository {
    Repository {
        ron_name: name.to_string(),
        // This repository demands no credential of its own; the server-wide
        // posture decides. The default, and what every config here means.
        ron_require_auth: false,
        ron_require_role: None,
        ron_visibility: Default::default(),
        // The CONTAINER. Unchanged, and deliberately still meaningful.
        ron_repo_type: "znippy".to_string(),
        ron_upstreams: vec![],
        ron_in: None,
        ron_out: None,
        ron_members: vec![],
        ron_archive_path: None,
        ron_data_dir: Some(data_dir.to_string_lossy().into_owned()),
        ron_base_url: None,
        ron_writable_archive: None,
        ron_upstream_repo: None,
        ron_upstream_format: Some("znippy".to_string()),
        // The READER.
        ron_handler: handler.map(str::to_string),
        ron_upstream_auth: None,
        ron_trusted_roots: None,
        ron_negative_cache_secs: None,
        backend_repository: None,
        // Not an upstream, so nothing publishes a digest to hold it against.
        upstream_attestation: None,
        wired_upstreams: vec![],
        wired_members: vec![],
    }
}

fn seeded_store(tag: &str) -> PathBuf {
    let dir = scratch(tag);
    std::fs::write(dir.join("tillsynia-20260706.znippy"), BUNDLE).unwrap();
    dir
}

/// A catalog holding ONLY the loaded module — no linked handler under that name,
/// so a green here cannot be the linked one answering.
fn loaded_only() -> HandlerCatalog {
    let plugins = plugin_dir_with_modules(&[SKID_MODULE]);
    let mut c = HandlerCatalog::new();
    let rejected = c
        .load_dir(&plugins)
        .unwrap_or_else(|e| panic!("loading {}: {e}", plugins.display()));
    assert!(rejected.is_empty(), "modules rejected: {rejected:?}");
    assert_eq!(
        c.accepted(),
        vec!["skidbladnir".to_string()],
        "the module did not register under the name its own manifest declares"
    );
    assert!(
        matches!(c.origin("skidbladnir"), Some(Origin::Loaded(_))),
        "this catalog must hold the LOADED half only"
    );
    c
}

/// A catalog holding ONLY the linked handler — nothing scanned.
fn linked_only() -> HandlerCatalog {
    let c = builtin_catalog().expect("builtin catalog");
    assert_eq!(c.origin("skidbladnir"), Some(Origin::Linked));
    c
}

/// **The claim.** One config string, two utterly different mechanisms behind
/// it, and every answer identical — asserted on served bytes and served
/// responses, never on a state round-trip.
#[test]
fn one_ron_handler_string_resolves_to_a_linked_handler_and_to_a_loaded_module_alike() {
    let id = ArtifactId {
        namespace: None,
        name: "tillsynia".into(),
        version: "20260706".into(),
    };

    for (kind, catalog) in [("linked", linked_only()), ("loaded", loaded_only())] {
        let store = seeded_store(&format!("store-{kind}"));
        let mut repo = repo_named("bundles", &store, Some("skidbladnir"));
        repo.backend_from_config_with(&catalog)
            .unwrap_or_else(|e| panic!("{kind}: ron_handler = skidbladnir did not wire: {e:#}"));
        let backend = repo.backend_repository.as_ref().expect("backend built");

        assert_eq!(backend.name(), "bundles", "{kind}");
        assert_eq!(backend.format(), ArtifactFormat::Znippy, "{kind}");
        assert!(backend.is_writable(), "{kind}");

        // The bytes.
        assert_eq!(
            backend.fetch(&id).expect("fetch").as_deref(),
            Some(BUNDLE),
            "{kind}"
        );

        // The HTTP door.
        let (status, headers, body) = backend
            .handle_http2_request("GET", "/bundles/tillsynia/20260706", b"")
            .unwrap_or_else(|e| panic!("{kind}: http: {e:#}"));
        assert_eq!(status, 200, "{kind}");
        assert_eq!(
            body, BUNDLE,
            "{kind}: the HTTP door served different bytes than fetch"
        );
        assert!(
            headers
                .iter()
                .any(|(k, v)| k == "content-type" && v == "application/vnd.znippy.bundle"),
            "{kind}: {headers:?}"
        );

        // The classifier — the route no generic znippy backend has, reached over
        // the ABI in one case and by a direct call in the other.
        let (status, _, body) = backend
            .handle_http2_request(
                "GET",
                "/bundles/tillsynia/20260706/classify/secrets/secrets.age",
                b"",
            )
            .unwrap_or_else(|e| panic!("{kind}: classify: {e:#}"));
        assert_eq!(status, 200, "{kind}");
        assert_eq!(
            String::from_utf8_lossy(&body),
            "kind\tsecret\ncomponent\tsecrets\nencrypted\ttrue",
            "{kind}"
        );

        // A write through the config-selected backend really lands where the
        // operator's data directory points — the handler does not choose.
        let other = ArtifactId {
            namespace: None,
            name: "njord".into(),
            version: "1".into(),
        };
        backend
            .put(&other, b"pushed")
            .unwrap_or_else(|e| panic!("{kind}: put: {e:#}"));
        assert_eq!(
            std::fs::read(store.join("njord-1.znippy")).expect("the put did not reach the store"),
            b"pushed",
            "{kind}"
        );
    }
}

/// A module in the plugin directory is a handler the config may name **without
/// holger being rebuilt** — so the error for a typo must list it too, or an
/// operator cannot discover what a deployment actually offers.
#[test]
fn the_accepted_list_names_loaded_modules_not_only_linked_handlers() {
    let plugins = plugin_dir_with_modules(&["holger_handler_rust_toolchain_wasm.wasm"]);
    let mut catalog = HandlerCatalog::new();
    // Deliberately NOT the builtins: the only handler here arrived as a file.
    catalog.load_dir(&plugins).expect("load_dir");

    let store = seeded_store("accepted");
    let mut repo = repo_named("bundles", &store, Some("rust_toolchain"));
    let err = repo
        .backend_from_config_with(&catalog)
        .expect_err("an underscore typo resolved to the hyphenated handler");
    let chain = format!("{err:#}");
    assert!(
        chain.contains("rust_toolchain"),
        "the error does not name the bad value: {chain}"
    );
    assert!(
        chain.contains("rust-toolchain"),
        "the accepted list omits the handler that came from the plugin directory: {chain}"
    );
    assert!(
        chain.contains(&plugins.display().to_string()),
        "the error does not say WHERE it looked: {chain}"
    );
    assert!(
        repo.backend_repository.is_none(),
        "a rejected handler still wired"
    );
}

/// A configured plugin directory that is not there is a **boot error**, not a
/// catalog that quietly holds fewer handlers than the operator wrote down.
#[test]
fn a_missing_plugin_directory_stops_the_boot() {
    let gone = scratch("gone");
    std::fs::remove_dir_all(&gone).unwrap();

    let mut catalog = builtin_catalog().unwrap();
    let err = catalog
        .load_dir(&gone)
        .expect_err("a missing plugin directory returned Ok");
    let msg = err.to_string();
    assert!(msg.contains(&gone.display().to_string()), "{msg}");
    assert!(
        msg.contains(common::BUILD_CMD),
        "no build command in: {msg}"
    );
}

/// Two handlers cannot share one config string. A module declaring a name a
/// linked handler already holds is REJECTED and reported — and the handler that
/// held the name still serves, so the collision changes nothing silently.
#[test]
fn a_module_cannot_take_a_name_a_linked_handler_already_holds() {
    let plugins = plugin_dir_with_modules(&[SKID_MODULE]);
    let mut catalog = builtin_catalog().unwrap();
    let rejected = catalog.load_dir(&plugins).expect("load_dir");

    assert_eq!(
        rejected.len(),
        1,
        "the colliding module was accepted or silently dropped"
    );
    assert!(rejected[0].path.ends_with(SKID_MODULE));
    assert!(
        rejected[0].reason.contains("already registered"),
        "the rejection does not say why: {}",
        rejected[0].reason
    );
    assert_eq!(
        catalog.origin("skidbladnir"),
        Some(Origin::Linked),
        "the module replaced the linked handler that held the name"
    );

    // And the name still serves.
    let store = seeded_store("collision");
    let mut repo = repo_named("bundles", &store, Some("skidbladnir"));
    repo.backend_from_config_with(&catalog).expect("wire");
    let id = ArtifactId {
        namespace: None,
        name: "tillsynia".into(),
        version: "20260706".into(),
    };
    assert_eq!(
        repo.backend_repository
            .unwrap()
            .fetch(&id)
            .expect("fetch")
            .as_deref(),
        Some(BUNDLE)
    );
}