add frontend
This commit is contained in:
@@ -1,37 +1,72 @@
|
||||
//! Plugin discovery + dispatch. Implements [`PluginDispatchPort`] over the
|
||||
//! Extism [`PluginRuntime`].
|
||||
//! Plugin discovery + dispatch + admin management. Implements
|
||||
//! [`PluginDispatchPort`] and [`PluginManagementPort`] over the Extism
|
||||
//! [`PluginRuntime`].
|
||||
//!
|
||||
//! Discovery scans a directory of plugin subdirectories (each `plugin.toml` +
|
||||
//! `.wasm`) at startup; a plugin that fails validation or load is audit-logged
|
||||
//! and skipped, never fatal. Dispatch builds a fresh sandbox per invocation on
|
||||
//! the blocking pool, so a slow or hostile plugin never stalls async workers or
|
||||
//! the upload path that triggered it.
|
||||
//!
|
||||
//! The same in-memory plugin set backs both ports, guarded by an `RwLock`: a
|
||||
//! management op (install / toggle / remove) takes the write lock and is
|
||||
//! reflected on the live dispatch path with no restart. Enable/disable state is
|
||||
//! persisted as a `.disabled` marker file in the plugin's own directory so it
|
||||
//! survives a restart without a database.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use super::manifest;
|
||||
use super::runtime::{InvokeOutcome, PluginRuntime};
|
||||
use crate::application::ports::plugin_ports::{
|
||||
EVENT_FILE_UPLOADED, FileUploadedEvent, OXICLOUD_PLUGIN_ABI, PluginContext, PluginDispatchPort,
|
||||
PluginInput,
|
||||
OXICLOUD_PLUGIN_ABI, PluginContext, PluginDispatchPort, PluginEvent, PluginInfo, PluginInput,
|
||||
PluginManagementPort, PluginMgmtError, event_export_name,
|
||||
};
|
||||
use crate::common::config::PluginConfig;
|
||||
|
||||
/// Name of the marker file that, when present in a plugin's directory, loads it
|
||||
/// disabled. Created/removed by [`PluginManagementPort::set_enabled`].
|
||||
const DISABLED_MARKER: &str = ".disabled";
|
||||
|
||||
/// A validated, loadable plugin held in memory.
|
||||
struct LoadedPlugin {
|
||||
id: String,
|
||||
name: String,
|
||||
version: String,
|
||||
abi: u32,
|
||||
subscribe: HashSet<String>,
|
||||
/// Whether dispatch delivers events to this plugin. Mirrors the on-disk
|
||||
/// `.disabled` marker.
|
||||
enabled: bool,
|
||||
/// The plugin's own directory (not necessarily named after `id`). Used to
|
||||
/// write the disabled marker and to delete the plugin on removal.
|
||||
dir: PathBuf,
|
||||
runtime: Arc<PluginRuntime>,
|
||||
}
|
||||
|
||||
impl LoadedPlugin {
|
||||
fn info(&self) -> PluginInfo {
|
||||
let mut subscriptions: Vec<String> = self.subscribe.iter().cloned().collect();
|
||||
subscriptions.sort();
|
||||
PluginInfo {
|
||||
id: self.id.clone(),
|
||||
name: self.name.clone(),
|
||||
version: self.version.clone(),
|
||||
abi: self.abi,
|
||||
subscriptions,
|
||||
enabled: self.enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns all loaded plugins and dispatches events to them.
|
||||
pub struct ExtismPluginManager {
|
||||
config: PluginConfig,
|
||||
plugins: Vec<LoadedPlugin>,
|
||||
/// Root directory plugins are discovered in and installed into.
|
||||
root_dir: PathBuf,
|
||||
plugins: RwLock<Vec<LoadedPlugin>>,
|
||||
}
|
||||
|
||||
impl ExtismPluginManager {
|
||||
@@ -51,7 +86,11 @@ impl ExtismPluginManager {
|
||||
error = %e,
|
||||
"plugins directory not readable; no plugins loaded"
|
||||
);
|
||||
return Self { config, plugins };
|
||||
return Self {
|
||||
config,
|
||||
root_dir: dir.to_path_buf(),
|
||||
plugins: RwLock::new(plugins),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -65,6 +104,7 @@ impl ExtismPluginManager {
|
||||
tracing::info!(
|
||||
target: "oxicloud::plugins",
|
||||
plugin_id = %loaded.id,
|
||||
enabled = loaded.enabled,
|
||||
dir = %path.display(),
|
||||
"plugin loaded"
|
||||
);
|
||||
@@ -90,7 +130,11 @@ impl ExtismPluginManager {
|
||||
dir = %dir.display(),
|
||||
"plugin discovery complete"
|
||||
);
|
||||
Self { config, plugins }
|
||||
Self {
|
||||
config,
|
||||
root_dir: dir.to_path_buf(),
|
||||
plugins: RwLock::new(plugins),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate and load a single plugin directory. Returns a stable audit
|
||||
@@ -108,47 +152,73 @@ impl ExtismPluginManager {
|
||||
let wasm_bytes = std::fs::read(&wasm_path).map_err(|_| "wasm_unreadable")?;
|
||||
|
||||
let runtime = PluginRuntime::new(manifest.plugin.id.clone(), wasm_bytes);
|
||||
// Probe abi_version on a throwaway instance; rejects lying/unloadable wasm.
|
||||
match runtime.check_loadable(config) {
|
||||
InvokeOutcome::Ok => {}
|
||||
InvokeOutcome::AbiMismatch { .. } => return Err("abi_mismatch"),
|
||||
_ => return Err("not_loadable"),
|
||||
}
|
||||
// Probe a throwaway instance: abi must match AND every subscribed event
|
||||
// must have its `on_<event>` handler exported.
|
||||
let required_exports: Vec<String> = manifest
|
||||
.events
|
||||
.subscribe
|
||||
.iter()
|
||||
.map(|e| event_export_name(e))
|
||||
.collect();
|
||||
Self::probe(config, &runtime, &required_exports)?;
|
||||
|
||||
Ok(LoadedPlugin {
|
||||
id: manifest.plugin.id,
|
||||
name: manifest.plugin.name,
|
||||
version: manifest.plugin.version,
|
||||
abi: manifest.plugin.abi,
|
||||
subscribe: manifest.events.subscribe.into_iter().collect(),
|
||||
enabled: !dir.join(DISABLED_MARKER).exists(),
|
||||
dir: dir.to_path_buf(),
|
||||
runtime: Arc::new(runtime),
|
||||
})
|
||||
}
|
||||
|
||||
/// Probe loadability, mapping the runtime outcome to a stable reason key.
|
||||
fn probe(
|
||||
config: &PluginConfig,
|
||||
runtime: &PluginRuntime,
|
||||
required_exports: &[String],
|
||||
) -> Result<(), &'static str> {
|
||||
match runtime.check_loadable(config, required_exports) {
|
||||
InvokeOutcome::Ok => Ok(()),
|
||||
InvokeOutcome::AbiMismatch { .. } => Err("abi_mismatch"),
|
||||
InvokeOutcome::MissingExport(_) => Err("missing_export"),
|
||||
_ => Err("not_loadable"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of successfully loaded plugins (used by DI for the startup summary
|
||||
/// and by tests).
|
||||
pub fn loaded_count(&self) -> usize {
|
||||
self.plugins.len()
|
||||
self.read_plugins().len()
|
||||
}
|
||||
|
||||
fn read_plugins(&self) -> std::sync::RwLockReadGuard<'_, Vec<LoadedPlugin>> {
|
||||
self.plugins.read().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn write_plugins(&self) -> std::sync::RwLockWriteGuard<'_, Vec<LoadedPlugin>> {
|
||||
self.plugins.write().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginDispatchPort for ExtismPluginManager {
|
||||
fn dispatch_file_uploaded(&self, event: FileUploadedEvent) {
|
||||
for plugin in &self.plugins {
|
||||
if !plugin.subscribe.contains(EVENT_FILE_UPLOADED) {
|
||||
fn dispatch(&self, event: PluginEvent) {
|
||||
for plugin in self.read_plugins().iter() {
|
||||
if !plugin.enabled || !plugin.subscribe.contains(event.name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let input = PluginInput {
|
||||
abi: OXICLOUD_PLUGIN_ABI,
|
||||
event: EVENT_FILE_UPLOADED.to_string(),
|
||||
event: event.name.to_string(),
|
||||
context: PluginContext {
|
||||
plugin_id: plugin.id.clone(),
|
||||
user_id: event.user_id.clone(),
|
||||
invocation_id: event.invocation_id.clone(),
|
||||
},
|
||||
payload: json!({
|
||||
"path": event.path,
|
||||
"size": event.size,
|
||||
"mime": event.mime,
|
||||
}),
|
||||
payload: event.payload.clone(),
|
||||
};
|
||||
let input_json = match serde_json::to_string(&input) {
|
||||
Ok(j) => j,
|
||||
@@ -167,11 +237,12 @@ impl PluginDispatchPort for ExtismPluginManager {
|
||||
let config = self.config.clone();
|
||||
let plugin_id = plugin.id.clone();
|
||||
let invocation_id = event.invocation_id.clone();
|
||||
let export = event_export_name(event.name);
|
||||
|
||||
// Run the synchronous wasm call off the async workers. Fire-and-forget:
|
||||
// the upload already succeeded; plugins are post-hoc observers.
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = runtime.invoke(&config, &invocation_id, &input_json);
|
||||
let result = runtime.invoke(&config, &export, &invocation_id, &input_json);
|
||||
if !result.outcome.is_ok() {
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
@@ -188,6 +259,172 @@ impl PluginDispatchPort for ExtismPluginManager {
|
||||
}
|
||||
|
||||
fn has_subscribers(&self, event: &str) -> bool {
|
||||
self.plugins.iter().any(|p| p.subscribe.contains(event))
|
||||
self.read_plugins()
|
||||
.iter()
|
||||
.any(|p| p.enabled && p.subscribe.contains(event))
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginManagementPort for ExtismPluginManager {
|
||||
fn list(&self) -> Vec<PluginInfo> {
|
||||
let mut infos: Vec<PluginInfo> = self.read_plugins().iter().map(|p| p.info()).collect();
|
||||
infos.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
infos
|
||||
}
|
||||
|
||||
fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), PluginMgmtError> {
|
||||
let mut plugins = self.write_plugins();
|
||||
let plugin = plugins
|
||||
.iter_mut()
|
||||
.find(|p| p.id == id)
|
||||
.ok_or(PluginMgmtError::NotFound)?;
|
||||
|
||||
let marker = plugin.dir.join(DISABLED_MARKER);
|
||||
if enabled {
|
||||
match std::fs::remove_file(&marker) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(PluginMgmtError::Io(e.to_string())),
|
||||
}
|
||||
} else {
|
||||
std::fs::write(&marker, b"").map_err(|e| PluginMgmtError::Io(e.to_string()))?;
|
||||
}
|
||||
plugin.enabled = enabled;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install(&self, manifest_toml: &str, wasm: Vec<u8>) -> Result<PluginInfo, PluginMgmtError> {
|
||||
// Validate the manifest and the wasm before touching the filesystem.
|
||||
let manifest = manifest::parse_and_validate(manifest_toml)
|
||||
.map_err(|e| PluginMgmtError::Rejected(e.reason()))?;
|
||||
|
||||
// `id` becomes a directory name and `entrypoint` a filename — both must
|
||||
// be single, traversal-free path components.
|
||||
if !is_safe_component(&manifest.plugin.id) {
|
||||
return Err(PluginMgmtError::Rejected("bad_id"));
|
||||
}
|
||||
if !is_safe_component(&manifest.plugin.entrypoint) {
|
||||
return Err(PluginMgmtError::Rejected("bad_entrypoint"));
|
||||
}
|
||||
|
||||
let required_exports: Vec<String> = manifest
|
||||
.events
|
||||
.subscribe
|
||||
.iter()
|
||||
.map(|e| event_export_name(e))
|
||||
.collect();
|
||||
let runtime = PluginRuntime::new(manifest.plugin.id.clone(), wasm.clone());
|
||||
Self::probe(&self.config, &runtime, &required_exports)
|
||||
.map_err(PluginMgmtError::Rejected)?;
|
||||
|
||||
let id = manifest.plugin.id.clone();
|
||||
let target = self.root_dir.join(&id);
|
||||
|
||||
// Hold the write lock across the collision check and the directory swap
|
||||
// so two concurrent installs of the same id cannot race. Admin installs
|
||||
// are rare; readers block only briefly.
|
||||
let mut plugins = self.write_plugins();
|
||||
if plugins.iter().any(|p| p.id == id) || target.exists() {
|
||||
return Err(PluginMgmtError::IdExists);
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(&self.root_dir).map_err(|e| PluginMgmtError::Io(e.to_string()))?;
|
||||
// Write to a temp dir then rename, so a crash mid-write never leaves a
|
||||
// half-written plugin discoverable.
|
||||
let tmp = tempfile::Builder::new()
|
||||
.prefix(".tmp-install-")
|
||||
.tempdir_in(&self.root_dir)
|
||||
.map_err(|e| PluginMgmtError::Io(e.to_string()))?;
|
||||
std::fs::write(tmp.path().join("plugin.toml"), manifest_toml)
|
||||
.map_err(|e| PluginMgmtError::Io(e.to_string()))?;
|
||||
std::fs::write(tmp.path().join(&manifest.plugin.entrypoint), &wasm)
|
||||
.map_err(|e| PluginMgmtError::Io(e.to_string()))?;
|
||||
let tmp_path = tmp.keep();
|
||||
if let Err(e) = std::fs::rename(&tmp_path, &target) {
|
||||
let _ = std::fs::remove_dir_all(&tmp_path);
|
||||
return Err(PluginMgmtError::Io(e.to_string()));
|
||||
}
|
||||
|
||||
let loaded = LoadedPlugin {
|
||||
id: id.clone(),
|
||||
name: manifest.plugin.name.clone(),
|
||||
version: manifest.plugin.version.clone(),
|
||||
abi: manifest.plugin.abi,
|
||||
subscribe: manifest.events.subscribe.iter().cloned().collect(),
|
||||
enabled: true,
|
||||
dir: target,
|
||||
runtime: Arc::new(runtime),
|
||||
};
|
||||
let info = loaded.info();
|
||||
plugins.push(loaded);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
fn install_bundle(&self, zip: Vec<u8>) -> Result<PluginInfo, PluginMgmtError> {
|
||||
use std::io::{Cursor, Read};
|
||||
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(zip))
|
||||
.map_err(|_| PluginMgmtError::Rejected("bad_zip"))?;
|
||||
|
||||
// Locate `plugin.toml` — at the archive root or under a single wrapping
|
||||
// folder (e.g. `myplugin/plugin.toml`).
|
||||
let manifest_name = archive
|
||||
.file_names()
|
||||
.find(|n| !n.ends_with('/') && (*n == "plugin.toml" || n.ends_with("/plugin.toml")))
|
||||
.map(str::to_owned)
|
||||
.ok_or(PluginMgmtError::Rejected("no_manifest_in_zip"))?;
|
||||
|
||||
let mut manifest_toml = String::new();
|
||||
archive
|
||||
.by_name(&manifest_name)
|
||||
.map_err(|_| PluginMgmtError::Rejected("no_manifest_in_zip"))?
|
||||
.read_to_string(&mut manifest_toml)
|
||||
.map_err(|_| PluginMgmtError::Rejected("bad_zip"))?;
|
||||
|
||||
// Parse just to learn the entrypoint name; `install` does the full
|
||||
// validation (and rejects a traversal-unsafe entrypoint).
|
||||
let manifest = manifest::parse_and_validate(&manifest_toml)
|
||||
.map_err(|e| PluginMgmtError::Rejected(e.reason()))?;
|
||||
|
||||
// Resolve the entrypoint relative to the manifest's folder in the zip.
|
||||
let prefix = match manifest_name.rfind('/') {
|
||||
Some(i) => &manifest_name[..=i],
|
||||
None => "",
|
||||
};
|
||||
let wasm_name = format!("{prefix}{}", manifest.plugin.entrypoint);
|
||||
|
||||
let mut wasm = Vec::new();
|
||||
archive
|
||||
.by_name(&wasm_name)
|
||||
.map_err(|_| PluginMgmtError::Rejected("entrypoint_not_in_zip"))?
|
||||
.read_to_end(&mut wasm)
|
||||
.map_err(|_| PluginMgmtError::Rejected("bad_zip"))?;
|
||||
|
||||
self.install(&manifest_toml, wasm)
|
||||
}
|
||||
|
||||
fn remove(&self, id: &str) -> Result<(), PluginMgmtError> {
|
||||
let mut plugins = self.write_plugins();
|
||||
let pos = plugins
|
||||
.iter()
|
||||
.position(|p| p.id == id)
|
||||
.ok_or(PluginMgmtError::NotFound)?;
|
||||
let removed = plugins.remove(pos);
|
||||
match std::fs::remove_dir_all(&removed.dir) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(PluginMgmtError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `s` is a single, traversal-free path component safe to use as a
|
||||
/// directory or file name under the plugins root.
|
||||
fn is_safe_component(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s != "."
|
||||
&& s != ".."
|
||||
&& !s.contains('/')
|
||||
&& !s.contains('\\')
|
||||
&& !s.contains('\0')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Manager-level tests for the admin management surface (install / toggle /
|
||||
//! remove) and disabled-state persistence. These drive a real Extism sandbox,
|
||||
//! so they run only under `cargo test --features plugins`.
|
||||
//!
|
||||
//! The `.wasm` fixtures are the same ones the runtime tests use, built by
|
||||
//! `scripts/build-plugin-hello.sh`.
|
||||
|
||||
use super::ExtismPluginManager;
|
||||
use crate::application::ports::plugin_ports::{PluginDispatchPort, PluginManagementPort};
|
||||
use crate::common::config::PluginConfig;
|
||||
|
||||
fn cfg() -> PluginConfig {
|
||||
PluginConfig::default()
|
||||
}
|
||||
|
||||
fn fixture(name: &str) -> Vec<u8> {
|
||||
let path = format!(
|
||||
"{}/tests/fixtures/plugins/{}",
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
name
|
||||
);
|
||||
std::fs::read(&path).unwrap_or_else(|e| {
|
||||
panic!("missing fixture {path}: {e}\n run scripts/build-plugin-hello.sh to (re)build it")
|
||||
})
|
||||
}
|
||||
|
||||
/// A valid manifest for the `hello.wasm` fixture (subscribes to both events).
|
||||
fn hello_manifest() -> String {
|
||||
r#"
|
||||
[plugin]
|
||||
id = "com.example.hello"
|
||||
name = "Hello"
|
||||
version = "0.1.0"
|
||||
abi = 0
|
||||
entrypoint = "hello.wasm"
|
||||
|
||||
[events]
|
||||
subscribe = ["file.uploaded", "user.login"]
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A manifest that parses fine but points at the `wrong_abi.wasm` fixture,
|
||||
/// which reports ABI 1 at runtime.
|
||||
fn wrong_abi_manifest() -> String {
|
||||
r#"
|
||||
[plugin]
|
||||
id = "com.example.wrongabi"
|
||||
name = "Wrong ABI"
|
||||
version = "0.1.0"
|
||||
abi = 0
|
||||
entrypoint = "wrong_abi.wasm"
|
||||
|
||||
[events]
|
||||
subscribe = ["file.uploaded"]
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_loads_plugin_and_writes_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
|
||||
assert_eq!(mgr.loaded_count(), 0);
|
||||
|
||||
let info = mgr
|
||||
.install(&hello_manifest(), fixture("hello.wasm"))
|
||||
.expect("install should succeed");
|
||||
|
||||
assert_eq!(info.id, "com.example.hello");
|
||||
assert_eq!(info.name, "Hello");
|
||||
assert!(info.enabled);
|
||||
assert_eq!(info.subscriptions, vec!["file.uploaded", "user.login"]);
|
||||
assert_eq!(mgr.loaded_count(), 1);
|
||||
|
||||
let plugin_dir = tmp.path().join("com.example.hello");
|
||||
assert!(plugin_dir.join("plugin.toml").exists());
|
||||
assert!(plugin_dir.join("hello.wasm").exists());
|
||||
|
||||
// The live dispatch path sees it immediately.
|
||||
assert!(mgr.has_subscribers("file.uploaded"));
|
||||
}
|
||||
|
||||
/// Build an in-memory `.zip` with the given entries (name, bytes).
|
||||
fn make_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
use zip::write::SimpleFileOptions;
|
||||
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in entries {
|
||||
writer
|
||||
.start_file(*name, SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
writer.write_all(bytes).unwrap();
|
||||
}
|
||||
writer.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_bundle_from_zip_loads_plugin() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
|
||||
|
||||
// Wrap everything under a top-level folder to exercise prefix resolution.
|
||||
let zip = make_zip(&[
|
||||
("hello/plugin.toml", hello_manifest().as_bytes()),
|
||||
("hello/hello.wasm", &fixture("hello.wasm")),
|
||||
]);
|
||||
|
||||
let info = mgr
|
||||
.install_bundle(zip)
|
||||
.expect("bundle install should succeed");
|
||||
assert_eq!(info.id, "com.example.hello");
|
||||
assert!(info.enabled);
|
||||
assert_eq!(mgr.loaded_count(), 1);
|
||||
assert!(
|
||||
tmp.path()
|
||||
.join("com.example.hello")
|
||||
.join("hello.wasm")
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_bundle_without_manifest_is_rejected() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
|
||||
|
||||
let zip = make_zip(&[("hello.wasm", &fixture("hello.wasm"))]);
|
||||
let err = mgr
|
||||
.install_bundle(zip)
|
||||
.expect_err("a zip without plugin.toml must be rejected");
|
||||
assert_eq!(err.reason(), "no_manifest_in_zip");
|
||||
assert_eq!(mgr.loaded_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_bundle_missing_entrypoint_is_rejected() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
|
||||
|
||||
// Manifest declares entrypoint = "hello.wasm", but the zip omits it.
|
||||
let zip = make_zip(&[("plugin.toml", hello_manifest().as_bytes())]);
|
||||
let err = mgr
|
||||
.install_bundle(zip)
|
||||
.expect_err("a zip missing the entrypoint wasm must be rejected");
|
||||
assert_eq!(err.reason(), "entrypoint_not_in_zip");
|
||||
assert_eq!(mgr.loaded_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_bundle_with_garbage_is_rejected() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
|
||||
let err = mgr
|
||||
.install_bundle(b"not a zip file".to_vec())
|
||||
.expect_err("non-zip bytes must be rejected");
|
||||
assert_eq!(err.reason(), "bad_zip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_duplicate_id_is_rejected() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
|
||||
mgr.install(&hello_manifest(), fixture("hello.wasm"))
|
||||
.unwrap();
|
||||
|
||||
let err = mgr
|
||||
.install(&hello_manifest(), fixture("hello.wasm"))
|
||||
.expect_err("second install of the same id must fail");
|
||||
assert_eq!(err.reason(), "id_exists");
|
||||
assert_eq!(mgr.loaded_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_wrong_abi_is_rejected() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
|
||||
|
||||
let err = mgr
|
||||
.install(&wrong_abi_manifest(), fixture("wrong_abi.wasm"))
|
||||
.expect_err("a plugin reporting the wrong ABI must be rejected");
|
||||
assert_eq!(err.reason(), "abi_mismatch");
|
||||
assert_eq!(mgr.loaded_count(), 0);
|
||||
// Nothing should have been written to disk.
|
||||
assert!(!tmp.path().join("com.example.wrongabi").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disable_stops_dispatch_and_persists_across_reload() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
|
||||
mgr.install(&hello_manifest(), fixture("hello.wasm"))
|
||||
.unwrap();
|
||||
assert!(mgr.has_subscribers("file.uploaded"));
|
||||
|
||||
mgr.set_enabled("com.example.hello", false).unwrap();
|
||||
assert!(!mgr.has_subscribers("file.uploaded"));
|
||||
assert!(
|
||||
tmp.path()
|
||||
.join("com.example.hello")
|
||||
.join(".disabled")
|
||||
.exists()
|
||||
);
|
||||
|
||||
// A fresh manager re-reads the marker and loads it disabled.
|
||||
drop(mgr);
|
||||
let reloaded = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
|
||||
assert_eq!(reloaded.loaded_count(), 1);
|
||||
let info = reloaded.list();
|
||||
assert_eq!(info.len(), 1);
|
||||
assert!(!info[0].enabled);
|
||||
assert!(!reloaded.has_subscribers("file.uploaded"));
|
||||
|
||||
// Re-enabling removes the marker.
|
||||
reloaded.set_enabled("com.example.hello", true).unwrap();
|
||||
assert!(reloaded.has_subscribers("file.uploaded"));
|
||||
assert!(
|
||||
!tmp.path()
|
||||
.join("com.example.hello")
|
||||
.join(".disabled")
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_enabled_unknown_id_is_not_found() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
|
||||
let err = mgr.set_enabled("does.not.exist", false).unwrap_err();
|
||||
assert_eq!(err.reason(), "not_found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_unloads_and_deletes_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
|
||||
mgr.install(&hello_manifest(), fixture("hello.wasm"))
|
||||
.unwrap();
|
||||
let plugin_dir = tmp.path().join("com.example.hello");
|
||||
assert!(plugin_dir.exists());
|
||||
|
||||
mgr.remove("com.example.hello").unwrap();
|
||||
assert_eq!(mgr.loaded_count(), 0);
|
||||
assert!(!plugin_dir.exists());
|
||||
|
||||
let err = mgr.remove("com.example.hello").unwrap_err();
|
||||
assert_eq!(err.reason(), "not_found");
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::application::ports::plugin_ports::{EVENT_FILE_UPLOADED, OXICLOUD_PLUGIN_ABI};
|
||||
use crate::application::ports::plugin_ports::{KNOWN_EVENTS, OXICLOUD_PLUGIN_ABI};
|
||||
|
||||
/// Parsed `plugin.toml`. `#[serde(deny_unknown_fields)]` on every struct turns
|
||||
/// stray keys into load errors rather than silently ignored config.
|
||||
@@ -90,7 +90,7 @@ pub fn parse_and_validate(toml_str: &str) -> Result<PluginManifest, ManifestErro
|
||||
return Err(ManifestError::NoEvents);
|
||||
}
|
||||
for event in &manifest.events.subscribe {
|
||||
if event != EVENT_FILE_UPLOADED {
|
||||
if !KNOWN_EVENTS.contains(&event.as_str()) {
|
||||
return Err(ManifestError::UnknownEvent(event.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,7 @@ pub mod runtime;
|
||||
|
||||
pub use manager::ExtismPluginManager;
|
||||
|
||||
#[cfg(test)]
|
||||
mod manager_test;
|
||||
#[cfg(test)]
|
||||
mod runtime_test;
|
||||
|
||||
@@ -58,7 +58,9 @@ pub enum InvokeOutcome {
|
||||
LoadError(String),
|
||||
/// `abi_version` returned a value the host does not speak.
|
||||
AbiMismatch { got: u32 },
|
||||
/// `handle` returned bytes that are not a valid `PluginOutput`.
|
||||
/// A subscribed event has no matching `on_<event>` export in the module.
|
||||
MissingExport(String),
|
||||
/// The event handler returned bytes that are not a valid `PluginOutput`.
|
||||
MalformedOutput(String),
|
||||
/// The serialized input exceeded the configured cap; nothing was invoked.
|
||||
MalformedInput { size: usize, max: usize },
|
||||
@@ -78,6 +80,7 @@ impl InvokeOutcome {
|
||||
InvokeOutcome::Timeout => "timeout",
|
||||
InvokeOutcome::LoadError(_) => "load_error",
|
||||
InvokeOutcome::AbiMismatch { .. } => "abi_mismatch",
|
||||
InvokeOutcome::MissingExport(_) => "missing_export",
|
||||
InvokeOutcome::MalformedOutput(_) => "malformed_output",
|
||||
InvokeOutcome::MalformedInput { .. } => "malformed_input",
|
||||
}
|
||||
@@ -126,25 +129,35 @@ impl PluginRuntime {
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Probe `abi_version` on a throwaway instance. Used at load time so a lying
|
||||
/// or unloadable plugin is rejected before it is ever registered.
|
||||
pub fn check_loadable(&self, cfg: &PluginConfig) -> InvokeOutcome {
|
||||
/// Probe a throwaway instance at load time: check `abi_version`, then verify
|
||||
/// every `required_export` (the `on_<event>` symbol for each subscribed
|
||||
/// event) actually exists in the module. Rejects lying, unloadable, or
|
||||
/// incompletely-implemented plugins before they are ever registered.
|
||||
pub fn check_loadable(&self, cfg: &PluginConfig, required_exports: &[String]) -> InvokeOutcome {
|
||||
let logs = UserData::new(LogContext::default());
|
||||
let mut plugin = match self.build(cfg, logs) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return InvokeOutcome::LoadError(e.to_string()),
|
||||
};
|
||||
match plugin.call::<(), u32>("abi_version", ()) {
|
||||
Ok(v) if v == OXICLOUD_PLUGIN_ABI => InvokeOutcome::Ok,
|
||||
Ok(v) => InvokeOutcome::AbiMismatch { got: v },
|
||||
Err(e) => classify_call_error(e),
|
||||
Ok(v) if v == OXICLOUD_PLUGIN_ABI => {}
|
||||
Ok(v) => return InvokeOutcome::AbiMismatch { got: v },
|
||||
Err(e) => return classify_call_error(e),
|
||||
}
|
||||
for export in required_exports {
|
||||
if !plugin.function_exists(export) {
|
||||
return InvokeOutcome::MissingExport(export.clone());
|
||||
}
|
||||
}
|
||||
InvokeOutcome::Ok
|
||||
}
|
||||
|
||||
/// Run one `handle` invocation, fully fault-isolated.
|
||||
/// Run one event-handler invocation, fully fault-isolated. `export` is the
|
||||
/// `on_<event>` symbol to call (see `event_export_name`).
|
||||
pub fn invoke(
|
||||
&self,
|
||||
cfg: &PluginConfig,
|
||||
export: &str,
|
||||
invocation_id: &str,
|
||||
input_json: &str,
|
||||
) -> InvokeResult {
|
||||
@@ -192,7 +205,7 @@ impl PluginRuntime {
|
||||
}
|
||||
|
||||
// The actual call. Traps, timeouts, and OOM all surface here as Err.
|
||||
let outcome = match plugin.call::<&str, String>("handle", input_json) {
|
||||
let outcome = match plugin.call::<&str, String>(export, input_json) {
|
||||
Ok(out) => match serde_json::from_str::<PluginOutput>(&out) {
|
||||
Ok(parsed) if parsed.ok => InvokeOutcome::Ok,
|
||||
Ok(parsed) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::time::{Duration, Instant};
|
||||
use super::ExtismPluginManager;
|
||||
use super::manifest;
|
||||
use super::runtime::{InvokeOutcome, PluginRuntime};
|
||||
use crate::application::ports::plugin_ports::event_export_name;
|
||||
use crate::common::config::PluginConfig;
|
||||
|
||||
fn cfg() -> PluginConfig {
|
||||
@@ -27,7 +28,7 @@ fn fixture(name: &str) -> Vec<u8> {
|
||||
})
|
||||
}
|
||||
|
||||
fn sample_input() -> String {
|
||||
fn file_uploaded_input() -> String {
|
||||
serde_json::json!({
|
||||
"abi": 0,
|
||||
"event": "file.uploaded",
|
||||
@@ -41,21 +42,38 @@ fn sample_input() -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// ---- The M0 exit criterion: the full loop -----------------------------------
|
||||
fn user_login_input() -> String {
|
||||
serde_json::json!({
|
||||
"abi": 0,
|
||||
"event": "user.login",
|
||||
"context": {
|
||||
"plugin_id": "com.example.hello",
|
||||
"user_id": "u_test",
|
||||
"invocation_id": "inv_login_0001"
|
||||
},
|
||||
"payload": {
|
||||
"user_id": "u_test",
|
||||
"username": "alice",
|
||||
"email": "alice@example.com",
|
||||
"first_login": true,
|
||||
"is_external": false
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// ---- The M0 exit criterion: the full loop, per event ------------------------
|
||||
|
||||
#[test]
|
||||
fn acceptance_hello_returns_ok_and_calls_host_log() {
|
||||
fn acceptance_file_uploaded_returns_ok_and_calls_host_log() {
|
||||
let rt = PluginRuntime::new("com.example.hello", fixture("hello.wasm"));
|
||||
let result = rt.invoke(&cfg(), "inv_test_0001", &sample_input());
|
||||
let result = rt.invoke(&cfg(), "on_file_uploaded", "inv", &file_uploaded_input());
|
||||
|
||||
// 1. handle returned a well-formed PluginOutput with ok = true.
|
||||
assert!(
|
||||
result.outcome.is_ok(),
|
||||
"plugin did not complete: {:?}",
|
||||
result.outcome
|
||||
);
|
||||
|
||||
// 2. The plugin called the host `log` function (plugin -> host).
|
||||
assert!(
|
||||
result.logs.iter().any(|(level, msg)| level == "info"
|
||||
&& msg.contains("hello plugin saw upload: /photos/2026/cat.jpg")),
|
||||
@@ -64,6 +82,24 @@ fn acceptance_hello_returns_ok_and_calls_host_log() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acceptance_user_login_returns_ok_and_calls_host_log() {
|
||||
let rt = PluginRuntime::new("com.example.hello", fixture("hello.wasm"));
|
||||
let result = rt.invoke(&cfg(), "on_user_login", "inv", &user_login_input());
|
||||
|
||||
assert!(
|
||||
result.outcome.is_ok(),
|
||||
"plugin did not complete: {:?}",
|
||||
result.outcome
|
||||
);
|
||||
assert!(
|
||||
result.logs.iter().any(|(level, msg)| level == "info"
|
||||
&& msg.contains("hello plugin saw login: user u_test (first_login=true)")),
|
||||
"expected the plugin's user.login log line, got: {:?}",
|
||||
result.logs
|
||||
);
|
||||
}
|
||||
|
||||
// ---- The guarantees, not just the happy path --------------------------------
|
||||
|
||||
#[test]
|
||||
@@ -71,17 +107,45 @@ fn rejects_wrong_abi() {
|
||||
let rt = PluginRuntime::new("com.example.wrong-abi", fixture("wrong_abi.wasm"));
|
||||
assert!(
|
||||
matches!(
|
||||
rt.check_loadable(&cfg()),
|
||||
rt.check_loadable(&cfg(), &[]),
|
||||
InvokeOutcome::AbiMismatch { got: 1 }
|
||||
),
|
||||
"wrong-abi plugin should be rejected at load"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_requires_subscribed_event_exports() {
|
||||
let cfg = cfg();
|
||||
let login_export = vec![event_export_name("user.login")];
|
||||
|
||||
// hello.wasm exports both handlers -> loadable for user.login.
|
||||
let hello = PluginRuntime::new("com.example.hello", fixture("hello.wasm"));
|
||||
assert!(matches!(
|
||||
hello.check_loadable(&cfg, &login_export),
|
||||
InvokeOutcome::Ok
|
||||
));
|
||||
|
||||
// omit_login.wasm lacks on_user_login -> rejected when it claims user.login.
|
||||
let omit = PluginRuntime::new("com.example.omit", fixture("omit_login.wasm"));
|
||||
assert!(
|
||||
matches!(
|
||||
omit.check_loadable(&cfg, &login_export),
|
||||
InvokeOutcome::MissingExport(ref e) if e == "on_user_login"
|
||||
),
|
||||
"omit_login must be rejected for a user.login subscription"
|
||||
);
|
||||
// …but it is fine for file.uploaded, which it does export.
|
||||
assert!(matches!(
|
||||
omit.check_loadable(&cfg, &[event_export_name("file.uploaded")]),
|
||||
InvokeOutcome::Ok
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_a_panicking_plugin() {
|
||||
let rt = PluginRuntime::new("com.example.panic", fixture("panic.wasm"));
|
||||
let result = rt.invoke(&cfg(), "inv", &sample_input());
|
||||
let result = rt.invoke(&cfg(), "on_file_uploaded", "inv", &file_uploaded_input());
|
||||
assert!(
|
||||
matches!(result.outcome, InvokeOutcome::Trap(_)),
|
||||
"expected a contained trap, got {:?}",
|
||||
@@ -94,7 +158,7 @@ fn contains_a_panicking_plugin() {
|
||||
fn enforces_timeout() {
|
||||
let rt = PluginRuntime::new("com.example.sleep", fixture("sleep.wasm"));
|
||||
let start = Instant::now();
|
||||
let result = rt.invoke(&cfg(), "inv", &sample_input());
|
||||
let result = rt.invoke(&cfg(), "on_file_uploaded", "inv", &file_uploaded_input());
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
assert!(
|
||||
@@ -111,9 +175,7 @@ fn enforces_timeout() {
|
||||
#[test]
|
||||
fn no_network() {
|
||||
let rt = PluginRuntime::new("com.example.net", fixture("net.wasm"));
|
||||
let result = rt.invoke(&cfg(), "inv", &sample_input());
|
||||
// No allowed_hosts are granted, so the outbound call is denied and the
|
||||
// plugin cannot complete successfully.
|
||||
let result = rt.invoke(&cfg(), "on_file_uploaded", "inv", &file_uploaded_input());
|
||||
assert!(
|
||||
!result.outcome.is_ok(),
|
||||
"network access should be denied, got {:?}",
|
||||
@@ -121,47 +183,75 @@ fn no_network() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manager_loads_and_dispatches() {
|
||||
use crate::application::ports::plugin_ports::{FileUploadedEvent, PluginDispatchPort};
|
||||
// ---- Manager discovery + dispatch ------------------------------------------
|
||||
|
||||
/// Write a one-plugin directory (plugin.toml + the given wasm) under a tempdir
|
||||
/// and load a manager from it.
|
||||
fn manager_with(wasm_name: &str, subscribe_toml: &str) -> (tempfile::TempDir, ExtismPluginManager) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plugin_dir = tmp.path().join("hello");
|
||||
std::fs::create_dir_all(&plugin_dir).unwrap();
|
||||
std::fs::write(plugin_dir.join("hello.wasm"), fixture("hello.wasm")).unwrap();
|
||||
let dir = tmp.path().join("plugin");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("plugin.wasm"), fixture(wasm_name)).unwrap();
|
||||
std::fs::write(
|
||||
plugin_dir.join("plugin.toml"),
|
||||
r#"
|
||||
dir.join("plugin.toml"),
|
||||
format!(
|
||||
r#"
|
||||
[plugin]
|
||||
id = "com.example.hello"
|
||||
name = "Hello"
|
||||
id = "com.example.test"
|
||||
name = "Test"
|
||||
version = "0.1.0"
|
||||
abi = 0
|
||||
entrypoint = "hello.wasm"
|
||||
entrypoint = "plugin.wasm"
|
||||
|
||||
[events]
|
||||
subscribe = ["file.uploaded"]
|
||||
"#,
|
||||
subscribe = {subscribe_toml}
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let manager = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
|
||||
(tmp, manager)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manager_loads_and_dispatches_both_events() {
|
||||
use crate::application::ports::plugin_ports::{
|
||||
EVENT_FILE_UPLOADED, EVENT_USER_LOGIN, PluginDispatchPort, PluginEvent,
|
||||
};
|
||||
|
||||
let (_tmp, manager) = manager_with("hello.wasm", r#"["file.uploaded", "user.login"]"#);
|
||||
assert_eq!(manager.loaded_count(), 1, "the valid plugin should load");
|
||||
assert!(manager.has_subscribers("file.uploaded"));
|
||||
assert!(manager.has_subscribers("user.login"));
|
||||
assert!(!manager.has_subscribers("file.deleted"));
|
||||
|
||||
// Dispatch runs the plugin on the blocking pool; it must not panic or block.
|
||||
manager.dispatch_file_uploaded(FileUploadedEvent {
|
||||
path: "/a.txt".into(),
|
||||
size: 3,
|
||||
mime: "text/plain".into(),
|
||||
// Both dispatches run the plugin on the blocking pool; neither may panic.
|
||||
manager.dispatch(PluginEvent {
|
||||
name: EVENT_FILE_UPLOADED,
|
||||
user_id: Some("u_test".into()),
|
||||
invocation_id: "inv_dispatch".into(),
|
||||
invocation_id: "inv_upload".into(),
|
||||
payload: serde_json::json!({ "path": "/a.txt", "size": 3, "mime": "text/plain" }),
|
||||
});
|
||||
manager.dispatch(PluginEvent {
|
||||
name: EVENT_USER_LOGIN,
|
||||
user_id: Some("u_test".into()),
|
||||
invocation_id: "inv_login".into(),
|
||||
payload: serde_json::json!({ "user_id": "u_test", "first_login": false }),
|
||||
});
|
||||
// Give the spawned task time to complete before the test runtime shuts down.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_rejects_plugin_missing_a_subscribed_export() {
|
||||
// omit_login.wasm subscribes to user.login but doesn't export on_user_login.
|
||||
let (_tmp, rejected) = manager_with("omit_login.wasm", r#"["user.login"]"#);
|
||||
assert_eq!(rejected.loaded_count(), 0, "missing export -> not loaded");
|
||||
|
||||
// The same wasm is fine when it only claims an event it actually exports.
|
||||
let (_tmp2, loaded) = manager_with("omit_login.wasm", r#"["file.uploaded"]"#);
|
||||
assert_eq!(loaded.loaded_count(), 1);
|
||||
}
|
||||
|
||||
// ---- Manifest validation (no wasm needed) -----------------------------------
|
||||
|
||||
const VALID_MANIFEST: &str = r#"
|
||||
@@ -182,6 +272,15 @@ fn manifest_accepts_valid() {
|
||||
assert_eq!(m.plugin.id, "com.example.hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_accepts_user_login_and_combined() {
|
||||
let login = VALID_MANIFEST.replace(r#"["file.uploaded"]"#, r#"["user.login"]"#);
|
||||
assert!(manifest::parse_and_validate(&login).is_ok());
|
||||
|
||||
let both = VALID_MANIFEST.replace(r#"["file.uploaded"]"#, r#"["file.uploaded", "user.login"]"#);
|
||||
assert!(manifest::parse_and_validate(&both).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_rejects_unknown_field() {
|
||||
let toml = format!("{VALID_MANIFEST}\nbogus_top_level = true\n");
|
||||
|
||||
Reference in New Issue
Block a user