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