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

//! **Which handler READS the container.**
//!
//! A repository config already names the container:
//!
//! ```text
//! ron_upstream_format = "znippy"     the container
//! ```
//!
//! and `server/lib/src/repository.rs` maps that string onto
//! `ArtifactFormat::Znippy`. Two handlers that both land on `Znippy` — a
//! Skidbladnir deploy bundle and a Rust-toolchain bundle are both sealed
//! `.znippy` files — cannot be told apart from there. znippy *is* a container:
//! compressed files in a directory, sometimes with an extra index and extra
//! functionality. What the config could not say is **who reads the extra index**.
//!
//! ```text
//! ron_handler         = "skidbladnir"   the reader; empty = generic znippy
//! ```
//!
//! This catalog is the lookup behind that string, and its whole job is that the
//! string does not care which kind of handler answers it:
//!
//! ```text
//!   register_linked(SkidbladnirHandler::new) ──┐
//!                                              ├─► "skidbladnir" ─► HandlerBackend
//!   load_dir("/var/lib/holger/plugins") ───────┘
//! ```
//!
//! A **linked** handler names itself from its own [`PackageHandler::manifest`];
//! a **loaded** `.wasm` names itself from the manifest it reports over the ABI.
//! Neither name is invented by the registrar, which is what makes the two
//! interchangeable under one config string — and is why a new handler is a
//! string in the config plus a `.wasm` in the directory, with no holger rebuild.
//!
//! # An unknown name is a boot error
//!
//! The precedent is `ron_upstream_format` in the same config file: a value that
//! is present but unparseable hard-fails, because collapsing it with
//! `.unwrap_or(default)` meant a typo silently built a repository with the wrong
//! shape and every coordinate 404'd with no warning at boot. Same here. The
//! error lists what *is* accepted — including every handler found in the plugin
//! directory, which is the half an operator cannot read off the source.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use holger_plugin_abi::PackageHandler;
// Only the probe store (a loaded module's manifest read) implements this, and
// that exists only where the wasm host does.
#[cfg(feature = "wasm-plugins")]
use holger_plugin_abi::BlobStore;

use crate::backend::{HandlerBackend, HandlerRuntime, LinkedHandler, SharedStore};
use crate::registry::ScanError;
use crate::store::DirStore;

/// Builds one runtime over a caller-supplied store. A handler is mounted once
/// per repository that names it, so this is a factory rather than an instance.
type LinkedMount = Box<dyn Fn(SharedStore) -> Box<dyn HandlerRuntime> + Send + Sync>;

/// Where a catalog entry came from. Carried only so an operator-facing listing
/// can say *linked* or name the file — never consulted when mounting, because a
/// caller that branched on it would be re-introducing the distinction this
/// design exists to remove.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Origin {
    /// Compiled into this holger.
    Linked,
    /// Loaded from this `.wasm`.
    Loaded(PathBuf),
}

impl std::fmt::Display for Origin {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Origin::Linked => write!(f, "linked"),
            Origin::Loaded(p) => write!(f, "loaded from {}", p.display()),
        }
    }
}

enum Entry {
    Linked(LinkedMount),
    #[cfg(feature = "wasm-plugins")]
    Loaded(Box<crate::wasm::WasmHandler>),
}

impl Entry {
    fn origin(&self) -> Origin {
        match self {
            Entry::Linked(_) => Origin::Linked,
            #[cfg(feature = "wasm-plugins")]
            Entry::Loaded(m) => Origin::Loaded(m.path().to_path_buf()),
        }
    }

    fn runtime(&self, store: SharedStore) -> Box<dyn HandlerRuntime> {
        match self {
            Entry::Linked(mount) => mount(store),
            #[cfg(feature = "wasm-plugins")]
            Entry::Loaded(module) => module.with_store(store).into_runtime(),
        }
    }
}

/// One module in a plugin directory that could not join the catalog. Reported,
/// never dropped: a broken or duplicate module is an operator error that must be
/// visible, and the modules beside it still register.
#[derive(Debug)]
pub struct RejectedModule {
    pub path: PathBuf,
    pub reason: String,
}

