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

//! **Registration reads a directory, not `server/lib/Cargo.toml`.**
//!
//! That is the whole point of the lane. Today holger's sixteen repository
//! backends are unconditional path dependencies of `server/lib`
//! (`server/lib/Cargo.toml:21-51`) — none optional, none feature-gated — so
//! adding a package handler means editing a manifest and rebuilding the server.
//! A module dropped into the plugin directory is registered on the next start
//! with no rebuild at all.
//!
//! # Absence is loud
//!
//! A plugin directory that is missing, unreadable, or a file rather than a
//! directory is a named [`ScanError`], never an empty list. This tree's disease
//! is the check that returns `true` because it never ran; a plugin loader that
//! answered "no plugins" to a typo'd path would be the same shape, and would be
//! discovered as a 404 in production rather than as an error at boot.
//!
//! A directory that exists and genuinely holds no `.wasm` is a different
//! answer — an empty registry with `scanned` pointing at the real path — and the
//! caller can tell the two apart because only one of them is an `Err`.

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

use crate::backend::{HandlerBackend, SharedStore};
#[cfg(feature = "wasm-plugins")]
use crate::wasm::WasmHandler;

/// Extension a package-handler module must carry.
pub const MODULE_EXT: &str = "wasm";

/// Why a plugin directory could not be scanned at all. Distinct from a single
/// module failing to load, which leaves the other modules usable.
#[derive(Debug)]
pub enum ScanError {
    /// The path does not exist.
    Missing { dir: PathBuf },
    /// The path exists but is not a directory.
    NotADirectory { dir: PathBuf },
    /// The directory could not be read.
    Unreadable {
        dir: PathBuf,
        source: std::io::Error,
    },
    /// A plugin directory was configured, but this build carries no wasm host
    /// (`wasm-plugins` off). Loud rather than "zero modules found": a tier that
    /// cannot load a module must not report the same thing as a tier that
    /// looked and found none.
    NoWasmHost { dir: PathBuf },
    /// A module in the directory did not satisfy the configured trust policy —
    /// unsigned, badly signed, signed by somebody this holger does not trust, or
    /// unverifiable in this build.
    ///
    /// A `ScanError` and not a [`LoadFailure`] **on purpose**: a broken module
    /// leaves the others serving, but a module that fails PROVENANCE means the
    /// plugin directory is not what the operator thinks it is, and carrying on
    /// with a quietly smaller catalog turns a trust failure into a 404 in
    /// production. Boot stops here instead.
    UntrustedModule { path: PathBuf, reason: String },
    /// A module stood BELOW this box's anti-rollback floor for its `(supplier,
    /// product)`, declared no identity the floor could place, or the floor file
    /// itself could not be read. A boot stop for the same reason as
    /// `UntrustedModule`: a correctly signed old module is a rollback, and a
    /// catalog that quietly served it would be the whole hole W14 closes.
    RolledBack { path: PathBuf, reason: String },
}

impl std::fmt::Display for ScanError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ScanError::Missing { dir } => write!(
                f,
                "plugin directory {} does not exist. holger was told to load package handlers \
                 from it, so an empty registry here would be a silent \"no such artifact\" on \
                 every request. Create it, or remove the plugin-directory setting.\n\
                 Build a module with:\n    cargo xtask build-wasm-plugins",
                dir.display()
            ),
            ScanError::NotADirectory { dir } => {
                write!(f, "plugin path {} is not a directory", dir.display())
            }
            ScanError::Unreadable { dir, source } => {
                write!(
                    f,
                    "plugin directory {} could not be read: {source}",
                    dir.display()
                )
            }
            ScanError::NoWasmHost { dir } => write!(
                f,
                "plugin directory {} is configured, but this holger was built WITHOUT the \
                 `wasm-plugins` feature, so no .wasm package handler can be loaded from it. \
                 Rebuild with `--features wasm-plugins`, or remove the plugin-directory setting.",
                dir.display()
            ),
            ScanError::UntrustedModule { path, reason } => write!(
                f,
                "{} did not satisfy this holger's package-handler trust policy: {reason}\n\
                 The module is NOT skipped — a plugin directory holding a module that cannot \
                 prove its provenance is refused at boot, because a catalog silently missing \
                 one handler shows up as a 404 on a repository the operator can see in the \
                 RON file.",
                path.display()
            ),
            ScanError::RolledBack { path, reason } => write!(
                f,
                "{} was refused by this holger's anti-rollback floor: {reason}\n\
                 The module is NOT skipped — a module that stands below the highest version \
                 this box ever activated is a rollback however well it is signed, and the boot \
                 stops here rather than serving it.",
                path.display()
            ),
        }
    }
}

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

