init plugins
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)'] }
|
||||
|
||||
@@ -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
@@ -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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+46
@@ -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)"}'
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Generated
+379
@@ -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"
|
||||
@@ -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
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user