impl std::fmt::Display for RejectedModule {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}: {}", self.path.display(), self.reason)
    }
}

/// Why a configured `ron_handler` could not be honoured.
#[derive(Debug)]
pub enum HandlerError {
    /// No handler answers to that name. Carries the full accepted set so the
    /// message can be acted on without reading the source or the directory.
    Unknown {
        requested: String,
        accepted: Vec<String>,
        searched: Vec<PathBuf>,
    },
    /// The handler exists but declared something holger cannot mount (an
    /// unknown `ArtifactFormat`, say).
    Unmountable { handler: String, reason: String },
}

impl std::fmt::Display for HandlerError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            HandlerError::Unknown {
                requested,
                accepted,
                searched,
            } => {
                write!(f, "unknown ron_handler '{requested}'. ")?;
                if accepted.is_empty() {
                    write!(f, "This holger has NO package handlers at all")?;
                } else {
                    write!(f, "Accepted: {}", accepted.join(", "))?;
                }
                if searched.is_empty() {
                    write!(
                        f,
                        " (no plugin directory is configured, so only linked handlers are \
                         available). Leave ron_handler empty for the generic znippy backend."
                    )
                } else {
                    let dirs: Vec<String> =
                        searched.iter().map(|d| d.display().to_string()).collect();
                    write!(
                        f,
                        " — linked handlers plus every .wasm in {}. Build a module with:\n    \
                         cargo xtask build-wasm-plugins\n\
                         Leave ron_handler empty for the generic znippy backend.",
                        dirs.join(", ")
                    )
                }
            }
            HandlerError::Unmountable { handler, reason } => {
                write!(f, "package handler '{handler}' cannot be mounted: {reason}")
            }
        }
    }
}

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

/// Every package handler this holger can put behind a `ron_handler` string.
///
/// Built once at startup and consulted per repository, so the plugin directory
/// is read — and each module compiled — exactly once no matter how many
/// repositories name a handler.
#[derive(Default)]
pub struct HandlerCatalog {
    entries: BTreeMap<String, Entry>,
    searched: Vec<PathBuf>,
    /// One line per loaded module saying how it stood against the anti-rollback
    /// floor — "first activation sets the floor", "raises the floor", or the
    /// NAMED absence of a floor policy. Never dropped: the server logs them.
    floor_notes: Vec<String>,
}

impl HandlerCatalog {
    pub fn new() -> Self {
        Self::default()
    }

    /// Register a handler compiled into this holger.
    ///
    /// `make` is called once immediately to read the handler's own manifest —
    /// **the name is the handler's, not the caller's**. A registrar that passed
    /// a name in could mount `SkidbladnirHandler` under `"rust-toolchain"`, and
    /// the config string would then mean two different things depending on
    /// whether it resolved to a linked handler or a module.
    ///
    /// Returns the registered name. A second handler claiming a name already
    /// held is an `Err` rather than a silent replacement.
    pub fn register_linked<H, F>(&mut self, make: F) -> Result<String, String>
    where
        H: PackageHandler + Send + Sync + 'static,
        F: Fn() -> H + Send + Sync + 'static,
    {
        let name = make().manifest().handler;
        if name.trim().is_empty() {
            return Err("a linked handler declared an empty name in its manifest".to_string());
        }
        if let Some(existing) = self.entries.get(&name) {
            return Err(format!(
                "handler name '{name}' is already registered ({}); one config string cannot \
                 mean two handlers",
                existing.origin()
            ));
        }
        let mount: LinkedMount = Box::new(move |store| Box::new(LinkedHandler::new(make(), store)));
        self.entries.insert(name.clone(), Entry::Linked(mount));
        Ok(name)
    }