/// One module that was found but could not be used. Kept rather than dropped:
/// a broken module in the directory is an operator error that must be visible,
/// and the modules beside it still load.
#[derive(Debug)]
pub struct LoadFailure {
    pub path: PathBuf,
    pub reason: String,
}

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

/// The result of scanning one plugin directory.
#[derive(Debug)]
pub struct Registry {
    /// The directory actually read — echoed back so a log line names the real
    /// path rather than the configured one.
    pub scanned: PathBuf,
    /// Handlers that loaded, ready to be attached to the route table.
    pub backends: Vec<HandlerBackend>,
    /// Modules that were present and failed. **Never empty-and-ignored**: a
    /// caller that drops this is dropping the only report a broken plugin makes.
    pub failures: Vec<LoadFailure>,
}

impl Registry {
    /// True when the directory held no `.wasm` at all — as distinct from a
    /// directory that could not be read, which is a [`ScanError`].
    pub fn is_empty(&self) -> bool {
        self.backends.is_empty() && self.failures.is_empty()
    }

    /// A one-line summary for a startup log.
    pub fn summary(&self) -> String {
        format!(
            "{}: {} handler(s) loaded, {} failed",
            self.scanned.display(),
            self.backends.len(),
            self.failures.len()
        )
    }
}

/// The default mount policy: the repository is named after the module file's
/// stem, and its store is `<data_root>/<stem>`.
///
/// Naming the repository after the **file** rather than the handler is
/// deliberate — two copies of the same handler serving two different bundle
/// stores is an ordinary deployment, and keying on the handler name would make
/// the second one silently replace the first in the route table.
pub struct FileStemMounts {
    pub data_root: PathBuf,
}

impl FileStemMounts {
    /// Repository name for a module found at `path` with handler name `handler`.
    pub fn repo_name(&self, path: &Path, handler: &str) -> String {
        path.file_stem()
            .map(|s| s.to_string_lossy().into_owned())
            .unwrap_or_else(|| handler.to_string())
    }

    /// The store that module gets.
    pub fn store(&self, path: &Path, handler: &str) -> SharedStore {
        std::sync::Arc::new(crate::store::DirStore::new(
            self.data_root.join(self.repo_name(path, handler)),
        ))
    }
}

/// Every `.wasm` in `dir`, sorted — the one directory walk both
/// [`scan`] and [`crate::catalog::HandlerCatalog::load_dir`] read a plugin
/// directory through, so "what counts as a module here" is decided once.
///
/// The three ways a directory can be unusable are three distinct errors;
/// `read_dir` alone reports "not found" for all of them.
pub fn module_paths(dir: impl AsRef<Path>) -> Result<Vec<PathBuf>, ScanError> {
    let dir = dir.as_ref().to_path_buf();

    if !dir.exists() {
        return Err(ScanError::Missing { dir });
    }
    if !dir.is_dir() {
        return Err(ScanError::NotADirectory { dir });
    }
    let rd = std::fs::read_dir(&dir).map_err(|source| ScanError::Unreadable {
        dir: dir.clone(),
        source,
    })?;

    let mut modules: Vec<PathBuf> = Vec::new();
    for entry in rd {
        let entry = match entry {
            Ok(e) => e,
            Err(source) => return Err(ScanError::Unreadable { dir, source }),
        };
        let path = entry.path();
        if path.is_file() && path.extension().is_some_and(|e| e == MODULE_EXT) {
            modules.push(path);
        }
    }
    // Deterministic order, so a name collision resolves the same way on every
    // start instead of following the filesystem's iteration order.
    modules.sort();
    Ok(modules)
}

/// Scan `dir` for `.wasm` package handlers and load each one under the default
/// policy — bounded execution, no provenance requirement.
#[cfg(feature = "wasm-plugins")]
pub fn scan(dir: impl AsRef<Path>, mounts: &FileStemMounts) -> Result<Registry, ScanError> {
    scan_with(dir, mounts, &crate::PluginPolicy::default())
}

/// [`scan`] under an explicit policy.
///
/// A module that fails the TRUST half is a [`ScanError::UntrustedModule`] — the
/// whole scan stops. A module that merely fails to load stays a [`LoadFailure`],
/// because the modules beside it are still serving and still honest.
#[cfg(feature = "wasm-plugins")]
pub fn scan_with(
    dir: impl AsRef<Path>,
    mounts: &FileStemMounts,
    policy: &crate::PluginPolicy,
) -> Result<Registry, ScanError> {
    let dir = dir.as_ref().to_path_buf();
    let modules = module_paths(&dir)?;

    let mut backends = Vec::new();
    let mut failures = Vec::new();
    for path in modules {
        // The store must be known before load, because the module can call back
        // into it during `plugin_manifest`. The handler name is not known yet,
        // so the mount policy is asked with the file stem standing in — which is
        // what `FileStemMounts` keys on anyway.
        let provisional = path
            .file_stem()
            .map(|s| s.to_string_lossy().into_owned())
            .unwrap_or_default();
        let store = mounts.store(&path, &provisional);

        trust_gate_pub(&path, policy)?;
        match WasmHandler::load_with(&path, store, policy) {
            Ok(handler) => {
                let name = handler.manifest().handler.clone();
                let repo = mounts.repo_name(&path, &name);
                match HandlerBackend::new(repo, handler.into_runtime()) {
                    Ok(b) => backends.push(b),
                    Err(reason) => failures.push(LoadFailure { path, reason }),
                }
            }
            Err(reason) => failures.push(LoadFailure { path, reason }),
        }
    }

    Ok(Registry {
        scanned: dir,
        backends,
        failures,
    })
}

