diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..e926d27e --- /dev/null +++ b/.envrc @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd793c29..f584e87e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 905f8a95..468d6c8f 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Cargo.lock b/Cargo.lock index 9144fd87..f837cab2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,15 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f27d075294830fcab6f66e320dab524bc6d048f4a151698e153205559113772" +[[package]] +name = "addr2line" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -118,6 +127,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -148,6 +163,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + [[package]] name = "arc-swap" version = "1.9.1" @@ -231,7 +252,7 @@ dependencies = [ "serde", "serde_derive", "unicode-ident", - "winnow", + "winnow 1.0.3", ] [[package]] @@ -1023,6 +1044,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitmaps" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" +dependencies = [ + "typenum", +] + [[package]] name = "bitpacking" version = "0.9.3" @@ -1157,6 +1187,9 @@ name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +dependencies = [ + "allocator-api2", +] [[package]] name = "bytecheck" @@ -1220,6 +1253,72 @@ dependencies = [ "either", ] +[[package]] +name = "cap-fs-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "cap-primitives" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes", + "ipnet", + "maybe-owned", + "rustix 1.1.4", + "rustix-linux-procfs", + "windows-sys 0.59.0", + "winx", +] + +[[package]] +name = "cap-rand" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d" +dependencies = [ + "ambient-authority", + "rand 0.8.6", +] + +[[package]] +name = "cap-std" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes", + "rustix 1.1.4", +] + +[[package]] +name = "cap-time-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80" +dependencies = [ + "ambient-authority", + "cap-primitives", + "iana-time-zone", + "once_cell", + "rustix 1.1.4", + "winx", +] + [[package]] name = "castaway" version = "0.2.4" @@ -1238,6 +1337,24 @@ dependencies = [ "cipher", ] +[[package]] +name = "cbindgen" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20" +dependencies = [ + "heck", + "indexmap", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.117", + "tempfile", + "toml 0.9.12+spec-1.1.0", +] + [[package]] name = "cc" version = "1.2.61" @@ -1449,6 +1566,15 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1467,6 +1593,148 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-assembler-x64" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc822414b18d1f5b1b33ce1441534e311e62fef86ebb5b9d382af857d0272c9" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c646808b06f4532478d8d6057d74f15c3322f10d995d9486e7dcea405bf521a" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5996f01a686b2349cdb379083ec5ad3e8cb8767fb2d495d3a4f2ee4163a18d" +dependencies = [ + "cranelift-entity", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-bitset" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523fea83273f6a985520f57788809a4de2165794d9ab00fb1254fceb4f5aa00c" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d73d1e372730b5f64ed1a2bd9f01fe4686c8ec14a28034e3084e530c8d951878" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.16.1", + "libm", + "log", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "smallvec", + "target-lexicon", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0319c18165e93dc1ebf78946a8da0b1c341c95b4a39729a69574671639bdb5f" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9195cd8aeecb55e401aa96b2eaa55921636e8246c127ed7908f7ef7e0d40f270" + +[[package]] +name = "cranelift-control" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8976c2154b74136322befc74222ab5c7249edd7e2604f8cbef2b94975541ffb9" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6038b3147c7982f4951150d5f96c7c06c1e7214b99d4b4a98607aadf8ded89d1" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-frontend" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbd294abe236e23cc3d907b0936226b6a8342db7636daa9c7c72be1e323420e" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a90b6ed3aba84189352a87badeb93b2126d3724225a42dc67fdce53d1b139c" + +[[package]] +name = "cranelift-native" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3ec0cc1a54e22925eacf4fc3dc815f907734d3b377899d19d52bec04863e853" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948865622f87f30907bb46fbb081b235ae63c1896a99a83c26a003305c1fa82d" + [[package]] name = "crc" version = "3.3.0" @@ -1743,6 +2011,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c286de4e81ea2590afc24d754e0f83810c566f50a1388fa75ebd57928c0d9745" +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + [[package]] name = "der" version = "0.7.10" @@ -1788,6 +2065,27 @@ dependencies = [ "ctutils", ] +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -2038,6 +2336,73 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "extism" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b66cd9ac5c64b49c9bac69db3d1b10d8f9386e7caab73489c2f197ba43d5e05" +dependencies = [ + "anyhow", + "async-trait", + "cbindgen", + "extism-convert", + "extism-manifest", + "glob", + "libc", + "serde", + "serde_json", + "sha2 0.10.9", + "toml 0.9.12+spec-1.1.0", + "tracing", + "tracing-subscriber", + "ureq", + "url", + "uuid", + "wasi-common", + "wasmtime", + "wiggle", +] + +[[package]] +name = "extism-convert" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad19858c4c462309a8f3a20abec53e8603bda1eefda26c8bfab51d5516b40cbb" +dependencies = [ + "anyhow", + "base64 0.22.1", + "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 2.0.117", +] + +[[package]] +name = "extism-manifest" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2f59c8dadb5e0bde9a48c6ed45312e6ef625cbcd5f67c28459dbc8fe8bc0383" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] + [[package]] name = "fastcdc" version = "4.0.1" @@ -2070,6 +2435,17 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -2095,12 +2471,28 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "file-rotate" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a3ed82142801f5b1363f7d463963d114db80f467e860b1cd82228eaebc627a0" +dependencies = [ + "chrono", + "flate2", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.9" @@ -2159,6 +2551,17 @@ dependencies = [ "futures-core", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + [[package]] name = "fs2" version = "0.4.3" @@ -2175,7 +2578,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" dependencies = [ - "rustix", + "rustix 1.1.4", "windows-sys 0.59.0", ] @@ -2318,6 +2721,20 @@ dependencies = [ "slab", ] +[[package]] +name = "fxprof-processed-profile" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" +dependencies = [ + "bitflags", + "debugid", + "rustc-hash", + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -2411,6 +2828,18 @@ dependencies = [ "weezl", ] +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap", + "stable_deref_trait", +] + [[package]] name = "glob" version = "0.3.3" @@ -2501,6 +2930,8 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", + "serde", + "serde_core", ] [[package]] @@ -2772,7 +3203,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.4", "tokio", "tower-service", "tracing", @@ -2928,6 +3359,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "im-rc" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1955a75fa080c677d3972822ec4bad316169ab1cfc6c257a942c2265dbe5fe" +dependencies = [ + "bitmaps", + "rand_core 0.6.4", + "rand_xoshiro", + "sized-chunks", + "typenum", + "version_check", +] + [[package]] name = "image" version = "0.25.10" @@ -3011,6 +3456,22 @@ dependencies = [ "rustversion", ] +[[package]] +name = "io-extras" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + [[package]] name = "ipnet" version = "2.12.0" @@ -3051,6 +3512,26 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "ittapi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" +dependencies = [ + "anyhow", + "ittapi-sys", + "log", +] + +[[package]] +name = "ittapi-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" +dependencies = [ + "cc", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -3121,6 +3602,12 @@ dependencies = [ "spin 0.9.8", ] +[[package]] +name = "leb128" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -3244,6 +3731,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3320,6 +3813,38 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[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 2.0.117", +] + +[[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 = "matchers" version = "0.2.0" @@ -3341,6 +3866,12 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "md-5" version = "0.10.6" @@ -3376,6 +3907,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix 1.1.4", +] + [[package]] name = "memmap2" version = "0.9.10" @@ -3653,6 +4193,18 @@ dependencies = [ "libm", ] +[[package]] +name = "object" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271638cd5fa9cca89c4c304675ca658efc4e64a66c716b7cfe1afb4b9611dbbc" +dependencies = [ + "crc32fast", + "hashbrown 0.16.1", + "indexmap", + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -4099,7 +4651,9 @@ dependencies = [ "chrono", "dashmap 6.2.1", "dotenvy", + "extism", "fastcdc", + "file-rotate", "flate2", "fs2", "futures", @@ -4147,6 +4701,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-util", + "toml 1.1.2+spec-1.1.0", "tower", "tower-http", "tracing", @@ -4312,6 +4867,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + [[package]] name = "phf" version = "0.11.3" @@ -4602,6 +5167,26 @@ dependencies = [ "elliptic-curve", ] +[[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" @@ -4611,6 +5196,29 @@ 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 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ptr_meta" version = "0.1.4" @@ -4631,6 +5239,29 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pulley-interpreter" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec12fe19a9588315a49fe5704502a9c02d6a198303314b0c7c86123b06d29e5" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f7d5ef31ebf1b46cd7e722ffef934e670d7e462f49aa01cde07b9b76dca580" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pxfm" version = "0.1.29" @@ -4675,7 +5306,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.40", - "socket2 0.5.10", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -4712,7 +5343,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -4850,6 +5481,15 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rangemap" version = "1.7.1" @@ -4894,6 +5534,31 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regalloc2" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.17.0", + "log", + "rustc-hash", + "smallvec", +] + [[package]] name = "regex" version = "1.12.3" @@ -5032,6 +5697,25 @@ dependencies = [ "syn 1.0.109", ] +[[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 = "rsa" version = "0.9.10" @@ -5062,6 +5746,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -5077,6 +5767,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -5086,10 +5789,20 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.1.4", +] + [[package]] name = "rustls" version = "0.21.12" @@ -5253,6 +5966,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "seq-macro" @@ -5334,6 +6051,15 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -5346,6 +6072,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -5464,6 +6203,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]] +name = "sized-chunks" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" +dependencies = [ + "bitmaps", + "typenum", +] + [[package]] name = "sketches-ddsketch" version = "0.4.0" @@ -5824,6 +6573,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-interface" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" +dependencies = [ + "bitflags", + "cap-fs-ext", + "cap-std", + "fd-lock", + "io-lifetimes", + "rustix 0.38.44", + "windows-sys 0.59.0", + "winx", +] + [[package]] name = "tagptr" version = "0.2.0" @@ -5984,6 +6749,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" @@ -5993,10 +6764,19 @@ dependencies = [ "fastrand 2.4.1", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "termtree" version = "0.5.1" @@ -6176,6 +6956,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -6192,6 +6973,81 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[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 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[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 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.3" @@ -6448,12 +7304,47 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "percent-encoding", + "rustls 0.23.40", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots 1.0.7", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http 1.4.0", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -6479,6 +7370,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6579,6 +7476,32 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi-common" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46137f5bcc41a0f002ed14688e463665388a6f3a6662a12a8c315d4b8849791c" +dependencies = [ + "async-trait", + "bitflags", + "cap-fs-ext", + "cap-rand", + "cap-std", + "cap-time-ext", + "fs-set-times", + "io-extras", + "io-lifetimes", + "log", + "rustix 1.1.4", + "system-interface", + "thiserror 2.0.18", + "tracing", + "wasmtime", + "wasmtime-environ", + "wiggle", + "windows-sys 0.61.2", +] + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -6658,6 +7581,27 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-compose" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd23d12cc95c451c1306db5bc63075fbebb612bb70c53b4237b1ce5bc178343" +dependencies = [ + "anyhow", + "heck", + "im-rc", + "indexmap", + "log", + "petgraph", + "serde", + "serde_derive", + "serde_yaml", + "smallvec", + "wasm-encoder 0.245.1", + "wasmparser 0.245.1", + "wat", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -6665,7 +7609,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9dca005e69bf015e45577e415b9af8c67e8ee3c0e38b5b0add5aa92581ed5c" +dependencies = [ + "leb128fmt", + "wasmparser 0.245.1", +] + +[[package]] +name = "wasm-encoder" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f" +dependencies = [ + "leb128fmt", + "wasmparser 0.252.0", ] [[package]] @@ -6676,8 +7640,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap", - "wasm-encoder", - "wasmparser", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", ] [[package]] @@ -6705,6 +7669,329 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmparser" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e" +dependencies = [ + "bitflags", + "hashbrown 0.16.1", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" +dependencies = [ + "bitflags", + "indexmap", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41517a3716fbb8ccf46daa9c1325f760fcbff5168e75c7392288e410b91ac8" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.245.1", +] + +[[package]] +name = "wasmtime" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efb1ed5899dde98357cfdcf647a4614498798719793898245b4b34e663addabf" +dependencies = [ + "addr2line", + "async-trait", + "bitflags", + "bumpalo", + "cc", + "cfg-if", + "encoding_rs", + "futures", + "fxprof-processed-profile", + "gimli", + "ittapi", + "libc", + "log", + "mach2", + "memfd", + "object", + "once_cell", + "postcard", + "pulley-interpreter", + "rayon", + "rustix 1.1.4", + "semver", + "serde", + "serde_derive", + "serde_json", + "smallvec", + "target-lexicon", + "tempfile", + "wasm-compose", + "wasm-encoder 0.245.1", + "wasmparser 0.245.1", + "wasmtime-environ", + "wasmtime-internal-cache", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wasmtime-internal-winch", + "wat", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-environ" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4172382dcc785c31d0e862c6780a18f5dd437914d22c4691351f965ef751c821" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "hashbrown 0.16.1", + "indexmap", + "log", + "object", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "sha2 0.10.9", + "smallvec", + "target-lexicon", + "wasm-encoder 0.245.1", + "wasmparser 0.245.1", + "wasmprinter", + "wasmtime-internal-component-util", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-cache" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ed398988226d7aa0505ac6bb576e09532ad722d702ec4e66365d78ed695c95f" +dependencies = [ + "base64 0.22.1", + "directories-next", + "log", + "postcard", + "rustix 1.1.4", + "serde", + "serde_derive", + "sha2 0.10.9", + "toml 0.9.12+spec-1.1.0", + "wasmtime-environ", + "windows-sys 0.61.2", + "zstd", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae5ec9fff073ff13b81732d56a9515d761c245750bcda09093827f84130ebc25" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser 0.245.1", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935d9ab293ba27d1ec9aa7bc1b3a43993dbe961af2a8f23f90a11e1331b4c13f" + +[[package]] +name = "wasmtime-internal-core" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3820b174f477d2a7083209d1ad5353fcdb11eaea434b2137b8681029460dd3" +dependencies = [ + "anyhow", + "hashbrown 0.16.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1679d205caf9766c6aa309d45bb3e7c634d7725e3164404df33824b9f7c4fb7" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools 0.14.0", + "log", + "object", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.245.1", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1e505254058be5b0df458d670ee42d9eafe2349d04c1296e9dc01071dc20a85" +dependencies = [ + "cc", + "cfg-if", + "libc", + "rustix 1.1.4", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c2e05b345f1773e59c20e6ad7298fd6857cdea245023d88bb659c96d8f0ea72" +dependencies = [ + "cc", + "object", + "rustix 1.1.4", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b86701b234a4643e3f111869aa792b3a05a06e02d486ee9cb6c04dae16b52dab" +dependencies = [ + "cfg-if", + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-unwinder" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63558d801beb83dde9b336eb4ae049019aee26627926edb32cd119d7e4c83cd" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "log", + "object", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737c4d956fc3a848541a064afb683dd2771132a6b125be5baaf95c4379aa47df" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "wasmtime-internal-winch" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f599b79545e3bba0b7913406055ebede5bb0dabee9ba2015ef25a9f4c9f47807" +dependencies = [ + "cranelift-codegen", + "gimli", + "log", + "object", + "target-lexicon", + "wasmparser 0.245.1", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "winch-codegen", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2192a77a00b9a67800c2b4e1c70fb6abca79d6b529e53a2ef9dcdcc36090330d" +dependencies = [ + "anyhow", + "bitflags", + "heck", + "indexmap", + "wit-parser 0.245.1", +] + +[[package]] +name = "wast" +version = "35.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" +dependencies = [ + "leb128", +] + +[[package]] +name = "wast" +version = "252.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "942a3449d6a593fccc111a6241c8df52bda168af30e40bf9580d4394d7374c65" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder 0.252.0", +] + +[[package]] +name = "wat" +version = "1.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c72a4ba7088f7bac94cf516e49882bdf97068904a563768cf249efc839ec42cb" +dependencies = [ + "wast 252.0.0", +] + [[package]] name = "web-sys" version = "0.3.97" @@ -6759,6 +8046,47 @@ dependencies = [ "wasite", ] +[[package]] +name = "wiggle" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8cfd3db2f05619c6f36f257d84327c11546e28d61e3a1c1220aaad553bc4b0" +dependencies = [ + "bitflags", + "thiserror 2.0.18", + "tracing", + "wasmtime", + "wasmtime-environ", + "wiggle-macro", + "witx", +] + +[[package]] +name = "wiggle-generate" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd7a197903e5b4ff5e13aef9c891960d71e92073600ecf4c86c7e795ac1c803" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasmtime-environ", + "witx", +] + +[[package]] +name = "wiggle-macro" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6410b86fcec207070d9372b215d3470bad67215e6bbac46981a16999c4abbc28" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "wiggle-generate", +] + [[package]] name = "winapi" version = "0.3.9" @@ -6775,12 +8103,40 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "winch-codegen" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dbb0cf07b0dfe7b7a1ca8efb8f94ba98bd0fb144c411ea1665c78f0449e958" +dependencies = [ + "cranelift-assembler-x64", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.245.1", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -7071,6 +8427,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + [[package]] name = "winnow" version = "1.0.3" @@ -7080,6 +8442,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.59.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -7103,7 +8475,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck", - "wit-parser", + "wit-parser 0.244.0", ] [[package]] @@ -7150,10 +8522,10 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder", + "wasm-encoder 0.244.0", "wasm-metadata", - "wasmparser", - "wit-parser", + "wasmparser 0.244.0", + "wit-parser 0.244.0", ] [[package]] @@ -7171,7 +8543,38 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.244.0", +] + +[[package]] +name = "wit-parser" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330698718e82983499419494dd1e3d7811a457a9bf9f69734e8c5f07a2547929" +dependencies = [ + "anyhow", + "hashbrown 0.16.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.245.1", +] + +[[package]] +name = "witx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" +dependencies = [ + "anyhow", + "log", + "thiserror 1.0.69", + "wast 35.0.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 962cf1cf..1d00c362 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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)'] } diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 00000000..cf2ebd75 --- /dev/null +++ b/devenv.nix @@ -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" + ''; +} diff --git a/example.env b/example.env index ea78f21a..e1bbc0ae 100644 --- a/example.env +++ b/example.env @@ -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 # ----------------------------------------------------------------------------- diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..a391db4d --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..71061d6a --- /dev/null +++ b/flake.nix @@ -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 ]; + }; + } + ); + }; +} diff --git a/justfile b/justfile index f16df0fa..29f47595 100644 --- a/justfile +++ b/justfile @@ -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 diff --git a/scripts/build-plugin-hello.sh b/scripts/build-plugin-hello.sh new file mode 100755 index 00000000..737df488 --- /dev/null +++ b/scripts/build-plugin-hello.sh @@ -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)"}' diff --git a/scripts/build-plugin-zip.sh b/scripts/build-plugin-zip.sh new file mode 100755 index 00000000..f810e0f0 --- /dev/null +++ b/scripts/build-plugin-zip.sh @@ -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" diff --git a/src/application/adapters/mod.rs b/src/application/adapters/mod.rs index 08fbab7f..60ec06b9 100644 --- a/src/application/adapters/mod.rs +++ b/src/application/adapters/mod.rs @@ -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)] diff --git a/src/application/adapters/plugin_lifecycle_hook.rs b/src/application/adapters/plugin_lifecycle_hook.rs new file mode 100644 index 00000000..072114bb --- /dev/null +++ b/src/application/adapters/plugin_lifecycle_hook.rs @@ -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, + retrieval: Arc, +} + +impl PluginLifecycleHook { + pub fn new( + dispatch: Arc, + retrieval: Arc, + ) -> 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) {} +} diff --git a/src/application/adapters/plugin_user_lifecycle_hook.rs b/src/application/adapters/plugin_user_lifecycle_hook.rs new file mode 100644 index 00000000..8bfe24a4 --- /dev/null +++ b/src/application/adapters/plugin_user_lifecycle_hook.rs @@ -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, +} + +impl PluginUserLifecycleHook { + pub fn new(dispatch: Arc) -> 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>, + } + + 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(); + } +} diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index 68400c05..b19c940d 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -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; diff --git a/src/application/dtos/plugin_dto.rs b/src/application/dtos/plugin_dto.rs new file mode 100644 index 00000000..0f12a240 --- /dev/null +++ b/src/application/dtos/plugin_dto.rs @@ -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, + pub enabled: bool, +} + +impl From 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, + pub msg: String, +} + +impl From 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, + /// 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, + /// Case-insensitive substring filter on the message. + pub search: Option, + /// Max entries to return (clamped server-side). + pub limit: Option, + /// Newest-first entries to skip. + pub offset: Option, +} + +/// 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 for PluginRetentionDto { + fn from(s: RetentionSettings) -> Self { + Self { + retention_days: s.retention_days, + max_bytes: s.max_bytes, + } + } +} + +impl From for RetentionSettings { + fn from(d: PluginRetentionDto) -> Self { + Self { + retention_days: d.retention_days, + max_bytes: d.max_bytes, + } + } +} diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index 163cb70c..5e6c39be 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -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; diff --git a/src/application/ports/plugin_ports.rs b/src/application/ports/plugin_ports.rs new file mode 100644 index 00000000..c67df670 --- /dev/null +++ b/src/application/ports/plugin_ports.rs @@ -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_` (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_` 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; + + /// 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) -> Result; + + /// 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) -> Result; + + /// 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; + + /// 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; + + /// 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; +} + +/// 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, + /// 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, + /// Keep only entries whose message contains this substring (case-insensitive). + pub search: Option, + /// 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, + /// 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, + 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, + /// 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, + 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, +} diff --git a/src/common/config.rs b/src/common/config.rs index 1c7e0a93..a6f42c28 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -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, + /// 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..gz` + `retention.json`). + /// Default: `{storage_path}/.plugin-logs`. Env: `OXICLOUD_PLUGIN_LOG_DIR`. + pub log_dir: Option, + /// 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::()) + && 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::()) + && 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::()) + && 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::()) + && 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::()) + && 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::()) + && 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::()) + && 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::()) + && 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::()) + && 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::()) + && 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::()) + && 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::()) + && 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::()) && let Ok(val) = v { diff --git a/src/common/di.rs b/src/common/di.rs index 87de31b3..62a3e133 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -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, storage_usage: &Arc, content_index: Option>, + plugin_dispatch: Option< + Arc, + >, ) -> 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, + plugin_dispatch: Option< + Arc, + >, + ) -> Arc { + 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>, + Option>, + ) { + #[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 = + 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, pub nextcloud: Option, pub admin_settings_service: Option>, + /// 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>, pub storage_settings_service: Option>, pub migration_state: Arc>, pub trash_service: Option>, diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 2e989b0e..788ab933 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -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; diff --git a/src/infrastructure/services/plugins/log_retention_service.rs b/src/infrastructure/services/plugins/log_retention_service.rs new file mode 100644 index 00000000..b4758123 --- /dev/null +++ b/src/infrastructure/services/plugins/log_retention_service.rs @@ -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, + manager: Arc, + interval_hours: u64, +} + +impl PluginLogMaintenanceService { + pub fn new( + log_store: Arc, + manager: Arc, + 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) { + 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; + } + } +} diff --git a/src/infrastructure/services/plugins/log_store.rs b/src/infrastructure/services/plugins/log_store.rs new file mode 100644 index 00000000..d81051c6 --- /dev/null +++ b/src/infrastructure/services/plugins/log_store.rs @@ -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..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, + }, + Read { + plugin_id: String, + query: LogQuery, + reply: oneshot::Sender, + }, + Clear { + plugin_id: String, + reply: oneshot::Sender<()>, + }, + Remove { + plugin_id: String, + }, + GetRetention { + plugin_id: String, + reply: oneshot::Sender, + }, + SetRetention { + plugin_id: String, + settings: RetentionSettings, + reply: oneshot::Sender<()>, + }, + Sweep { + plugin_id: String, + now: DateTime, + }, +} + +/// 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, + live: broadcast::Sender, +} + +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 = 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) { + 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 { + 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>, + live: broadcast::Sender, +} + +impl Actor { + fn run(mut self, mut rx: mpsc::Receiver) { + 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> { + 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) { + 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 = 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 = 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::(&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) { + 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 = (*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) { + 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::(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); + } + } +} diff --git a/src/infrastructure/services/plugins/manager.rs b/src/infrastructure/services/plugins/manager.rs new file mode 100644 index 00000000..f695487e --- /dev/null +++ b/src/infrastructure/services/plugins/manager.rs @@ -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, + /// 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, +} + +impl LoadedPlugin { + fn info(&self) -> PluginInfo { + let mut subscriptions: Vec = 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>, + /// Per-plugin structured log storage (shared with the maintenance task). + log_store: Arc, + /// Caps concurrent plugin invocations across all plugins so dispatch can + /// shed load instead of flooding the shared blocking pool. + invocation_sem: Arc, +} + +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 { + 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 { + 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_` handler exported. + let required_exports: Vec = 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> { + self.plugins.read().unwrap_or_else(|e| e.into_inner()) + } + + fn write_plugins(&self) -> std::sync::RwLockWriteGuard<'_, Vec> { + 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 { + let mut infos: Vec = 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) -> Result { + // 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 = 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) -> Result { + 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 { + 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 { + 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 { + 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') +} diff --git a/src/infrastructure/services/plugins/manager_test.rs b/src/infrastructure/services/plugins/manager_test.rs new file mode 100644 index 00000000..e54dc932 --- /dev/null +++ b/src/infrastructure/services/plugins/manager_test.rs @@ -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 { + 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 { + 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" + ); +} diff --git a/src/infrastructure/services/plugins/manifest.rs b/src/infrastructure/services/plugins/manifest.rs new file mode 100644 index 00000000..780c872a --- /dev/null +++ b/src/infrastructure/services/plugins/manifest.rs @@ -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, +} + +#[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, +} + +/// 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 { + 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) +} diff --git a/src/infrastructure/services/plugins/mod.rs b/src/infrastructure/services/plugins/mod.rs new file mode 100644 index 00000000..944e9844 --- /dev/null +++ b/src/infrastructure/services/plugins/mod.rs @@ -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; diff --git a/src/infrastructure/services/plugins/runtime.rs b/src/infrastructure/services/plugins/runtime.rs new file mode 100644 index 00000000..b170acc1 --- /dev/null +++ b/src/infrastructure/services/plugins/runtime.rs @@ -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>>, +} + +/// 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::()?; + // 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_` 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, + /// 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>, + /// 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, +} + +impl PluginRuntime { + pub fn new(plugin_id: impl Into, wasm_bytes: Vec) -> 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 { + 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 { + // 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_` 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_` 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::(&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) + } +} diff --git a/src/infrastructure/services/plugins/runtime_test.rs b/src/infrastructure/services/plugins/runtime_test.rs new file mode 100644 index 00000000..de7e6afa --- /dev/null +++ b/src/infrastructure/services/plugins/runtime_test.rs @@ -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 { + 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" + ); +} diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index 711d0716..98556dd4 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -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> { .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, 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>, + headers: HeaderMap, +) -> Result { + admin_guard(&state, &headers).await?; + let mgmt = plugin_mgmt(&state)?; + let plugins: Vec = 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>, + headers: HeaderMap, + Path(id): Path, + Json(dto): Json, +) -> Result { + 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>, + headers: HeaderMap, + mut multipart: Multipart, +) -> Result { + let (admin_id, _) = admin_guard(&state, &headers).await?; + let mgmt = plugin_mgmt(&state)?; + + let mut bundle: Option> = 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>, + headers: HeaderMap, + Path(id): Path, +) -> Result { + 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>, + headers: HeaderMap, + Path(id): Path, + Query(q): Query, +) -> Result { + 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>, + headers: HeaderMap, + Path(id): Path, +) -> Result { + 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>, + headers: HeaderMap, + Path(id): Path, +) -> Result { + 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)) + } + 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>, + headers: HeaderMap, + Path(id): Path, +) -> Result { + 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>, + headers: HeaderMap, + Path(id): Path, + Json(dto): Json, +) -> Result { + 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))) +} diff --git a/static/admin.html b/static/admin.html index 88f7a70b..7e21c0e7 100644 --- a/static/admin.html +++ b/static/admin.html @@ -65,6 +65,9 @@ +
@@ -677,6 +680,147 @@
+ +
+ + + + + +
diff --git a/static/css/views/admin.css b/static/css/views/admin.css index 0d0b7cee..c4ed82c4 100644 --- a/static/css/views/admin.css +++ b/static/css/views/admin.css @@ -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; + } +} diff --git a/static/js/views/admin/admin.js b/static/js/views/admin/admin.js index 864778a2..e7f712eb 100644 --- a/static/js/views/admin/admin.js +++ b/static/js/views/admin/admin.js @@ -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} */ +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 = ` ${escapeHtml(`HTTP ${resp.status}`)}`; + return; + } + /** @type {{enabled: boolean, plugins: PluginInfo[]}} */ + const data = await resp.json(); + renderPluginRows(data.plugins || []); + } catch (e) { + tbody.innerHTML = ` ${escapeHtml(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }))}`; + } +} + +/** @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 = `${escapeHtml(i18n.t('admin.plugins_none') || 'No plugins installed.')}`; + return; + } + tbody.innerHTML = plugins + .map((p) => { + const events = (p.subscriptions || []).map((ev) => `${escapeHtml(ev)}`).join(' ') || '—'; + const statusLabel = p.enabled ? i18n.t('admin.plugins_enabled') || 'Enabled' : i18n.t('admin.plugins_disabled_badge') || 'Disabled'; + const statusBadge = `${escapeHtml(statusLabel)}`; + const toggleTitle = p.enabled ? i18n.t('admin.plugins_disable') || 'Disable' : i18n.t('admin.plugins_enable') || 'Enable'; + const toggleBtn = + ``; + const deleteBtn = + `'; + const detailsBtn = + `'; + return ( + '' + + `${escapeHtml(p.name)}` + + `${escapeHtml(p.id)}` + + `${escapeHtml(p.version)}` + + `${events}` + + `${statusBadge}` + + `
${detailsBtn}${toggleBtn}${deleteBtn}
` + + '' + ); + }) + .join(''); + + /** @type {NodeListOf} */ (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) => `${escapeHtml(ev)}`).join(' ') || '—'; + const statusLabel = p.enabled ? i18n.t('admin.plugins_enabled') || 'Enabled' : i18n.t('admin.plugins_disabled_badge') || 'Disabled'; + const statusBadge = `${escapeHtml(statusLabel)}`; + /** @param {string} label @param {string} value */ + const row = (label, value) => `
${escapeHtml(label)}
${value}
`; + meta.innerHTML = + row(i18n.t('admin.plugins_col_id') || 'ID', `${escapeHtml(p.id)}`) + + 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 = ` ${escapeHtml(`HTTP ${resp.status}`)}`; + return; + } + /** @type {PluginLogPage} */ + const page = await resp.json(); + pluginLogsTotal = page.total; + renderPluginLogRows(page.entries || []); + updatePluginLogsPagination(); + } catch (e) { + tbody.innerHTML = ` ${escapeHtml(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }))}`; + } +} + +/** @param {PluginLogEntry[]} entries */ +function renderPluginLogRows(entries) { + const tbody = document.getElementById('plugin-logs-tbody'); + if (!tbody) return; + if (entries.length === 0) { + tbody.innerHTML = `${escapeHtml(i18n.t('admin.plugins_logs_none') || 'No log entries.')}`; + return; + } + tbody.innerHTML = entries.map(pluginLogRowHtml).join(''); +} + +/** @param {PluginLogEntry} e */ +function pluginLogRowHtml(e) { + const level = (e.level || 'info').toLowerCase(); + const levelBadge = `${escapeHtml(level)}`; + const kind = e.kind === 'outcome' ? e.reason || 'outcome' : 'log'; + return ( + '' + + `${escapeHtml(timeAgo(e.ts))}` + + `${levelBadge}` + + `${escapeHtml(kind)}` + + `${escapeHtml(e.invocation_id)}` + + `${escapeHtml(e.msg)}` + + '' + ); +} + +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); }); diff --git a/static/locales/en.json b/static/locales/en.json index bc489e06..c829bf57 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -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.", diff --git a/tests/fixtures/plugins/hello.wasm b/tests/fixtures/plugins/hello.wasm new file mode 100755 index 00000000..f1518195 Binary files /dev/null and b/tests/fixtures/plugins/hello.wasm differ diff --git a/tests/fixtures/plugins/net.wasm b/tests/fixtures/plugins/net.wasm new file mode 100755 index 00000000..d7100fa7 Binary files /dev/null and b/tests/fixtures/plugins/net.wasm differ diff --git a/tests/fixtures/plugins/omit_login.wasm b/tests/fixtures/plugins/omit_login.wasm new file mode 100755 index 00000000..686e1812 Binary files /dev/null and b/tests/fixtures/plugins/omit_login.wasm differ diff --git a/tests/fixtures/plugins/panic.wasm b/tests/fixtures/plugins/panic.wasm new file mode 100755 index 00000000..18f47360 Binary files /dev/null and b/tests/fixtures/plugins/panic.wasm differ diff --git a/tests/fixtures/plugins/sleep.wasm b/tests/fixtures/plugins/sleep.wasm new file mode 100755 index 00000000..a6527a35 Binary files /dev/null and b/tests/fixtures/plugins/sleep.wasm differ diff --git a/tests/fixtures/plugins/wrong_abi.wasm b/tests/fixtures/plugins/wrong_abi.wasm new file mode 100755 index 00000000..f3108bc3 Binary files /dev/null and b/tests/fixtures/plugins/wrong_abi.wasm differ diff --git a/wasm/oxicloud-plugin-hello/Cargo.lock b/wasm/oxicloud-plugin-hello/Cargo.lock new file mode 100644 index 00000000..a26c6824 --- /dev/null +++ b/wasm/oxicloud-plugin-hello/Cargo.lock @@ -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" diff --git a/wasm/oxicloud-plugin-hello/Cargo.toml b/wasm/oxicloud-plugin-hello/Cargo.toml new file mode 100644 index 00000000..b1c75ae8 --- /dev/null +++ b/wasm/oxicloud-plugin-hello/Cargo.toml @@ -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 diff --git a/wasm/oxicloud-plugin-hello/plugin.toml b/wasm/oxicloud-plugin-hello/plugin.toml new file mode 100644 index 00000000..9e6d0d5a --- /dev/null +++ b/wasm/oxicloud-plugin-hello/plugin.toml @@ -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"] diff --git a/wasm/oxicloud-plugin-hello/src/lib.rs b/wasm/oxicloud-plugin-hello/src/lib.rs new file mode 100644 index 00000000..cb6b5bce --- /dev/null +++ b/wasm/oxicloud-plugin-hello/src/lib.rs @@ -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 { + Ok(0) +} + +/// `wrong_abi` variant: claim an ABI the host does not speak. +#[cfg(feature = "wrong_abi")] +#[plugin_fn] +pub fn abi_version() -> FnResult { + Ok(1) +} + +/// Handler for the `file.uploaded` event. +#[plugin_fn] +pub fn on_file_uploaded(input: String) -> FnResult { + // --- 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(""); + 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 { + let ev: serde_json::Value = serde_json::from_str(&input)?; + let user_id = ev["payload"]["user_id"].as_str().unwrap_or(""); + 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()) +}