    /// Read a plugin directory and register every `.wasm` in it under the name
    /// its own manifest declares.
    ///
    /// A directory that is missing, unreadable or a file is a [`ScanError`] —
    /// **never an empty catalog**. A directory that exists and holds no module
    /// is `Ok(vec![])`, which is a different answer, and the caller can tell
    /// them apart because only one of them is an `Err`.
    ///
    /// The returned rejections are modules that were present and did not
    /// register. They are not an error (the modules beside them still serve) and
    /// they must not be discarded — they are the only report a broken module
    /// makes.
    #[cfg(feature = "wasm-plugins")]
    pub fn load_dir(&mut self, dir: impl AsRef<Path>) -> Result<Vec<RejectedModule>, ScanError> {
        self.load_dir_with(dir, &crate::PluginPolicy::default())
    }

    /// [`HandlerCatalog::load_dir`] under an explicit policy.
    ///
    /// The two kinds of failure are kept apart on purpose. A module that fails
    /// the TRUST half is a [`ScanError::UntrustedModule`] and stops the boot: a
    /// plugin directory holding something that cannot prove its provenance is not
    /// a directory to serve from. A module that merely fails to LOAD is a
    /// [`RejectedModule`] as before — the handlers beside it are unaffected and
    /// still honest about what they are.
    #[cfg(feature = "wasm-plugins")]
    pub fn load_dir_with(
        &mut self,
        dir: impl AsRef<Path>,
        policy: &crate::PluginPolicy,
    ) -> Result<Vec<RejectedModule>, ScanError> {
        let dir = dir.as_ref().to_path_buf();
        let modules = crate::registry::module_paths(&dir)?;
        self.searched.push(dir);

        let mut rejected = Vec::new();
        for path in modules {
            crate::registry::trust_gate_pub(&path, policy)?;
            // Probed with a store that holds nothing and refuses writes: reading
            // a manifest must never touch — let alone create — a repository's
            // bytes. The real store is bound at mount time.
            // A plugin directory holds guests of more than one KIND. A module
            // exporting `plugin_abi` is an ABI v2 guest and belongs to its own
            // catalog; loading it here would fail on the v1 export set and be
            // reported as a broken handler, which it is not. Skipped by name, so
            // a directory listing stays explicable, and never silently.
            if is_v2_guest(&path) {
                eprintln!(
                    "{}: an ABI v2 guest, not a package handler — left to its own catalog",
                    path.display()
                );
                continue;
            }
            let probe: SharedStore = Arc::new(ProbeStore);
            match crate::wasm::WasmHandler::load_with(&path, probe, policy) {
                Ok(module) => {
                    // ── W14: the anti-rollback floor ──────────────────────────
                    // Placed AFTER the manifest is read (the identity is inside
                    // it, and so inside the signed bytes) and BEFORE the module
                    // is registered: a module below its lane's floor is a boot
                    // stop, not a rejected module, because a rollback that
                    // merely shortened the catalog would be served as a 404.
                    let admission = match policy.floor.store() {
                        Some(store) => store
                            .admit(&path, module.manifest().identity.as_ref())
                            .map_err(|reason| ScanError::RolledBack {
                                path: path.clone(),
                                reason,
                            })?,
                        None => crate::floor::Admission::NotKept {
                            module: path.display().to_string(),
                        },
                    };
                    let name = module.manifest().handler.clone();
                    if let Some(existing) = self.entries.get(&name) {
                        rejected.push(RejectedModule {
                            path,
                            reason: format!(
                                "declares handler name '{name}', which is already registered \
                                 ({}). One config string cannot mean two handlers, so this \
                                 module is NOT registered and the existing one still serves.",
                                existing.origin()
                            ),
                        });
                        continue;
                    }
                    // ACTIVATION: the module is in the catalog, so the floor
                    // is raised now — durably, staging + rename — and the note
                    // is kept for the server to log.
                    if let (Some(store), Some(identity)) =
                        (policy.floor.store(), admission.identity())
                    {
                        store.activate(&path, identity).map_err(|reason| {
                            ScanError::RolledBack {
                                path: path.clone(),
                                reason,
                            }
                        })?;
                    }
                    eprintln!("{}: {admission}", path.display());
                    self.floor_notes
                        .push(format!("{}: {admission}", path.display()));
                    self.entries.insert(name, Entry::Loaded(Box::new(module)));
                }
                Err(reason) => rejected.push(RejectedModule { path, reason }),
            }
        }
        Ok(rejected)
    }

