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"
);
}
}