init plugins

This commit is contained in:
Bradley Nelson
2026-06-16 17:57:57 -06:00
parent 23e78e9a9c
commit 87d68c5b6f
27 changed files with 3099 additions and 24 deletions
+1
View File
@@ -2,6 +2,7 @@
pub mod caldav_adapter;
pub mod carddav_adapter;
pub mod plugin_lifecycle_hook;
pub mod webdav_adapter;
#[cfg(test)]
@@ -0,0 +1,100 @@
//! Bridges the existing [`FileLifecycleHook`] fan-out to the plugin runtime.
//!
//! `FileLifecycleService` already notifies hooks on every file create/update.
//! This adapter turns those notifications into `file.uploaded` plugin events.
//! Because the hook signature carries only `file_id` (not path/size), it looks
//! the metadata up via [`FileRetrievalUseCase::get_file`] — off the request
//! path, and skipped entirely when no plugin subscribes.
use std::sync::Arc;
use uuid::Uuid;
use crate::application::ports::file_lifecycle::FileLifecycleHook;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::plugin_ports::{
EVENT_FILE_UPLOADED, FileUploadedEvent, PluginDispatchPort,
};
use crate::application::services::FileRetrievalService;
/// Lifecycle hook that forwards file create/update events to subscribed plugins.
pub struct PluginLifecycleHook {
dispatch: Arc<dyn PluginDispatchPort>,
retrieval: Arc<FileRetrievalService>,
}
impl PluginLifecycleHook {
pub fn new(
dispatch: Arc<dyn PluginDispatchPort>,
retrieval: Arc<FileRetrievalService>,
) -> Self {
Self {
dispatch,
retrieval,
}
}
/// Look up the file's metadata and dispatch a `file.uploaded` event. Cheap
/// early-out when nothing subscribes; otherwise the DB read and the plugin
/// run happen on a background task, never blocking the caller.
fn dispatch_upload(&self, file_id: &str) {
if !self.dispatch.has_subscribers(EVENT_FILE_UPLOADED) {
return;
}
let dispatch = self.dispatch.clone();
let retrieval = self.retrieval.clone();
let file_id = file_id.to_string();
tokio::spawn(async move {
let dto = match retrieval.get_file(&file_id).await {
Ok(dto) => dto,
Err(e) => {
tracing::warn!(
target: "oxicloud::plugins",
file_id = %file_id,
error = %e,
"plugin bridge: file metadata lookup failed; skipping dispatch"
);
return;
}
};
dispatch.dispatch_file_uploaded(FileUploadedEvent {
path: dto.path,
size: dto.size,
mime: dto.mime_type.to_string(),
user_id: dto.owner_id,
invocation_id: Uuid::new_v4().to_string(),
});
});
}
}
impl FileLifecycleHook for PluginLifecycleHook {
fn on_file_created(
&self,
file_id: &str,
_blob_hash: &str,
_content_type: &str,
_is_new_blob: bool,
) {
self.dispatch_upload(file_id);
}
fn on_file_updated(&self, file_id: &str, _blob_hash: &str, _content_type: &str) {
self.dispatch_upload(file_id);
}
// A copy creates a new file record, but its content already existed and was
// already observed on its original upload; M0 does not re-dispatch for it.
fn on_file_copied(
&self,
_file_id: &str,
_blob_hash: &str,
_content_type: &str,
_source_file_id: &str,
) {
}
fn on_file_deleted(&self, _file_id: &str) {}
}
+1
View File
@@ -17,6 +17,7 @@ pub mod folder_ports;
pub mod inbound;
pub mod music_ports;
pub mod outbound;
pub mod plugin_ports;
pub mod recent_ports;
pub mod share_ports;
pub mod storage_ports;
+80
View File
@@ -0,0 +1,80 @@
//! WASM plugin runtime ports (ABI v0 — M0 walking skeleton).
//!
//! This module is the *entire* contract surface the rest of the application
//! talks to. Concrete Extism types live in the infrastructure layer behind
//! [`PluginDispatchPort`], keeping the hexagonal boundary intact: nothing in
//! `application/` or `domain/` depends on the WASM runtime.
//!
//! The ABI is intentionally tiny (see the M0 spec):
//! - constant [`OXICLOUD_PLUGIN_ABI`] / namespace [`HOST_NAMESPACE`];
//! - plugin exports `abi_version` + `handle`;
//! - one host import `log` (observe-only — the only authority a plugin has).
use serde::{Deserialize, Serialize};
/// The single ABI version this host speaks. A breaking change bumps this and
/// the namespace suffix ([`HOST_NAMESPACE`]); plugins built against a different
/// value are rejected at load, never silently mis-run.
pub const OXICLOUD_PLUGIN_ABI: u32 = 0;
/// Namespace of the host functions a plugin may import. The `:v0` suffix is
/// part of the import path so a future `v1` is a *different* symbol.
pub const HOST_NAMESPACE: &str = "oxicloud:host:v0";
/// The only event emitted in M0.
pub const EVENT_FILE_UPLOADED: &str = "file.uploaded";
/// Outbound port: the application asks the (infrastructure) plugin runtime to
/// dispatch an event to every subscribed plugin. Dispatch is fire-and-forget —
/// the implementation owns all isolation, timeouts, and fault handling, and the
/// caller (a `FileLifecycleHook`) never awaits it.
pub trait PluginDispatchPort: Send + Sync + 'static {
/// Dispatch a `file.uploaded` event (metadata only) to subscribed plugins.
fn dispatch_file_uploaded(&self, event: FileUploadedEvent);
/// Cheap predicate so the bridge hook can skip the metadata lookup entirely
/// when no plugin subscribes to `event`.
fn has_subscribers(&self, event: &str) -> bool;
}
/// Metadata describing a freshly committed file. Carries **no file contents** —
/// only path, size, and MIME (privacy goal).
#[derive(Debug, Clone)]
pub struct FileUploadedEvent {
pub path: String,
pub size: u64,
pub mime: String,
/// Opaque owner id of the file, when known.
pub user_id: Option<String>,
/// Unique id minted per dispatch, correlating host logs with plugin output.
pub invocation_id: String,
}
// ---- Wire DTOs (ABI v0 JSON shapes, §3.4 of the spec) ----------------------
/// Serialized host → plugin and handed to `handle` as a UTF-8 JSON string.
#[derive(Debug, Clone, Serialize)]
pub struct PluginInput {
pub abi: u32,
pub event: String,
pub context: PluginContext,
pub payload: serde_json::Value,
}
/// Invocation context. `user_id` is the owner of the event; because each
/// invocation is a fresh instance, a plugin never sees two users at once.
#[derive(Debug, Clone, Serialize)]
pub struct PluginContext {
pub plugin_id: String,
pub user_id: Option<String>,
pub invocation_id: String,
}
/// Returned from `handle`. M0 has no `actions` array — the plugin cannot ask the
/// host to do anything (observe-only). Unknown fields are ignored.
#[derive(Debug, Clone, Deserialize)]
pub struct PluginOutput {
pub ok: bool,
#[serde(default)]
pub error: Option<String>,
}
+69
View File
@@ -936,6 +936,45 @@ impl Default for ContentSearchConfig {
}
}
/// WASM plugin runtime configuration (M0 walking skeleton).
///
/// The runtime is doubly gated: it is only compiled when the `plugins` cargo
/// feature is enabled, and only activated when `enabled` is `true`. The limits
/// below are conservative starting defaults, not part of the plugin ABI — each
/// deployment may tune them.
#[derive(Debug, Clone)]
pub struct PluginConfig {
/// Master switch. When disabled, no plugins are loaded and the lifecycle
/// bridge hook is never registered. Env: `OXICLOUD_ENABLE_PLUGINS`.
pub enabled: bool,
/// Directory scanned for plugins at startup; each plugin is a subdirectory
/// containing `plugin.toml` + its `.wasm`. Default: `{storage_path}/.plugins`.
/// Env: `OXICLOUD_PLUGINS_DIR`.
pub plugins_dir: Option<PathBuf>,
/// Wall-clock timeout for a single `handle` invocation. A runaway plugin
/// cannot stall the upload path beyond this. Default: 250.
/// Env: `OXICLOUD_PLUGIN_TIMEOUT_MS`.
pub invocation_timeout_ms: u64,
/// Max linear memory per plugin instance, in WASM pages (64 KiB each).
/// Default: 256 (≈ 16 MiB). Env: `OXICLOUD_PLUGIN_MAX_MEMORY_PAGES`.
pub max_memory_pages: u32,
/// Hard cap on the serialized event payload handed to a plugin. Default:
/// 256 KiB. Env: `OXICLOUD_PLUGIN_MAX_INPUT_BYTES`.
pub max_input_bytes: usize,
}
impl Default for PluginConfig {
fn default() -> Self {
Self {
enabled: false,
plugins_dir: None,
invocation_timeout_ms: 250,
max_memory_pages: 256,
max_input_bytes: 256 * 1024,
}
}
}
/// Global application configuration
#[derive(Debug, Clone)]
pub struct AppConfig {
@@ -977,6 +1016,8 @@ pub struct AppConfig {
pub i18n: I18nConfig,
/// Content-search configuration (embedded full-text index)
pub content_search: ContentSearchConfig,
/// WASM plugin runtime configuration
pub plugins: PluginConfig,
}
/// Server-side i18n knobs.
@@ -1029,6 +1070,7 @@ impl Default for AppConfig {
magic_link: MagicLinkConfig::default(),
i18n: I18nConfig::default(),
content_search: ContentSearchConfig::default(),
plugins: PluginConfig::default(),
}
}
}
@@ -1318,6 +1360,33 @@ impl AppConfig {
config.content_search.max_text_bytes = val;
}
// WASM plugin runtime
if let Ok(v) = env::var("OXICLOUD_ENABLE_PLUGINS").map(|v| v.parse::<bool>())
&& let Ok(val) = v
{
config.plugins.enabled = val;
}
if let Ok(dir) = env::var("OXICLOUD_PLUGINS_DIR")
&& !dir.trim().is_empty()
{
config.plugins.plugins_dir = Some(PathBuf::from(dir.trim()));
}
if let Ok(v) = env::var("OXICLOUD_PLUGIN_TIMEOUT_MS").map(|v| v.parse::<u64>())
&& let Ok(val) = v
{
config.plugins.invocation_timeout_ms = val;
}
if let Ok(v) = env::var("OXICLOUD_PLUGIN_MAX_MEMORY_PAGES").map(|v| v.parse::<u32>())
&& let Ok(val) = v
{
config.plugins.max_memory_pages = val;
}
if let Ok(v) = env::var("OXICLOUD_PLUGIN_MAX_INPUT_BYTES").map(|v| v.parse::<usize>())
&& let Ok(val) = v
{
config.plugins.max_input_bytes = val;
}
if let Ok(v) = env::var("OXICLOUD_EXPOSE_SYSTEM_USERS").map(|v| v.parse::<bool>())
&& let Ok(val) = v
{
+75 -9
View File
@@ -457,13 +457,28 @@ impl AppServiceFactory {
authz.clone(),
));
// Built before the upload/management services so the plugin lifecycle
// bridge (which looks file metadata up by id) can be wired into the
// dispatcher they receive. It depends only on repos + core, never on
// the upload service, so the reorder is safe.
let file_retrieval_service = Arc::new(FileRetrievalService::new_with_cache(
repos.file_read_repository.clone(),
core.file_content_cache.clone(),
core.image_transcode_service.clone(),
authz.clone(),
));
// Effective lifecycle dispatcher: the core hooks (thumbnails, metadata)
// plus, when the plugins feature is enabled, the WASM plugin bridge.
let file_lifecycle = self.effective_file_lifecycle(core, &file_retrieval_service);
let file_upload_service = Arc::new(
FileUploadService::new_with_read(
repos.file_write_repository.clone(),
repos.file_read_repository.clone(),
)
.with_content_cache(core.file_content_cache.clone())
.with_file_lifecycle_hook(core.file_lifecycle.clone())
.with_file_lifecycle_hook(file_lifecycle.clone())
.with_instant_upload(
authz.clone(),
core.dedup_service.clone(),
@@ -485,13 +500,6 @@ impl AppServiceFactory {
),
);
let file_retrieval_service = Arc::new(FileRetrievalService::new_with_cache(
repos.file_read_repository.clone(),
core.file_content_cache.clone(),
core.image_transcode_service.clone(),
authz.clone(),
));
// FileManagementService — ref_count handled by PG trigger, no dedup port needed
let file_management_service = Arc::new(
FileManagementService::with_trash(
@@ -502,7 +510,7 @@ impl AppServiceFactory {
Some(core.file_content_cache.clone()),
authz.clone(),
)
.with_file_lifecycle_hook(core.file_lifecycle.clone()),
.with_file_lifecycle_hook(file_lifecycle.clone()),
);
let file_use_case_factory = Arc::new(AppFileUseCaseFactory::new(
@@ -549,6 +557,64 @@ impl AppServiceFactory {
}
}
/// Builds the file lifecycle dispatcher handed to the upload/management
/// services. By default this is just the core dispatcher (thumbnails,
/// metadata). When the `plugins` feature is built and enabled, it wraps the
/// core dispatcher together with the WASM plugin bridge so plugins observe
/// `file.uploaded` events without any of the core hooks being aware of them.
fn effective_file_lifecycle(
&self,
core: &CoreServices,
file_retrieval: &Arc<FileRetrievalService>,
) -> Arc<dyn crate::application::ports::file_lifecycle::FileLifecycleHook> {
#[cfg(feature = "plugins")]
if self.config.plugins.enabled
&& let Some(manager) = self.create_plugin_manager()
{
use crate::application::adapters::plugin_lifecycle_hook::PluginLifecycleHook;
use crate::application::ports::plugin_ports::PluginDispatchPort;
let dispatch: Arc<dyn PluginDispatchPort> = manager;
let bridge = Arc::new(PluginLifecycleHook::new(dispatch, file_retrieval.clone()));
let composite = FileLifecycleService::new()
.with_hook(core.file_lifecycle.clone())
.with_hook(bridge);
return Arc::new(composite);
}
let _ = file_retrieval;
core.file_lifecycle.clone()
}
/// Discovers and loads WASM plugins when the `plugins` feature is enabled and
/// `OXICLOUD_ENABLE_PLUGINS=true`. Plugins live under
/// `OXICLOUD_PLUGINS_DIR` (default `{storage_path}/.plugins`); a missing or
/// empty directory simply yields no plugins.
#[cfg(feature = "plugins")]
fn create_plugin_manager(
&self,
) -> Option<Arc<crate::infrastructure::services::plugins::ExtismPluginManager>> {
if !self.config.plugins.enabled {
return None;
}
let dir = self
.config
.plugins
.plugins_dir
.clone()
.unwrap_or_else(|| self.config.storage_path.join(".plugins"));
let manager = crate::infrastructure::services::plugins::ExtismPluginManager::load_from_dir(
self.config.plugins.clone(),
&dir,
);
tracing::info!(
target: "oxicloud::plugins",
loaded = manager.loaded_count(),
"plugin manager initialized"
);
Some(Arc::new(manager))
}
/// Creates the audio metadata service (extracts ID3 tags from audio files)
pub fn create_audio_metadata_service(
&self,
+2
View File
@@ -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"
);
}