    /// Fail-closed stand-in for a build without the wasm host. A configured
    /// plugin directory that cannot be read is louder than a catalog that
    /// quietly holds only the linked handlers.
    #[cfg(not(feature = "wasm-plugins"))]
    pub fn load_dir(&mut self, dir: impl AsRef<Path>) -> Result<Vec<RejectedModule>, ScanError> {
        Err(ScanError::NoWasmHost {
            dir: dir.as_ref().to_path_buf(),
        })
    }

    /// Fail-closed twin of [`HandlerCatalog::load_dir_with`] for a build without
    /// the wasm host. The policy is irrelevant: there is nothing to apply it to.
    #[cfg(not(feature = "wasm-plugins"))]
    pub fn load_dir_with(
        &mut self,
        dir: impl AsRef<Path>,
        _policy: &crate::PluginPolicy,
    ) -> Result<Vec<RejectedModule>, ScanError> {
        self.load_dir(dir)
    }

    /// Every accepted `ron_handler` value, sorted.
    pub fn accepted(&self) -> Vec<String> {
        self.entries.keys().cloned().collect()
    }

    /// How each loaded module stood against the anti-rollback floor, in load order.
    pub fn floor_notes(&self) -> &[String] {
        &self.floor_notes
    }

    /// Where a handler came from, or `None` if the name is not registered.
    pub fn origin(&self, handler: &str) -> Option<Origin> {
        self.entries.get(handler).map(Entry::origin)
    }

    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Mount `handler` at repository `repo_name` over `store`.
    ///
    /// The repository name and the storage are the operator's; a handler never
    /// picks where it is served from or where it writes.
    pub fn mount(
        &self,
        handler: &str,
        repo_name: &str,
        store: SharedStore,
    ) -> Result<HandlerBackend, HandlerError> {
        let entry = self
            .entries
            .get(handler)
            .ok_or_else(|| HandlerError::Unknown {
                requested: handler.to_string(),
                accepted: self.accepted(),
                searched: self.searched.clone(),
            })?;
        HandlerBackend::new(repo_name, entry.runtime(store)).map_err(|reason| {
            HandlerError::Unmountable {
                handler: handler.to_string(),
                reason,
            }
        })
    }

    /// [`HandlerCatalog::mount`] over a directory store — what a repository
    /// config's data directory resolves to.
    pub fn mount_dir(
        &self,
        handler: &str,
        repo_name: &str,
        data_dir: impl AsRef<Path>,
    ) -> Result<HandlerBackend, HandlerError> {
        self.mount(
            handler,
            repo_name,
            Arc::new(DirStore::new(data_dir.as_ref().to_path_buf())),
        )
    }
}

/// The store a module is probed with while the catalog learns its name.
///
/// Holds nothing and refuses every write. A manifest read is identity, not I/O;
/// a module that tried to write during one would otherwise create files in
/// whichever repository happened to be probed first.
///
/// Only a loaded module is probed — a linked handler's manifest is a direct
/// call — so this exists only where the wasm host does.
#[cfg(feature = "wasm-plugins")]
struct ProbeStore;

#[cfg(feature = "wasm-plugins")]
impl BlobStore for ProbeStore {
    fn get(&self, _key: &str) -> Result<Option<Vec<u8>>, String> {
        Ok(None)
    }

    fn put(&self, key: &str, _data: &[u8]) -> Result<(), String> {
        Err(format!(
            "{key}: this module is being probed for its manifest and has no store bound yet"
        ))
    }

    fn list(&self, _prefix: &str) -> Result<Vec<(String, u64)>, String> {
        Ok(Vec::new())
    }
}

