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
+31
View File
@@ -24,6 +24,7 @@ jobs:
frontend: ${{ steps.filter.outputs.frontend }}
backend: ${{ steps.filter.outputs.backend }}
wasm: ${{ steps.filter.outputs.wasm }}
plugins: ${{ steps.filter.outputs.plugins }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
@@ -48,6 +49,13 @@ jobs:
wasm:
- 'wasm/**'
- 'scripts/build-wasm.sh'
plugins:
- 'wasm/oxicloud-plugin-hello/**'
- 'scripts/build-plugin-hello.sh'
- 'tests/fixtures/plugins/**'
- 'src/infrastructure/services/plugins/**'
- 'src/application/ports/plugin_ports.rs'
- 'src/application/adapters/plugin_lifecycle_hook.rs'
frontend-check:
name: Frontend — CSS and JS checks (format, lint, css-rules, types)
@@ -171,6 +179,29 @@ jobs:
workspaces: wasm/oxicloud-hash
- run: cargo test --release
# Plugin runtime (Extism). Rebuilds the committed .wasm fixtures from
# wasm/oxicloud-plugin-hello/ and fails if they drift from what is
# committed (staleness guard), then runs the plugin-runtime tests with
# the `plugins` feature. The wasm32 target is needed only to rebuild the
# fixtures; the host tests themselves do not need it.
plugins:
name: Plugins — fixtures + runtime tests
needs: changes
if: needs.changes.outputs.plugins == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- name: Rebuild committed wasm fixtures
run: bash scripts/build-plugin-hello.sh
- name: Fail if fixtures are stale (rebuild + commit them)
run: git diff --exit-code tests/fixtures/plugins/
- name: Run plugin runtime tests
run: cargo test --features plugins plugins::
rust-test:
name: Server Unit and Functionnal Tests
needs: changes
Generated
+1406 -15
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -78,11 +78,16 @@ tantivy = "0.26.1"
zip = { version = "8.6.0", default-features = false, features = ["deflate"] }
pdf-extract = "0.10.0"
nom-exif = "3.6.1"
extism = { version = "1.30.0", optional = true }
toml = { version = "1.1.2", optional = true }
[features]
default = []
test_utils = ["mockall"]
integration_tests = []
# WASM plugin runtime (Extism). Opt-in: bundles wasmtime, a large engine most
# deployments won't use. Activation also requires OXICLOUD_ENABLE_PLUGINS=true.
plugins = ["dep:extism", "dep:toml"]
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] }
+4
View File
@@ -5,6 +5,10 @@
enable = true;
channel = "stable"; # edition 2024 needs a recent stable (CLAUDE.md: Rust 1.93+)
components = [ "rustc" "cargo" "clippy" "rustfmt" "rust-analyzer" "rust-src" ];
# wasm32 std for building the WASM plugin fixtures and the vendored BLAKE3
# module (scripts/build-plugin-hello.sh, scripts/build-wasm.sh). Only needed
# to *rebuild* those committed artifacts, not for normal server builds/tests.
targets = [ "wasm32-unknown-unknown" ];
};
# Native build deps + the full justfile toolchain.
+19
View File
@@ -224,6 +224,25 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud
# Set to false to prevent users from browsing the user directory.
#OXICLOUD_EXPOSE_SYSTEM_USERS=true
# WASM plugin runtime (Extism). Requires a binary built with the `plugins`
# cargo feature (`cargo run --features plugins`); without that feature these
# vars are inert. Untrusted plugins run sandboxed: no filesystem, no network,
# capped memory, per-invocation timeout. (default: false)
#OXICLOUD_ENABLE_PLUGINS=false
# Directory scanned for plugins at startup; each plugin is a subdirectory with
# a plugin.toml + its .wasm. (default: {OXICLOUD_STORAGE_PATH}/.plugins)
#OXICLOUD_PLUGINS_DIR=
# Per-invocation wall-clock timeout in ms (default: 250)
#OXICLOUD_PLUGIN_TIMEOUT_MS=250
# Max linear memory per plugin instance, in 64 KiB WASM pages (default: 256 = 16 MiB)
#OXICLOUD_PLUGIN_MAX_MEMORY_PAGES=256
# Max serialized event payload handed to a plugin, in bytes (default: 262144 = 256 KiB)
#OXICLOUD_PLUGIN_MAX_INPUT_BYTES=262144
# -----------------------------------------------------------------------------
# STORAGE BACKEND
# -----------------------------------------------------------------------------
+15
View File
@@ -65,6 +65,21 @@ wasm-check:
wasm-test:
cd wasm/oxicloud-hash; cargo test --release
# Run the host plugin-runtime tests (compiles the Extism/wasmtime runtime).
test-plugins:
cargo test --features plugins
# Rebuild the committed plugin .wasm fixtures from wasm/oxicloud-plugin-hello/.
# Requires the wasm32 target (devenv provides it; else `rustup target add
# wasm32-unknown-unknown`). Commit the regenerated files; CI fails on drift.
plugin-build:
bash scripts/build-plugin-hello.sh
# fmt + clippy the example plugin crate (standalone workspace, wasm32 target).
plugin-check:
cd wasm/oxicloud-plugin-hello; cargo fmt --all
cd wasm/oxicloud-plugin-hello; cargo clippy --target wasm32-unknown-unknown --release -- -D warnings
# audit security (condition: cargo install cargo-audit)
audit:
cargo audit
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Rebuild the committed plugin .wasm fixtures used by the plugin-runtime tests
# (src/infrastructure/services/plugins/runtime_test.rs).
#
# The generated artifacts ARE committed — like the vendored BLAKE3 module — so
# regular builds and `cargo test --features plugins` never need the wasm
# toolchain. Re-run this only when wasm/oxicloud-plugin-hello/ changes, and
# commit the regenerated files. CI rebuilds them and fails on any diff.
#
# Requirements (one-time):
# - the wasm32-unknown-unknown target. In the devenv this is provided by
# `languages.rust.targets` in devenv.nix; otherwise:
# rustup target add wasm32-unknown-unknown
set -euo pipefail
cd "$(dirname "$0")/.."
CRATE=wasm/oxicloud-plugin-hello
OUT=tests/fixtures/plugins
ARTIFACT="$CRATE/target/wasm32-unknown-unknown/release/oxicloud_plugin_hello.wasm"
# Needs the wasm32-unknown-unknown target's std. In the devenv this comes from
# `languages.rust.targets` in devenv.nix; otherwise run
# `rustup target add wasm32-unknown-unknown`. cargo emits a clear "can't find
# crate for `std`" error below if it is missing.
mkdir -p "$OUT"
build() {
local variant="$1"; shift
echo "building $variant.wasm ${*:+(features: ${*#--features })}"
cargo build \
--manifest-path "$CRATE/Cargo.toml" \
--target wasm32-unknown-unknown \
--release "$@"
cp "$ARTIFACT" "$OUT/$variant.wasm"
}
build hello
build panic --features panic
build sleep --features sleep
build net --features net
build wrong_abi --features wrong_abi
echo "Built fixtures:"
ls -la "$OUT"/*.wasm | awk '{print " " $9 " (" $5 " bytes)"}'
+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"
);
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+379
View File
@@ -0,0 +1,379 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "extism-convert"
version = "1.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad19858c4c462309a8f3a20abec53e8603bda1eefda26c8bfab51d5516b40cbb"
dependencies = [
"anyhow",
"base64",
"bytemuck",
"extism-convert-macros",
"prost",
"rmp-serde",
"serde",
"serde_json",
]
[[package]]
name = "extism-convert-macros"
version = "1.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f2932799f6d9f9646f97b65287f6bb2addc75a0ee61e40fb24559a7540dd928"
dependencies = [
"manyhow",
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "extism-manifest"
version = "1.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2f59c8dadb5e0bde9a48c6ed45312e6ef625cbcd5f67c28459dbc8fe8bc0383"
dependencies = [
"base64",
"serde",
"serde_json",
]
[[package]]
name = "extism-pdk"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "352fcb5a66eb74145a1c4a01f2bd15d59c62c85be73aac8471880c65b26b798f"
dependencies = [
"anyhow",
"base64",
"extism-convert",
"extism-manifest",
"extism-pdk-derive",
"serde",
"serde_json",
]
[[package]]
name = "extism-pdk-derive"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d086daea5fd844e3c5ac69ddfe36df4a9a43e7218cf7d1f888182b089b09806c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "manyhow"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587"
dependencies = [
"manyhow-macros",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "manyhow-macros"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495"
dependencies = [
"proc-macro-utils",
"proc-macro2",
"quote",
]
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "oxicloud-plugin-hello"
version = "0.1.0"
dependencies = [
"extism-pdk",
"serde_json",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro-utils"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071"
dependencies = [
"proc-macro2",
"quote",
"smallvec",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "prost"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-derive"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
dependencies = [
"anyhow",
"itertools",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rmp"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
dependencies = [
"num-traits",
]
[[package]]
name = "rmp-serde"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
dependencies = [
"rmp",
"serde",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.25.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "winnow"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
dependencies = [
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "oxicloud-plugin-hello"
version = "0.1.0"
edition = "2021"
description = "Example OxiCloud plugin (ABI v0) — the artifact the plugin-runtime acceptance test loads. Cargo features build the misbehaving variants used by the failure-isolation tests."
publish = false
# Standalone workspace root: built only by scripts/build-plugin-hello.sh on the
# wasm32 target, never by the server's `cargo test --workspace` / clippy.
[workspace]
[lib]
crate-type = ["cdylib"]
[dependencies]
extism-pdk = "1"
serde_json = "1"
[features]
default = []
# Misbehaving variants for the failure-isolation tests. Each builds a separate
# fixture under tests/fixtures/plugins/ via the build script.
panic = [] # `handle` panics -> host must contain the trap
sleep = [] # `handle` busy-loops past the timeout
net = [] # `handle` attempts an outbound HTTP call (denied: no allowed_hosts)
wrong_abi = [] # `abi_version` returns 1 -> host must reject at load
[profile.release]
opt-level = "s"
lto = true
strip = true
+75
View File
@@ -0,0 +1,75 @@
//! Example OxiCloud plugin — ABI v0 (M0 walking skeleton).
//!
//! The default build is the well-behaved "hello" plugin: it reads the
//! `file.uploaded` event metadata, calls the host `log` function (the only
//! authority a plugin has), and returns `{"ok": true}`.
//!
//! Cargo features select the misbehaving variants the host's failure-isolation
//! tests load (`panic`, `sleep`, `net`, `wrong_abi`). See
//! `scripts/build-plugin-hello.sh`.
use extism_pdk::*;
/// The one host function OxiCloud exposes, imported from its namespaced module.
#[host_fn("oxicloud:host:v0")]
extern "ExtismHost" {
fn log(level: String, message: String);
}
/// Required export: which ABI this plugin was built against. The host rejects
/// the plugin at load if this does not equal its own `OXICLOUD_PLUGIN_ABI`.
#[cfg(not(feature = "wrong_abi"))]
#[plugin_fn]
pub fn abi_version() -> FnResult<u32> {
Ok(0)
}
/// `wrong_abi` variant: claim an ABI the host does not speak.
#[cfg(feature = "wrong_abi")]
#[plugin_fn]
pub fn abi_version() -> FnResult<u32> {
Ok(1)
}
/// Required export: the single event entry point.
#[plugin_fn]
pub fn handle(input: String) -> FnResult<String> {
// --- misbehaving variants (compiled in only under their feature) ---------
#[cfg(feature = "panic")]
panic!("intentional panic: exercises host failure isolation");
#[cfg(feature = "sleep")]
{
// Busy-loop forever; the host's wall-clock timeout must cancel us.
let mut spin: u64 = 0;
loop {
spin = spin.wrapping_add(1);
std::hint::black_box(spin);
}
}
#[cfg(feature = "net")]
{
// Attempt an outbound HTTP call. The host grants no `allowed_hosts`, so
// Extism denies this before any socket is opened (offline-deterministic)
// and the error propagates out of `handle`.
let req = HttpRequest::new("https://example.com/");
let _ = http::request::<()>(&req, None)?;
}
// --- well-behaved "hello" path ------------------------------------------
let ev: serde_json::Value = serde_json::from_str(&input)?;
let path = ev["payload"]["path"].as_str().unwrap_or("<unknown>");
let size = ev["payload"]["size"].as_u64().unwrap_or(0);
// Prove plugin -> host: call the only authority we have.
unsafe {
log(
"info".to_string(),
format!("hello plugin saw upload: {path} ({size} bytes)"),
)?;
}
// Prove plugin -> host return path.
Ok(serde_json::json!({ "ok": true }).to_string())
}