/// Ask the trust policy about one module BEFORE it is loaded, so a provenance
/// verdict becomes a [`ScanError::UntrustedModule`] (boot stops) rather than a
/// [`LoadFailure`] (boot continues with fewer handlers).
///
/// `WasmHandler::load_with` verifies again — deliberately. Verification is the
/// gate on the bytes it compiles, and there is no argument that lets a caller
/// tell it "already checked". This function exists only to CLASSIFY the failure,
/// and paying one BLAKE3 hash plus one CMS verify per module per boot is the
/// price of not having a second, skippable verification path.
#[cfg(feature = "wasm-plugins")]
pub(crate) fn trust_gate_pub(path: &Path, policy: &crate::PluginPolicy) -> Result<(), ScanError> {
    if !policy.trust.is_required() {
        return Ok(());
    }
    let bytes = std::fs::read(path).map_err(|e| ScanError::UntrustedModule {
        path: path.to_path_buf(),
        reason: format!("could not be read to verify it: {e}"),
    })?;
    crate::verify::verify_named(&policy.trust, path, &bytes).map_err(|reason| {
        ScanError::UntrustedModule {
            path: path.to_path_buf(),
            reason,
        }
    })?;
    Ok(())
}

#[cfg(all(test, feature = "wasm-plugins"))]
mod tests {
    use super::*;

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

    fn mounts(root: &Path) -> FileStemMounts {
        FileStemMounts {
            data_root: root.to_path_buf(),
        }
    }

    /// The condition this whole module exists for.
    #[test]
    fn a_missing_directory_is_an_error_not_an_empty_registry() {
        let dir = tmp("missing");
        let err = scan(&dir, &mounts(&dir)).expect_err("a missing plugin dir returned Ok");
        assert!(
            matches!(err, ScanError::Missing { .. }),
            "wrong variant: {err:?}"
        );
        let msg = err.to_string();
        assert!(
            msg.contains(&dir.display().to_string()),
            "the error does not name the dir: {msg}"
        );
        assert!(
            msg.contains("cargo xtask build-wasm-plugins"),
            "no build command in: {msg}"
        );
    }

    #[test]
    fn a_file_where_a_directory_was_expected_is_named_as_such() {
        let dir = tmp("notadir");
        std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
        std::fs::write(&dir, b"not a directory").unwrap();
        let err = scan(&dir, &mounts(&dir)).expect_err("a file returned Ok");
        assert!(
            matches!(err, ScanError::NotADirectory { .. }),
            "wrong variant: {err:?}"
        );
        let _ = std::fs::remove_file(&dir);
    }

    /// An existing-but-empty directory is a DIFFERENT answer from a missing one,
    /// and the caller must be able to tell them apart.
    #[test]
    fn an_empty_directory_is_ok_and_empty() {
        let dir = tmp("empty");
        std::fs::create_dir_all(&dir).unwrap();
        let reg = scan(&dir, &mounts(&dir)).expect("an empty dir is not an error");
        assert!(reg.is_empty());
        assert_eq!(reg.backends.len(), 0);
        assert_eq!(reg.scanned, dir);
    }

    /// A file that is not a valid module is reported, not silently skipped —
    /// and it does not abort the scan.
    #[test]
    fn a_corrupt_module_is_reported_as_a_failure() {
        let dir = tmp("corrupt");
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join("broken.wasm"), b"this is not wasm").unwrap();
        std::fs::write(dir.join("ignored.txt"), b"not a module").unwrap();

        let reg = scan(&dir, &mounts(&dir)).expect("scan");
        assert_eq!(reg.backends.len(), 0);
        assert_eq!(
            reg.failures.len(),
            1,
            "the corrupt module was skipped instead of reported"
        );
        assert!(reg.failures[0].path.ends_with("broken.wasm"));
        assert!(
            reg.failures[0].reason.contains("wasm"),
            "the failure does not say what was wrong: {}",
            reg.failures[0].reason
        );
        assert!(
            !reg.is_empty(),
            "a directory holding a broken module reported as empty"
        );
    }
}