/// Does this module export `plugin_abi`, and is it therefore an ABI v2 guest?
///
/// Read from the compiled module's export table rather than by searching the
/// bytes for a string: a name in the data section is not an export, and a
/// classification that can be spoofed by a string literal is not one.
///
/// A module that cannot be compiled at all answers `false` and goes on to the
/// package-handler loader, which will refuse it by name — the classification
/// must not be where a broken module disappears.
#[cfg(feature = "wasm-plugins")]
fn is_v2_guest(path: &Path) -> bool {
    let Ok(bytes) = std::fs::read(path) else {
        return false;
    };
    let Ok(engine) = wasmtime::Engine::new(&wasmtime::Config::new()) else {
        return false;
    };
    let Ok(module) = wasmtime::Module::from_binary(&engine, &bytes) else {
        return false;
    };
    let found = module
        .exports()
        .any(|e| e.name() == holger_plugin_abi::v2::exports::PLUGIN_ABI);
    found
}

#[cfg(test)]
mod tests {
    use super::*;
    use holger_plugin_abi::{
        BlobStore, WireArtifactEntry, WireArtifactId, WireHttpRequest, WireHttpResponse,
        WireManifest, ABI_VERSION,
    };
    // Asserted THROUGH the router's trait, not through inherent accessors: what
    // matters is what a mounted handler answers as a `RepositoryBackendTrait`.
    use traits::RepositoryBackendTrait;

    /// A handler with a name and format chosen by the test, so the assertions
    /// below cannot pass by coincidence with a real handler's constants.
    struct Named(&'static str, &'static str);

    impl PackageHandler for Named {
        fn manifest(&self) -> WireManifest {
            WireManifest {
                abi_version: ABI_VERSION,
                handler: self.0.to_string(),
                format: self.1.to_string(),
                writable: false,
                identity: None,
            }
        }
        fn fetch(
            &self,
            store: &dyn BlobStore,
            id: &WireArtifactId,
        ) -> Result<Option<Vec<u8>>, String> {
            store.get(&id.name)
        }
        fn put(
            &self,
            store: &dyn BlobStore,
            id: &WireArtifactId,
            data: &[u8],
        ) -> Result<(), String> {
            store.put(&id.name, data)
        }
        fn list(
            &self,
            _store: &dyn BlobStore,
            _f: Option<&str>,
            _l: usize,
        ) -> Result<Vec<WireArtifactEntry>, String> {
            Ok(Vec::new())
        }
        fn coordinate_for_path(&self, _s: &str) -> Option<WireArtifactId> {
            None
        }
        fn http(
            &self,
            _store: &dyn BlobStore,
            _r: &WireHttpRequest,
        ) -> Result<WireHttpResponse, String> {
            Ok(WireHttpResponse {
                status: 204,
                headers: Vec::new(),
                body: Vec::new(),
            })
        }
    }

