Merge pull request #475 from BCNelson/bcn/plugins

Add M0 WASM plugin system (sandboxed, observe-only)
This commit is contained in:
Dionisio Pozo
2026-06-17 12:41:49 +02:00
committed by GitHub
45 changed files with 7330 additions and 30 deletions
+7
View File
@@ -0,0 +1,7 @@
if ! has nix_direnv_version || ! nix_direnv_version 3.0.6; then
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.6/direnvrc" "sha256-RYcUJaRMf8oF5LznDrlCXbkOQrywm0HDv1VjYGaJGdM="
fi
export DEVENV_ROOT="$PWD"
use flake "path:$PWD" --no-pure-eval --impure
+32
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,14 @@ 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'
- 'src/application/adapters/plugin_user_lifecycle_hook.rs'
frontend-check:
name: Frontend — CSS and JS checks (format, lint, css-rules, types)
@@ -171,6 +180,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
+8
View File
@@ -5,6 +5,9 @@
# Processed static assets (generated by build.rs in release mode)
/static-dist/
# Built plugin bundles (just plugin-example-zip)
/dist/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
# Cargo.lock
@@ -101,3 +104,8 @@ tests/e2e/playwright/.auth/
# Test fixtures generated on-the-fly by tests/api/run.sh
tests/fixtures/chunk-over-cap-*.bin
wasm/oxicloud-hash/target/
# devenv / direnv
.devenv/
.direnv/
.devenv-state/
Generated
+1418 -15
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -10,7 +10,7 @@ mimalloc = { version = "0.1.52", default-features = false }
axum = { version = "0.8.9", features = ["multipart", "http1", "http2", "tokio", "macros"] }
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "io-util", "net", "time", "sync", "fs"] }
tokio-util = { version = "0.7.18", features = ["io", "codec", "compat"] }
tokio-stream = { version = "0.1.18", features = ["fs"] }
tokio-stream = { version = "0.1.18", features = ["fs", "sync"] }
bytes = "1.11.1"
tempfile = "3.27.0"
tower = "0.5.3"
@@ -78,11 +78,17 @@ 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 }
file-rotate = { version = "0.7.6", 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", "dep:file-rotate"]
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] }
+70
View File
@@ -0,0 +1,70 @@
{ pkgs, lib, config, ... }:
{
languages.rust = {
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.
packages = with pkgs; [
pkg-config
openssl
git
# task runner + Rust extras
just
cargo-audit
# frontend tooling (no root package.json — these are expected as global bins)
nodejs_22
biome
typescript # provides `tsc`
stylelint
# helper scripts (tools/check-icons.py, tools/check-missing-translations.py)
python3
# API/WebDAV functional tests + the DB-readiness probe in tests/common/spawn-db.sh
hurl
netcat-gnu # provides `nc`
];
# Dev Postgres on :5432, matching DATABASE_URL in example.env
# (postgres://postgres:postgres@localhost:5432/oxicloud).
services.postgres = {
enable = true;
listen_addresses = "127.0.0.1";
port = 5432;
initialDatabases = [ { name = "oxicloud"; } ];
# devenv's bootstrap superuser is the OS user with trust auth, so the
# password is not actually checked — but the `postgres` role must exist
# for the DATABASE_URL above to connect. Extensions per CLAUDE.md.
initialScript = ''
CREATE ROLE postgres SUPERUSER LOGIN PASSWORD 'postgres';
\connect oxicloud
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS ltree;
'';
};
# Keep cargo's build output OUT of the repo. The devenv shell is entered via
# `use flake "path:$PWD"` (.envrc), and a `path:` flake copies the whole working
# tree into the Nix store WITHOUT honoring .gitignore — so a multi-GB `target/`
# makes every direnv evaluation hang "copying … to the store". Redirecting
# CARGO_TARGET_DIR under $HOME (per-user, so it stays portable) keeps the source
# tree small. `builtins.getEnv` is fine here: the flake is evaluated --impure.
env.CARGO_TARGET_DIR = "${builtins.getEnv "HOME"}/.cache/oxicloud/target";
# Load .env (justfile uses `set dotenv-load`); contributor should `cp example.env .env`.
dotenv.enable = true;
enterShell = ''
echo "OxiCloud dev env — Rust $(rustc --version | cut -d' ' -f2), Node $(node --version), Postgres on :5432"
'';
}
+54
View File
@@ -224,6 +224,60 @@ 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
# Max plugin invocations running at once across all plugins. Past this, dispatch
# sheds load (drops the event, audit-logged) so plugins can't starve the shared
# blocking pool. (default: 16)
#OXICLOUD_PLUGIN_MAX_CONCURRENT_INVOCATIONS=16
# Idle window (seconds) after which a plugin's cached compiled module is dropped
# to reclaim memory; the next event recompiles from the on-disk cache. (default: 300)
#OXICLOUD_PLUGIN_CACHE_IDLE_TTL_SECS=300
# Decompressed-byte ceiling enforced while unpacking an install bundle (zip-bomb
# guard; the install route also caps the compressed body at 32 MiB). (default: 67108864 = 64 MiB)
#OXICLOUD_PLUGIN_MAX_BUNDLE_DECOMPRESSED_BYTES=67108864
# Directory for per-plugin structured logs, one subdir per plugin id.
# (default: {OXICLOUD_STORAGE_PATH}/.plugin-logs)
#OXICLOUD_PLUGIN_LOG_DIR=
# Size (bytes) at which a plugin's active events.jsonl rotates into a gzip segment. (default: 5242880 = 5 MiB)
#OXICLOUD_PLUGIN_LOG_MAX_FILE_BYTES=5242880
# Coarse ceiling on rotated .gz segments kept per plugin at write time. (default: 10)
#OXICLOUD_PLUGIN_LOG_MAX_SEGMENTS=10
# Default age (days) past which rotated log segments are pruned by the sweep;
# overridable per plugin in the admin UI. 0 = purge all rotated segments. (default: 30)
#OXICLOUD_PLUGIN_LOG_RETENTION_DAYS=30
# Default aggregate byte cap on kept log segments per plugin (oldest deleted
# first); overridable per plugin. 0 = purge all rotated segments. (default: 268435456 = 256 MiB)
#OXICLOUD_PLUGIN_LOG_TOTAL_MAX_BYTES=268435456
# Bounded depth of the log-write queue; a flood past this sheds the oldest batch
# rather than blocking dispatch or growing RAM. (default: 1024)
#OXICLOUD_PLUGIN_LOG_QUEUE_CAPACITY=1024
# -----------------------------------------------------------------------------
# STORAGE BACKEND
# -----------------------------------------------------------------------------
Generated
+358
View File
@@ -0,0 +1,358 @@
{
"nodes": {
"cachix": {
"inputs": {
"devenv": [
"devenv"
],
"flake-compat": [
"devenv",
"flake-compat"
],
"git-hooks": [
"devenv",
"git-hooks"
],
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1777487137,
"narHash": "sha256-TuvKVBX60mqyMT6OB5JqVEh1YIWtFMR/igLCaCdC9tw=",
"owner": "cachix",
"repo": "cachix",
"rev": "a66a440c321d35f7193472c317f42a55ccd1cb93",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "latest",
"repo": "cachix",
"type": "github"
}
},
"crate2nix": {
"flake": false,
"locked": {
"lastModified": 1772186516,
"narHash": "sha256-8s28pzmQ6TOIUzznwFibtW1CMieMUl1rYJIxoQYor58=",
"owner": "rossng",
"repo": "crate2nix",
"rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e",
"type": "github"
},
"original": {
"owner": "rossng",
"repo": "crate2nix",
"rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e",
"type": "github"
}
},
"devenv": {
"inputs": {
"cachix": "cachix",
"crate2nix": "crate2nix",
"flake-compat": "flake-compat",
"flake-parts": "flake-parts",
"ghostty": "ghostty",
"git-hooks": "git-hooks",
"nix": "nix",
"nixd": "nixd",
"nixpkgs": [
"nixpkgs"
],
"rust-overlay": "rust-overlay"
},
"locked": {
"lastModified": 1781627264,
"narHash": "sha256-TPj5d5MUyvuQZjsfDAAoYJ0SB+tNNNBrdCpD8XL0WeU=",
"owner": "cachix",
"repo": "devenv",
"rev": "0fe5629a2955141c336b95e384e2c793c01214fb",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"ghostty": {
"flake": false,
"locked": {
"lastModified": 1779069789,
"narHash": "sha256-ojo+gso45/6CVSuqfSVnlWpQ4d0QeLgwok+v/g3yu0E=",
"owner": "ghostty-org",
"repo": "ghostty",
"rev": "4b7bf0b20e3baf9c1ba10c63f2ad1fd853faea8f",
"type": "github"
},
"original": {
"owner": "ghostty-org",
"repo": "ghostty",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"gitignore": "gitignore",
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1778507602,
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"devenv",
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nix": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"flake-parts": [
"devenv",
"flake-parts"
],
"git-hooks-nix": [
"devenv",
"git-hooks"
],
"nixpkgs": [
"devenv",
"nixpkgs"
],
"nixpkgs-23-11": [
"devenv"
],
"nixpkgs-regression": [
"devenv"
]
},
"locked": {
"lastModified": 1779748925,
"narHash": "sha256-meIhqGC04O5VXbKSFXSQoOKp+XCq5RMnwAk1Guo0VQo=",
"owner": "cachix",
"repo": "nix",
"rev": "0bc443c8ff235c3547d09327b48aaa2ab98b15f2",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "devenv-2.34",
"repo": "nix",
"type": "github"
}
},
"nixd": {
"inputs": {
"flake-parts": [
"devenv",
"flake-parts"
],
"nixpkgs": [
"devenv",
"nixpkgs"
],
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1778381404,
"narHash": "sha256-FqhdOTA8vyoIpkHhbs2cCT7h6EWM7nsLeOYJc1ifQLE=",
"owner": "nix-community",
"repo": "nixd",
"rev": "e3e45eb76663f522e196b7f0cf34cab201db7779",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixd",
"type": "github"
}
},
"nixpkgs": {
"inputs": {
"nixpkgs-src": "nixpkgs-src"
},
"locked": {
"lastModified": 1781620901,
"narHash": "sha256-UF6scQlG+6lRkZBUpn/3KNavhOo5G8kDWhjVHcno8uc=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "2df109b343d3c68efd752e32a444a1d9b9f89afa",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github"
}
},
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1781454065,
"narHash": "sha256-d2xfDjnfRuf/xYGdu9VVRHiav/2w5hDL/5cw2TuVAXw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9eac87a12312b8f60dd52e1c6e1a265f6fc7f5fc",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay_2"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1779074409,
"narHash": "sha256-6aXy8Ga41iLVM8ibddFU1O5+wYWcBGNEfZzZuL91eIc=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "2a77b5b1dc952f214e8102acdef1622b68515560",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"rust-overlay_2": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1781580018,
"narHash": "sha256-BlTedbM77FmesD2ZqR73vhFy+y77UrhefV7IYw1pDsk=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "8bceba21a1ebea535c27c4dc723a0d5a4db9e386",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"devenv",
"nixd",
"nixpkgs"
]
},
"locked": {
"lastModified": 1775636079,
"narHash": "sha256-pc20NRoMdiar8oPQceQT47UUZMBTiMdUuWrYu2obUP0=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "790751ff7fd3801feeaf96d7dc416a8d581265ba",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+55
View File
@@ -0,0 +1,55 @@
{
description = "OxiCloud development environment";
inputs = {
nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling";
devenv.url = "github:cachix/devenv";
devenv.inputs.nixpkgs.follows = "nixpkgs";
rust-overlay.url = "github:oxalica/rust-overlay";
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
};
nixConfig = {
extra-trusted-public-keys = [
"devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw="
];
extra-substituters = [
"https://devenv.cachix.org"
];
};
outputs =
{
self,
nixpkgs,
devenv,
...
}@inputs:
let
forEachSystem = nixpkgs.lib.genAttrs [
"x86_64-linux"
"aarch64-linux"
"aarch64-darwin"
"x86_64-darwin"
];
in
{
packages = forEachSystem (system: {
devenv-up = self.devShells.${system}.default.config.procfileScript;
devenv-test = self.devShells.${system}.default.config.test;
});
devShells = forEachSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
default = devenv.lib.mkShell {
inherit inputs pkgs;
modules = [ ./devenv.nix ];
};
}
);
};
}
+20
View File
@@ -65,6 +65,26 @@ 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
# Build the example plugin and bundle plugin.toml + .wasm into an installable
# .zip at dist/oxicloud-plugin-hello.zip (upload via the admin Plugins tab).
plugin-example-zip:
bash scripts/build-plugin-zip.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
+51
View File
@@ -0,0 +1,51 @@
#!/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
# Pin the wasm build to the crate-local target dir. The devenv sets a global
# CARGO_TARGET_DIR (outside the repo) which would otherwise relocate the
# artifact away from the path below.
export CARGO_TARGET_DIR="$PWD/$CRATE/target"
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
build omit_login --features omit_login
echo "Built fixtures:"
ls -la "$OUT"/*.wasm | awk '{print " " $9 " (" $5 " bytes)"}'
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Build the example plugin (wasm/oxicloud-plugin-hello) and bundle its
# plugin.toml + compiled .wasm into an installable .zip — the exact shape the
# admin "Install a plugin" upload (and POST /api/admin/plugins) expects.
#
# Output: dist/oxicloud-plugin-hello.zip (plugin.toml + hello.wasm at the root)
#
# Requirements: the wasm32-unknown-unknown target. The devenv provides it via
# `languages.rust.targets` in devenv.nix; otherwise:
# rustup target add wasm32-unknown-unknown
set -euo pipefail
cd "$(dirname "$0")/.."
CRATE=wasm/oxicloud-plugin-hello
# Pin the wasm build to the crate-local target dir. The devenv sets a global
# CARGO_TARGET_DIR (outside the repo) which would otherwise relocate the
# artifact away from the path below.
export CARGO_TARGET_DIR="$PWD/$CRATE/target"
ARTIFACT="$CRATE/target/wasm32-unknown-unknown/release/oxicloud_plugin_hello.wasm"
OUT_DIR=dist
OUT_ZIP="$OUT_DIR/oxicloud-plugin-hello.zip"
OUT_ZIP_ABS="$PWD/$OUT_ZIP"
echo "building $CRATE on wasm32-unknown-unknown…"
cargo build \
--manifest-path "$CRATE/Cargo.toml" \
--target wasm32-unknown-unknown \
--release
# Stage plugin.toml + the module under the entrypoint name the manifest declares
# (entrypoint = "hello.wasm"), then zip the staging dir's contents at the root.
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
cp "$CRATE/plugin.toml" "$STAGE/plugin.toml"
cp "$ARTIFACT" "$STAGE/hello.wasm"
mkdir -p "$OUT_DIR"
rm -f "$OUT_ZIP"
# `zip` isn't in the devenv toolchain; python3 is. `-c` creates an archive,
# storing the given paths. Run from the staging dir so entries are at the root.
( cd "$STAGE" && python3 -m zipfile -c "$OUT_ZIP_ABS" plugin.toml hello.wasm )
echo "bundled → $OUT_ZIP"
python3 -m zipfile -l "$OUT_ZIP"
+2
View File
@@ -2,6 +2,8 @@
pub mod caldav_adapter;
pub mod carddav_adapter;
pub mod plugin_lifecycle_hook;
pub mod plugin_user_lifecycle_hook;
pub mod webdav_adapter;
#[cfg(test)]
@@ -0,0 +1,103 @@
//! 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, PluginDispatchPort, PluginEvent,
};
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(PluginEvent {
name: EVENT_FILE_UPLOADED,
user_id: dto.owner_id,
invocation_id: Uuid::new_v4().to_string(),
payload: serde_json::json!({
"path": dto.path,
"size": dto.size,
"mime": dto.mime_type.to_string(),
}),
});
});
}
}
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) {}
}
@@ -0,0 +1,164 @@
//! Bridges the [`UserLifecycleHook`] fan-out to the plugin runtime.
//!
//! `UserLifecycleService` already notifies hooks on user create/login/logout/
//! delete. This adapter turns the *login* event into a `user.login` plugin
//! event. It references only the [`PluginDispatchPort`] trait (not Extism), so
//! it is always compiled and the Extism dependency stays in the infrastructure
//! layer.
//!
//! Privacy note: the `user.login` payload is deliberately minimal — an opaque
//! `user_id` plus two non-identifying booleans (`first_login`, `is_external`).
//! It carries no email or username, so no PII reaches untrusted plugins in M0.
//! When the permissions system lands, richer fields (email, username) can be
//! added back behind a granted permission.
use std::sync::Arc;
use async_trait::async_trait;
use uuid::Uuid;
use crate::application::ports::plugin_ports::{EVENT_USER_LOGIN, PluginDispatchPort, PluginEvent};
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook};
use crate::common::errors::DomainError;
use crate::domain::entities::user::User;
/// Lifecycle hook that forwards successful logins to subscribed plugins.
pub struct PluginUserLifecycleHook {
dispatch: Arc<dyn PluginDispatchPort>,
}
impl PluginUserLifecycleHook {
pub fn new(dispatch: Arc<dyn PluginDispatchPort>) -> Self {
Self { dispatch }
}
}
#[async_trait]
impl UserLifecycleHook for PluginUserLifecycleHook {
fn name(&self) -> &'static str {
"plugins"
}
async fn on_user_login(&self, user: &User) -> Result<(), DomainError> {
if self.dispatch.has_subscribers(EVENT_USER_LOGIN) {
self.dispatch.dispatch(PluginEvent {
name: EVENT_USER_LOGIN,
user_id: Some(user.id().to_string()),
invocation_id: Uuid::new_v4().to_string(),
payload: serde_json::json!({
"user_id": user.id().to_string(),
"first_login": user.last_login_at().is_none(),
"is_external": user.is_external(),
}),
});
}
// Returns immediately — dispatch is fire-and-forget (the runtime runs the
// plugin on the blocking pool), so login latency is unaffected.
Ok(())
}
// M0 emits only `user.login`. The trait forces an explicit decision on the
// other three events; they are deliberate no-ops (reserved for future events
// like `user.created` / `user.deleted`).
async fn on_user_created(&self, _user: &User) -> Result<(), DomainError> {
Ok(())
}
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
Ok(())
}
async fn on_user_deleted(
&self,
_user: &User,
_mode: DeletionMode,
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), DomainError> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
use crate::domain::entities::user::UserRole;
/// Records dispatched events so the test can assert the bridge built the
/// right `user.login` event without a runtime or DB.
#[derive(Default)]
struct RecordingDispatch {
events: Mutex<Vec<PluginEvent>>,
}
impl PluginDispatchPort for RecordingDispatch {
fn dispatch(&self, event: PluginEvent) {
self.events.lock().unwrap().push(event);
}
fn has_subscribers(&self, _event: &str) -> bool {
true
}
}
#[tokio::test]
async fn on_user_login_dispatches_user_login_event() {
let recorder = Arc::new(RecordingDispatch::default());
let hook = PluginUserLifecycleHook::new(recorder.clone());
let user = User::new(
"alice@example.com".to_string(),
Some("alice".to_string()),
None,
None,
None,
UserRole::User,
0,
false,
)
.unwrap();
hook.on_user_login(&user).await.unwrap();
let events = recorder.events.lock().unwrap();
assert_eq!(events.len(), 1, "exactly one event dispatched");
let ev = &events[0];
assert_eq!(ev.name, EVENT_USER_LOGIN);
assert_eq!(ev.user_id.as_deref(), Some(user.id().to_string().as_str()));
assert_eq!(ev.payload["user_id"], user.id().to_string());
assert_eq!(ev.payload["first_login"], true); // last_login_at is None
assert_eq!(ev.payload["is_external"], false);
// Minimal payload: no PII fields.
assert!(ev.payload.get("email").is_none(), "must not leak email");
assert!(
ev.payload.get("username").is_none(),
"must not leak username"
);
}
#[tokio::test]
async fn skips_dispatch_when_no_subscribers() {
struct NoSubscribers;
impl PluginDispatchPort for NoSubscribers {
fn dispatch(&self, _event: PluginEvent) {
panic!("must not dispatch when nothing subscribes");
}
fn has_subscribers(&self, _event: &str) -> bool {
false
}
}
let hook = PluginUserLifecycleHook::new(Arc::new(NoSubscribers));
let user = User::new(
"bob@example.com".to_string(),
None,
None,
None,
None,
UserRole::User,
0,
false,
)
.unwrap();
hook.on_user_login(&user).await.unwrap();
}
}
+1
View File
@@ -14,6 +14,7 @@ pub mod grant_dto;
pub mod i18n_dto;
pub mod pagination;
pub mod playlist_dto;
pub mod plugin_dto;
pub mod recent_dto;
pub mod search_dto;
pub mod settings_dto;
+135
View File
@@ -0,0 +1,135 @@
//! DTOs for the admin plugin-management API.
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::application::ports::plugin_ports::{LogEntry, LogPage, PluginInfo, RetentionSettings};
/// A single installed plugin as returned by `GET /api/admin/plugins`.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PluginInfoDto {
pub id: String,
pub name: String,
pub version: String,
pub abi: u32,
/// Events the plugin subscribes to (e.g. `file.uploaded`).
pub subscriptions: Vec<String>,
pub enabled: bool,
}
impl From<PluginInfo> for PluginInfoDto {
fn from(p: PluginInfo) -> Self {
Self {
id: p.id,
name: p.name,
version: p.version,
abi: p.abi,
subscriptions: p.subscriptions,
enabled: p.enabled,
}
}
}
/// Request body for `PUT /api/admin/plugins/{id}/enabled`.
#[derive(Debug, Deserialize, ToSchema)]
pub struct SetEnabledDto {
pub enabled: bool,
}
/// A single structured log entry as returned by the admin log viewer / stream.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PluginLogEntryDto {
/// RFC 3339 timestamp.
pub ts: String,
pub invocation_id: String,
/// `"plugin"` (plugin-emitted line) or `"outcome"` (host invocation result).
pub kind: String,
/// `debug` | `info` | `warn` | `error`.
pub level: String,
/// Stable outcome key for `kind = "outcome"`.
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub msg: String,
}
impl From<LogEntry> for PluginLogEntryDto {
fn from(e: LogEntry) -> Self {
Self {
ts: e.ts,
invocation_id: e.invocation_id,
kind: e.kind,
level: e.level,
reason: e.reason,
msg: e.msg,
}
}
}
/// One page of log entries, newest first.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PluginLogPageDto {
pub entries: Vec<PluginLogEntryDto>,
/// Total entries matching the filter (across all pages).
pub total: usize,
pub limit: usize,
pub offset: usize,
}
impl PluginLogPageDto {
pub fn from_page(page: LogPage, limit: usize, offset: usize) -> Self {
Self {
entries: page
.entries
.into_iter()
.map(PluginLogEntryDto::from)
.collect(),
total: page.total,
limit,
offset,
}
}
}
/// Query string for `GET /api/admin/plugins/{id}/logs`.
#[derive(Debug, Deserialize, IntoParams)]
pub struct PluginLogQueryDto {
/// Keep only entries at this level (`debug`/`info`/`warn`/`error`).
pub level: Option<String>,
/// Case-insensitive substring filter on the message.
pub search: Option<String>,
/// Max entries to return (clamped server-side).
pub limit: Option<usize>,
/// Newest-first entries to skip.
pub offset: Option<usize>,
}
/// Per-plugin retention policy (request + response body).
///
/// Both limits are accepted as-is, including `0`, which means "purge all rotated
/// segments on the next sweep" (the active log file is never touched). This is
/// intentional — an operator can deliberately keep nothing.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)]
pub struct PluginRetentionDto {
/// Delete rotated segments older than this many days. `0` = keep none.
pub retention_days: u32,
/// Aggregate byte ceiling on kept segments for the plugin. `0` = keep none.
pub max_bytes: u64,
}
impl From<RetentionSettings> for PluginRetentionDto {
fn from(s: RetentionSettings) -> Self {
Self {
retention_days: s.retention_days,
max_bytes: s.max_bytes,
}
}
}
impl From<PluginRetentionDto> for RetentionSettings {
fn from(d: PluginRetentionDto) -> Self {
Self {
retention_days: d.retention_days,
max_bytes: d.max_bytes,
}
}
}
+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;
+258
View File
@@ -0,0 +1,258 @@
//! 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` plus one handler per event it subscribes to,
//! named `on_<event>` (see [`event_export_name`]) — e.g. `on_file_uploaded`,
//! `on_user_login`;
//! - one host import `log` (observe-only — the only authority a plugin has).
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
/// 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";
/// File committed (created or content-replaced). Payload is metadata only.
pub const EVENT_FILE_UPLOADED: &str = "file.uploaded";
/// A user authenticated successfully.
pub const EVENT_USER_LOGIN: &str = "user.login";
/// Every event the host can emit. Manifest validation accepts only these — a
/// `subscribe` entry outside this set rejects the plugin at load. Adding an
/// event is purely additive (no ABI bump): append its name here, build the
/// payload in a bridge, register that bridge in DI.
pub const KNOWN_EVENTS: &[&str] = &[EVENT_FILE_UPLOADED, EVENT_USER_LOGIN];
/// The plugin export the host calls for `event`: `on_<event>` with dots replaced
/// by underscores (a WASM export must be a valid identifier). A plugin handles an
/// event by exporting this symbol; the host calls exactly the export matching the
/// dispatched event. `file.uploaded` → `on_file_uploaded`; `user.login` →
/// `on_user_login`.
pub fn event_export_name(event: &str) -> String {
format!("on_{}", event.replace('.', "_"))
}
/// Outbound port: the application asks the (infrastructure) plugin runtime to
/// dispatch an event to every subscribed plugin. Dispatch is fire-and-forget —
/// the implementation owns all isolation, timeouts, and fault handling, and the
/// caller (a lifecycle hook bridge) never awaits it.
pub trait PluginDispatchPort: Send + Sync + 'static {
/// Dispatch an event to every plugin subscribed to `event.name`.
fn dispatch(&self, event: PluginEvent);
/// Cheap predicate so a bridge can skip building the payload entirely when
/// no plugin subscribes to `event`.
fn has_subscribers(&self, event: &str) -> bool;
}
/// Inbound port: admin management of installed plugins (list / toggle / install
/// / remove). The concrete implementation (the infrastructure
/// `ExtismPluginManager`) owns the same in-memory plugin set the dispatch port
/// reads, so a toggle or install takes effect on the live dispatch path with no
/// restart. All operations are admin-gated at the HTTP layer.
#[async_trait]
pub trait PluginManagementPort: Send + Sync + 'static {
/// Every installed plugin, enabled or not, with its load-time metadata.
fn list(&self) -> Vec<PluginInfo>;
/// Enable or disable a plugin by id. The change is persisted so it survives
/// a restart, and is reflected immediately by [`PluginDispatchPort`].
fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), PluginMgmtError>;
/// Validate and install a new plugin from its `plugin.toml` text and `.wasm`
/// bytes, writing it to the plugins directory and loading it (enabled). The
/// id is taken from the manifest; a clash with an existing plugin is
/// rejected with [`PluginMgmtError::IdExists`].
fn install(&self, manifest_toml: &str, wasm: Vec<u8>) -> Result<PluginInfo, PluginMgmtError>;
/// Install a plugin from a `.zip` bundle containing `plugin.toml` and the
/// `.wasm` named by its `entrypoint` (both at the archive root or together
/// under a single top-level folder). Extracts the two and delegates to
/// [`PluginManagementPort::install`].
fn install_bundle(&self, zip: Vec<u8>) -> Result<PluginInfo, PluginMgmtError>;
/// Unload a plugin and delete its directory.
fn remove(&self, id: &str) -> Result<(), PluginMgmtError>;
/// Read a filtered, paginated page of a plugin's structured log entries
/// (newest first). `NotFound` if no such plugin is installed.
async fn read_logs(&self, id: &str, query: LogQuery) -> Result<LogPage, PluginMgmtError>;
/// Delete all persisted log files for a plugin (keeps the plugin installed).
async fn clear_logs(&self, id: &str) -> Result<(), PluginMgmtError>;
/// The plugin's effective per-plugin retention (its on-disk override, or the
/// configured defaults when none is set).
async fn get_retention(&self, id: &str) -> Result<RetentionSettings, PluginMgmtError>;
/// Persist a per-plugin retention override (age + aggregate size).
async fn set_retention(
&self,
id: &str,
settings: RetentionSettings,
) -> Result<(), PluginMgmtError>;
/// Subscribe to newly-written log entries across *all* plugins, for live
/// tailing. Callers filter by `plugin_id`. A lagging receiver loses the
/// oldest buffered events (`RecvError::Lagged`) but never blocks the writer.
fn subscribe_logs(&self) -> broadcast::Receiver<PluginLogEvent>;
}
/// A single structured log entry — both the on-disk JSONL row and the unit the
/// admin viewer / live stream surfaces.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogEntry {
/// RFC 3339 timestamp the entry was recorded at.
pub ts: String,
/// The dispatch invocation this entry belongs to (correlates lines with the
/// outcome row of the same invocation).
pub invocation_id: String,
/// `"plugin"` for a line the plugin emitted via `log`, `"outcome"` for the
/// host's record of how the invocation ended.
pub kind: String,
/// `debug` | `info` | `warn` | `error`.
pub level: String,
/// Stable outcome key (`InvokeOutcome::reason()`) for `kind = "outcome"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
/// Human-readable message.
pub msg: String,
}
/// Filter + pagination for [`PluginManagementPort::read_logs`].
#[derive(Debug, Clone, Default)]
pub struct LogQuery {
/// Keep only entries at this level (exact match) when set.
pub level: Option<String>,
/// Keep only entries whose message contains this substring (case-insensitive).
pub search: Option<String>,
/// Number of newest-first entries to skip.
pub offset: usize,
/// Maximum number of entries to return.
pub limit: usize,
}
/// One page of log entries plus the total number matching the filter.
#[derive(Debug, Clone)]
pub struct LogPage {
/// Entries for this page, newest first.
pub entries: Vec<LogEntry>,
/// Total entries matching the filter (across all pages).
pub total: usize,
}
/// Per-plugin log retention policy. Persisted next to the plugin's logs.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct RetentionSettings {
/// Delete rotated segments older than this many days.
pub retention_days: u32,
/// Aggregate byte ceiling on kept segments for the plugin (oldest deleted
/// first past this).
pub max_bytes: u64,
}
/// A newly-written entry published on the live-tail broadcast channel.
#[derive(Debug, Clone)]
pub struct PluginLogEvent {
/// The plugin the entry belongs to (subscribers filter on this).
pub plugin_id: String,
/// The entry itself.
pub entry: LogEntry,
}
/// A single installed plugin's load-time metadata, as surfaced to the admin UI.
#[derive(Debug, Clone)]
pub struct PluginInfo {
pub id: String,
pub name: String,
pub version: String,
pub abi: u32,
pub subscriptions: Vec<String>,
pub enabled: bool,
}
/// Why a management operation failed. `reason()` yields the stable, machine
/// readable key used in audit logs and surfaced to the UI.
#[derive(Debug)]
pub enum PluginMgmtError {
/// No plugin with that id is installed.
NotFound,
/// An install was attempted for an id that already exists.
IdExists,
/// The bundle failed manifest or runtime validation. Carries the stable
/// reason key from `ManifestError::reason()` / `InvokeOutcome::reason()`,
/// plus a few install-only keys (`bad_id`, `bad_entrypoint`, `bad_zip`,
/// `no_manifest_in_zip`, `entrypoint_not_in_zip`, `too_large`).
Rejected(&'static str),
/// A filesystem error while writing or removing the plugin.
Io(String),
}
impl PluginMgmtError {
/// Stable key for `tracing` audit lines; never reworded across releases.
pub fn reason(&self) -> &'static str {
match self {
PluginMgmtError::NotFound => "not_found",
PluginMgmtError::IdExists => "id_exists",
PluginMgmtError::Rejected(r) => r,
PluginMgmtError::Io(_) => "io_error",
}
}
}
/// A single event to fan out to plugins. The `payload` JSON shape is specific to
/// each `name` and is built by that event's bridge — the runtime is event-blind
/// and never inspects it. Payloads carry metadata only, never file contents.
#[derive(Debug, Clone)]
pub struct PluginEvent {
/// One of [`KNOWN_EVENTS`].
pub name: &'static str,
/// Opaque id of the user the event concerns, when known.
pub user_id: Option<String>,
/// Unique id minted per dispatch, correlating host logs with plugin output.
pub invocation_id: String,
/// Event-specific payload handed to the plugin as `PluginInput.payload`.
pub payload: serde_json::Value,
}
// ---- Wire DTOs (ABI v0 JSON shapes, §3.4 of the spec) ----------------------
/// 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>,
}
+161
View File
@@ -936,6 +936,90 @@ 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,
/// Directory under which per-plugin log files live (one subdir per plugin id,
/// holding `events.jsonl` + rotated `events.jsonl.<ts>.gz` + `retention.json`).
/// Default: `{storage_path}/.plugin-logs`. Env: `OXICLOUD_PLUGIN_LOG_DIR`.
pub log_dir: Option<PathBuf>,
/// Size at which a plugin's active `events.jsonl` is rotated into a new gzip
/// segment. Default: 5 MiB. Env: `OXICLOUD_PLUGIN_LOG_MAX_FILE_BYTES`.
pub log_max_file_bytes: u64,
/// Coarse ceiling on the number of rotated `.gz` segments kept per plugin
/// (file-rotate `FileLimit::MaxFiles`); the real limits are the per-plugin
/// retention sweep. Default: 10. Env: `OXICLOUD_PLUGIN_LOG_MAX_SEGMENTS`.
pub log_max_segments: u32,
/// Default age (in days) past which a plugin's rotated log segments are
/// pruned by the maintenance sweep. Overridable per plugin via its
/// `retention.json`. Default: 30. Env: `OXICLOUD_PLUGIN_LOG_RETENTION_DAYS`.
pub log_retention_days: u32,
/// Default aggregate byte cap on kept log segments for a single plugin; the
/// sweep deletes oldest-first past this. Overridable per plugin. Default:
/// 256 MiB. Env: `OXICLOUD_PLUGIN_LOG_TOTAL_MAX_BYTES`.
pub log_total_max_bytes: u64,
/// Max plugin invocations running concurrently across all plugins. Dispatch
/// sheds load (drops the event, audit-logged) past this rather than
/// unbounded `spawn_blocking`, so plugins can't starve the shared blocking
/// pool. Default: 16. Env: `OXICLOUD_PLUGIN_MAX_CONCURRENT_INVOCATIONS`.
pub max_concurrent_invocations: usize,
/// Bounded depth of the log-store command channel. A flood past this drops
/// the oldest-arriving log batch (never blocks dispatch). Default: 1024.
/// Env: `OXICLOUD_PLUGIN_LOG_QUEUE_CAPACITY`.
pub log_queue_capacity: usize,
/// Idle window after which a plugin's cached compiled module is dropped to
/// reclaim memory; the next event recompiles from wasmtime's on-disk cache.
/// Default: 300 (5 min). Env: `OXICLOUD_PLUGIN_CACHE_IDLE_TTL_SECS`.
pub cache_idle_ttl_secs: u64,
/// Aggregate decompressed-byte ceiling enforced while unpacking an install
/// bundle (zip-bomb guard; the install route also caps the compressed body).
/// Default: 64 MiB. Env: `OXICLOUD_PLUGIN_MAX_BUNDLE_DECOMPRESSED_BYTES`.
pub max_bundle_decompressed_bytes: u64,
}
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,
log_dir: None,
log_max_file_bytes: 5 * 1024 * 1024,
log_max_segments: 10,
log_retention_days: 30,
log_total_max_bytes: 256 * 1024 * 1024,
max_concurrent_invocations: 16,
log_queue_capacity: 1024,
cache_idle_ttl_secs: 300,
max_bundle_decompressed_bytes: 64 * 1024 * 1024,
}
}
}
/// Global application configuration
#[derive(Debug, Clone)]
pub struct AppConfig {
@@ -977,6 +1061,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 +1115,7 @@ impl Default for AppConfig {
magic_link: MagicLinkConfig::default(),
i18n: I18nConfig::default(),
content_search: ContentSearchConfig::default(),
plugins: PluginConfig::default(),
}
}
}
@@ -1318,6 +1405,80 @@ 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(dir) = env::var("OXICLOUD_PLUGIN_LOG_DIR")
&& !dir.trim().is_empty()
{
config.plugins.log_dir = Some(PathBuf::from(dir.trim()));
}
if let Ok(v) = env::var("OXICLOUD_PLUGIN_LOG_MAX_FILE_BYTES").map(|v| v.parse::<u64>())
&& let Ok(val) = v
{
config.plugins.log_max_file_bytes = val;
}
if let Ok(v) = env::var("OXICLOUD_PLUGIN_LOG_MAX_SEGMENTS").map(|v| v.parse::<u32>())
&& let Ok(val) = v
{
config.plugins.log_max_segments = val;
}
if let Ok(v) = env::var("OXICLOUD_PLUGIN_LOG_RETENTION_DAYS").map(|v| v.parse::<u32>())
&& let Ok(val) = v
{
config.plugins.log_retention_days = val;
}
if let Ok(v) = env::var("OXICLOUD_PLUGIN_LOG_TOTAL_MAX_BYTES").map(|v| v.parse::<u64>())
&& let Ok(val) = v
{
config.plugins.log_total_max_bytes = val;
}
if let Ok(v) =
env::var("OXICLOUD_PLUGIN_MAX_CONCURRENT_INVOCATIONS").map(|v| v.parse::<usize>())
&& let Ok(val) = v
{
config.plugins.max_concurrent_invocations = val;
}
if let Ok(v) = env::var("OXICLOUD_PLUGIN_LOG_QUEUE_CAPACITY").map(|v| v.parse::<usize>())
&& let Ok(val) = v
{
config.plugins.log_queue_capacity = val;
}
if let Ok(v) = env::var("OXICLOUD_PLUGIN_CACHE_IDLE_TTL_SECS").map(|v| v.parse::<u64>())
&& let Ok(val) = v
{
config.plugins.cache_idle_ttl_secs = val;
}
if let Ok(v) =
env::var("OXICLOUD_PLUGIN_MAX_BUNDLE_DECOMPRESSED_BYTES").map(|v| v.parse::<u64>())
&& let Ok(val) = v
{
config.plugins.max_bundle_decompressed_bytes = val;
}
if let Ok(v) = env::var("OXICLOUD_EXPOSE_SYSTEM_USERS").map(|v| v.parse::<bool>())
&& let Ok(val) = v
{
+159 -12
View File
@@ -442,6 +442,7 @@ impl AppServiceFactory {
}
/// Initializes the application services
#[allow(clippy::too_many_arguments)] // DI composition root — params are services, not a smell
pub fn create_application_services(
&self,
core: &CoreServices,
@@ -450,6 +451,9 @@ impl AppServiceFactory {
authz: &Arc<PgAclEngine>,
storage_usage: &Arc<StorageUsageService>,
content_index: Option<Arc<TantivyContentIndex>>,
plugin_dispatch: Option<
Arc<dyn crate::application::ports::plugin_ports::PluginDispatchPort>,
>,
) -> ApplicationServices {
// Main services
let folder_service = Arc::new(FolderService::new(
@@ -457,13 +461,29 @@ 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, plugin_dispatch);
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 +505,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 +515,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 +562,114 @@ impl AppServiceFactory {
}
}
/// Builds the file lifecycle dispatcher handed to the upload/management
/// services. By default this is just the core dispatcher (thumbnails,
/// metadata). When a plugin dispatch is present, it wraps the core dispatcher
/// together with the WASM plugin bridge so plugins observe `file.uploaded`
/// events without any of the core hooks being aware of them.
fn effective_file_lifecycle(
&self,
core: &CoreServices,
file_retrieval: &Arc<FileRetrievalService>,
plugin_dispatch: Option<
Arc<dyn crate::application::ports::plugin_ports::PluginDispatchPort>,
>,
) -> Arc<dyn crate::application::ports::file_lifecycle::FileLifecycleHook> {
if let Some(dispatch) = plugin_dispatch {
use crate::application::adapters::plugin_lifecycle_hook::PluginLifecycleHook;
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);
}
core.file_lifecycle.clone()
}
/// The single plugin manager, exposed as the two ports it serves: the
/// dispatch port (shared by every event bridge — file, user, …) and the
/// management port (stored on `AppState` for the admin API). Both wrap the
/// *same* `Arc`, so an install or toggle through the management port takes
/// effect on the live dispatch path with no restart.
///
/// Returns trait objects so call sites stay feature-agnostic; the
/// `#[cfg(feature = "plugins")]` is confined to this body. Both are `None`
/// when the feature is off or `OXICLOUD_ENABLE_PLUGINS` is false.
#[allow(clippy::type_complexity)]
fn create_plugin_ports(
&self,
) -> (
Option<Arc<dyn crate::application::ports::plugin_ports::PluginDispatchPort>>,
Option<Arc<dyn crate::application::ports::plugin_ports::PluginManagementPort>>,
) {
#[cfg(feature = "plugins")]
{
if self.config.plugins.enabled {
let dir = self
.config
.plugins
.plugins_dir
.clone()
.unwrap_or_else(|| self.config.storage_path.join(".plugins"));
// Resolve the log root to a sibling of the plugins dir by default
// so an uninstall never wipes another plugin's logs, and pass it
// into the manager via the config it owns.
let mut plugin_config = self.config.plugins.clone();
if plugin_config.log_dir.is_none() {
plugin_config.log_dir = Some(self.config.storage_path.join(".plugin-logs"));
}
let manager = Arc::new(
crate::infrastructure::services::plugins::ExtismPluginManager::load_from_dir(
plugin_config,
&dir,
),
);
tracing::info!(
target: "oxicloud::plugins",
loaded = manager.loaded_count(),
"plugin manager initialized"
);
let dispatch: Arc<dyn crate::application::ports::plugin_ports::PluginDispatchPort> =
manager.clone();
let management: Arc<
dyn crate::application::ports::plugin_ports::PluginManagementPort,
> = manager.clone();
// Background maintenance: prune each plugin's rotated log
// segments by age + aggregate size on a schedule. Depends only on
// the log store + the management port, so no special ordering.
crate::infrastructure::services::plugins::PluginLogMaintenanceService::new(
manager.log_store(),
management.clone(),
6, // hours between sweeps
)
.start();
// Periodic idle-eviction of cached compiled modules: frees the
// memory of plugins not invoked within the configured TTL; the
// next event recompiles transparently. Cheap, so it ticks often.
{
let evictor = manager.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
tick.tick().await;
evictor.evict_idle_compiled();
}
});
}
return (Some(dispatch), Some(management));
}
}
(None, None)
}
/// Creates the audio metadata service (extracts ID3 tags from audio files)
pub fn create_audio_metadata_service(
&self,
@@ -850,6 +971,13 @@ impl AppServiceFactory {
// worker starts further down with the maintenance pool.
let content_index = self.create_content_index();
// Single plugin manager, surfaced as its two ports: the dispatch port
// (shared by every event bridge — file uploads here, user logins at the
// auth-services wiring below) and the management port (stored on
// AppState for the admin API). Created once so plugins load exactly once
// regardless of how many events they observe.
let (plugin_dispatch, plugin_management) = self.create_plugin_ports();
// 4. Application services (with trash + authz already wired)
let mut apps = self.create_application_services(
&core,
@@ -858,6 +986,7 @@ impl AppServiceFactory {
&authorization,
&storage_usage,
content_index.as_ref().map(|(idx, _)| idx.clone()),
plugin_dispatch.clone(),
);
// 5. Share service
@@ -947,7 +1076,7 @@ impl AppServiceFactory {
pool.clone(),
),
);
let user_lifecycle = Arc::new(
let mut user_lifecycle_builder =
crate::application::services::user_lifecycle_service::UserLifecycleService::new()
.with_hook(Arc::new(
crate::application::services::user_lifecycle_service::AuditLifecycleHook,
@@ -970,8 +1099,20 @@ impl AppServiceFactory {
.with_hook(Arc::new(
crate::application::services::external_identity_service::ExternalIdentityLifecycleHook::new()
.with_magic_link_repo(magic_link_repo.clone()),
)),
);
));
// Plugin user.login bridge — shares the single plugin dispatch with
// the file-upload bridge. Registered only when plugins are active;
// inert until auth is enabled (the dispatcher is never fired otherwise).
if let Some(dispatch) = &plugin_dispatch {
user_lifecycle_builder = user_lifecycle_builder.with_hook(Arc::new(
crate::application::adapters::plugin_user_lifecycle_hook::PluginUserLifecycleHook::new(
dispatch.clone(),
),
));
}
let user_lifecycle = Arc::new(user_lifecycle_builder);
// Auth services. Folder service no longer threaded here —
// PR 3 moved home-folder provisioning into
@@ -1105,6 +1246,7 @@ impl AppServiceFactory {
nextcloud: nextcloud_services,
admin_settings_service: None,
storage_settings_service: None,
plugin_management,
migration_state: Arc::new(tokio::sync::RwLock::new(MigrationState::default())),
trash_service,
share_service,
@@ -1545,6 +1687,11 @@ pub struct AppState {
pub auth_service: Option<AuthServices>,
pub nextcloud: Option<NextcloudServices>,
pub admin_settings_service: Option<Arc<AdminSettingsService>>,
/// WASM plugin management (list/install/toggle/remove), backing the admin
/// Plugins tab. `None` when the `plugins` feature is compiled out or
/// `OXICLOUD_ENABLE_PLUGINS` is false — the admin endpoints return 503 then.
pub plugin_management:
Option<Arc<dyn crate::application::ports::plugin_ports::PluginManagementPort>>,
pub storage_settings_service: Option<Arc<StorageSettingsService>>,
pub migration_state: Arc<tokio::sync::RwLock<MigrationState>>,
pub trash_service: Option<Arc<TrashService>>,
+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,70 @@
//! Background plugin-log maintenance: a periodic sweep that prunes each
//! installed plugin's rotated log segments by age + aggregate size.
//!
//! Modeled on [`crate::infrastructure::services::trash_cleanup_service`]: a
//! single spawned task on a fixed interval, an immediate first run, and
//! log-and-continue on error. `file-rotate` already compresses + caps segment
//! *count* at write time; this is the only thing that enforces the per-plugin
//! age/byte retention and the only thing that ever prunes *idle* plugins (which
//! never trigger a write-time rotation).
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use tokio::time;
use tracing::{debug, info};
use super::log_store::PluginLogStore;
use crate::application::ports::plugin_ports::PluginManagementPort;
/// Periodically sweeps every installed plugin's logs against its retention.
pub struct PluginLogMaintenanceService {
log_store: Arc<PluginLogStore>,
manager: Arc<dyn PluginManagementPort>,
interval_hours: u64,
}
impl PluginLogMaintenanceService {
pub fn new(
log_store: Arc<PluginLogStore>,
manager: Arc<dyn PluginManagementPort>,
interval_hours: u64,
) -> Self {
Self {
log_store,
manager,
interval_hours: interval_hours.max(1),
}
}
/// Spawn the periodic sweep task.
pub fn start(&self) {
let log_store = self.log_store.clone();
let manager = self.manager.clone();
let interval_hours = self.interval_hours;
info!(
"Starting plugin log maintenance job with interval of {} hours",
interval_hours
);
tokio::spawn(async move {
let mut interval = time::interval(Duration::from_secs(interval_hours * 60 * 60));
// First tick fires immediately.
loop {
interval.tick().await;
Self::sweep_all(&log_store, &manager).await;
}
});
}
async fn sweep_all(log_store: &PluginLogStore, manager: &Arc<dyn PluginManagementPort>) {
let now = Utc::now();
let plugins = manager.list();
debug!("Plugin log sweep over {} plugin(s)", plugins.len());
for plugin in plugins {
log_store.request_sweep(&plugin.id, now).await;
}
}
}
@@ -0,0 +1,731 @@
//! Per-plugin structured log storage — an async, in-order actor over disk files.
//!
//! Every plugin gets its own directory under the log root:
//! ```text
//! {root}/{plugin_id}/events.jsonl # active (file-rotate writes here)
//! {root}/{plugin_id}/events.jsonl.<timestamp>.gz # rotated + gzip'd (immutable)
//! {root}/{plugin_id}/retention.json # per-plugin retention override
//! ```
//!
//! **Async + strictly in order.** All file mutations funnel through a single
//! background thread that owns the per-plugin [`FileRotate`] writers. Because
//! there is exactly one consumer draining one channel FIFO, batches land in
//! enqueue order with no locks, and the dispatch path never blocks on IO — it
//! just sends. Rotation, gzip-on-rotate and a coarse segment ceiling are handled
//! by `file-rotate`; per-plugin age + aggregate-byte retention is the [`sweep`]
//! (run on a schedule), the only thing that ever prunes *idle* plugins.
//!
//! [`sweep`]: PluginLogStore::request_sweep
use std::collections::HashMap;
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use chrono::{DateTime, Duration, Utc};
use file_rotate::{
ContentLimit, FileRotate,
compression::Compression,
suffix::{AppendTimestamp, FileLimit},
};
use flate2::read::GzDecoder;
use tokio::sync::{broadcast, mpsc, oneshot};
use super::runtime::InvokeOutcome;
use crate::application::ports::plugin_ports::{
LogEntry, LogPage, LogQuery, PluginLogEvent, RetentionSettings,
};
/// Name of the active (uncompressed) log file inside a plugin's log dir.
const ACTIVE_FILE: &str = "events.jsonl";
/// Marker file holding a plugin's retention override.
const RETENTION_FILE: &str = "retention.json";
/// Live broadcast buffer; a slow tailer past this gets `Lagged` (never blocks).
const LIVE_CAPACITY: usize = 256;
/// Commands processed in receipt order by the single actor thread.
enum LogCommand {
Append {
plugin_id: String,
entries: Vec<LogEntry>,
},
Read {
plugin_id: String,
query: LogQuery,
reply: oneshot::Sender<LogPage>,
},
Clear {
plugin_id: String,
reply: oneshot::Sender<()>,
},
Remove {
plugin_id: String,
},
GetRetention {
plugin_id: String,
reply: oneshot::Sender<RetentionSettings>,
},
SetRetention {
plugin_id: String,
settings: RetentionSettings,
reply: oneshot::Sender<()>,
},
Sweep {
plugin_id: String,
now: DateTime<Utc>,
},
}
/// Cheap, cloneable handle to the log actor. Held by the plugin manager and the
/// maintenance task; `subscribe_logs` hands receivers to SSE clients.
pub struct PluginLogStore {
tx: mpsc::Sender<LogCommand>,
live: broadcast::Sender<PluginLogEvent>,
}
impl PluginLogStore {
/// Spawn the actor thread and return a handle. `default_retention` is applied
/// to any plugin lacking an explicit `retention.json`. `queue_capacity`
/// bounds the command channel — a flood past it sheds the oldest-arriving
/// batch rather than growing RAM or blocking dispatch.
pub fn new(
root: PathBuf,
max_file_bytes: u64,
max_segments: u32,
default_retention: RetentionSettings,
queue_capacity: usize,
) -> Self {
let (tx, rx) = mpsc::channel(queue_capacity.max(1));
let (live, _) = broadcast::channel(LIVE_CAPACITY);
let actor = Actor {
root,
max_file_bytes: max_file_bytes.max(1),
max_segments,
default_retention,
writers: HashMap::new(),
live: live.clone(),
};
// A dedicated OS thread so the blocking file/gzip IO never stalls a tokio
// worker. `blocking_recv` is valid here (no runtime on this thread).
std::thread::Builder::new()
.name("plugin-log-store".into())
.spawn(move || actor.run(rx))
.expect("spawn plugin-log-store thread");
Self { tx, live }
}
/// Enqueue a batch (plugin-emitted lines + the host outcome) for one
/// invocation. Called from the dispatch `spawn_blocking` closure. Uses a
/// non-blocking `try_send`: under flood it sheds the batch (logged) rather
/// than blocking the blocking-pool thread or growing RAM unboundedly. A full
/// queue or a gone actor is swallowed — logging must never break dispatch.
pub fn append(
&self,
plugin_id: &str,
invocation_id: &str,
lines: &[(String, String)],
outcome: &InvokeOutcome,
) {
let ts = Utc::now().to_rfc3339();
let mut entries: Vec<LogEntry> = lines
.iter()
.map(|(level, msg)| LogEntry {
ts: ts.clone(),
invocation_id: invocation_id.to_string(),
kind: "plugin".to_string(),
level: level.clone(),
reason: None,
msg: msg.clone(),
})
.collect();
let (level, msg) = outcome.log_detail();
entries.push(LogEntry {
ts,
invocation_id: invocation_id.to_string(),
kind: "outcome".to_string(),
level: level.to_string(),
reason: Some(outcome.reason().to_string()),
msg,
});
if let Err(e) = self.tx.try_send(LogCommand::Append {
plugin_id: plugin_id.to_string(),
entries,
}) {
tracing::warn!(
target: "oxicloud::plugins",
plugin_id = %plugin_id,
error = %e,
"dropping plugin log batch: queue full or log actor unavailable"
);
}
}
/// Read a filtered, paginated page of a plugin's entries (newest first).
pub async fn read_page(&self, plugin_id: &str, query: LogQuery) -> LogPage {
let (reply, rx) = oneshot::channel();
if self
.tx
.send(LogCommand::Read {
plugin_id: plugin_id.to_string(),
query,
reply,
})
.await
.is_err()
{
return LogPage {
entries: Vec::new(),
total: 0,
};
}
rx.await.unwrap_or(LogPage {
entries: Vec::new(),
total: 0,
})
}
/// Delete a plugin's log files (keeps `retention.json`).
pub async fn clear(&self, plugin_id: &str) {
let (reply, rx) = oneshot::channel();
if self
.tx
.send(LogCommand::Clear {
plugin_id: plugin_id.to_string(),
reply,
})
.await
.is_ok()
{
let _ = rx.await;
}
}
/// Delete a plugin's entire log directory (on uninstall). Fire-and-forget
/// and non-blocking, so it's safe to call from the synchronous management
/// path without stalling an async worker.
pub fn remove_plugin_logs(&self, plugin_id: &str) {
let _ = self.tx.try_send(LogCommand::Remove {
plugin_id: plugin_id.to_string(),
});
}
/// The plugin's effective retention (override or configured default).
pub async fn get_retention(&self, plugin_id: &str) -> RetentionSettings {
let (reply, rx) = oneshot::channel();
if self
.tx
.send(LogCommand::GetRetention {
plugin_id: plugin_id.to_string(),
reply,
})
.await
.is_ok()
&& let Ok(s) = rx.await
{
return s;
}
// Fall back to a conservative default if the actor is gone.
RetentionSettings {
retention_days: 30,
max_bytes: 256 * 1024 * 1024,
}
}
/// Persist a per-plugin retention override.
pub async fn set_retention(&self, plugin_id: &str, settings: RetentionSettings) {
let (reply, rx) = oneshot::channel();
if self
.tx
.send(LogCommand::SetRetention {
plugin_id: plugin_id.to_string(),
settings,
reply,
})
.await
.is_ok()
{
let _ = rx.await;
}
}
/// Ask the actor to prune a plugin's segments by age + aggregate size.
pub async fn request_sweep(&self, plugin_id: &str, now: DateTime<Utc>) {
let _ = self
.tx
.send(LogCommand::Sweep {
plugin_id: plugin_id.to_string(),
now,
})
.await;
}
/// Subscribe to newly-written entries across all plugins (live tailing).
pub fn subscribe(&self) -> broadcast::Receiver<PluginLogEvent> {
self.live.subscribe()
}
}
/// The single owner of all log-file state. Runs on its own thread.
struct Actor {
root: PathBuf,
max_file_bytes: u64,
max_segments: u32,
default_retention: RetentionSettings,
writers: HashMap<String, FileRotate<AppendTimestamp>>,
live: broadcast::Sender<PluginLogEvent>,
}
impl Actor {
fn run(mut self, mut rx: mpsc::Receiver<LogCommand>) {
while let Some(cmd) = rx.blocking_recv() {
match cmd {
LogCommand::Append { plugin_id, entries } => {
self.handle_append(&plugin_id, entries)
}
LogCommand::Read {
plugin_id,
query,
reply,
} => {
let _ = reply.send(self.read_page(&plugin_id, &query));
}
LogCommand::Clear { plugin_id, reply } => {
self.clear(&plugin_id);
let _ = reply.send(());
}
LogCommand::Remove { plugin_id } => self.remove(&plugin_id),
LogCommand::GetRetention { plugin_id, reply } => {
let _ = reply.send(self.get_retention(&plugin_id));
}
LogCommand::SetRetention {
plugin_id,
settings,
reply,
} => {
self.set_retention(&plugin_id, settings);
let _ = reply.send(());
}
LogCommand::Sweep { plugin_id, now } => self.sweep(&plugin_id, now),
}
}
}
fn plugin_dir(&self, plugin_id: &str) -> PathBuf {
self.root.join(plugin_id)
}
/// Lazily build (or fetch) the rotating writer for a plugin.
fn writer_for(&mut self, plugin_id: &str) -> Option<&mut FileRotate<AppendTimestamp>> {
if !self.writers.contains_key(plugin_id) {
let path = self.plugin_dir(plugin_id).join(ACTIVE_FILE);
let writer = FileRotate::new(
path,
AppendTimestamp::default(FileLimit::MaxFiles(self.max_segments as usize)),
ContentLimit::BytesSurpassed(self.max_file_bytes as usize),
Compression::OnRotate(0),
#[cfg(unix)]
None,
);
self.writers.insert(plugin_id.to_string(), writer);
}
self.writers.get_mut(plugin_id)
}
fn handle_append(&mut self, plugin_id: &str, entries: Vec<LogEntry>) {
let mut buf = Vec::new();
for entry in &entries {
if serde_json::to_writer(&mut buf, entry).is_ok() {
buf.push(b'\n');
}
}
if let Some(writer) = self.writer_for(plugin_id)
&& let Err(e) = writer.write_all(&buf).and_then(|_| writer.flush())
{
tracing::warn!(
target: "oxicloud::plugins",
plugin_id = %plugin_id,
error = %e,
"failed to write plugin log batch"
);
return;
}
// Publish only after the durable write, so the live tail never shows an
// entry a subsequent read wouldn't. No subscribers => send is a no-op.
for entry in entries {
let _ = self.live.send(PluginLogEvent {
plugin_id: plugin_id.to_string(),
entry,
});
}
}
fn read_page(&self, plugin_id: &str, query: &LogQuery) -> LogPage {
let dir = self.plugin_dir(plugin_id);
// Gather rotated segments oldest→newest (by mtime), then the active file.
let mut segments: Vec<(PathBuf, SystemTime)> = Vec::new();
let mut active: Option<PathBuf> = None;
if let Ok(read_dir) = fs::read_dir(&dir) {
for entry in read_dir.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name == ACTIVE_FILE {
active = Some(path);
} else if name.starts_with("events.jsonl.") {
let mtime = entry
.metadata()
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
segments.push((path, mtime));
}
}
}
segments.sort_by_key(|(_, mtime)| *mtime);
let mut all: Vec<LogEntry> = Vec::new();
for (path, _) in &segments {
read_entries_into(path, query, &mut all);
}
if let Some(path) = &active {
read_entries_into(path, query, &mut all);
}
// `all` is chronological (oldest→newest); the viewer wants newest first.
all.reverse();
let total = all.len();
let entries = all
.into_iter()
.skip(query.offset)
.take(query.limit)
.collect();
LogPage { entries, total }
}
fn clear(&mut self, plugin_id: &str) {
// Drop the open writer first so the active file can be removed cleanly.
self.writers.remove(plugin_id);
let dir = self.plugin_dir(plugin_id);
if let Ok(read_dir) = fs::read_dir(&dir) {
for entry in read_dir.flatten() {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str())
&& (name == ACTIVE_FILE || name.starts_with("events.jsonl."))
{
let _ = fs::remove_file(&path);
}
}
}
}
fn remove(&mut self, plugin_id: &str) {
self.writers.remove(plugin_id);
let _ = fs::remove_dir_all(self.plugin_dir(plugin_id));
}
fn get_retention(&self, plugin_id: &str) -> RetentionSettings {
let path = self.plugin_dir(plugin_id).join(RETENTION_FILE);
fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str::<RetentionSettings>(&s).ok())
.unwrap_or(self.default_retention)
}
fn set_retention(&self, plugin_id: &str, settings: RetentionSettings) {
let dir = self.plugin_dir(plugin_id);
if let Err(e) = fs::create_dir_all(&dir) {
tracing::warn!(
target: "oxicloud::plugins",
plugin_id = %plugin_id, error = %e,
"failed to create plugin log dir for retention"
);
return;
}
match serde_json::to_string_pretty(&settings) {
Ok(json) => {
if let Err(e) = fs::write(dir.join(RETENTION_FILE), json) {
tracing::warn!(
target: "oxicloud::plugins",
plugin_id = %plugin_id, error = %e,
"failed to persist plugin retention"
);
}
}
Err(e) => tracing::warn!(
target: "oxicloud::plugins",
plugin_id = %plugin_id, error = %e,
"failed to serialize plugin retention"
),
}
}
/// Prune rotated segments older than the plugin's retention window, then
/// enforce the aggregate byte cap (oldest deleted first). Never touches the
/// active file.
fn sweep(&self, plugin_id: &str, now: DateTime<Utc>) {
let dir = self.plugin_dir(plugin_id);
let retention = self.get_retention(plugin_id);
let cutoff = now - Duration::days(retention.retention_days as i64);
let mut segments: Vec<(PathBuf, SystemTime, u64)> = Vec::new();
let Ok(read_dir) = fs::read_dir(&dir) else {
return;
};
for entry in read_dir.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.starts_with("events.jsonl.") {
continue; // skip the active file, retention.json, etc.
}
let Ok(meta) = entry.metadata() else { continue };
let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
segments.push((path, mtime, meta.len()));
}
// 1) Age-based pruning.
let mut purged = 0u64;
segments.retain(|(path, mtime, _)| {
let dt: DateTime<Utc> = (*mtime).into();
if dt < cutoff {
let _ = fs::remove_file(path);
purged += 1;
false
} else {
true
}
});
// 2) Aggregate byte cap, oldest deleted first.
segments.sort_by_key(|(_, mtime, _)| *mtime);
let mut total: u64 = segments.iter().map(|(_, _, size)| *size).sum();
let mut idx = 0;
while total > retention.max_bytes && idx < segments.len() {
let (path, _, size) = &segments[idx];
let _ = fs::remove_file(path);
total = total.saturating_sub(*size);
purged += 1;
idx += 1;
}
if purged > 0 {
tracing::debug!(
target: "oxicloud::plugins",
plugin_id = %plugin_id,
purged,
"plugin log retention sweep removed segments"
);
}
}
}
/// Read one segment (gzip if `.gz`, else plain), parse each line as a
/// [`LogEntry`], apply the filter, and append matches to `out`. Malformed lines
/// are skipped — a torn final line never aborts a read.
fn read_entries_into(path: &Path, query: &LogQuery, out: &mut Vec<LogEntry>) {
let Ok(file) = fs::File::open(path) else {
return;
};
let content = if path.extension().and_then(|e| e.to_str()) == Some("gz") {
let mut s = String::new();
if GzDecoder::new(file).read_to_string(&mut s).is_err() {
return;
}
s
} else {
let mut s = String::new();
let mut file = file;
if file.read_to_string(&mut s).is_err() {
return;
}
s
};
let search = query.search.as_ref().map(|s| s.to_lowercase());
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
let Ok(entry) = serde_json::from_str::<LogEntry>(line) else {
continue;
};
if let Some(level) = &query.level
&& &entry.level != level
{
continue;
}
if let Some(needle) = &search
&& !entry.msg.to_lowercase().contains(needle)
{
continue;
}
out.push(entry);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn settings(days: u32, max_bytes: u64) -> RetentionSettings {
RetentionSettings {
retention_days: days,
max_bytes,
}
}
fn entry(level: &str, msg: &str) -> LogEntry {
LogEntry {
ts: Utc::now().to_rfc3339(),
invocation_id: "inv".into(),
kind: "plugin".into(),
level: level.into(),
reason: None,
msg: msg.into(),
}
}
fn new_actor(root: PathBuf, max_file_bytes: u64, max_segments: u32) -> Actor {
let (live, _) = broadcast::channel(16);
Actor {
root,
max_file_bytes: max_file_bytes.max(1),
max_segments,
default_retention: settings(30, 1 << 30),
writers: HashMap::new(),
live,
}
}
#[test]
fn ordering_and_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let mut actor = new_actor(dir.path().to_path_buf(), 1 << 20, 5);
for i in 0..50 {
actor.handle_append("p", vec![entry("info", &format!("line {i}"))]);
}
let page = actor.read_page(
"p",
&LogQuery {
level: None,
search: None,
offset: 0,
limit: 10,
},
);
assert_eq!(page.total, 50);
assert_eq!(page.entries.len(), 10);
// Newest first.
assert_eq!(page.entries[0].msg, "line 49");
assert_eq!(page.entries[9].msg, "line 40");
}
#[test]
fn filter_by_level_and_search() {
let dir = tempfile::tempdir().unwrap();
let mut actor = new_actor(dir.path().to_path_buf(), 1 << 20, 5);
actor.handle_append("p", vec![entry("info", "hello world")]);
actor.handle_append("p", vec![entry("error", "BOOM failure")]);
actor.handle_append("p", vec![entry("info", "another HELLO")]);
let q = LogQuery {
level: Some("info".into()),
search: Some("hello".into()),
offset: 0,
limit: 100,
};
let page = actor.read_page("p", &q);
assert_eq!(page.total, 2);
assert!(page.entries.iter().all(|e| e.level == "info"));
assert!(
page.entries
.iter()
.all(|e| e.msg.to_lowercase().contains("hello"))
);
}
#[test]
fn rotation_creates_compressed_segments() {
let dir = tempfile::tempdir().unwrap();
// Tiny byte cap forces frequent rotation; a high segment cap keeps every
// segment so the cross-segment read can be checked end to end (the byte
// cap is then exercised separately by `sweep_age_and_size`).
let mut actor = new_actor(dir.path().to_path_buf(), 256, 100_000);
for i in 0..200 {
actor.handle_append(
"p",
vec![entry("info", &format!("padding line number {i}"))],
);
}
let plugin_dir = dir.path().join("p");
let gz = fs::read_dir(&plugin_dir)
.unwrap()
.flatten()
.filter(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.map(|x| x == "gz")
.unwrap_or(false)
})
.count();
assert!(gz > 0, "expected at least one rotated .gz segment");
// All originally-written lines must still be readable across segments.
let page = actor.read_page(
"p",
&LogQuery {
level: None,
search: None,
offset: 0,
limit: 1000,
},
);
assert_eq!(page.total, 200);
}
#[test]
fn retention_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let actor = new_actor(dir.path().to_path_buf(), 1 << 20, 5);
assert_eq!(actor.get_retention("p").retention_days, 30); // default
actor.set_retention("p", settings(7, 1234));
let r = actor.get_retention("p");
assert_eq!(r.retention_days, 7);
assert_eq!(r.max_bytes, 1234);
}
#[test]
fn sweep_age_and_size() {
let dir = tempfile::tempdir().unwrap();
let plugin_dir = dir.path().join("p");
fs::create_dir_all(&plugin_dir).unwrap();
// One "old" rotated segment and one "fresh" one.
let old = plugin_dir.join("events.jsonl.20200101T000000.gz");
let fresh = plugin_dir.join("events.jsonl.20990101T000000.gz");
fs::write(&old, b"x").unwrap();
fs::write(&fresh, b"y").unwrap();
// Backdate the "old" file's mtime well past the retention window.
let long_ago = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
filetime_set(&old, long_ago);
let actor = new_actor(dir.path().to_path_buf(), 1 << 20, 5);
// 1-day retention: the backdated file must go, the fresh one stays.
actor.sweep("p", Utc::now());
assert!(!old.exists(), "age-expired segment should be purged");
assert!(fresh.exists(), "recent segment should be kept");
}
/// Minimal mtime setter for tests (no extra dep): rewrite + set via filetime
/// is unavailable, so emulate "old" by relying on a very old written time is
/// not possible portably; instead we set it through `fs` utimes if present.
fn filetime_set(path: &Path, when: SystemTime) {
// `set_file_mtime` isn't in std; approximate by opening and using the
// platform fallback: on failure the test still meaningfully exercises
// the size path. We use a best-effort via `File::set_modified` (1.75+).
if let Ok(f) = fs::OpenOptions::new().write(true).open(path) {
let _ = f.set_modified(when);
}
}
}
@@ -0,0 +1,594 @@
//! Plugin discovery + dispatch + admin management. Implements
//! [`PluginDispatchPort`] and [`PluginManagementPort`] over the Extism
//! [`PluginRuntime`].
//!
//! Discovery scans a directory of plugin subdirectories (each `plugin.toml` +
//! `.wasm`) at startup; a plugin that fails validation or load is audit-logged
//! and skipped, never fatal. Dispatch builds a fresh sandbox per invocation on
//! the blocking pool, so a slow or hostile plugin never stalls async workers or
//! the upload path that triggered it.
//!
//! The same in-memory plugin set backs both ports, guarded by an `RwLock`: a
//! management op (install / toggle / remove) takes the write lock and is
//! reflected on the live dispatch path with no restart. Enable/disable state is
//! persisted as a `.disabled` marker file in the plugin's own directory so it
//! survives a restart without a database.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use async_trait::async_trait;
use tokio::sync::Semaphore;
use super::log_store::PluginLogStore;
use super::manifest;
use super::runtime::{InvokeOutcome, PluginRuntime};
use crate::application::ports::plugin_ports::{
LogPage, LogQuery, OXICLOUD_PLUGIN_ABI, PluginContext, PluginDispatchPort, PluginEvent,
PluginInfo, PluginInput, PluginLogEvent, PluginManagementPort, PluginMgmtError,
RetentionSettings, event_export_name,
};
use crate::common::config::PluginConfig;
use tokio::sync::broadcast;
/// Name of the marker file that, when present in a plugin's directory, loads it
/// disabled. Created/removed by [`PluginManagementPort::set_enabled`].
const DISABLED_MARKER: &str = ".disabled";
/// A validated, loadable plugin held in memory.
struct LoadedPlugin {
id: String,
name: String,
version: String,
abi: u32,
subscribe: HashSet<String>,
/// Whether dispatch delivers events to this plugin. Mirrors the on-disk
/// `.disabled` marker.
enabled: bool,
/// The plugin's own directory (not necessarily named after `id`). Used to
/// write the disabled marker and to delete the plugin on removal.
dir: PathBuf,
runtime: Arc<PluginRuntime>,
}
impl LoadedPlugin {
fn info(&self) -> PluginInfo {
let mut subscriptions: Vec<String> = self.subscribe.iter().cloned().collect();
subscriptions.sort();
PluginInfo {
id: self.id.clone(),
name: self.name.clone(),
version: self.version.clone(),
abi: self.abi,
subscriptions,
enabled: self.enabled,
}
}
}
/// Owns all loaded plugins and dispatches events to them.
pub struct ExtismPluginManager {
config: PluginConfig,
/// Root directory plugins are discovered in and installed into.
root_dir: PathBuf,
plugins: RwLock<Vec<LoadedPlugin>>,
/// Per-plugin structured log storage (shared with the maintenance task).
log_store: Arc<PluginLogStore>,
/// Caps concurrent plugin invocations across all plugins so dispatch can
/// shed load instead of flooding the shared blocking pool.
invocation_sem: Arc<Semaphore>,
}
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 {
// The log root is a sibling of the plugins dir by default (or the
// configured override); it lives outside any individual plugin dir so a
// plugin uninstall (`remove_dir_all`) never wipes another's logs.
let log_dir = config
.log_dir
.clone()
.unwrap_or_else(|| dir.join(".plugin-logs"));
let log_store = Arc::new(PluginLogStore::new(
log_dir.clone(),
config.log_max_file_bytes,
config.log_max_segments,
RetentionSettings {
retention_days: config.log_retention_days,
max_bytes: config.log_total_max_bytes,
},
config.log_queue_capacity,
));
let invocation_sem = Arc::new(Semaphore::new(config.max_concurrent_invocations.max(1)));
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,
root_dir: dir.to_path_buf(),
plugins: RwLock::new(plugins),
log_store,
invocation_sem,
};
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
// Never treat the log root as a plugin directory.
if path == log_dir {
continue;
}
match Self::load_one(&config, &path) {
Ok(loaded) => {
tracing::info!(
target: "oxicloud::plugins",
plugin_id = %loaded.id,
enabled = loaded.enabled,
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,
root_dir: dir.to_path_buf(),
plugins: RwLock::new(plugins),
log_store,
invocation_sem,
}
}
/// The shared log store, handed to the maintenance task by DI.
pub fn log_store(&self) -> Arc<PluginLogStore> {
self.log_store.clone()
}
/// 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())?;
// The entrypoint becomes a path joined onto the plugin dir; reject a
// traversal-unsafe value on disk too (mirrors the `install` check), so a
// hand-placed manifest can't read a `.wasm` outside its own directory.
if !is_safe_component(&manifest.plugin.entrypoint) {
return Err("bad_entrypoint");
}
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 a throwaway instance: abi must match AND every subscribed event
// must have its `on_<event>` handler exported.
let required_exports: Vec<String> = manifest
.events
.subscribe
.iter()
.map(|e| event_export_name(e))
.collect();
Self::probe(config, &runtime, &required_exports)?;
Ok(LoadedPlugin {
id: manifest.plugin.id,
name: manifest.plugin.name,
version: manifest.plugin.version,
abi: manifest.plugin.abi,
subscribe: manifest.events.subscribe.into_iter().collect(),
enabled: !dir.join(DISABLED_MARKER).exists(),
dir: dir.to_path_buf(),
runtime: Arc::new(runtime),
})
}
/// Probe loadability, mapping the runtime outcome to a stable reason key.
fn probe(
config: &PluginConfig,
runtime: &PluginRuntime,
required_exports: &[String],
) -> Result<(), &'static str> {
match runtime.check_loadable(config, required_exports) {
InvokeOutcome::Ok => Ok(()),
InvokeOutcome::AbiMismatch { .. } => Err("abi_mismatch"),
InvokeOutcome::MissingExport(_) => Err("missing_export"),
_ => Err("not_loadable"),
}
}
/// Number of successfully loaded plugins (used by DI for the startup summary
/// and by tests).
pub fn loaded_count(&self) -> usize {
self.read_plugins().len()
}
/// Drop the cached compiled module of every plugin idle past the configured
/// TTL, reclaiming memory. Driven by a periodic timer in DI; the next event
/// to a freed plugin recompiles transparently.
pub fn evict_idle_compiled(&self) {
let ttl = Duration::from_secs(self.config.cache_idle_ttl_secs);
let mut evicted = 0usize;
for plugin in self.read_plugins().iter() {
if plugin.runtime.evict_if_idle(ttl) {
evicted += 1;
}
}
if evicted > 0 {
tracing::debug!(
target: "oxicloud::plugins",
evicted,
"evicted idle compiled plugin modules"
);
}
}
fn read_plugins(&self) -> std::sync::RwLockReadGuard<'_, Vec<LoadedPlugin>> {
self.plugins.read().unwrap_or_else(|e| e.into_inner())
}
fn write_plugins(&self) -> std::sync::RwLockWriteGuard<'_, Vec<LoadedPlugin>> {
self.plugins.write().unwrap_or_else(|e| e.into_inner())
}
/// `NotFound` unless a plugin with this id is currently installed. Checked
/// before any log-file access so an HTTP-supplied id can't reach the
/// filesystem for a plugin that doesn't exist.
fn ensure_installed(&self, id: &str) -> Result<(), PluginMgmtError> {
if self.read_plugins().iter().any(|p| p.id == id) {
Ok(())
} else {
Err(PluginMgmtError::NotFound)
}
}
}
impl PluginDispatchPort for ExtismPluginManager {
fn dispatch(&self, event: PluginEvent) {
for plugin in self.read_plugins().iter() {
if !plugin.enabled || !plugin.subscribe.contains(event.name) {
continue;
}
let input = PluginInput {
abi: OXICLOUD_PLUGIN_ABI,
event: event.name.to_string(),
context: PluginContext {
plugin_id: plugin.id.clone(),
user_id: event.user_id.clone(),
invocation_id: event.invocation_id.clone(),
},
payload: event.payload.clone(),
};
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;
}
};
// Load shedding: cap concurrent invocations so a flood of events (or
// slow plugins) can't exhaust the shared blocking pool. Past the cap
// the event is dropped — plugins are observe-only, so shedding is
// safe; we just record it.
let permit = match self.invocation_sem.clone().try_acquire_owned() {
Ok(p) => p,
Err(_) => {
tracing::warn!(
target: "audit",
event = "plugin.dispatch_shed",
reason = "at_capacity",
plugin_id = %plugin.id,
invocation_id = %event.invocation_id,
plugin_event = %event.name,
"👮🏻‍♂️ plugin event dropped: invocation limit reached"
);
continue;
}
};
let runtime = plugin.runtime.clone();
let config = self.config.clone();
let plugin_id = plugin.id.clone();
let invocation_id = event.invocation_id.clone();
let export = event_export_name(event.name);
let log_store = self.log_store.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 || {
// Hold the permit for the lifetime of the invocation.
let _permit = permit;
let result = runtime.invoke(&config, &export, &invocation_id, &input_json);
// Persist every invocation (the plugin's own log lines plus the
// host outcome) to the plugin's structured log. Ordered, async,
// and non-fatal — a failed write never affects the request.
log_store.append(&plugin_id, &invocation_id, &result.logs, &result.outcome);
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.read_plugins()
.iter()
.any(|p| p.enabled && p.subscribe.contains(event))
}
}
#[async_trait]
impl PluginManagementPort for ExtismPluginManager {
fn list(&self) -> Vec<PluginInfo> {
let mut infos: Vec<PluginInfo> = self.read_plugins().iter().map(|p| p.info()).collect();
infos.sort_by(|a, b| a.id.cmp(&b.id));
infos
}
fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), PluginMgmtError> {
let mut plugins = self.write_plugins();
let plugin = plugins
.iter_mut()
.find(|p| p.id == id)
.ok_or(PluginMgmtError::NotFound)?;
let marker = plugin.dir.join(DISABLED_MARKER);
if enabled {
match std::fs::remove_file(&marker) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(PluginMgmtError::Io(e.to_string())),
}
} else {
std::fs::write(&marker, b"").map_err(|e| PluginMgmtError::Io(e.to_string()))?;
}
plugin.enabled = enabled;
Ok(())
}
fn install(&self, manifest_toml: &str, wasm: Vec<u8>) -> Result<PluginInfo, PluginMgmtError> {
// Validate the manifest and the wasm before touching the filesystem.
let manifest = manifest::parse_and_validate(manifest_toml)
.map_err(|e| PluginMgmtError::Rejected(e.reason()))?;
// `id` becomes a directory name and `entrypoint` a filename — both must
// be single, traversal-free path components.
if !is_safe_component(&manifest.plugin.id) {
return Err(PluginMgmtError::Rejected("bad_id"));
}
if !is_safe_component(&manifest.plugin.entrypoint) {
return Err(PluginMgmtError::Rejected("bad_entrypoint"));
}
let required_exports: Vec<String> = manifest
.events
.subscribe
.iter()
.map(|e| event_export_name(e))
.collect();
let runtime = PluginRuntime::new(manifest.plugin.id.clone(), wasm.clone());
Self::probe(&self.config, &runtime, &required_exports)
.map_err(PluginMgmtError::Rejected)?;
let id = manifest.plugin.id.clone();
let target = self.root_dir.join(&id);
// Hold the write lock across the collision check and the directory swap
// so two concurrent installs of the same id cannot race. Admin installs
// are rare; readers block only briefly.
let mut plugins = self.write_plugins();
if plugins.iter().any(|p| p.id == id) || target.exists() {
return Err(PluginMgmtError::IdExists);
}
std::fs::create_dir_all(&self.root_dir).map_err(|e| PluginMgmtError::Io(e.to_string()))?;
// Write to a temp dir then rename, so a crash mid-write never leaves a
// half-written plugin discoverable.
let tmp = tempfile::Builder::new()
.prefix(".tmp-install-")
.tempdir_in(&self.root_dir)
.map_err(|e| PluginMgmtError::Io(e.to_string()))?;
std::fs::write(tmp.path().join("plugin.toml"), manifest_toml)
.map_err(|e| PluginMgmtError::Io(e.to_string()))?;
std::fs::write(tmp.path().join(&manifest.plugin.entrypoint), &wasm)
.map_err(|e| PluginMgmtError::Io(e.to_string()))?;
let tmp_path = tmp.keep();
if let Err(e) = std::fs::rename(&tmp_path, &target) {
let _ = std::fs::remove_dir_all(&tmp_path);
return Err(PluginMgmtError::Io(e.to_string()));
}
let loaded = LoadedPlugin {
id: id.clone(),
name: manifest.plugin.name.clone(),
version: manifest.plugin.version.clone(),
abi: manifest.plugin.abi,
subscribe: manifest.events.subscribe.iter().cloned().collect(),
enabled: true,
dir: target,
runtime: Arc::new(runtime),
};
let info = loaded.info();
plugins.push(loaded);
Ok(info)
}
fn install_bundle(&self, zip: Vec<u8>) -> Result<PluginInfo, PluginMgmtError> {
use std::io::{Cursor, Read};
// Aggregate decompressed ceiling, enforced as each entry is unpacked so
// a zip bomb can't blow up memory before validation (the route also caps
// the compressed body). We only ever extract two named entries.
let max_decompressed: u64 = self.config.max_bundle_decompressed_bytes;
let mut archive = zip::ZipArchive::new(Cursor::new(zip))
.map_err(|_| PluginMgmtError::Rejected("bad_zip"))?;
// Locate `plugin.toml` — at the archive root or under a single wrapping
// folder (e.g. `myplugin/plugin.toml`).
let manifest_name = archive
.file_names()
.find(|n| !n.ends_with('/') && (*n == "plugin.toml" || n.ends_with("/plugin.toml")))
.map(str::to_owned)
.ok_or(PluginMgmtError::Rejected("no_manifest_in_zip"))?;
let mut manifest_toml = String::new();
{
let entry = archive
.by_name(&manifest_name)
.map_err(|_| PluginMgmtError::Rejected("no_manifest_in_zip"))?;
entry
.take(max_decompressed + 1)
.read_to_string(&mut manifest_toml)
.map_err(|_| PluginMgmtError::Rejected("bad_zip"))?;
}
if manifest_toml.len() as u64 > max_decompressed {
return Err(PluginMgmtError::Rejected("too_large"));
}
// Parse just to learn the entrypoint name; `install` does the full
// validation (and rejects a traversal-unsafe entrypoint).
let manifest = manifest::parse_and_validate(&manifest_toml)
.map_err(|e| PluginMgmtError::Rejected(e.reason()))?;
// Resolve the entrypoint relative to the manifest's folder in the zip.
let prefix = match manifest_name.rfind('/') {
Some(i) => &manifest_name[..=i],
None => "",
};
let wasm_name = format!("{prefix}{}", manifest.plugin.entrypoint);
// Budget the wasm against what the manifest already consumed.
let remaining = max_decompressed - manifest_toml.len() as u64;
let mut wasm = Vec::new();
{
let entry = archive
.by_name(&wasm_name)
.map_err(|_| PluginMgmtError::Rejected("entrypoint_not_in_zip"))?;
entry
.take(remaining + 1)
.read_to_end(&mut wasm)
.map_err(|_| PluginMgmtError::Rejected("bad_zip"))?;
}
if wasm.len() as u64 > remaining {
return Err(PluginMgmtError::Rejected("too_large"));
}
self.install(&manifest_toml, wasm)
}
fn remove(&self, id: &str) -> Result<(), PluginMgmtError> {
let mut plugins = self.write_plugins();
let pos = plugins
.iter()
.position(|p| p.id == id)
.ok_or(PluginMgmtError::NotFound)?;
let removed = plugins.remove(pos);
// Also reclaim the plugin's logs so a later reinstall of the same id
// doesn't inherit stale entries.
self.log_store.remove_plugin_logs(id);
match std::fs::remove_dir_all(&removed.dir) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(PluginMgmtError::Io(e.to_string())),
}
}
async fn read_logs(&self, id: &str, query: LogQuery) -> Result<LogPage, PluginMgmtError> {
self.ensure_installed(id)?;
Ok(self.log_store.read_page(id, query).await)
}
async fn clear_logs(&self, id: &str) -> Result<(), PluginMgmtError> {
self.ensure_installed(id)?;
self.log_store.clear(id).await;
Ok(())
}
async fn get_retention(&self, id: &str) -> Result<RetentionSettings, PluginMgmtError> {
self.ensure_installed(id)?;
Ok(self.log_store.get_retention(id).await)
}
async fn set_retention(
&self,
id: &str,
settings: RetentionSettings,
) -> Result<(), PluginMgmtError> {
self.ensure_installed(id)?;
self.log_store.set_retention(id, settings).await;
Ok(())
}
fn subscribe_logs(&self) -> broadcast::Receiver<PluginLogEvent> {
self.log_store.subscribe()
}
}
/// Whether `s` is a single, traversal-free path component safe to use as a
/// directory or file name under the plugins root.
fn is_safe_component(s: &str) -> bool {
!s.is_empty()
&& s != "."
&& s != ".."
&& !s.contains('/')
&& !s.contains('\\')
&& !s.contains('\0')
}
@@ -0,0 +1,315 @@
//! Manager-level tests for the admin management surface (install / toggle /
//! remove) and disabled-state persistence. These drive a real Extism sandbox,
//! so they run only under `cargo test --features plugins`.
//!
//! The `.wasm` fixtures are the same ones the runtime tests use, built by
//! `scripts/build-plugin-hello.sh`.
use super::ExtismPluginManager;
use crate::application::ports::plugin_ports::{PluginDispatchPort, PluginManagementPort};
use crate::common::config::PluginConfig;
fn cfg() -> PluginConfig {
PluginConfig::default()
}
fn fixture(name: &str) -> Vec<u8> {
let path = format!(
"{}/tests/fixtures/plugins/{}",
env!("CARGO_MANIFEST_DIR"),
name
);
std::fs::read(&path).unwrap_or_else(|e| {
panic!("missing fixture {path}: {e}\n run scripts/build-plugin-hello.sh to (re)build it")
})
}
/// A valid manifest for the `hello.wasm` fixture (subscribes to both events).
fn hello_manifest() -> String {
r#"
[plugin]
id = "com.example.hello"
name = "Hello"
version = "0.1.0"
abi = 0
entrypoint = "hello.wasm"
[events]
subscribe = ["file.uploaded", "user.login"]
"#
.to_string()
}
/// A manifest that parses fine but points at the `wrong_abi.wasm` fixture,
/// which reports ABI 1 at runtime.
fn wrong_abi_manifest() -> String {
r#"
[plugin]
id = "com.example.wrongabi"
name = "Wrong ABI"
version = "0.1.0"
abi = 0
entrypoint = "wrong_abi.wasm"
[events]
subscribe = ["file.uploaded"]
"#
.to_string()
}
#[test]
fn install_loads_plugin_and_writes_files() {
let tmp = tempfile::tempdir().unwrap();
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
assert_eq!(mgr.loaded_count(), 0);
let info = mgr
.install(&hello_manifest(), fixture("hello.wasm"))
.expect("install should succeed");
assert_eq!(info.id, "com.example.hello");
assert_eq!(info.name, "Hello");
assert!(info.enabled);
assert_eq!(info.subscriptions, vec!["file.uploaded", "user.login"]);
assert_eq!(mgr.loaded_count(), 1);
let plugin_dir = tmp.path().join("com.example.hello");
assert!(plugin_dir.join("plugin.toml").exists());
assert!(plugin_dir.join("hello.wasm").exists());
// The live dispatch path sees it immediately.
assert!(mgr.has_subscribers("file.uploaded"));
}
/// Build an in-memory `.zip` with the given entries (name, bytes).
fn make_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
use std::io::Write;
use zip::write::SimpleFileOptions;
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in entries {
writer
.start_file(*name, SimpleFileOptions::default())
.unwrap();
writer.write_all(bytes).unwrap();
}
writer.finish().unwrap().into_inner()
}
#[test]
fn install_bundle_from_zip_loads_plugin() {
let tmp = tempfile::tempdir().unwrap();
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
// Wrap everything under a top-level folder to exercise prefix resolution.
let zip = make_zip(&[
("hello/plugin.toml", hello_manifest().as_bytes()),
("hello/hello.wasm", &fixture("hello.wasm")),
]);
let info = mgr
.install_bundle(zip)
.expect("bundle install should succeed");
assert_eq!(info.id, "com.example.hello");
assert!(info.enabled);
assert_eq!(mgr.loaded_count(), 1);
assert!(
tmp.path()
.join("com.example.hello")
.join("hello.wasm")
.exists()
);
}
#[test]
fn install_bundle_without_manifest_is_rejected() {
let tmp = tempfile::tempdir().unwrap();
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
let zip = make_zip(&[("hello.wasm", &fixture("hello.wasm"))]);
let err = mgr
.install_bundle(zip)
.expect_err("a zip without plugin.toml must be rejected");
assert_eq!(err.reason(), "no_manifest_in_zip");
assert_eq!(mgr.loaded_count(), 0);
}
#[test]
fn install_bundle_missing_entrypoint_is_rejected() {
let tmp = tempfile::tempdir().unwrap();
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
// Manifest declares entrypoint = "hello.wasm", but the zip omits it.
let zip = make_zip(&[("plugin.toml", hello_manifest().as_bytes())]);
let err = mgr
.install_bundle(zip)
.expect_err("a zip missing the entrypoint wasm must be rejected");
assert_eq!(err.reason(), "entrypoint_not_in_zip");
assert_eq!(mgr.loaded_count(), 0);
}
#[test]
fn install_bundle_oversized_is_rejected() {
let tmp = tempfile::tempdir().unwrap();
// Tiny decompressed ceiling so the ~130 KiB wasm fixture trips it cheaply.
let mut config = cfg();
config.max_bundle_decompressed_bytes = 1024;
let mgr = ExtismPluginManager::load_from_dir(config, tmp.path());
let zip = make_zip(&[
("plugin.toml", hello_manifest().as_bytes()),
("hello.wasm", &fixture("hello.wasm")),
]);
let err = mgr
.install_bundle(zip)
.expect_err("a bundle over the decompressed ceiling must be rejected");
assert_eq!(err.reason(), "too_large");
assert_eq!(mgr.loaded_count(), 0);
}
#[test]
fn install_bundle_with_garbage_is_rejected() {
let tmp = tempfile::tempdir().unwrap();
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
let err = mgr
.install_bundle(b"not a zip file".to_vec())
.expect_err("non-zip bytes must be rejected");
assert_eq!(err.reason(), "bad_zip");
}
#[test]
fn install_duplicate_id_is_rejected() {
let tmp = tempfile::tempdir().unwrap();
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
mgr.install(&hello_manifest(), fixture("hello.wasm"))
.unwrap();
let err = mgr
.install(&hello_manifest(), fixture("hello.wasm"))
.expect_err("second install of the same id must fail");
assert_eq!(err.reason(), "id_exists");
assert_eq!(mgr.loaded_count(), 1);
}
#[test]
fn install_wrong_abi_is_rejected() {
let tmp = tempfile::tempdir().unwrap();
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
let err = mgr
.install(&wrong_abi_manifest(), fixture("wrong_abi.wasm"))
.expect_err("a plugin reporting the wrong ABI must be rejected");
assert_eq!(err.reason(), "abi_mismatch");
assert_eq!(mgr.loaded_count(), 0);
// Nothing should have been written to disk.
assert!(!tmp.path().join("com.example.wrongabi").exists());
}
#[test]
fn disable_stops_dispatch_and_persists_across_reload() {
let tmp = tempfile::tempdir().unwrap();
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
mgr.install(&hello_manifest(), fixture("hello.wasm"))
.unwrap();
assert!(mgr.has_subscribers("file.uploaded"));
mgr.set_enabled("com.example.hello", false).unwrap();
assert!(!mgr.has_subscribers("file.uploaded"));
assert!(
tmp.path()
.join("com.example.hello")
.join(".disabled")
.exists()
);
// A fresh manager re-reads the marker and loads it disabled.
drop(mgr);
let reloaded = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
assert_eq!(reloaded.loaded_count(), 1);
let info = reloaded.list();
assert_eq!(info.len(), 1);
assert!(!info[0].enabled);
assert!(!reloaded.has_subscribers("file.uploaded"));
// Re-enabling removes the marker.
reloaded.set_enabled("com.example.hello", true).unwrap();
assert!(reloaded.has_subscribers("file.uploaded"));
assert!(
!tmp.path()
.join("com.example.hello")
.join(".disabled")
.exists()
);
}
#[test]
fn set_enabled_unknown_id_is_not_found() {
let tmp = tempfile::tempdir().unwrap();
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
let err = mgr.set_enabled("does.not.exist", false).unwrap_err();
assert_eq!(err.reason(), "not_found");
}
#[test]
fn remove_unloads_and_deletes_directory() {
let tmp = tempfile::tempdir().unwrap();
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
mgr.install(&hello_manifest(), fixture("hello.wasm"))
.unwrap();
let plugin_dir = tmp.path().join("com.example.hello");
assert!(plugin_dir.exists());
mgr.remove("com.example.hello").unwrap();
assert_eq!(mgr.loaded_count(), 0);
assert!(!plugin_dir.exists());
let err = mgr.remove("com.example.hello").unwrap_err();
assert_eq!(err.reason(), "not_found");
}
/// Regression guard: dispatch must persist a log entry for *every* invocation,
/// including a successful one (it previously only logged failures). We dispatch
/// a `file.uploaded` event and then poll the plugin's structured log until an
/// `outcome` row appears.
#[tokio::test(flavor = "multi_thread")]
async fn dispatch_writes_outcome_row_on_success() {
use crate::application::ports::plugin_ports::{EVENT_FILE_UPLOADED, LogQuery, PluginEvent};
let tmp = tempfile::tempdir().unwrap();
let mgr = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
mgr.install(&hello_manifest(), fixture("hello.wasm"))
.unwrap();
mgr.dispatch(PluginEvent {
name: EVENT_FILE_UPLOADED,
user_id: None,
invocation_id: "test-invocation".to_string(),
payload: serde_json::json!({ "path": "/x.txt", "size": 1, "mime": "text/plain" }),
});
// dispatch is fire-and-forget on the blocking pool, and the log write is an
// ordered async hand-off; poll until the outcome row is durable.
let mut found = false;
for _ in 0..50 {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let page = mgr
.read_logs(
"com.example.hello",
LogQuery {
level: None,
search: None,
offset: 0,
limit: 100,
},
)
.await
.unwrap();
if page.entries.iter().any(|e| e.kind == "outcome") {
found = true;
break;
}
}
assert!(
found,
"dispatch should persist an outcome row for the invocation"
);
}
@@ -0,0 +1,104 @@
//! `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::{KNOWN_EVENTS, OXICLOUD_PLUGIN_ABI};
/// Parsed `plugin.toml`. `#[serde(deny_unknown_fields)]` on every struct turns
/// stray keys into load errors rather than silently ignored config.
#[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. Each must be one of `KNOWN_EVENTS`
/// (`"file.uploaded"`, `"user.login"`); an unknown name rejects the plugin.
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 !KNOWN_EVENTS.contains(&event.as_str()) {
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,21 @@
//! 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 log_retention_service;
pub mod log_store;
pub mod manager;
pub mod manifest;
pub mod runtime;
pub use log_retention_service::PluginLogMaintenanceService;
pub use log_store::PluginLogStore;
pub use manager::ExtismPluginManager;
#[cfg(test)]
mod manager_test;
#[cfg(test)]
mod runtime_test;
@@ -0,0 +1,347 @@
//! The Extism runtime wrapper — a cached compiled module, instantiated fresh per
//! invocation.
//!
//! 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.
//!
//! **Compilation is amortized.** A plugin's WASM is compiled once into an
//! [`extism::CompiledPlugin`] and cached; every invocation builds a *fresh*
//! [`extism::Plugin`] instance from it (a new Store/memory → no cross-user
//! state), but pays no recompilation. Per-invocation log attribution rides
//! `call_with_host_context` rather than a baked `UserData`, so the same compiled
//! module serves concurrent invocations without sharing the log buffer. An idle
//! plugin's compiled module is dropped by [`PluginRuntime::evict_if_idle`] to
//! reclaim memory; the next event recompiles (cheaply, from wasmtime's on-disk
//! compilation cache).
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};
use extism::{
CompiledPlugin, CurrentPlugin, Manifest as ExtismManifest, PTR, PluginBuilder, UserData, Val,
Wasm,
};
use crate::application::ports::plugin_ports::{HOST_NAMESPACE, OXICLOUD_PLUGIN_ABI, PluginOutput};
use crate::common::config::PluginConfig;
/// Per-invocation host context, handed to one `handle` call via
/// `call_with_host_context` and read back by the `log` host function. Each
/// invocation gets its own, so a reused compiled module never mixes two
/// invocations' log lines. `lines` is an `Arc` the caller retains a clone of, to
/// read what the plugin emitted after the call returns.
struct LogSink {
plugin_id: String,
invocation_id: String,
lines: Arc<Mutex<Vec<(String, String)>>>,
}
/// The entire authority surface: log(level, message) -> (). Observe-only — it
/// reads nothing and mutates no host state beyond the per-call sink. Unknown
/// levels clamp to "info". Written without the `host_fn!` macro so it can read
/// the per-invocation [`LogSink`] from the host context.
fn oxi_log(
plugin: &mut CurrentPlugin,
inputs: &[Val],
_outputs: &mut [Val],
_user_data: UserData<()>,
) -> Result<(), extism::Error> {
let level: String = plugin.memory_get_val(&inputs[0])?;
let message: String = plugin.memory_get_val(&inputs[1])?;
let level = match level.as_str() {
"debug" | "info" | "warn" | "error" => level,
_ => "info".to_string(),
};
let ctx = plugin.host_context::<LogSink>()?;
// The message is a structured field, never interpolated into the format
// string — a plugin can't inject newlines into the operational log stream.
tracing::info!(
target: "oxicloud::plugins",
plugin_id = %ctx.plugin_id,
invocation_id = %ctx.invocation_id,
plugin_level = %level,
plugin_message = %message,
"plugin log"
);
ctx.lines
.lock()
.unwrap_or_else(|e| e.into_inner())
.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 },
/// A subscribed event has no matching `on_<event>` export in the module.
MissingExport(String),
/// The event handler returned bytes that are not a valid `PluginOutput`.
MalformedOutput(String),
/// The serialized input exceeded the configured cap; nothing was invoked.
MalformedInput { size: usize, max: usize },
}
impl InvokeOutcome {
pub fn is_ok(&self) -> bool {
matches!(self, InvokeOutcome::Ok)
}
/// The `(level, message)` to record for this outcome in a plugin's log file.
/// `Ok` is an `info` "completed"; every contained failure is a `warn`/`error`
/// carrying its detail. The stable machine key is [`InvokeOutcome::reason`].
pub fn log_detail(&self) -> (&'static str, String) {
match self {
InvokeOutcome::Ok => ("info", "invocation completed".to_string()),
InvokeOutcome::PluginError(e) => ("warn", e.clone()),
InvokeOutcome::Trap(e) => ("error", e.clone()),
InvokeOutcome::Timeout => ("error", "wall-clock timeout".to_string()),
InvokeOutcome::LoadError(e) => ("error", e.clone()),
InvokeOutcome::AbiMismatch { got } => {
("error", format!("abi mismatch: plugin reported {got}"))
}
InvokeOutcome::MissingExport(s) => ("error", format!("missing export: {s}")),
InvokeOutcome::MalformedOutput(e) => ("warn", e.clone()),
InvokeOutcome::MalformedInput { size, max } => {
("warn", format!("input too large: {size} bytes (max {max})"))
}
}
}
/// 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::MissingExport(_) => "missing_export",
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 plugin: the wasm bytes plus a lazily-built, idle-evictable compiled
/// module. A fresh *instance* is built for every invocation (no reuse → no
/// cross-user state); only the *compilation* is shared.
pub struct PluginRuntime {
plugin_id: String,
wasm_bytes: Vec<u8>,
/// The cached compiled module, `None` until first use or after idle
/// eviction. Guarded by an `RwLock`: invocations take the read lock to
/// instantiate concurrently; (re)compilation and eviction take the write
/// lock.
compiled: RwLock<Option<CompiledPlugin>>,
/// Last time an instance was built, for idle eviction. Separate lock so it
/// can be stamped while only holding `compiled` for read.
last_used: Mutex<Instant>,
}
impl PluginRuntime {
pub fn new(plugin_id: impl Into<String>, wasm_bytes: Vec<u8>) -> Self {
Self {
plugin_id: plugin_id.into(),
wasm_bytes,
compiled: RwLock::new(None),
last_used: Mutex::new(Instant::now()),
}
}
/// Compile the WASM into a reusable [`CompiledPlugin`], wiring the sandbox
/// limits and the sole host import. wasmtime's on-disk cache (extism's
/// default) makes a repeat compile after eviction cheap.
fn compile(&self, cfg: &PluginConfig) -> Result<CompiledPlugin, 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],
[],
UserData::new(()),
oxi_log,
)
.compile()
}
/// Build a fresh instance from the (cached, lazily-compiled) module. Stamps
/// `last_used` so the idle sweep leaves an actively-used plugin alone.
fn instantiate(&self, cfg: &PluginConfig) -> Result<extism::Plugin, InvokeOutcome> {
// Fast path: already compiled.
{
let guard = self.compiled.read().unwrap_or_else(|e| e.into_inner());
if let Some(compiled) = guard.as_ref() {
*self.last_used.lock().unwrap_or_else(|e| e.into_inner()) = Instant::now();
return extism::Plugin::new_from_compiled(compiled)
.map_err(|e| InvokeOutcome::LoadError(e.to_string()));
}
}
// Slow path: compile under the write lock (double-checked).
let mut guard = self.compiled.write().unwrap_or_else(|e| e.into_inner());
if guard.is_none() {
match self.compile(cfg) {
Ok(c) => *guard = Some(c),
Err(e) => return Err(InvokeOutcome::LoadError(e.to_string())),
}
}
let compiled = guard.as_ref().expect("compiled present after compile");
*self.last_used.lock().unwrap_or_else(|e| e.into_inner()) = Instant::now();
extism::Plugin::new_from_compiled(compiled)
.map_err(|e| InvokeOutcome::LoadError(e.to_string()))
}
/// Drop the cached compiled module if it hasn't been used within `ttl`,
/// reclaiming its memory. Returns whether anything was evicted. The next
/// invocation recompiles transparently.
pub fn evict_if_idle(&self, ttl: Duration) -> bool {
let idle = self
.last_used
.lock()
.unwrap_or_else(|e| e.into_inner())
.elapsed()
>= ttl;
if !idle {
return false;
}
let mut guard = self.compiled.write().unwrap_or_else(|e| e.into_inner());
guard.take().is_some()
}
/// Probe loadability: compile (caching the module), check `abi_version`,
/// then verify every `required_export` (the `on_<event>` symbol for each
/// subscribed event) exists. Rejects lying, unloadable, or
/// incompletely-implemented plugins before they are ever registered.
pub fn check_loadable(&self, cfg: &PluginConfig, required_exports: &[String]) -> InvokeOutcome {
let mut plugin = match self.instantiate(cfg) {
Ok(p) => p,
Err(o) => return o,
};
match plugin.call::<(), u32>("abi_version", ()) {
Ok(v) if v == OXICLOUD_PLUGIN_ABI => {}
Ok(v) => return InvokeOutcome::AbiMismatch { got: v },
Err(e) => return classify_call_error(e),
}
for export in required_exports {
if !plugin.function_exists(export) {
return InvokeOutcome::MissingExport(export.clone());
}
}
InvokeOutcome::Ok
}
/// Run one event-handler invocation, fully fault-isolated. `export` is the
/// `on_<event>` symbol to call (see `event_export_name`).
pub fn invoke(
&self,
cfg: &PluginConfig,
export: &str,
invocation_id: &str,
input_json: &str,
) -> InvokeResult {
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 lines = Arc::new(Mutex::new(Vec::new()));
let drain = || lines.lock().unwrap_or_else(|e| e.into_inner()).clone();
let mut plugin = match self.instantiate(cfg) {
Ok(p) => p,
Err(outcome) => {
return InvokeResult {
outcome,
logs: drain(),
};
}
};
// Version negotiation at the door (cheap; no recompile).
match plugin.call::<(), u32>("abi_version", ()) {
Ok(v) if v == OXICLOUD_PLUGIN_ABI => {}
Ok(v) => {
return InvokeResult {
outcome: InvokeOutcome::AbiMismatch { got: v },
logs: drain(),
};
}
Err(e) => {
return InvokeResult {
outcome: classify_call_error(e),
logs: drain(),
};
}
}
let sink = LogSink {
plugin_id: self.plugin_id.clone(),
invocation_id: invocation_id.to_string(),
lines: lines.clone(),
};
// The actual call. Traps, timeouts, and OOM all surface here as Err.
let outcome = match plugin
.call_with_host_context::<&str, String, LogSink>(export, input_json, sink)
{
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(),
}
// `plugin` (instance) dropped here -> sandbox memory reclaimed. The
// compiled module stays cached for the next invocation.
}
}
/// 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)
}
}
@@ -0,0 +1,350 @@
//! 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::application::ports::plugin_ports::event_export_name;
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 file_uploaded_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()
}
fn user_login_input() -> String {
serde_json::json!({
"abi": 0,
"event": "user.login",
"context": {
"plugin_id": "com.example.hello",
"user_id": "u_test",
"invocation_id": "inv_login_0001"
},
"payload": {
"user_id": "u_test",
"username": "alice",
"email": "alice@example.com",
"first_login": true,
"is_external": false
}
})
.to_string()
}
// ---- The M0 exit criterion: the full loop, per event ------------------------
#[test]
fn acceptance_file_uploaded_returns_ok_and_calls_host_log() {
let rt = PluginRuntime::new("com.example.hello", fixture("hello.wasm"));
let result = rt.invoke(&cfg(), "on_file_uploaded", "inv", &file_uploaded_input());
assert!(
result.outcome.is_ok(),
"plugin did not complete: {:?}",
result.outcome
);
assert!(
result.logs.iter().any(|(level, msg)| level == "info"
&& msg.contains("hello plugin saw upload: /photos/2026/cat.jpg")),
"expected the plugin's host log line, got: {:?}",
result.logs
);
}
#[test]
fn acceptance_user_login_returns_ok_and_calls_host_log() {
let rt = PluginRuntime::new("com.example.hello", fixture("hello.wasm"));
let result = rt.invoke(&cfg(), "on_user_login", "inv", &user_login_input());
assert!(
result.outcome.is_ok(),
"plugin did not complete: {:?}",
result.outcome
);
assert!(
result.logs.iter().any(|(level, msg)| level == "info"
&& msg.contains("hello plugin saw login: user u_test (first_login=true)")),
"expected the plugin's user.login log line, got: {:?}",
result.logs
);
}
// ---- The guarantees, not just the happy path --------------------------------
#[test]
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 load_requires_subscribed_event_exports() {
let cfg = cfg();
let login_export = vec![event_export_name("user.login")];
// hello.wasm exports both handlers -> loadable for user.login.
let hello = PluginRuntime::new("com.example.hello", fixture("hello.wasm"));
assert!(matches!(
hello.check_loadable(&cfg, &login_export),
InvokeOutcome::Ok
));
// omit_login.wasm lacks on_user_login -> rejected when it claims user.login.
let omit = PluginRuntime::new("com.example.omit", fixture("omit_login.wasm"));
assert!(
matches!(
omit.check_loadable(&cfg, &login_export),
InvokeOutcome::MissingExport(ref e) if e == "on_user_login"
),
"omit_login must be rejected for a user.login subscription"
);
// …but it is fine for file.uploaded, which it does export.
assert!(matches!(
omit.check_loadable(&cfg, &[event_export_name("file.uploaded")]),
InvokeOutcome::Ok
));
}
#[test]
fn contains_a_panicking_plugin() {
let rt = PluginRuntime::new("com.example.panic", fixture("panic.wasm"));
let result = rt.invoke(&cfg(), "on_file_uploaded", "inv", &file_uploaded_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(), "on_file_uploaded", "inv", &file_uploaded_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 idle_eviction_drops_and_recompiles() {
let rt = PluginRuntime::new("com.example.hello", fixture("hello.wasm"));
// First invoke compiles + caches the module.
let r1 = rt.invoke(&cfg(), "on_file_uploaded", "inv1", &file_uploaded_input());
assert!(r1.outcome.is_ok(), "first invoke: {:?}", r1.outcome);
// Idle past a zero TTL -> the cached module is dropped.
assert!(
rt.evict_if_idle(Duration::ZERO),
"a just-idle module should be evicted"
);
// Nothing left to evict the second time.
assert!(
!rt.evict_if_idle(Duration::ZERO),
"second eviction is a no-op"
);
// The next invoke recompiles transparently and still works.
let r2 = rt.invoke(&cfg(), "on_file_uploaded", "inv2", &file_uploaded_input());
assert!(
r2.outcome.is_ok(),
"recompile after eviction: {:?}",
r2.outcome
);
// A long TTL never evicts a freshly-used module.
assert!(
!rt.evict_if_idle(Duration::from_secs(3600)),
"a fresh module must not be evicted"
);
}
#[test]
fn no_network() {
let rt = PluginRuntime::new("com.example.net", fixture("net.wasm"));
let result = rt.invoke(&cfg(), "on_file_uploaded", "inv", &file_uploaded_input());
assert!(
!result.outcome.is_ok(),
"network access should be denied, got {:?}",
result.outcome
);
}
// ---- Manager discovery + dispatch ------------------------------------------
/// Write a one-plugin directory (plugin.toml + the given wasm) under a tempdir
/// and load a manager from it.
fn manager_with(wasm_name: &str, subscribe_toml: &str) -> (tempfile::TempDir, ExtismPluginManager) {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("plugin");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("plugin.wasm"), fixture(wasm_name)).unwrap();
std::fs::write(
dir.join("plugin.toml"),
format!(
r#"
[plugin]
id = "com.example.test"
name = "Test"
version = "0.1.0"
abi = 0
entrypoint = "plugin.wasm"
[events]
subscribe = {subscribe_toml}
"#
),
)
.unwrap();
let manager = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
(tmp, manager)
}
#[tokio::test]
async fn manager_loads_and_dispatches_both_events() {
use crate::application::ports::plugin_ports::{
EVENT_FILE_UPLOADED, EVENT_USER_LOGIN, PluginDispatchPort, PluginEvent,
};
let (_tmp, manager) = manager_with("hello.wasm", r#"["file.uploaded", "user.login"]"#);
assert_eq!(manager.loaded_count(), 1, "the valid plugin should load");
assert!(manager.has_subscribers("file.uploaded"));
assert!(manager.has_subscribers("user.login"));
assert!(!manager.has_subscribers("file.deleted"));
// Both dispatches run the plugin on the blocking pool; neither may panic.
manager.dispatch(PluginEvent {
name: EVENT_FILE_UPLOADED,
user_id: Some("u_test".into()),
invocation_id: "inv_upload".into(),
payload: serde_json::json!({ "path": "/a.txt", "size": 3, "mime": "text/plain" }),
});
manager.dispatch(PluginEvent {
name: EVENT_USER_LOGIN,
user_id: Some("u_test".into()),
invocation_id: "inv_login".into(),
payload: serde_json::json!({ "user_id": "u_test", "first_login": false }),
});
tokio::time::sleep(Duration::from_millis(300)).await;
}
#[test]
fn manager_rejects_plugin_missing_a_subscribed_export() {
// omit_login.wasm subscribes to user.login but doesn't export on_user_login.
let (_tmp, rejected) = manager_with("omit_login.wasm", r#"["user.login"]"#);
assert_eq!(rejected.loaded_count(), 0, "missing export -> not loaded");
// The same wasm is fine when it only claims an event it actually exports.
let (_tmp2, loaded) = manager_with("omit_login.wasm", r#"["file.uploaded"]"#);
assert_eq!(loaded.loaded_count(), 1);
}
// ---- Manifest validation (no wasm needed) -----------------------------------
const VALID_MANIFEST: &str = r#"
[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_accepts_user_login_and_combined() {
let login = VALID_MANIFEST.replace(r#"["file.uploaded"]"#, r#"["user.login"]"#);
assert!(manifest::parse_and_validate(&login).is_ok());
let both = VALID_MANIFEST.replace(r#"["file.uploaded"]"#, r#"["file.uploaded", "user.login"]"#);
assert!(manifest::parse_and_validate(&both).is_ok());
}
#[test]
fn manifest_rejects_unknown_field() {
let toml = format!("{VALID_MANIFEST}\nbogus_top_level = true\n");
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"
);
}
+338 -2
View File
@@ -1,17 +1,25 @@
use axum::{
Router,
extract::{Json, Path, Query, State},
extract::{DefaultBodyLimit, Json, Multipart, Path, Query, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
response::{
IntoResponse,
sse::{Event, KeepAlive, Sse},
},
routing::{delete, get, post, put},
};
use crate::application::dtos::plugin_dto::{
PluginInfoDto, PluginLogEntryDto, PluginLogPageDto, PluginLogQueryDto, PluginRetentionDto,
SetEnabledDto,
};
use crate::application::dtos::settings_dto::{
AdminCreateUserDto, AdminResetPasswordDto, DashboardStatsDto, ListUsersQueryDto,
MigrationStateDto, SaveOidcSettingsDto, SaveStorageSettingsDto, SendSmtpTestDto, SmtpInfoDto,
SmtpTestResultDto, StartMigrationDto, TestOidcConnectionDto, TestStorageConnectionDto,
UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto, VerifyMigrationDto,
};
use crate::application::ports::plugin_ports::{LogQuery, PluginManagementPort, PluginMgmtError};
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::admin::require_admin;
@@ -60,6 +68,23 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
.route("/audio/metadata/reextract", post(reextract_audio_metadata))
// Image/video capture metadata (Photos timeline backfill)
.route("/photos/metadata/reextract", post(reextract_image_metadata))
// Plugin management
.route("/plugins", get(list_plugins))
// Install caps the request body at 32 MiB (overriding the global
// multi-GB upload limit) — a plugin bundle is small; the unpack also
// enforces a 64 MiB decompressed ceiling.
.route(
"/plugins",
post(install_plugin).layer(DefaultBodyLimit::max(32 * 1024 * 1024)),
)
.route("/plugins/{id}/enabled", put(set_plugin_enabled))
.route("/plugins/{id}", delete(delete_plugin))
// Plugin logs + per-plugin retention
.route("/plugins/{id}/logs", get(get_plugin_logs))
.route("/plugins/{id}/logs", delete(clear_plugin_logs))
.route("/plugins/{id}/logs/stream", get(stream_plugin_logs))
.route("/plugins/{id}/retention", get(get_plugin_retention))
.route("/plugins/{id}/retention", put(set_plugin_retention))
// SMTP diagnostics
.route("/smtp/info", get(get_smtp_info))
.route("/smtp/test", post(send_smtp_test))
@@ -1440,3 +1465,314 @@ async fn send_smtp_test(
Ok(Json(result))
}
// ---- Plugin management -----------------------------------------------------
/// Resolve the plugin-management port, or 503 when plugins are compiled out or
/// disabled via `OXICLOUD_ENABLE_PLUGINS`. The admin UI treats this 503 as the
/// "plugins disabled" state rather than an error.
fn plugin_mgmt(state: &AppState) -> Result<&Arc<dyn PluginManagementPort>, AppError> {
state.plugin_management.as_ref().ok_or_else(|| {
AppError::new(
StatusCode::SERVICE_UNAVAILABLE,
"Plugins are disabled",
"PluginsDisabled",
)
})
}
/// Map a management-layer error to an HTTP error. NotFound → 404, IdExists →
/// 409, Rejected → 400 (with the stable reason key in the message), Io → 500.
fn map_mgmt_err(err: &PluginMgmtError) -> AppError {
match err {
PluginMgmtError::NotFound => AppError::not_found("Plugin not found"),
PluginMgmtError::IdExists => {
AppError::conflict("A plugin with this id is already installed")
}
PluginMgmtError::Rejected(reason) => AppError::new(
StatusCode::BAD_REQUEST,
format!("Plugin rejected: {reason}"),
"PluginRejected",
),
PluginMgmtError::Io(msg) => {
AppError::internal_error(format!("Plugin operation failed: {msg}"))
}
}
}
/// GET /api/admin/plugins — list installed plugins.
pub async fn list_plugins(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
let plugins: Vec<PluginInfoDto> = mgmt.list().into_iter().map(PluginInfoDto::from).collect();
// `enabled` reports that the plugin *subsystem* is active (reaching here
// means it is — `plugin_mgmt` returns 503 otherwise, which the UI reads as
// the disabled state). Per-plugin enablement is each entry's own `enabled`.
Ok((
StatusCode::OK,
Json(serde_json::json!({ "enabled": true, "plugins": plugins })),
))
}
/// PUT /api/admin/plugins/{id}/enabled — enable or disable a plugin.
pub async fn set_plugin_enabled(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<SetEnabledDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
mgmt.set_enabled(&id, dto.enabled)
.map_err(|e| map_mgmt_err(&e))?;
if dto.enabled {
tracing::info!(
target: "audit",
event = "plugin.enabled",
plugin_id = %id,
admin_id = %admin_id,
"👮🏻‍♂️ plugin enabled by admin"
);
} else {
tracing::info!(
target: "audit",
event = "plugin.disabled",
plugin_id = %id,
admin_id = %admin_id,
"👮🏻‍♂️ plugin disabled by admin"
);
}
Ok((
StatusCode::OK,
Json(serde_json::json!({
"message": if dto.enabled { "Plugin enabled" } else { "Plugin disabled" },
"id": id,
"enabled": dto.enabled,
})),
))
}
/// POST /api/admin/plugins — install a plugin from a multipart body with a
/// single `bundle` part: a `.zip` containing `plugin.toml` and its `.wasm`.
pub async fn install_plugin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
mut multipart: Multipart,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
let mut bundle: Option<Vec<u8>> = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| AppError::bad_request(format!("Invalid multipart body: {e}")))?
{
if field.name() == Some("bundle") {
bundle = Some(
field
.bytes()
.await
.map_err(|e| AppError::bad_request(format!("Invalid bundle part: {e}")))?
.to_vec(),
);
}
}
let bundle = match bundle {
Some(b) => b,
None => {
tracing::warn!(
target: "audit",
event = "plugin.install_rejected",
reason = "missing_part",
admin_id = %admin_id,
"👮🏻‍♂️ plugin install rejected: missing 'bundle' part"
);
return Err(AppError::bad_request("A 'bundle' (.zip) part is required"));
}
};
match mgmt.install_bundle(bundle) {
Ok(info) => {
tracing::info!(
target: "audit",
event = "plugin.installed",
plugin_id = %info.id,
admin_id = %admin_id,
"👮🏻‍♂️ plugin installed by admin"
);
Ok((StatusCode::CREATED, Json(PluginInfoDto::from(info))))
}
Err(e) => {
tracing::warn!(
target: "audit",
event = "plugin.install_rejected",
reason = e.reason(),
admin_id = %admin_id,
"👮🏻‍♂️ plugin install rejected"
);
Err(map_mgmt_err(&e))
}
}
}
/// DELETE /api/admin/plugins/{id} — uninstall a plugin and delete its files.
pub async fn delete_plugin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
mgmt.remove(&id).map_err(|e| map_mgmt_err(&e))?;
tracing::info!(
target: "audit",
event = "plugin.removed",
plugin_id = %id,
admin_id = %admin_id,
"👮🏻‍♂️ plugin removed by admin"
);
Ok((
StatusCode::OK,
Json(serde_json::json!({ "message": "Plugin removed", "id": id })),
))
}
/// GET /api/admin/plugins/{id}/logs — a filtered, paginated page of a plugin's
/// structured log entries (newest first).
pub async fn get_plugin_logs(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Query(q): Query<PluginLogQueryDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
let limit = q.limit.unwrap_or(50).clamp(1, 500);
let offset = q.offset.unwrap_or(0);
let page = mgmt
.read_logs(
&id,
LogQuery {
level: q.level,
search: q.search,
offset,
limit,
},
)
.await
.map_err(|e| map_mgmt_err(&e))?;
Ok(Json(PluginLogPageDto::from_page(page, limit, offset)))
}
/// DELETE /api/admin/plugins/{id}/logs — wipe a plugin's persisted logs.
pub async fn clear_plugin_logs(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
mgmt.clear_logs(&id).await.map_err(|e| map_mgmt_err(&e))?;
tracing::info!(
target: "audit",
event = "plugin.logs_cleared",
plugin_id = %id,
admin_id = %admin_id,
"👮🏻‍♂️ plugin logs cleared by admin"
);
Ok((
StatusCode::OK,
Json(serde_json::json!({ "message": "Plugin logs cleared", "id": id })),
))
}
/// GET /api/admin/plugins/{id}/logs/stream — Server-Sent Events live tail. Each
/// `message` event carries one new log entry (JSON); a `lagged` event signals
/// the client should resync after falling behind. Auth rides the access cookie,
/// so `EventSource` works without setting headers.
pub async fn stream_plugin_logs(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
use tokio_stream::StreamExt;
use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError};
admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
if !mgmt.list().iter().any(|p| p.id == id) {
return Err(AppError::not_found("Plugin not found"));
}
let rx = mgmt.subscribe_logs();
let want = id;
let stream = BroadcastStream::new(rx).filter_map(move |res| match res {
Ok(ev) if ev.plugin_id == want => {
let dto = PluginLogEntryDto::from(ev.entry);
let event = Event::default()
.json_data(&dto)
.unwrap_or_else(|_| Event::default().comment("serialize error"));
Some(Ok::<Event, std::convert::Infallible>(event))
}
Ok(_) => None,
Err(BroadcastStreamRecvError::Lagged(n)) => {
Some(Ok(Event::default().event("lagged").data(n.to_string())))
}
});
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}
/// GET /api/admin/plugins/{id}/retention — the plugin's effective retention.
pub async fn get_plugin_retention(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
let settings = mgmt
.get_retention(&id)
.await
.map_err(|e| map_mgmt_err(&e))?;
Ok(Json(PluginRetentionDto::from(settings)))
}
/// PUT /api/admin/plugins/{id}/retention — set the plugin's retention policy.
pub async fn set_plugin_retention(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<PluginRetentionDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
mgmt.set_retention(&id, dto.into())
.await
.map_err(|e| map_mgmt_err(&e))?;
tracing::info!(
target: "audit",
event = "plugin.retention_updated",
plugin_id = %id,
admin_id = %admin_id,
retention_days = dto.retention_days,
max_bytes = dto.max_bytes,
"👮🏻‍♂️ plugin log retention updated by admin"
);
Ok((StatusCode::OK, Json(dto)))
}
+144
View File
@@ -65,6 +65,9 @@
<button class="admin-tab" id="tab-btn-smtp">
<i class="fas fa-envelope"></i> <span data-i18n="admin.tab_smtp">SMTP</span>
</button>
<button class="admin-tab" id="tab-btn-plugins">
<i class="fas fa-puzzle-piece"></i> <span data-i18n="admin.tab_plugins">Plugins</span>
</button>
</div>
<div id="tab-dashboard" class="tab-content active">
@@ -677,6 +680,147 @@
<div id="smtp-test-result" class="alert" style="display:none; margin-top:14px;"></div>
</div>
</div>
<div id="tab-plugins" class="tab-content">
<div id="plugins-disabled" class="admin-card hidden">
<h2>
<i class="fas fa-puzzle-piece"></i> <span data-i18n="admin.plugins_title">Plugins</span>
</h2>
<p class="muted" data-i18n="admin.plugins_disabled">
Plugins are disabled on this server. Set OXICLOUD_ENABLE_PLUGINS=true (and build with the "plugins" feature) to manage WASM plugins here.
</p>
</div>
<div id="plugins-main" class="hidden">
<div class="admin-card">
<h2>
<i class="fas fa-upload"></i> <span data-i18n="admin.plugins_install_title">Install a plugin</span>
</h2>
<p class="muted" data-i18n="admin.plugins_install_intro">
Upload a plugin bundle (.zip) containing plugin.toml and its compiled WebAssembly module (.wasm). The manifest is validated and the module is probed before installation.
</p>
<div class="form-group">
<label for="plugin-bundle-file" data-i18n="admin.plugins_bundle_label">Plugin bundle (.zip)</label>
<input id="plugin-bundle-file" type="file" accept=".zip,application/zip" />
</div>
<button id="btn-plugin-install" class="btn btn-primary">
<i class="fas fa-upload"></i> <span data-i18n="admin.plugins_install">Install plugin</span>
</button>
<div id="plugin-install-result" class="alert" style="display:none; margin-top:14px;"></div>
</div>
<div class="admin-card">
<h2>
<i class="fas fa-puzzle-piece"></i> <span data-i18n="admin.plugins_installed_title">Installed plugins</span>
</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th data-i18n="admin.plugins_col_name">Name</th>
<th data-i18n="admin.plugins_col_id">ID</th>
<th data-i18n="admin.plugins_col_version">Version</th>
<th data-i18n="admin.plugins_col_events">Events</th>
<th data-i18n="admin.plugins_col_status">Status</th>
<th data-i18n="admin.plugins_col_actions">Actions</th>
</tr>
</thead>
<tbody id="plugins-tbody">
<tr>
<td colspan="6" class="table-loading-cell">
<i class="fas fa-spinner fa-spin"></i>
<span data-i18n="admin.plugins_loading">Loading plugins…</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="plugin-detail-view" class="hidden">
<button id="plugin-detail-back" class="btn btn-secondary btn-sm" style="margin-bottom:14px;">
<i class="fas fa-arrow-left"></i> <span data-i18n="admin.plugins_back">Back to plugins</span>
</button>
<div class="admin-card">
<h2>
<i class="fas fa-puzzle-piece"></i> <span id="plugin-detail-name"></span>
</h2>
<dl class="plugin-detail-meta" id="plugin-detail-meta"></dl>
</div>
<div class="admin-card">
<h2>
<i class="fas fa-clock-rotate-left"></i> <span data-i18n="admin.plugins_retention_title">Log retention</span>
</h2>
<p class="muted" data-i18n="admin.plugins_retention_intro">
Rotated log segments older than the retention window, or beyond the size cap, are pruned on a schedule.
</p>
<div class="form-group">
<label for="plugin-retention-days" data-i18n="admin.plugins_retention_days">Retention (days)</label>
<input id="plugin-retention-days" type="number" min="0" step="1" />
</div>
<div class="form-group">
<label for="plugin-retention-max-mb" data-i18n="admin.plugins_retention_max_mb">Max log size (MB)</label>
<input id="plugin-retention-max-mb" type="number" min="0" step="1" />
</div>
<button id="plugin-retention-save" class="btn btn-primary">
<i class="fas fa-save"></i> <span data-i18n="admin.plugins_retention_save">Save retention</span>
</button>
<div id="plugin-retention-result" class="alert" style="display:none; margin-top:14px;"></div>
</div>
<div class="admin-card">
<h2>
<i class="fas fa-list"></i> <span data-i18n="admin.plugins_logs_title">Logs</span>
</h2>
<div class="plugin-logs-toolbar">
<select id="plugin-logs-level" class="form-control">
<option value="" data-i18n="admin.plugins_logs_level_all">All levels</option>
<option value="debug">debug</option>
<option value="info">info</option>
<option value="warn">warn</option>
<option value="error">error</option>
</select>
<input id="plugin-logs-search" type="search" class="form-control" data-i18n-placeholder="admin.plugins_logs_search" placeholder="Search messages…" />
<label class="plugin-logs-live">
<input id="plugin-logs-live" type="checkbox" checked />
<span data-i18n="admin.plugins_logs_live">Live</span>
</label>
<button id="plugin-logs-refresh" class="btn btn-sm btn-secondary" title="Refresh">
<i class="fas fa-sync"></i>
</button>
<button id="plugin-logs-clear" class="btn btn-sm btn-danger">
<i class="fas fa-trash-alt"></i> <span data-i18n="admin.plugins_logs_clear">Clear</span>
</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th data-i18n="admin.plugins_logs_col_time">Time</th>
<th data-i18n="admin.plugins_logs_col_level">Level</th>
<th data-i18n="admin.plugins_logs_col_kind">Kind</th>
<th data-i18n="admin.plugins_logs_col_invocation">Invocation</th>
<th data-i18n="admin.plugins_logs_col_message">Message</th>
</tr>
</thead>
<tbody id="plugin-logs-tbody"></tbody>
</table>
</div>
<div class="pagination">
<span id="plugin-logs-info" class="muted"></span>
<button id="plugin-logs-prev" class="btn btn-sm btn-secondary">
<i class="fas fa-chevron-left"></i>
</button>
<button id="plugin-logs-next" class="btn btn-sm btn-secondary">
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
+86
View File
@@ -1149,3 +1149,89 @@ details[open] summary {
margin-top: var(--space-4);
flex-wrap: wrap;
}
/* ── Plugin detail page ── */
.plugin-detail-meta {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--space-2) var(--space-4);
margin: 0;
}
.plugin-detail-meta dt {
color: var(--color-text-subtle);
font-weight: var(--weight-semibold);
}
.plugin-detail-meta dd {
margin: 0;
}
.plugin-logs-toolbar {
display: flex;
align-items: center;
gap: var(--space-2-5);
flex-wrap: wrap;
margin-bottom: var(--space-3);
}
.plugin-logs-toolbar .form-control {
width: auto;
}
.plugin-logs-toolbar #plugin-logs-search {
flex: 1 1 200px;
min-width: 160px;
}
.plugin-logs-live {
display: inline-flex;
align-items: center;
gap: var(--space-1-5);
color: var(--color-text-subtle);
font-size: var(--text-xs);
white-space: nowrap;
}
.plugin-log-level {
display: inline-block;
text-transform: uppercase;
font-size: var(--text-2xs);
font-weight: var(--weight-semibold);
padding: 2px var(--space-2);
border-radius: var(--radius-md);
}
.plugin-log-level--debug {
background: var(--color-border-light);
color: var(--color-text-subtle);
}
.plugin-log-level--info {
background: var(--color-badge-blue-bg);
color: var(--color-badge-blue-text);
}
.plugin-log-level--warn {
background: var(--color-warning-bg-light);
color: var(--color-badge-warning-text);
}
.plugin-log-level--error {
background: var(--color-error-bg);
color: var(--color-error-text-dark);
}
.plugin-log-ts,
.plugin-log-inv {
color: var(--color-text-subtle);
font-size: var(--text-xs);
white-space: nowrap;
}
.plugin-log-msg {
word-break: break-word;
}
/* Brief highlight when a row arrives via the live stream. */
.plugin-log-row--new {
animation: plugin-log-flash 1.2s ease-out;
}
@keyframes plugin-log-flash {
from {
background: var(--color-badge-blue-bg);
}
to {
background: transparent;
}
}
+555
View File
@@ -13,6 +13,17 @@ let usersPage = 0;
const PAGE_SIZE = 50;
let totalUsers = 0;
/* Plugin detail / log viewer state */
const PLUGIN_LOGS_PAGE_SIZE = 50;
/** @type {Record<string, PluginInfo>} */
let pluginsById = {};
/** @type {string|null} */
let pluginDetailId = null;
let pluginLogsPage = 0;
let pluginLogsTotal = 0;
/** @type {EventSource|null} */
let pluginLogStream = null;
/**
* Escape a string for safe embedding inside a JS string literal within an HTML attribute.
* @param {string} s
@@ -148,10 +159,13 @@ function switchTab(name, el) {
// visible — without this it would keep hitting the API every 2 s
// (and updating hidden DOM) for as long as a migration runs.
if (name !== 'storage') stopMigrationPolling();
// Leaving the plugins tab tears down the live log stream.
if (name !== 'plugins') stopLogStream();
if (name === 'users') loadUsers();
if (name === 'dashboard') loadDashboard();
if (name === 'storage') loadStorage();
if (name === 'smtp') loadSmtp();
if (name === 'plugins') loadPlugins();
}
async function loadDashboard() {
@@ -1280,6 +1294,514 @@ async function sendSmtpTest() {
}
}
/* ── Plugins ── */
/**
* @typedef {Object} PluginInfo
* @property {string} id
* @property {string} name
* @property {string} version
* @property {number} abi
* @property {string[]} subscriptions
* @property {boolean} enabled
*/
/**
* Load installed plugins into the table. A 503 means plugins are disabled on
* the server — show the explanatory banner instead of the management UI.
*/
async function loadPlugins() {
const tbody = document.getElementById('plugins-tbody');
const disabledEl = document.getElementById('plugins-disabled');
const mainEl = document.getElementById('plugins-main');
if (!tbody || !disabledEl || !mainEl) return;
// Always return to the list view (and stop any live tail) when (re)loading.
closePluginDetail();
try {
const resp = await fetch(`${API}/admin/plugins`, {
headers: headers(),
credentials: 'same-origin'
});
if (resp.status === 503) {
disabledEl.classList.remove('hidden');
mainEl.classList.add('hidden');
return;
}
disabledEl.classList.add('hidden');
mainEl.classList.remove('hidden');
if (!resp.ok) {
tbody.innerHTML = `<tr><td colspan="6" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(`HTTP ${resp.status}`)}</td></tr>`;
return;
}
/** @type {{enabled: boolean, plugins: PluginInfo[]}} */
const data = await resp.json();
renderPluginRows(data.plugins || []);
} catch (e) {
tbody.innerHTML = `<tr><td colspan="6" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }))}</td></tr>`;
}
}
/** @param {PluginInfo[]} plugins */
function renderPluginRows(plugins) {
const tbody = document.getElementById('plugins-tbody');
if (!tbody) return;
pluginsById = {};
plugins.forEach((p) => {
pluginsById[p.id] = p;
});
if (plugins.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" class="table-status-empty">${escapeHtml(i18n.t('admin.plugins_none') || 'No plugins installed.')}</td></tr>`;
return;
}
tbody.innerHTML = plugins
.map((p) => {
const events = (p.subscriptions || []).map((ev) => `<code>${escapeHtml(ev)}</code>`).join(' ') || '—';
const statusLabel = p.enabled ? i18n.t('admin.plugins_enabled') || 'Enabled' : i18n.t('admin.plugins_disabled_badge') || 'Disabled';
const statusBadge = `<span class="badge badge-${p.enabled ? 'active' : 'inactive'}">${escapeHtml(statusLabel)}</span>`;
const toggleTitle = p.enabled ? i18n.t('admin.plugins_disable') || 'Disable' : i18n.t('admin.plugins_enable') || 'Enable';
const toggleBtn =
`<button class="btn btn-sm ${p.enabled ? 'btn-secondary' : 'btn-success'} plugin-action-btn" data-action="toggle" data-pid="${_escJs(p.id)}" data-enabled="${p.enabled}" title="${escapeHtml(toggleTitle)}">` +
`<i class="fas fa-${p.enabled ? 'pause' : 'play'}"></i></button>`;
const deleteBtn =
`<button class="btn btn-sm btn-danger plugin-action-btn" data-action="delete" data-pid="${_escJs(p.id)}" data-pname="${_escJs(p.name)}" title="${escapeHtml(i18n.t('admin.plugins_delete') || 'Delete')}">` +
'<i class="fas fa-trash-alt"></i></button>';
const detailsBtn =
`<button class="btn btn-sm btn-secondary plugin-action-btn" data-action="details" data-pid="${_escJs(p.id)}" title="${escapeHtml(i18n.t('admin.plugins_details') || 'Logs & details')}">` +
'<i class="fas fa-list"></i></button>';
return (
'<tr>' +
`<td>${escapeHtml(p.name)}</td>` +
`<td><code>${escapeHtml(p.id)}</code></td>` +
`<td>${escapeHtml(p.version)}</td>` +
`<td>${events}</td>` +
`<td>${statusBadge}</td>` +
`<td><div class="actions-row">${detailsBtn}${toggleBtn}${deleteBtn}</div></td>` +
'</tr>'
);
})
.join('');
/** @type {NodeListOf<HTMLButtonElement>} */ (document.querySelectorAll('#plugins-tbody .plugin-action-btn')).forEach((btn) => {
btn.addEventListener('click', () => {
const action = btn.dataset.action;
if (action === 'toggle') togglePlugin(btn.dataset.pid, btn.dataset.enabled !== 'true');
else if (action === 'delete') deletePlugin(btn.dataset.pid, btn.dataset.pname);
else if (action === 'details') openPluginDetail(btn.dataset.pid);
});
});
}
/**
* @param {string|undefined} id
* @param {boolean} enable
*/
async function togglePlugin(id, enable) {
if (!id) return;
try {
const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}/enabled`, {
method: 'PUT',
headers: headers(),
credentials: 'same-origin',
body: JSON.stringify({ enabled: enable })
});
if (resp.ok) {
loadPlugins();
} else {
const e = await resp.json().catch(() => ({}));
alert(e.message || i18n.t('admin.error_generic'));
}
} catch (e) {
alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
}
}
/**
* @param {string|undefined} id
* @param {string|undefined} name
*/
async function deletePlugin(id, name) {
if (!id) return;
const ok = await showConfirm(i18n.t('admin.plugins_confirm_delete', { name: name || id }));
if (!ok) return;
try {
const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: headers(),
credentials: 'same-origin'
});
if (resp.ok) {
loadPlugins();
} else {
const e = await resp.json().catch(() => ({}));
alert(e.message || i18n.t('admin.error_generic'));
}
} catch (e) {
alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
}
}
/**
* Install a plugin from the selected .zip bundle. The multipart Content-Type
* (with its boundary) is set by the browser — do not override it.
*/
async function installPlugin() {
const bundleInput = /** @type {HTMLInputElement | null} */ (document.getElementById('plugin-bundle-file'));
const btn = /** @type {HTMLButtonElement | null} */ (document.getElementById('btn-plugin-install'));
const resultEl = document.getElementById('plugin-install-result');
if (!bundleInput || !resultEl) return;
const bundleFile = bundleInput.files?.[0];
if (!bundleFile) {
resultEl.className = 'alert alert-error';
resultEl.style.display = 'block';
resultEl.textContent = i18n.t('admin.plugins_install_missing_bundle') || 'Select a plugin bundle (.zip).';
return;
}
const form = new FormData();
form.append('bundle', bundleFile);
if (btn) btn.disabled = true;
resultEl.className = 'alert alert-info';
resultEl.style.display = 'block';
resultEl.textContent = i18n.t('admin.plugins_installing') || 'Installing…';
try {
const resp = await fetch(`${API}/admin/plugins`, {
method: 'POST',
headers: { ...getCsrfHeaders() },
credentials: 'same-origin',
body: form
});
if (!resp.ok) {
const e = await resp.json().catch(() => ({}));
resultEl.className = 'alert alert-error';
resultEl.textContent = e.message || `HTTP ${resp.status}`;
return;
}
/** @type {PluginInfo} */
const info = await resp.json();
resultEl.className = 'alert alert-success';
resultEl.textContent = i18n.t('admin.plugins_installed', { name: info.name }) || `Installed ${info.name}.`;
bundleInput.value = '';
loadPlugins();
} catch (e) {
resultEl.className = 'alert alert-error';
resultEl.textContent = i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message });
} finally {
if (btn) btn.disabled = false;
}
}
/* ── Plugin detail page (metadata + retention + logs + live tail) ── */
/**
* @typedef {Object} PluginLogEntry
* @property {string} ts
* @property {string} invocation_id
* @property {string} kind
* @property {string} level
* @property {string} [reason]
* @property {string} msg
*/
/**
* @typedef {Object} PluginLogPage
* @property {PluginLogEntry[]} entries
* @property {number} total
* @property {number} limit
* @property {number} offset
*/
/**
* Open the detail page for a plugin: metadata, retention form, and its logs
* (with a live tail). Hides the list view until the user goes back.
* @param {string|undefined} id
*/
function openPluginDetail(id) {
if (!id) return;
const plugin = pluginsById[id];
if (!plugin) return;
pluginDetailId = id;
pluginLogsPage = 0;
hideElement('plugins-main');
showElement('plugin-detail-view');
const nameEl = document.getElementById('plugin-detail-name');
if (nameEl) nameEl.textContent = plugin.name;
renderPluginMeta(plugin);
loadPluginRetention();
loadPluginLogs();
startLogStream(id);
}
/** Return to the installed-plugins list and stop the live stream. */
function closePluginDetail() {
stopLogStream();
pluginDetailId = null;
hideElement('plugin-detail-view');
showElement('plugins-main');
}
/** @param {PluginInfo} p */
function renderPluginMeta(p) {
const meta = document.getElementById('plugin-detail-meta');
if (!meta) return;
const events = (p.subscriptions || []).map((ev) => `<code>${escapeHtml(ev)}</code>`).join(' ') || '—';
const statusLabel = p.enabled ? i18n.t('admin.plugins_enabled') || 'Enabled' : i18n.t('admin.plugins_disabled_badge') || 'Disabled';
const statusBadge = `<span class="badge badge-${p.enabled ? 'active' : 'inactive'}">${escapeHtml(statusLabel)}</span>`;
/** @param {string} label @param {string} value */
const row = (label, value) => `<dt>${escapeHtml(label)}</dt><dd>${value}</dd>`;
meta.innerHTML =
row(i18n.t('admin.plugins_col_id') || 'ID', `<code>${escapeHtml(p.id)}</code>`) +
row(i18n.t('admin.plugins_col_version') || 'Version', escapeHtml(p.version)) +
row('ABI', escapeHtml(String(p.abi))) +
row(i18n.t('admin.plugins_col_events') || 'Events', events) +
row(i18n.t('admin.plugins_col_status') || 'Status', statusBadge);
}
/** Read the current log filter from the toolbar inputs. */
function pluginLogFilter() {
const level = /** @type {HTMLSelectElement|null} */ (document.getElementById('plugin-logs-level'))?.value || '';
const search = /** @type {HTMLInputElement|null} */ (document.getElementById('plugin-logs-search'))?.value || '';
return { level, search };
}
/** Load a page of the current plugin's logs into the table. */
async function loadPluginLogs() {
const id = pluginDetailId;
const tbody = document.getElementById('plugin-logs-tbody');
if (!id || !tbody) return;
const { level, search } = pluginLogFilter();
const params = new URLSearchParams();
params.set('limit', String(PLUGIN_LOGS_PAGE_SIZE));
params.set('offset', String(pluginLogsPage * PLUGIN_LOGS_PAGE_SIZE));
if (level) params.set('level', level);
if (search) params.set('search', search);
try {
const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}/logs?${params.toString()}`, {
headers: headers(),
credentials: 'same-origin'
});
if (!resp.ok) {
tbody.innerHTML = `<tr><td colspan="5" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(`HTTP ${resp.status}`)}</td></tr>`;
return;
}
/** @type {PluginLogPage} */
const page = await resp.json();
pluginLogsTotal = page.total;
renderPluginLogRows(page.entries || []);
updatePluginLogsPagination();
} catch (e) {
tbody.innerHTML = `<tr><td colspan="5" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }))}</td></tr>`;
}
}
/** @param {PluginLogEntry[]} entries */
function renderPluginLogRows(entries) {
const tbody = document.getElementById('plugin-logs-tbody');
if (!tbody) return;
if (entries.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" class="table-status-empty">${escapeHtml(i18n.t('admin.plugins_logs_none') || 'No log entries.')}</td></tr>`;
return;
}
tbody.innerHTML = entries.map(pluginLogRowHtml).join('');
}
/** @param {PluginLogEntry} e */
function pluginLogRowHtml(e) {
const level = (e.level || 'info').toLowerCase();
const levelBadge = `<span class="plugin-log-level plugin-log-level--${escapeHtml(level)}">${escapeHtml(level)}</span>`;
const kind = e.kind === 'outcome' ? e.reason || 'outcome' : 'log';
return (
'<tr>' +
`<td class="plugin-log-ts">${escapeHtml(timeAgo(e.ts))}</td>` +
`<td>${levelBadge}</td>` +
`<td><code>${escapeHtml(kind)}</code></td>` +
`<td><code class="plugin-log-inv">${escapeHtml(e.invocation_id)}</code></td>` +
`<td class="plugin-log-msg">${escapeHtml(e.msg)}</td>` +
'</tr>'
);
}
function updatePluginLogsPagination() {
const info = document.getElementById('plugin-logs-info');
const prev = /** @type {HTMLButtonElement|null} */ (document.getElementById('plugin-logs-prev'));
const next = /** @type {HTMLButtonElement|null} */ (document.getElementById('plugin-logs-next'));
if (info) {
if (pluginLogsTotal === 0) {
info.textContent = i18n.t('admin.plugins_logs_none') || 'No log entries.';
} else {
const from = pluginLogsPage * PLUGIN_LOGS_PAGE_SIZE + 1;
const to = Math.min((pluginLogsPage + 1) * PLUGIN_LOGS_PAGE_SIZE, pluginLogsTotal);
info.textContent = i18n.t('admin.plugins_logs_showing', { from, to, total: pluginLogsTotal }) || `Showing ${from}–${to} of ${pluginLogsTotal}`;
}
}
if (prev) prev.disabled = pluginLogsPage === 0;
if (next) next.disabled = (pluginLogsPage + 1) * PLUGIN_LOGS_PAGE_SIZE >= pluginLogsTotal;
}
/**
* Open the SSE live tail for the given plugin.
* @param {string} id
*/
function startLogStream(id) {
stopLogStream();
const live = /** @type {HTMLInputElement|null} */ (document.getElementById('plugin-logs-live'));
if (live && !live.checked) return;
const es = new EventSource(`${API}/admin/plugins/${encodeURIComponent(id)}/logs/stream`, { withCredentials: true });
es.onmessage = (ev) => {
try {
/** @type {PluginLogEntry} */
const entry = JSON.parse(ev.data);
onLiveLogEntry(entry);
} catch {
/* ignore malformed frames */
}
};
es.addEventListener('lagged', () => {
// Fell behind the broadcast buffer — resync from the server.
loadPluginLogs();
});
pluginLogStream = es;
}
/** Close the live tail if open. */
function stopLogStream() {
if (pluginLogStream) {
pluginLogStream.close();
pluginLogStream = null;
}
}
/**
* Handle a streamed entry: only prepend when viewing the newest page and the
* entry passes the active filter, so the live tail never fights pagination.
* @param {PluginLogEntry} entry
*/
function onLiveLogEntry(entry) {
if (pluginLogsPage !== 0) return;
const { level, search } = pluginLogFilter();
if (level && (entry.level || '').toLowerCase() !== level.toLowerCase()) return;
if (search && !(entry.msg || '').toLowerCase().includes(search.toLowerCase())) return;
const tbody = document.getElementById('plugin-logs-tbody');
if (!tbody) return;
// Drop any "empty" placeholder row before inserting the first live entry.
const placeholder = tbody.querySelector('.table-status-empty');
if (placeholder) tbody.innerHTML = '';
tbody.insertAdjacentHTML('afterbegin', pluginLogRowHtml(entry));
const firstRow = tbody.firstElementChild;
if (firstRow) firstRow.classList.add('plugin-log-row--new');
// Keep the page bounded to one page-worth of rows.
while (tbody.children.length > PLUGIN_LOGS_PAGE_SIZE) {
const last = tbody.lastElementChild;
if (!last) break;
last.remove();
}
pluginLogsTotal += 1;
updatePluginLogsPagination();
}
/** Load the current plugin's retention into the form. */
async function loadPluginRetention() {
const id = pluginDetailId;
if (!id) return;
try {
const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}/retention`, {
headers: headers(),
credentials: 'same-origin'
});
if (!resp.ok) return;
/** @type {{retention_days: number, max_bytes: number}} */
const r = await resp.json();
const daysEl = /** @type {HTMLInputElement|null} */ (document.getElementById('plugin-retention-days'));
const mbEl = /** @type {HTMLInputElement|null} */ (document.getElementById('plugin-retention-max-mb'));
if (daysEl) daysEl.value = String(r.retention_days);
if (mbEl) mbEl.value = String(Math.round(r.max_bytes / (1024 * 1024)));
} catch {
/* leave fields as-is on error */
}
}
/** Persist the retention form for the current plugin. */
async function savePluginRetention() {
const id = pluginDetailId;
const resultEl = document.getElementById('plugin-retention-result');
if (!id || !resultEl) return;
const days = Number(/** @type {HTMLInputElement} */ (document.getElementById('plugin-retention-days')).value);
const mb = Number(/** @type {HTMLInputElement} */ (document.getElementById('plugin-retention-max-mb')).value);
if (!Number.isFinite(days) || days < 0 || !Number.isFinite(mb) || mb < 0) {
resultEl.className = 'alert alert-error';
resultEl.style.display = 'block';
resultEl.textContent = i18n.t('admin.plugins_retention_invalid') || 'Enter non-negative numbers.';
return;
}
try {
const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}/retention`, {
method: 'PUT',
headers: headers(),
credentials: 'same-origin',
body: JSON.stringify({ retention_days: Math.round(days), max_bytes: Math.round(mb) * 1024 * 1024 })
});
if (resp.ok) {
resultEl.className = 'alert alert-success';
resultEl.style.display = 'block';
resultEl.textContent = i18n.t('admin.plugins_retention_saved') || 'Retention saved.';
} else {
const e = await resp.json().catch(() => ({}));
resultEl.className = 'alert alert-error';
resultEl.style.display = 'block';
resultEl.textContent = e.message || `HTTP ${resp.status}`;
}
} catch (e) {
resultEl.className = 'alert alert-error';
resultEl.style.display = 'block';
resultEl.textContent = i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message });
}
}
/** Clear all persisted logs for the current plugin. */
async function clearPluginLogs() {
const id = pluginDetailId;
if (!id) return;
const ok = await showConfirm(i18n.t('admin.plugins_logs_confirm_clear') || 'Clear all logs for this plugin?');
if (!ok) return;
try {
const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}/logs`, {
method: 'DELETE',
headers: headers(),
credentials: 'same-origin'
});
if (resp.ok) {
pluginLogsPage = 0;
loadPluginLogs();
} else {
const e = await resp.json().catch(() => ({}));
alert(e.message || i18n.t('admin.error_generic'));
}
} catch (e) {
alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
}
}
function pluginLogsPrevPage() {
if (pluginLogsPage > 0) {
pluginLogsPage--;
loadPluginLogs();
}
}
function pluginLogsNextPage() {
if ((pluginLogsPage + 1) * PLUGIN_LOGS_PAGE_SIZE < pluginLogsTotal) {
pluginLogsPage++;
loadPluginLogs();
}
}
/* ── Apply i18n when translations load / change ── */
document.addEventListener('translationsLoaded', () => {
i18n.translatePage();
@@ -1312,8 +1834,41 @@ document.getElementById('tab-btn-smtp').addEventListener('click', function () {
switchTab('smtp', this);
});
document.getElementById('tab-btn-plugins').addEventListener('click', function () {
switchTab('plugins', this);
});
document.getElementById('btn-smtp-test').addEventListener('click', sendSmtpTest);
document.getElementById('btn-plugin-install').addEventListener('click', installPlugin);
/* Plugin detail page controls */
document.getElementById('plugin-detail-back')?.addEventListener('click', closePluginDetail);
document.getElementById('plugin-retention-save')?.addEventListener('click', savePluginRetention);
document.getElementById('plugin-logs-refresh')?.addEventListener('click', loadPluginLogs);
document.getElementById('plugin-logs-clear')?.addEventListener('click', clearPluginLogs);
document.getElementById('plugin-logs-prev')?.addEventListener('click', pluginLogsPrevPage);
document.getElementById('plugin-logs-next')?.addEventListener('click', pluginLogsNextPage);
document.getElementById('plugin-logs-level')?.addEventListener('change', () => {
pluginLogsPage = 0;
loadPluginLogs();
});
let pluginLogsSearchTimer = 0;
document.getElementById('plugin-logs-search')?.addEventListener('input', () => {
window.clearTimeout(pluginLogsSearchTimer);
pluginLogsSearchTimer = window.setTimeout(() => {
pluginLogsPage = 0;
loadPluginLogs();
}, 250);
});
document.getElementById('plugin-logs-live')?.addEventListener('change', function () {
if (/** @type {HTMLInputElement} */ (this).checked) {
if (pluginDetailId) startLogStream(pluginDetailId);
} else {
stopLogStream();
}
});
document.getElementById('ds-registration').addEventListener('change', function () {
toggleRegistration(/** @type {HTMLInputElement} */ (this).checked);
});
+47
View File
@@ -731,6 +731,53 @@
"migration_verify_failed": "Verification failed",
"migration_failed_blobs": "failed blobs",
"testing": "Testing…",
"tab_plugins": "Plugins",
"plugins_title": "Plugins",
"plugins_disabled": "Plugins are disabled on this server. Set OXICLOUD_ENABLE_PLUGINS=true (and build with the \"plugins\" feature) to manage WASM plugins here.",
"plugins_install_title": "Install a plugin",
"plugins_install_intro": "Upload a plugin bundle (.zip) containing plugin.toml and its compiled WebAssembly module (.wasm). The manifest is validated and the module is probed before installation.",
"plugins_bundle_label": "Plugin bundle (.zip)",
"plugins_install": "Install plugin",
"plugins_installed_title": "Installed plugins",
"plugins_col_name": "Name",
"plugins_col_id": "ID",
"plugins_col_version": "Version",
"plugins_col_events": "Events",
"plugins_col_status": "Status",
"plugins_col_actions": "Actions",
"plugins_loading": "Loading plugins…",
"plugins_none": "No plugins installed.",
"plugins_enabled": "Enabled",
"plugins_disabled_badge": "Disabled",
"plugins_enable": "Enable",
"plugins_disable": "Disable",
"plugins_delete": "Delete",
"plugins_confirm_delete": "Delete plugin \"{{name}}\"? Its files will be removed from the server.",
"plugins_installing": "Installing…",
"plugins_installed": "Installed {{name}}.",
"plugins_install_missing_bundle": "Select a plugin bundle (.zip).",
"plugins_details": "Logs & details",
"plugins_back": "Back to plugins",
"plugins_retention_title": "Log retention",
"plugins_retention_intro": "Rotated log segments older than the retention window, or beyond the size cap, are pruned on a schedule.",
"plugins_retention_days": "Retention (days)",
"plugins_retention_max_mb": "Max log size (MB)",
"plugins_retention_save": "Save retention",
"plugins_retention_saved": "Retention saved.",
"plugins_retention_invalid": "Enter non-negative numbers.",
"plugins_logs_title": "Logs",
"plugins_logs_level_all": "All levels",
"plugins_logs_search": "Search messages…",
"plugins_logs_live": "Live",
"plugins_logs_clear": "Clear",
"plugins_logs_confirm_clear": "Clear all logs for this plugin?",
"plugins_logs_none": "No log entries.",
"plugins_logs_col_time": "Time",
"plugins_logs_col_level": "Level",
"plugins_logs_col_kind": "Kind",
"plugins_logs_col_invocation": "Invocation",
"plugins_logs_col_message": "Message",
"plugins_logs_showing": "Showing {{from}}–{{to}} of {{total}}",
"tab_smtp": "SMTP",
"smtp_title": "Outbound Email (SMTP)",
"smtp_intro": "SMTP is configured exclusively via environment variables (OXICLOUD_SMTP_*). The values below are read from the running server — to change them, edit the environment and restart OxiCloud.",
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.
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"
+32
View File
@@ -0,0 +1,32 @@
[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 = [] # `on_file_uploaded` panics -> host must contain the trap
sleep = [] # `on_file_uploaded` busy-loops past the timeout
net = [] # `on_file_uploaded` attempts an outbound HTTP call (denied: no allowed_hosts)
wrong_abi = [] # `abi_version` returns 1 -> host must reject at load
omit_login = [] # drops the `on_user_login` export -> host rejects a user.login subscription
[profile.release]
opt-level = "s"
lto = true
strip = true
+11
View File
@@ -0,0 +1,11 @@
# Manifest for the example OxiCloud plugin (ABI v0). `just plugin-example-zip`
# bundles this together with the compiled module into an installable .zip.
[plugin]
id = "com.example.hello"
name = "Hello"
version = "0.1.0"
abi = 0
entrypoint = "hello.wasm"
[events]
subscribe = ["file.uploaded", "user.login"]
+95
View File
@@ -0,0 +1,95 @@
//! Example OxiCloud plugin — ABI v0 (M0 walking skeleton).
//!
//! The default build is the well-behaved "hello" plugin. It exports one handler
//! per event it subscribes to — `on_file_uploaded` and `on_user_login` — each of
//! which reads the event metadata, calls the host `log` function (the only
//! authority a plugin has), and returns `{"ok": true}`.
//!
//! Cargo features select the variants the host's tests load:
//! - `panic` / `sleep` / `net` — make `on_file_uploaded` misbehave (failure
//! isolation, timeout, network-denial tests);
//! - `wrong_abi` — `abi_version` returns 1 (load-rejection test);
//! - `omit_login` — drops the `on_user_login` export (missing-export test).
//!
//! 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)
}
/// Handler for the `file.uploaded` event.
#[plugin_fn]
pub fn on_file_uploaded(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 the handler.
let req = HttpRequest::new("https://example.com/");
let _ = http::request::<()>(&req, None)?;
}
// --- well-behaved 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);
unsafe {
log(
"info".to_string(),
format!("hello plugin saw upload: {path} ({size} bytes)"),
)?;
}
Ok(serde_json::json!({ "ok": true }).to_string())
}
/// Handler for the `user.login` event. Dropped by the `omit_login` variant so
/// the host's missing-export validation has something to reject.
#[cfg(not(feature = "omit_login"))]
#[plugin_fn]
pub fn on_user_login(input: String) -> FnResult<String> {
let ev: serde_json::Value = serde_json::from_str(&input)?;
let user_id = ev["payload"]["user_id"].as_str().unwrap_or("<unknown>");
let first_login = ev["payload"]["first_login"].as_bool().unwrap_or(false);
unsafe {
log(
"info".to_string(),
format!("hello plugin saw login: user {user_id} (first_login={first_login})"),
)?;
}
Ok(serde_json::json!({ "ok": true }).to_string())
}