add frontend

This commit is contained in:
Bradley Nelson
2026-06-16 21:26:36 -06:00
parent 87d68c5b6f
commit 803150635c
32 changed files with 1654 additions and 152 deletions
+1
View File
@@ -3,6 +3,7 @@
pub mod caldav_adapter;
pub mod carddav_adapter;
pub mod plugin_lifecycle_hook;
pub mod plugin_user_lifecycle_hook;
pub mod webdav_adapter;
#[cfg(test)]
@@ -13,7 +13,7 @@ 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,
EVENT_FILE_UPLOADED, PluginDispatchPort, PluginEvent,
};
use crate::application::services::FileRetrievalService;
@@ -59,12 +59,15 @@ impl PluginLifecycleHook {
}
};
dispatch.dispatch_file_uploaded(FileUploadedEvent {
path: dto.path,
size: dto.size,
mime: dto.mime_type.to_string(),
dispatch.dispatch(PluginEvent {
name: EVENT_FILE_UPLOADED,
user_id: dto.owner_id,
invocation_id: Uuid::new_v4().to_string(),
payload: serde_json::json!({
"path": dto.path,
"size": dto.size,
"mime": dto.mime_type.to_string(),
}),
});
});
}
@@ -0,0 +1,160 @@
//! Bridges the [`UserLifecycleHook`] fan-out to the plugin runtime.
//!
//! `UserLifecycleService` already notifies hooks on user create/login/logout/
//! delete. This adapter turns the *login* event into a `user.login` plugin
//! event. It references only the [`PluginDispatchPort`] trait (not Extism), so
//! it is always compiled and the Extism dependency stays in the infrastructure
//! layer.
//!
//! Privacy note: the `user.login` payload includes the user's email — PII handed
//! to untrusted plugins with no permission gate in M0. This is acceptable only
//! because plugins are admin-installed today; when the permissions system lands,
//! sensitive payload fields should be gated behind a granted permission.
use std::sync::Arc;
use async_trait::async_trait;
use uuid::Uuid;
use crate::application::ports::plugin_ports::{EVENT_USER_LOGIN, PluginDispatchPort, PluginEvent};
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook};
use crate::common::errors::DomainError;
use crate::domain::entities::user::User;
/// Lifecycle hook that forwards successful logins to subscribed plugins.
pub struct PluginUserLifecycleHook {
dispatch: Arc<dyn PluginDispatchPort>,
}
impl PluginUserLifecycleHook {
pub fn new(dispatch: Arc<dyn PluginDispatchPort>) -> Self {
Self { dispatch }
}
}
#[async_trait]
impl UserLifecycleHook for PluginUserLifecycleHook {
fn name(&self) -> &'static str {
"plugins"
}
async fn on_user_login(&self, user: &User) -> Result<(), DomainError> {
if self.dispatch.has_subscribers(EVENT_USER_LOGIN) {
self.dispatch.dispatch(PluginEvent {
name: EVENT_USER_LOGIN,
user_id: Some(user.id().to_string()),
invocation_id: Uuid::new_v4().to_string(),
payload: serde_json::json!({
"user_id": user.id().to_string(),
"username": user.username(),
"email": user.email(),
"first_login": user.last_login_at().is_none(),
"is_external": user.is_external(),
}),
});
}
// Returns immediately — dispatch is fire-and-forget (the runtime runs the
// plugin on the blocking pool), so login latency is unaffected.
Ok(())
}
// M0 emits only `user.login`. The trait forces an explicit decision on the
// other three events; they are deliberate no-ops (reserved for future events
// like `user.created` / `user.deleted`).
async fn on_user_created(&self, _user: &User) -> Result<(), DomainError> {
Ok(())
}
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
Ok(())
}
async fn on_user_deleted(
&self,
_user: &User,
_mode: DeletionMode,
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), DomainError> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
use crate::domain::entities::user::UserRole;
/// Records dispatched events so the test can assert the bridge built the
/// right `user.login` event without a runtime or DB.
#[derive(Default)]
struct RecordingDispatch {
events: Mutex<Vec<PluginEvent>>,
}
impl PluginDispatchPort for RecordingDispatch {
fn dispatch(&self, event: PluginEvent) {
self.events.lock().unwrap().push(event);
}
fn has_subscribers(&self, _event: &str) -> bool {
true
}
}
#[tokio::test]
async fn on_user_login_dispatches_user_login_event() {
let recorder = Arc::new(RecordingDispatch::default());
let hook = PluginUserLifecycleHook::new(recorder.clone());
let user = User::new(
"alice@example.com".to_string(),
Some("alice".to_string()),
None,
None,
None,
UserRole::User,
0,
false,
)
.unwrap();
hook.on_user_login(&user).await.unwrap();
let events = recorder.events.lock().unwrap();
assert_eq!(events.len(), 1, "exactly one event dispatched");
let ev = &events[0];
assert_eq!(ev.name, EVENT_USER_LOGIN);
assert_eq!(ev.user_id.as_deref(), Some(user.id().to_string().as_str()));
assert_eq!(ev.payload["email"], "alice@example.com");
assert_eq!(ev.payload["username"], "alice");
assert_eq!(ev.payload["first_login"], true); // last_login_at is None
assert_eq!(ev.payload["is_external"], false);
}
#[tokio::test]
async fn skips_dispatch_when_no_subscribers() {
struct NoSubscribers;
impl PluginDispatchPort for NoSubscribers {
fn dispatch(&self, _event: PluginEvent) {
panic!("must not dispatch when nothing subscribes");
}
fn has_subscribers(&self, _event: &str) -> bool {
false
}
}
let hook = PluginUserLifecycleHook::new(Arc::new(NoSubscribers));
let user = User::new(
"bob@example.com".to_string(),
None,
None,
None,
None,
UserRole::User,
0,
false,
)
.unwrap();
hook.on_user_login(&user).await.unwrap();
}
}
+1
View File
@@ -14,6 +14,7 @@ pub mod grant_dto;
pub mod i18n_dto;
pub mod pagination;
pub mod playlist_dto;
pub mod plugin_dto;
pub mod recent_dto;
pub mod search_dto;
pub mod settings_dto;
+37
View File
@@ -0,0 +1,37 @@
//! DTOs for the admin plugin-management API.
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::application::ports::plugin_ports::PluginInfo;
/// A single installed plugin as returned by `GET /api/admin/plugins`.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PluginInfoDto {
pub id: String,
pub name: String,
pub version: String,
pub abi: u32,
/// Events the plugin subscribes to (e.g. `file.uploaded`).
pub subscriptions: Vec<String>,
pub enabled: bool,
}
impl From<PluginInfo> for PluginInfoDto {
fn from(p: PluginInfo) -> Self {
Self {
id: p.id,
name: p.name,
version: p.version,
abi: p.abi,
subscriptions: p.subscriptions,
enabled: p.enabled,
}
}
}
/// Request body for `PUT /api/admin/plugins/{id}/enabled`.
#[derive(Debug, Deserialize, ToSchema)]
pub struct SetEnabledDto {
pub enabled: bool,
}
+104 -14
View File
@@ -7,7 +7,9 @@
//!
//! The ABI is intentionally tiny (see the M0 spec):
//! - constant [`OXICLOUD_PLUGIN_ABI`] / namespace [`HOST_NAMESPACE`];
//! - plugin exports `abi_version` + `handle`;
//! - plugin exports `abi_version` plus one handler per event it subscribes to,
//! named `on_<event>` (see [`event_export_name`]) — e.g. `on_file_uploaded`,
//! `on_user_login`;
//! - one host import `log` (observe-only — the only authority a plugin has).
use serde::{Deserialize, Serialize};
@@ -21,33 +23,121 @@ pub const OXICLOUD_PLUGIN_ABI: u32 = 0;
/// 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.
/// File committed (created or content-replaced). Payload is metadata only.
pub const EVENT_FILE_UPLOADED: &str = "file.uploaded";
/// A user authenticated successfully.
pub const EVENT_USER_LOGIN: &str = "user.login";
/// Every event the host can emit. Manifest validation accepts only these — a
/// `subscribe` entry outside this set rejects the plugin at load. Adding an
/// event is purely additive (no ABI bump): append its name here, build the
/// payload in a bridge, register that bridge in DI.
pub const KNOWN_EVENTS: &[&str] = &[EVENT_FILE_UPLOADED, EVENT_USER_LOGIN];
/// The plugin export the host calls for `event`: `on_<event>` with dots replaced
/// by underscores (a WASM export must be a valid identifier). A plugin handles an
/// event by exporting this symbol; the host calls exactly the export matching the
/// dispatched event. `file.uploaded` → `on_file_uploaded`; `user.login` →
/// `on_user_login`.
pub fn event_export_name(event: &str) -> String {
format!("on_{}", event.replace('.', "_"))
}
/// 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.
/// caller (a lifecycle hook bridge) 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);
/// Dispatch an event to every plugin subscribed to `event.name`.
fn dispatch(&self, event: PluginEvent);
/// Cheap predicate so the bridge hook can skip the metadata lookup entirely
/// when no plugin subscribes to `event`.
/// Cheap predicate so a bridge can skip building the payload 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).
/// Inbound port: admin management of installed plugins (list / toggle / install
/// / remove). The concrete implementation (the infrastructure
/// `ExtismPluginManager`) owns the same in-memory plugin set the dispatch port
/// reads, so a toggle or install takes effect on the live dispatch path with no
/// restart. All operations are admin-gated at the HTTP layer.
pub trait PluginManagementPort: Send + Sync + 'static {
/// Every installed plugin, enabled or not, with its load-time metadata.
fn list(&self) -> Vec<PluginInfo>;
/// Enable or disable a plugin by id. The change is persisted so it survives
/// a restart, and is reflected immediately by [`PluginDispatchPort`].
fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), PluginMgmtError>;
/// Validate and install a new plugin from its `plugin.toml` text and `.wasm`
/// bytes, writing it to the plugins directory and loading it (enabled). The
/// id is taken from the manifest; a clash with an existing plugin is
/// rejected with [`PluginMgmtError::IdExists`].
fn install(&self, manifest_toml: &str, wasm: Vec<u8>) -> Result<PluginInfo, PluginMgmtError>;
/// Install a plugin from a `.zip` bundle containing `plugin.toml` and the
/// `.wasm` named by its `entrypoint` (both at the archive root or together
/// under a single top-level folder). Extracts the two and delegates to
/// [`PluginManagementPort::install`].
fn install_bundle(&self, zip: Vec<u8>) -> Result<PluginInfo, PluginMgmtError>;
/// Unload a plugin and delete its directory.
fn remove(&self, id: &str) -> Result<(), PluginMgmtError>;
}
/// A single installed plugin's load-time metadata, as surfaced to the admin UI.
#[derive(Debug, Clone)]
pub struct FileUploadedEvent {
pub path: String,
pub size: u64,
pub mime: String,
/// Opaque owner id of the file, when known.
pub struct PluginInfo {
pub id: String,
pub name: String,
pub version: String,
pub abi: u32,
pub subscriptions: Vec<String>,
pub enabled: bool,
}
/// Why a management operation failed. `reason()` yields the stable, machine
/// readable key used in audit logs and surfaced to the UI.
#[derive(Debug)]
pub enum PluginMgmtError {
/// No plugin with that id is installed.
NotFound,
/// An install was attempted for an id that already exists.
IdExists,
/// The bundle failed manifest or runtime validation. Carries the stable
/// reason key from `ManifestError::reason()` / `InvokeOutcome::reason()`,
/// plus a few install-only keys (`bad_id`, `bad_entrypoint`, `bad_zip`,
/// `no_manifest_in_zip`, `entrypoint_not_in_zip`).
Rejected(&'static str),
/// A filesystem error while writing or removing the plugin.
Io(String),
}
impl PluginMgmtError {
/// Stable key for `tracing` audit lines; never reworded across releases.
pub fn reason(&self) -> &'static str {
match self {
PluginMgmtError::NotFound => "not_found",
PluginMgmtError::IdExists => "id_exists",
PluginMgmtError::Rejected(r) => r,
PluginMgmtError::Io(_) => "io_error",
}
}
}
/// A single event to fan out to plugins. The `payload` JSON shape is specific to
/// each `name` and is built by that event's bridge — the runtime is event-blind
/// and never inspects it. Payloads carry metadata only, never file contents.
#[derive(Debug, Clone)]
pub struct PluginEvent {
/// One of [`KNOWN_EVENTS`].
pub name: &'static str,
/// Opaque id of the user the event concerns, when known.
pub user_id: Option<String>,
/// Unique id minted per dispatch, correlating host logs with plugin output.
pub invocation_id: String,
/// Event-specific payload handed to the plugin as `PluginInput.payload`.
pub payload: serde_json::Value,
}
// ---- Wire DTOs (ABI v0 JSON shapes, §3.4 of the spec) ----------------------
+85 -39
View File
@@ -442,6 +442,7 @@ impl AppServiceFactory {
}
/// Initializes the application services
#[allow(clippy::too_many_arguments)] // DI composition root — params are services, not a smell
pub fn create_application_services(
&self,
core: &CoreServices,
@@ -450,6 +451,9 @@ impl AppServiceFactory {
authz: &Arc<PgAclEngine>,
storage_usage: &Arc<StorageUsageService>,
content_index: Option<Arc<TantivyContentIndex>>,
plugin_dispatch: Option<
Arc<dyn crate::application::ports::plugin_ports::PluginDispatchPort>,
>,
) -> ApplicationServices {
// Main services
let folder_service = Arc::new(FolderService::new(
@@ -470,7 +474,8 @@ impl AppServiceFactory {
// 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_lifecycle =
self.effective_file_lifecycle(core, &file_retrieval_service, plugin_dispatch);
let file_upload_service = Arc::new(
FileUploadService::new_with_read(
@@ -559,22 +564,20 @@ 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.
/// metadata). When a plugin dispatch is present, 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>,
plugin_dispatch: Option<
Arc<dyn crate::application::ports::plugin_ports::PluginDispatchPort>,
>,
) -> Arc<dyn crate::application::ports::file_lifecycle::FileLifecycleHook> {
#[cfg(feature = "plugins")]
if self.config.plugins.enabled
&& let Some(manager) = self.create_plugin_manager()
{
if let Some(dispatch) = plugin_dispatch {
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())
@@ -582,37 +585,54 @@ impl AppServiceFactory {
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(
/// The single plugin manager, exposed as the two ports it serves: the
/// dispatch port (shared by every event bridge — file, user, …) and the
/// management port (stored on `AppState` for the admin API). Both wrap the
/// *same* `Arc`, so an install or toggle through the management port takes
/// effect on the live dispatch path with no restart.
///
/// Returns trait objects so call sites stay feature-agnostic; the
/// `#[cfg(feature = "plugins")]` is confined to this body. Both are `None`
/// when the feature is off or `OXICLOUD_ENABLE_PLUGINS` is false.
#[allow(clippy::type_complexity)]
fn create_plugin_ports(
&self,
) -> Option<Arc<crate::infrastructure::services::plugins::ExtismPluginManager>> {
if !self.config.plugins.enabled {
return None;
) -> (
Option<Arc<dyn crate::application::ports::plugin_ports::PluginDispatchPort>>,
Option<Arc<dyn crate::application::ports::plugin_ports::PluginManagementPort>>,
) {
#[cfg(feature = "plugins")]
{
if self.config.plugins.enabled {
let dir = self
.config
.plugins
.plugins_dir
.clone()
.unwrap_or_else(|| self.config.storage_path.join(".plugins"));
let manager = Arc::new(
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"
);
let dispatch: Arc<dyn crate::application::ports::plugin_ports::PluginDispatchPort> =
manager.clone();
let management: Arc<
dyn crate::application::ports::plugin_ports::PluginManagementPort,
> = manager;
return (Some(dispatch), Some(management));
}
}
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))
(None, None)
}
/// Creates the audio metadata service (extracts ID3 tags from audio files)
@@ -916,6 +936,13 @@ impl AppServiceFactory {
// worker starts further down with the maintenance pool.
let content_index = self.create_content_index();
// Single plugin manager, surfaced as its two ports: the dispatch port
// (shared by every event bridge — file uploads here, user logins at the
// auth-services wiring below) and the management port (stored on
// AppState for the admin API). Created once so plugins load exactly once
// regardless of how many events they observe.
let (plugin_dispatch, plugin_management) = self.create_plugin_ports();
// 4. Application services (with trash + authz already wired)
let mut apps = self.create_application_services(
&core,
@@ -924,6 +951,7 @@ impl AppServiceFactory {
&authorization,
&storage_usage,
content_index.as_ref().map(|(idx, _)| idx.clone()),
plugin_dispatch.clone(),
);
// 5. Share service
@@ -1013,7 +1041,7 @@ impl AppServiceFactory {
pool.clone(),
),
);
let user_lifecycle = Arc::new(
let mut user_lifecycle_builder =
crate::application::services::user_lifecycle_service::UserLifecycleService::new()
.with_hook(Arc::new(
crate::application::services::user_lifecycle_service::AuditLifecycleHook,
@@ -1036,8 +1064,20 @@ impl AppServiceFactory {
.with_hook(Arc::new(
crate::application::services::external_identity_service::ExternalIdentityLifecycleHook::new()
.with_magic_link_repo(magic_link_repo.clone()),
)),
);
));
// Plugin user.login bridge — shares the single plugin dispatch with
// the file-upload bridge. Registered only when plugins are active;
// inert until auth is enabled (the dispatcher is never fired otherwise).
if let Some(dispatch) = &plugin_dispatch {
user_lifecycle_builder = user_lifecycle_builder.with_hook(Arc::new(
crate::application::adapters::plugin_user_lifecycle_hook::PluginUserLifecycleHook::new(
dispatch.clone(),
),
));
}
let user_lifecycle = Arc::new(user_lifecycle_builder);
// Auth services. Folder service no longer threaded here —
// PR 3 moved home-folder provisioning into
@@ -1171,6 +1211,7 @@ impl AppServiceFactory {
nextcloud: nextcloud_services,
admin_settings_service: None,
storage_settings_service: None,
plugin_management,
migration_state: Arc::new(tokio::sync::RwLock::new(MigrationState::default())),
trash_service,
share_service,
@@ -1611,6 +1652,11 @@ pub struct AppState {
pub auth_service: Option<AuthServices>,
pub nextcloud: Option<NextcloudServices>,
pub admin_settings_service: Option<Arc<AdminSettingsService>>,
/// WASM plugin management (list/install/toggle/remove), backing the admin
/// Plugins tab. `None` when the `plugins` feature is compiled out or
/// `OXICLOUD_ENABLE_PLUGINS` is false — the admin endpoints return 503 then.
pub plugin_management:
Option<Arc<dyn crate::application::ports::plugin_ports::PluginManagementPort>>,
pub storage_settings_service: Option<Arc<StorageSettingsService>>,
pub migration_state: Arc<tokio::sync::RwLock<MigrationState>>,
pub trash_service: Option<Arc<TrashService>>,
+266 -29
View File
@@ -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;
+22 -9
View File
@@ -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");
+185 -1
View File
@@ -1,17 +1,19 @@
use axum::{
Router,
extract::{Json, Path, Query, State},
extract::{Json, Multipart, Path, Query, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
routing::{delete, get, post, put},
};
use crate::application::dtos::plugin_dto::{PluginInfoDto, SetEnabledDto};
use crate::application::dtos::settings_dto::{
AdminCreateUserDto, AdminResetPasswordDto, DashboardStatsDto, ListUsersQueryDto,
MigrationStateDto, SaveOidcSettingsDto, SaveStorageSettingsDto, SendSmtpTestDto, SmtpInfoDto,
SmtpTestResultDto, StartMigrationDto, TestOidcConnectionDto, TestStorageConnectionDto,
UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto, VerifyMigrationDto,
};
use crate::application::ports::plugin_ports::{PluginManagementPort, PluginMgmtError};
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::admin::require_admin;
@@ -60,6 +62,11 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
.route("/audio/metadata/reextract", post(reextract_audio_metadata))
// Image/video capture metadata (Photos timeline backfill)
.route("/photos/metadata/reextract", post(reextract_image_metadata))
// Plugin management
.route("/plugins", get(list_plugins))
.route("/plugins", post(install_plugin))
.route("/plugins/{id}/enabled", put(set_plugin_enabled))
.route("/plugins/{id}", delete(delete_plugin))
// SMTP diagnostics
.route("/smtp/info", get(get_smtp_info))
.route("/smtp/test", post(send_smtp_test))
@@ -1440,3 +1447,180 @@ async fn send_smtp_test(
Ok(Json(result))
}
// ---- Plugin management -----------------------------------------------------
/// Resolve the plugin-management port, or 503 when plugins are compiled out or
/// disabled via `OXICLOUD_ENABLE_PLUGINS`. The admin UI treats this 503 as the
/// "plugins disabled" state rather than an error.
fn plugin_mgmt(state: &AppState) -> Result<&Arc<dyn PluginManagementPort>, AppError> {
state.plugin_management.as_ref().ok_or_else(|| {
AppError::new(
StatusCode::SERVICE_UNAVAILABLE,
"Plugins are disabled",
"PluginsDisabled",
)
})
}
/// Map a management-layer error to an HTTP error. NotFound → 404, IdExists →
/// 409, Rejected → 400 (with the stable reason key in the message), Io → 500.
fn map_mgmt_err(err: &PluginMgmtError) -> AppError {
match err {
PluginMgmtError::NotFound => AppError::not_found("Plugin not found"),
PluginMgmtError::IdExists => {
AppError::conflict("A plugin with this id is already installed")
}
PluginMgmtError::Rejected(reason) => AppError::new(
StatusCode::BAD_REQUEST,
format!("Plugin rejected: {reason}"),
"PluginRejected",
),
PluginMgmtError::Io(msg) => {
AppError::internal_error(format!("Plugin operation failed: {msg}"))
}
}
}
/// GET /api/admin/plugins — list installed plugins.
pub async fn list_plugins(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
let plugins: Vec<PluginInfoDto> = mgmt.list().into_iter().map(PluginInfoDto::from).collect();
Ok((
StatusCode::OK,
Json(serde_json::json!({ "enabled": true, "plugins": plugins })),
))
}
/// PUT /api/admin/plugins/{id}/enabled — enable or disable a plugin.
pub async fn set_plugin_enabled(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<SetEnabledDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
mgmt.set_enabled(&id, dto.enabled)
.map_err(|e| map_mgmt_err(&e))?;
if dto.enabled {
tracing::info!(
target: "audit",
event = "plugin.enabled",
plugin_id = %id,
admin_id = %admin_id,
"👮🏻‍♂️ plugin enabled by admin"
);
} else {
tracing::info!(
target: "audit",
event = "plugin.disabled",
plugin_id = %id,
admin_id = %admin_id,
"👮🏻‍♂️ plugin disabled by admin"
);
}
Ok((
StatusCode::OK,
Json(serde_json::json!({
"message": if dto.enabled { "Plugin enabled" } else { "Plugin disabled" },
"id": id,
"enabled": dto.enabled,
})),
))
}
/// POST /api/admin/plugins — install a plugin from a multipart body with a
/// single `bundle` part: a `.zip` containing `plugin.toml` and its `.wasm`.
pub async fn install_plugin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
mut multipart: Multipart,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
let mut bundle: Option<Vec<u8>> = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| AppError::bad_request(format!("Invalid multipart body: {e}")))?
{
if field.name() == Some("bundle") {
bundle = Some(
field
.bytes()
.await
.map_err(|e| AppError::bad_request(format!("Invalid bundle part: {e}")))?
.to_vec(),
);
}
}
let bundle = match bundle {
Some(b) => b,
None => {
tracing::warn!(
target: "audit",
event = "plugin.install_rejected",
reason = "missing_part",
admin_id = %admin_id,
"👮🏻‍♂️ plugin install rejected: missing 'bundle' part"
);
return Err(AppError::bad_request("A 'bundle' (.zip) part is required"));
}
};
match mgmt.install_bundle(bundle) {
Ok(info) => {
tracing::info!(
target: "audit",
event = "plugin.installed",
plugin_id = %info.id,
admin_id = %admin_id,
"👮🏻‍♂️ plugin installed by admin"
);
Ok((StatusCode::CREATED, Json(PluginInfoDto::from(info))))
}
Err(e) => {
tracing::warn!(
target: "audit",
event = "plugin.install_rejected",
reason = e.reason(),
admin_id = %admin_id,
"👮🏻‍♂️ plugin install rejected"
);
Err(map_mgmt_err(&e))
}
}
}
/// DELETE /api/admin/plugins/{id} — uninstall a plugin and delete its files.
pub async fn delete_plugin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
mgmt.remove(&id).map_err(|e| map_mgmt_err(&e))?;
tracing::info!(
target: "audit",
event = "plugin.removed",
plugin_id = %id,
admin_id = %admin_id,
"👮🏻‍♂️ plugin removed by admin"
);
Ok((
StatusCode::OK,
Json(serde_json::json!({ "message": "Plugin removed", "id": id })),
))
}