    fn tmp(name: &str) -> PathBuf {
        let p = std::env::temp_dir().join(format!("holger-catalog-{name}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&p);
        std::fs::create_dir_all(&p).unwrap();
        p
    }

    /// The name comes from the handler's manifest, not from the registrar.
    #[test]
    fn a_linked_handler_registers_under_its_own_manifest_name() {
        let mut c = HandlerCatalog::new();
        assert_eq!(
            c.register_linked(|| Named("skidbladnir", "znippy"))
                .unwrap(),
            "skidbladnir"
        );
        assert_eq!(c.accepted(), vec!["skidbladnir".to_string()]);
        assert_eq!(c.origin("skidbladnir"), Some(Origin::Linked));
        let b = c
            .mount_dir("skidbladnir", "bundles", tmp("mount"))
            .expect("mount");
        assert_eq!(
            b.name(),
            "bundles",
            "the repository name is the operator's, not the handler's"
        );
        assert_eq!(b.handler_name(), "skidbladnir");
    }

    /// The condition this module exists for: a typo must not fall back.
    #[test]
    fn an_unknown_name_is_an_error_that_lists_what_is_accepted() {
        let mut c = HandlerCatalog::new();
        c.register_linked(|| Named("skidbladnir", "znippy"))
            .unwrap();
        c.register_linked(|| Named("rust-toolchain", "znippy"))
            .unwrap();

        let err = c
            .mount_dir("skidbladnr", "bundles", tmp("typo"))
            .expect_err("a typo'd handler name resolved to something");
        assert!(matches!(err, HandlerError::Unknown { .. }), "{err:?}");
        let msg = err.to_string();
        assert!(
            msg.contains("skidbladnr"),
            "the error does not name what was asked for: {msg}"
        );
        assert!(
            msg.contains("skidbladnir"),
            "the error does not list what IS accepted: {msg}"
        );
        assert!(
            msg.contains("rust-toolchain"),
            "the accepted list is incomplete: {msg}"
        );
    }

    /// Two handlers under one name would make the config string ambiguous.
    #[test]
    fn a_second_handler_cannot_take_a_name_already_held() {
        let mut c = HandlerCatalog::new();
        c.register_linked(|| Named("skidbladnir", "znippy"))
            .unwrap();
        let err = c
            .register_linked(|| Named("skidbladnir", "raw"))
            .expect_err("a duplicate name was accepted");
        assert!(err.contains("already registered"), "{err}");
        // And the FIRST one still serves — the collision did not replace it.
        // (`znippy` vs `raw` is what tells them apart; the name alone cannot.)
        assert_eq!(
            c.mount_dir("skidbladnir", "r", tmp("dup"))
                .unwrap()
                .format(),
            traits::ArtifactFormat::Znippy,
            "the duplicate replaced the handler already holding the name"
        );
    }

    /// A handler declaring a format holger does not know is refused by name,
    /// never mounted as `Raw`.
    #[test]
    fn a_handler_declaring_an_unknown_format_is_refused_by_name() {
        let mut c = HandlerCatalog::new();
        c.register_linked(|| Named("odd", "not-a-format")).unwrap();
        let err = c
            .mount_dir("odd", "r", tmp("badfmt"))
            .expect_err("an unknown format mounted");
        assert!(matches!(err, HandlerError::Unmountable { .. }), "{err:?}");
        assert!(err.to_string().contains("not-a-format"), "{err}");
    }

    /// An empty catalog says so rather than naming an empty accepted list.
    #[test]
    fn an_empty_catalog_says_it_has_no_handlers() {
        let c = HandlerCatalog::new();
        assert!(c.is_empty());
        let msg = c
            .mount_dir("skidbladnir", "r", tmp("none"))
            .unwrap_err()
            .to_string();
        assert!(msg.contains("NO package handlers"), "{msg}");
    }

    /// The probe store must not be a place a module can write to.
    #[cfg(feature = "wasm-plugins")]
    #[test]
    fn the_probe_store_refuses_writes_and_holds_nothing() {
        let p = ProbeStore;
        assert_eq!(p.get("anything").unwrap(), None);
        assert_eq!(p.list("").unwrap(), Vec::new());
        assert!(
            p.put("x", b"bytes").is_err(),
            "a probe store accepted a write"
        );
    }

    /// A configured plugin directory that does not exist is an error, not an
    /// empty catalog — in BOTH builds. Without the wasm host the reason differs,
    /// but it is still an `Err`.
    #[test]
    fn a_missing_plugin_directory_is_an_error_not_an_empty_catalog() {
        let mut c = HandlerCatalog::new();
        let gone = tmp("gone");
        std::fs::remove_dir_all(&gone).unwrap();
        let err = c
            .load_dir(&gone)
            .expect_err("a missing plugin dir returned Ok");
        #[cfg(feature = "wasm-plugins")]
        assert!(matches!(err, ScanError::Missing { .. }), "{err:?}");
        #[cfg(not(feature = "wasm-plugins"))]
        assert!(matches!(err, ScanError::NoWasmHost { .. }), "{err:?}");
        assert!(
            err.to_string().contains(&gone.display().to_string()),
            "{err}"
        );
    }
}