init plugins
This commit is contained in:
@@ -22,6 +22,8 @@ pub mod password_hasher;
|
||||
pub mod path_resolver_service;
|
||||
pub mod path_service;
|
||||
pub mod pg_acl_engine;
|
||||
#[cfg(feature = "plugins")]
|
||||
pub mod plugins;
|
||||
pub mod retry_blob_backend;
|
||||
pub mod s3_blob_backend;
|
||||
pub mod search_index;
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Plugin discovery + dispatch. Implements [`PluginDispatchPort`] 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.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::manifest;
|
||||
use super::runtime::{InvokeOutcome, PluginRuntime};
|
||||
use crate::application::ports::plugin_ports::{
|
||||
EVENT_FILE_UPLOADED, FileUploadedEvent, OXICLOUD_PLUGIN_ABI, PluginContext, PluginDispatchPort,
|
||||
PluginInput,
|
||||
};
|
||||
use crate::common::config::PluginConfig;
|
||||
|
||||
/// A validated, loadable plugin held in memory.
|
||||
struct LoadedPlugin {
|
||||
id: String,
|
||||
subscribe: HashSet<String>,
|
||||
runtime: Arc<PluginRuntime>,
|
||||
}
|
||||
|
||||
/// Owns all loaded plugins and dispatches events to them.
|
||||
pub struct ExtismPluginManager {
|
||||
config: PluginConfig,
|
||||
plugins: Vec<LoadedPlugin>,
|
||||
}
|
||||
|
||||
impl ExtismPluginManager {
|
||||
/// Scan `dir` for plugins and build a manager from those that validate and
|
||||
/// load. Returns an empty manager (logging the cause) if `dir` is absent or
|
||||
/// unreadable — a missing plugins directory is normal, not an error.
|
||||
pub fn load_from_dir(config: PluginConfig, dir: &Path) -> Self {
|
||||
let mut plugins = Vec::new();
|
||||
let mut rejected = 0usize;
|
||||
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::info!(
|
||||
target: "oxicloud::plugins",
|
||||
dir = %dir.display(),
|
||||
error = %e,
|
||||
"plugins directory not readable; no plugins loaded"
|
||||
);
|
||||
return Self { config, plugins };
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
match Self::load_one(&config, &path) {
|
||||
Ok(loaded) => {
|
||||
tracing::info!(
|
||||
target: "oxicloud::plugins",
|
||||
plugin_id = %loaded.id,
|
||||
dir = %path.display(),
|
||||
"plugin loaded"
|
||||
);
|
||||
plugins.push(loaded);
|
||||
}
|
||||
Err(reason) => {
|
||||
rejected += 1;
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "plugin.load_rejected",
|
||||
reason = reason,
|
||||
plugin_dir = %path.display(),
|
||||
"👮🏻♂️ plugin rejected at load"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
target: "oxicloud::plugins",
|
||||
loaded = plugins.len(),
|
||||
rejected,
|
||||
dir = %dir.display(),
|
||||
"plugin discovery complete"
|
||||
);
|
||||
Self { config, plugins }
|
||||
}
|
||||
|
||||
/// Validate and load a single plugin directory. Returns a stable audit
|
||||
/// `reason` key on rejection.
|
||||
fn load_one(config: &PluginConfig, dir: &Path) -> Result<LoadedPlugin, &'static str> {
|
||||
let manifest_path = dir.join("plugin.toml");
|
||||
if !manifest_path.exists() {
|
||||
return Err("no_manifest");
|
||||
}
|
||||
let toml_str =
|
||||
std::fs::read_to_string(&manifest_path).map_err(|_| "manifest_unreadable")?;
|
||||
let manifest = manifest::parse_and_validate(&toml_str).map_err(|e| e.reason())?;
|
||||
|
||||
let wasm_path = dir.join(&manifest.plugin.entrypoint);
|
||||
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"),
|
||||
}
|
||||
|
||||
Ok(LoadedPlugin {
|
||||
id: manifest.plugin.id,
|
||||
subscribe: manifest.events.subscribe.into_iter().collect(),
|
||||
runtime: Arc::new(runtime),
|
||||
})
|
||||
}
|
||||
|
||||
/// Number of successfully loaded plugins (used by DI for the startup summary
|
||||
/// and by tests).
|
||||
pub fn loaded_count(&self) -> usize {
|
||||
self.plugins.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginDispatchPort for ExtismPluginManager {
|
||||
fn dispatch_file_uploaded(&self, event: FileUploadedEvent) {
|
||||
for plugin in &self.plugins {
|
||||
if !plugin.subscribe.contains(EVENT_FILE_UPLOADED) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let input = PluginInput {
|
||||
abi: OXICLOUD_PLUGIN_ABI,
|
||||
event: EVENT_FILE_UPLOADED.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,
|
||||
}),
|
||||
};
|
||||
let input_json = match serde_json::to_string(&input) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "oxicloud::plugins",
|
||||
plugin_id = %plugin.id,
|
||||
error = %e,
|
||||
"failed to serialize plugin input; skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let runtime = plugin.runtime.clone();
|
||||
let config = self.config.clone();
|
||||
let plugin_id = plugin.id.clone();
|
||||
let invocation_id = event.invocation_id.clone();
|
||||
|
||||
// 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);
|
||||
if !result.outcome.is_ok() {
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "plugin.invocation_failed",
|
||||
reason = result.outcome.reason(),
|
||||
plugin_id = %plugin_id,
|
||||
invocation_id = %invocation_id,
|
||||
detail = ?result.outcome,
|
||||
"👮🏻♂️ plugin invocation failed"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn has_subscribers(&self, event: &str) -> bool {
|
||||
self.plugins.iter().any(|p| p.subscribe.contains(event))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! `plugin.toml` parsing + load-time validation (ABI v0).
|
||||
//!
|
||||
//! The manifest is the host's source of truth for *what to load and when to
|
||||
//! call it*. Validation fails closed: unknown sections/keys, a mismatched ABI,
|
||||
//! an unknown subscribed event, or any non-empty `[permissions]` (M0 grants
|
||||
//! none) all reject the plugin. A rejected plugin is skipped, never fatal.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::application::ports::plugin_ports::{EVENT_FILE_UPLOADED, OXICLOUD_PLUGIN_ABI};
|
||||
|
||||
/// Parsed `plugin.toml`. `#[serde(deny_unknown_fields)]` on every struct turns
|
||||
/// stray keys into load errors rather than silently ignored config.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginManifest {
|
||||
pub plugin: PluginSection,
|
||||
pub events: EventsSection,
|
||||
/// M0: must be empty. Any key here rejects the plugin (no grantable
|
||||
/// permissions exist yet). Kept as a free map so future keys are *detected*,
|
||||
/// not parsed.
|
||||
#[serde(default)]
|
||||
pub permissions: BTreeMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginSection {
|
||||
/// Reverse-DNS, unique per instance.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// The plugin's own semver.
|
||||
pub version: String,
|
||||
/// Must equal [`OXICLOUD_PLUGIN_ABI`].
|
||||
pub abi: u32,
|
||||
/// Path to the `.wasm`, relative to the manifest.
|
||||
pub entrypoint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EventsSection {
|
||||
/// Events this plugin wants. M0 accepts only `"file.uploaded"`.
|
||||
pub subscribe: Vec<String>,
|
||||
}
|
||||
|
||||
/// Why a manifest was rejected. `reason()` yields the stable, machine-readable
|
||||
/// key used in audit logs.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ManifestError {
|
||||
#[error("failed to parse plugin.toml: {0}")]
|
||||
Parse(String),
|
||||
#[error("plugin declares ABI {got}, host speaks {want}")]
|
||||
AbiMismatch { got: u32, want: u32 },
|
||||
#[error("events.subscribe must not be empty")]
|
||||
NoEvents,
|
||||
#[error("unknown event '{0}' in events.subscribe")]
|
||||
UnknownEvent(String),
|
||||
#[error("permissions must be empty in ABI v0 (found key '{0}')")]
|
||||
PermissionsNotEmpty(String),
|
||||
}
|
||||
|
||||
impl ManifestError {
|
||||
/// Stable key for `tracing` audit lines; never reworded across releases.
|
||||
pub fn reason(&self) -> &'static str {
|
||||
match self {
|
||||
ManifestError::Parse(_) => "parse_error",
|
||||
ManifestError::AbiMismatch { .. } => "abi_mismatch",
|
||||
ManifestError::NoEvents => "no_events",
|
||||
ManifestError::UnknownEvent(_) => "unknown_event",
|
||||
ManifestError::PermissionsNotEmpty(_) => "permissions_not_empty",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse and validate a `plugin.toml` body. Does not touch the `.wasm`; the
|
||||
/// caller probes `abi_version` separately after a successful parse.
|
||||
pub fn parse_and_validate(toml_str: &str) -> Result<PluginManifest, ManifestError> {
|
||||
let manifest: PluginManifest =
|
||||
toml::from_str(toml_str).map_err(|e| ManifestError::Parse(e.to_string()))?;
|
||||
|
||||
if manifest.plugin.abi != OXICLOUD_PLUGIN_ABI {
|
||||
return Err(ManifestError::AbiMismatch {
|
||||
got: manifest.plugin.abi,
|
||||
want: OXICLOUD_PLUGIN_ABI,
|
||||
});
|
||||
}
|
||||
|
||||
if manifest.events.subscribe.is_empty() {
|
||||
return Err(ManifestError::NoEvents);
|
||||
}
|
||||
for event in &manifest.events.subscribe {
|
||||
if event != EVENT_FILE_UPLOADED {
|
||||
return Err(ManifestError::UnknownEvent(event.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((key, _)) = manifest.permissions.iter().next() {
|
||||
return Err(ManifestError::PermissionsNotEmpty(key.clone()));
|
||||
}
|
||||
|
||||
Ok(manifest)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! WASM plugin runtime (Extism) — M0 walking skeleton.
|
||||
//!
|
||||
//! Compiled only under the `plugins` cargo feature. The application layer talks
|
||||
//! to [`manager::ExtismPluginManager`] through the
|
||||
//! [`crate::application::ports::plugin_ports::PluginDispatchPort`] trait, so the
|
||||
//! Extism types here never leak past the infrastructure boundary.
|
||||
|
||||
pub mod manager;
|
||||
pub mod manifest;
|
||||
pub mod runtime;
|
||||
|
||||
pub use manager::ExtismPluginManager;
|
||||
|
||||
#[cfg(test)]
|
||||
mod runtime_test;
|
||||
@@ -0,0 +1,230 @@
|
||||
//! The Extism runtime wrapper — one sandboxed, per-invocation WASM instance.
|
||||
//!
|
||||
//! Isolation is the point: no WASI, no filesystem, no network, a memory cap, and
|
||||
//! a wall-clock timeout. The only authority a plugin has is the host `log`
|
||||
//! function. Every boundary crossing is wrapped so a trap/timeout/OOM/malformed
|
||||
//! output is captured as an [`InvokeOutcome`] and never propagates to the caller.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use extism::{Manifest as ExtismManifest, PTR, PluginBuilder, UserData, Wasm};
|
||||
|
||||
use crate::application::ports::plugin_ports::{HOST_NAMESPACE, OXICLOUD_PLUGIN_ABI, PluginOutput};
|
||||
use crate::common::config::PluginConfig;
|
||||
|
||||
/// Per-invocation host state: the plugin's identity (for log attribution) plus
|
||||
/// the buffer the `log` host function appends to. Shared with the running
|
||||
/// instance via [`UserData`]; read back after the call via [`drain`].
|
||||
#[derive(Default)]
|
||||
pub struct LogContext {
|
||||
pub plugin_id: String,
|
||||
pub invocation_id: String,
|
||||
pub lines: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
// The entire authority surface: log(level, message) -> (). Observe-only — it
|
||||
// reads nothing and mutates no host state. Unknown levels clamp to "info".
|
||||
extism::host_fn!(oxi_log(user_data: LogContext; level: String, message: String) {
|
||||
let level = match level.as_str() {
|
||||
"debug" | "info" | "warn" | "error" => level,
|
||||
_ => "info".to_string(),
|
||||
};
|
||||
let ud = user_data.get()?;
|
||||
let mut ctx = ud.lock().unwrap();
|
||||
tracing::info!(
|
||||
target: "oxicloud::plugins",
|
||||
plugin_id = %ctx.plugin_id,
|
||||
invocation_id = %ctx.invocation_id,
|
||||
plugin_level = %level,
|
||||
"plugin log: {message}"
|
||||
);
|
||||
ctx.lines.push((level, message));
|
||||
Ok(())
|
||||
});
|
||||
|
||||
/// The result of one boundary crossing. Only `Ok` is a success; every other
|
||||
/// variant is a contained failure the host audit-logs and moves past.
|
||||
#[derive(Debug)]
|
||||
pub enum InvokeOutcome {
|
||||
/// `handle` returned `{"ok": true}`.
|
||||
Ok,
|
||||
/// `handle` returned `{"ok": false, "error": ...}`.
|
||||
PluginError(String),
|
||||
/// A wasm trap (panic/`unreachable`/OOM/etc.).
|
||||
Trap(String),
|
||||
/// The wall-clock timeout cancelled the call.
|
||||
Timeout,
|
||||
/// The instance could not be built (bad/unloadable wasm, unresolved import).
|
||||
LoadError(String),
|
||||
/// `abi_version` returned a value the host does not speak.
|
||||
AbiMismatch { got: u32 },
|
||||
/// `handle` 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 },
|
||||
}
|
||||
|
||||
impl InvokeOutcome {
|
||||
pub fn is_ok(&self) -> bool {
|
||||
matches!(self, InvokeOutcome::Ok)
|
||||
}
|
||||
|
||||
/// Stable, machine-readable key for audit logs.
|
||||
pub fn reason(&self) -> &'static str {
|
||||
match self {
|
||||
InvokeOutcome::Ok => "ok",
|
||||
InvokeOutcome::PluginError(_) => "plugin_error",
|
||||
InvokeOutcome::Trap(_) => "trap",
|
||||
InvokeOutcome::Timeout => "timeout",
|
||||
InvokeOutcome::LoadError(_) => "load_error",
|
||||
InvokeOutcome::AbiMismatch { .. } => "abi_mismatch",
|
||||
InvokeOutcome::MalformedOutput(_) => "malformed_output",
|
||||
InvokeOutcome::MalformedInput { .. } => "malformed_input",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome plus whatever the plugin logged (for tests and tracing).
|
||||
pub struct InvokeResult {
|
||||
pub outcome: InvokeOutcome,
|
||||
pub logs: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// A loaded-but-not-instantiated plugin: the wasm bytes plus identity. A fresh
|
||||
/// instance is built for every invocation (no reuse → no cross-user state).
|
||||
pub struct PluginRuntime {
|
||||
plugin_id: String,
|
||||
wasm_bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
impl PluginRuntime {
|
||||
pub fn new(plugin_id: impl Into<String>, wasm_bytes: Vec<u8>) -> Self {
|
||||
Self {
|
||||
plugin_id: plugin_id.into(),
|
||||
wasm_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plugin_id(&self) -> &str {
|
||||
&self.plugin_id
|
||||
}
|
||||
|
||||
/// Build a fresh, fully locked-down instance for one invocation.
|
||||
fn build(
|
||||
&self,
|
||||
cfg: &PluginConfig,
|
||||
logs: UserData<LogContext>,
|
||||
) -> Result<extism::Plugin, extism::Error> {
|
||||
let manifest = ExtismManifest::new([Wasm::data(self.wasm_bytes.clone())])
|
||||
.with_memory_max(cfg.max_memory_pages) // pages × 64 KiB
|
||||
.with_timeout(Duration::from_millis(cfg.invocation_timeout_ms))
|
||||
.disallow_all_hosts(); // no outbound network
|
||||
// No allowed_paths -> no filesystem. with_wasi(false) -> no ambient authority.
|
||||
PluginBuilder::new(manifest)
|
||||
.with_wasi(false)
|
||||
.with_function_in_namespace(HOST_NAMESPACE, "log", [PTR, PTR], [], logs, oxi_log)
|
||||
.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 {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one `handle` invocation, fully fault-isolated.
|
||||
pub fn invoke(
|
||||
&self,
|
||||
cfg: &PluginConfig,
|
||||
invocation_id: &str,
|
||||
input_json: &str,
|
||||
) -> InvokeResult {
|
||||
if input_json.len() > cfg.max_input_bytes {
|
||||
return InvokeResult {
|
||||
outcome: InvokeOutcome::MalformedInput {
|
||||
size: input_json.len(),
|
||||
max: cfg.max_input_bytes,
|
||||
},
|
||||
logs: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
let logs = UserData::new(LogContext {
|
||||
plugin_id: self.plugin_id.clone(),
|
||||
invocation_id: invocation_id.to_string(),
|
||||
lines: Vec::new(),
|
||||
});
|
||||
|
||||
let mut plugin = match self.build(cfg, logs.clone()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return InvokeResult {
|
||||
outcome: InvokeOutcome::LoadError(e.to_string()),
|
||||
logs: drain(&logs),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Version negotiation at the door.
|
||||
match plugin.call::<(), u32>("abi_version", ()) {
|
||||
Ok(v) if v == OXICLOUD_PLUGIN_ABI => {}
|
||||
Ok(v) => {
|
||||
return InvokeResult {
|
||||
outcome: InvokeOutcome::AbiMismatch { got: v },
|
||||
logs: drain(&logs),
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
return InvokeResult {
|
||||
outcome: classify_call_error(e),
|
||||
logs: drain(&logs),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// The actual call. Traps, timeouts, and OOM all surface here as Err.
|
||||
let outcome = match plugin.call::<&str, String>("handle", input_json) {
|
||||
Ok(out) => match serde_json::from_str::<PluginOutput>(&out) {
|
||||
Ok(parsed) if parsed.ok => InvokeOutcome::Ok,
|
||||
Ok(parsed) => {
|
||||
InvokeOutcome::PluginError(parsed.error.unwrap_or_else(|| "unspecified".into()))
|
||||
}
|
||||
Err(e) => InvokeOutcome::MalformedOutput(e.to_string()),
|
||||
},
|
||||
Err(e) => classify_call_error(e),
|
||||
};
|
||||
|
||||
InvokeResult {
|
||||
outcome,
|
||||
logs: drain(&logs),
|
||||
}
|
||||
// `plugin` dropped here -> sandbox memory reclaimed.
|
||||
}
|
||||
}
|
||||
|
||||
/// Extism signals a wall-clock timeout with `Error::msg("timeout")`; everything
|
||||
/// else from a `call` is a trap (panic, `unreachable`, OOM, etc.).
|
||||
fn classify_call_error(e: extism::Error) -> InvokeOutcome {
|
||||
let msg = e.to_string();
|
||||
if msg.to_ascii_lowercase().contains("timeout") {
|
||||
InvokeOutcome::Timeout
|
||||
} else {
|
||||
InvokeOutcome::Trap(msg)
|
||||
}
|
||||
}
|
||||
|
||||
fn drain(logs: &UserData<LogContext>) -> Vec<(String, String)> {
|
||||
logs.get()
|
||||
.ok()
|
||||
.map(|m| m.lock().unwrap().lines.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Plugin-runtime acceptance + failure-isolation tests, plus manifest-validation
|
||||
//! unit tests.
|
||||
//!
|
||||
//! The `.wasm` fixtures are built and committed by `scripts/build-plugin-hello.sh`
|
||||
//! from `wasm/oxicloud-plugin-hello/`. Run with `cargo test --features plugins`.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::ExtismPluginManager;
|
||||
use super::manifest;
|
||||
use super::runtime::{InvokeOutcome, PluginRuntime};
|
||||
use crate::common::config::PluginConfig;
|
||||
|
||||
fn cfg() -> PluginConfig {
|
||||
PluginConfig::default()
|
||||
}
|
||||
|
||||
/// Load a committed `.wasm` fixture, failing with a build hint if it's missing.
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
fn sample_input() -> String {
|
||||
serde_json::json!({
|
||||
"abi": 0,
|
||||
"event": "file.uploaded",
|
||||
"context": {
|
||||
"plugin_id": "com.example.hello",
|
||||
"user_id": "u_test",
|
||||
"invocation_id": "inv_test_0001"
|
||||
},
|
||||
"payload": { "path": "/photos/2026/cat.jpg", "size": 81234, "mime": "image/jpeg" }
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// ---- The M0 exit criterion: the full loop -----------------------------------
|
||||
|
||||
#[test]
|
||||
fn acceptance_hello_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());
|
||||
|
||||
// 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")),
|
||||
"expected the plugin's host log line, got: {:?}",
|
||||
result.logs
|
||||
);
|
||||
}
|
||||
|
||||
// ---- The guarantees, not just the happy path --------------------------------
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_abi() {
|
||||
let rt = PluginRuntime::new("com.example.wrong-abi", fixture("wrong_abi.wasm"));
|
||||
assert!(
|
||||
matches!(
|
||||
rt.check_loadable(&cfg()),
|
||||
InvokeOutcome::AbiMismatch { got: 1 }
|
||||
),
|
||||
"wrong-abi plugin should be rejected at load"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_a_panicking_plugin() {
|
||||
let rt = PluginRuntime::new("com.example.panic", fixture("panic.wasm"));
|
||||
let result = rt.invoke(&cfg(), "inv", &sample_input());
|
||||
assert!(
|
||||
matches!(result.outcome, InvokeOutcome::Trap(_)),
|
||||
"expected a contained trap, got {:?}",
|
||||
result.outcome
|
||||
);
|
||||
// Reaching this line at all proves the host process survived the trap.
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 elapsed = start.elapsed();
|
||||
|
||||
assert!(
|
||||
matches!(result.outcome, InvokeOutcome::Timeout),
|
||||
"expected a timeout, got {:?}",
|
||||
result.outcome
|
||||
);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(2),
|
||||
"timeout took too long to fire: {elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[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.
|
||||
assert!(
|
||||
!result.outcome.is_ok(),
|
||||
"network access should be denied, got {:?}",
|
||||
result.outcome
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manager_loads_and_dispatches() {
|
||||
use crate::application::ports::plugin_ports::{FileUploadedEvent, PluginDispatchPort};
|
||||
|
||||
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();
|
||||
std::fs::write(
|
||||
plugin_dir.join("plugin.toml"),
|
||||
r#"
|
||||
[plugin]
|
||||
id = "com.example.hello"
|
||||
name = "Hello"
|
||||
version = "0.1.0"
|
||||
abi = 0
|
||||
entrypoint = "hello.wasm"
|
||||
|
||||
[events]
|
||||
subscribe = ["file.uploaded"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let manager = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
|
||||
assert_eq!(manager.loaded_count(), 1, "the valid plugin should load");
|
||||
assert!(manager.has_subscribers("file.uploaded"));
|
||||
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(),
|
||||
user_id: Some("u_test".into()),
|
||||
invocation_id: "inv_dispatch".into(),
|
||||
});
|
||||
// Give the spawned task time to complete before the test runtime shuts down.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
}
|
||||
|
||||
// ---- Manifest validation (no wasm needed) -----------------------------------
|
||||
|
||||
const VALID_MANIFEST: &str = r#"
|
||||
[plugin]
|
||||
id = "com.example.hello"
|
||||
name = "Hello"
|
||||
version = "0.1.0"
|
||||
abi = 0
|
||||
entrypoint = "hello.wasm"
|
||||
|
||||
[events]
|
||||
subscribe = ["file.uploaded"]
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn manifest_accepts_valid() {
|
||||
let m = manifest::parse_and_validate(VALID_MANIFEST).expect("valid manifest");
|
||||
assert_eq!(m.plugin.id, "com.example.hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_rejects_unknown_field() {
|
||||
let toml = format!("{VALID_MANIFEST}\nbogus_top_level = true\n");
|
||||
assert_eq!(
|
||||
manifest::parse_and_validate(&toml).unwrap_err().reason(),
|
||||
"parse_error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_rejects_abi_mismatch() {
|
||||
let toml = VALID_MANIFEST.replace("abi = 0", "abi = 1");
|
||||
assert_eq!(
|
||||
manifest::parse_and_validate(&toml).unwrap_err().reason(),
|
||||
"abi_mismatch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_rejects_unknown_event() {
|
||||
let toml = VALID_MANIFEST.replace(r#"["file.uploaded"]"#, r#"["file.deleted"]"#);
|
||||
assert_eq!(
|
||||
manifest::parse_and_validate(&toml).unwrap_err().reason(),
|
||||
"unknown_event"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_rejects_nonempty_permissions() {
|
||||
let toml = format!("{VALID_MANIFEST}\n[permissions]\nfs = \"/tmp\"\n");
|
||||
assert_eq!(
|
||||
manifest::parse_and_validate(&toml).unwrap_err().reason(),
|
||||
"permissions_not_empty"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user