diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd793c29..9e3a9339 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,13 @@ jobs: wasm: - 'wasm/**' - 'scripts/build-wasm.sh' + plugins: + - 'wasm/oxicloud-plugin-hello/**' + - 'scripts/build-plugin-hello.sh' + - 'tests/fixtures/plugins/**' + - 'src/infrastructure/services/plugins/**' + - 'src/application/ports/plugin_ports.rs' + - 'src/application/adapters/plugin_lifecycle_hook.rs' frontend-check: name: Frontend — CSS and JS checks (format, lint, css-rules, types) @@ -171,6 +179,29 @@ jobs: workspaces: wasm/oxicloud-hash - run: cargo test --release + # Plugin runtime (Extism). Rebuilds the committed .wasm fixtures from + # wasm/oxicloud-plugin-hello/ and fails if they drift from what is + # committed (staleness guard), then runs the plugin-runtime tests with + # the `plugins` feature. The wasm32 target is needed only to rebuild the + # fixtures; the host tests themselves do not need it. + plugins: + name: Plugins — fixtures + runtime tests + needs: changes + if: needs.changes.outputs.plugins == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + - name: Rebuild committed wasm fixtures + run: bash scripts/build-plugin-hello.sh + - name: Fail if fixtures are stale (rebuild + commit them) + run: git diff --exit-code tests/fixtures/plugins/ + - name: Run plugin runtime tests + run: cargo test --features plugins plugins:: + rust-test: name: Server Unit and Functionnal Tests needs: changes diff --git a/Cargo.lock b/Cargo.lock index 9144fd87..a23beaa9 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" @@ -2101,6 +2477,12 @@ 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 +2541,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 +2568,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 +2711,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 +2818,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 +2920,8 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", + "serde", + "serde_core", ] [[package]] @@ -2772,7 +3193,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.4", "tokio", "tower-service", "tracing", @@ -2928,6 +3349,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 +3446,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 +3502,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 +3592,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 +3721,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 +3803,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 +3856,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 +3897,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 +4183,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,6 +4641,7 @@ dependencies = [ "chrono", "dashmap 6.2.1", "dotenvy", + "extism", "fastcdc", "flate2", "fs2", @@ -4147,6 +4690,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-util", + "toml 1.1.2+spec-1.1.0", "tower", "tower-http", "tracing", @@ -4312,6 +4856,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 +5156,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 +5185,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 +5228,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 +5295,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 +5332,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -4850,6 +5470,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 +5523,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 +5686,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 +5735,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 +5756,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 +5778,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 +5955,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 +6040,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 +6061,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 +6192,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 +6562,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 +6738,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 +6753,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" @@ -6192,6 +6961,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 +7292,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 +7358,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 +7464,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 +7569,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 +7597,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 +7628,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap", - "wasm-encoder", - "wasmparser", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", ] [[package]] @@ -6705,6 +7657,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 +8034,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 +8091,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 +8415,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 +8430,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 +8463,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck", - "wit-parser", + "wit-parser 0.244.0", ] [[package]] @@ -7150,10 +8510,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 +8531,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..09a2e9eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,11 +78,16 @@ tantivy = "0.26.1" zip = { version = "8.6.0", default-features = false, features = ["deflate"] } pdf-extract = "0.10.0" nom-exif = "3.6.1" +extism = { version = "1.30.0", optional = true } +toml = { version = "1.1.2", optional = true } [features] default = [] test_utils = ["mockall"] integration_tests = [] +# WASM plugin runtime (Extism). Opt-in: bundles wasmtime, a large engine most +# deployments won't use. Activation also requires OXICLOUD_ENABLE_PLUGINS=true. +plugins = ["dep:extism", "dep:toml"] [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] } diff --git a/devenv.nix b/devenv.nix index 89711562..fb5e4ee2 100644 --- a/devenv.nix +++ b/devenv.nix @@ -5,6 +5,10 @@ enable = true; channel = "stable"; # edition 2024 needs a recent stable (CLAUDE.md: Rust 1.93+) components = [ "rustc" "cargo" "clippy" "rustfmt" "rust-analyzer" "rust-src" ]; + # wasm32 std for building the WASM plugin fixtures and the vendored BLAKE3 + # module (scripts/build-plugin-hello.sh, scripts/build-wasm.sh). Only needed + # to *rebuild* those committed artifacts, not for normal server builds/tests. + targets = [ "wasm32-unknown-unknown" ]; }; # Native build deps + the full justfile toolchain. diff --git a/example.env b/example.env index ea78f21a..10bd1a99 100644 --- a/example.env +++ b/example.env @@ -224,6 +224,25 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud # Set to false to prevent users from browsing the user directory. #OXICLOUD_EXPOSE_SYSTEM_USERS=true +# WASM plugin runtime (Extism). Requires a binary built with the `plugins` +# cargo feature (`cargo run --features plugins`); without that feature these +# vars are inert. Untrusted plugins run sandboxed: no filesystem, no network, +# capped memory, per-invocation timeout. (default: false) +#OXICLOUD_ENABLE_PLUGINS=false + +# Directory scanned for plugins at startup; each plugin is a subdirectory with +# a plugin.toml + its .wasm. (default: {OXICLOUD_STORAGE_PATH}/.plugins) +#OXICLOUD_PLUGINS_DIR= + +# Per-invocation wall-clock timeout in ms (default: 250) +#OXICLOUD_PLUGIN_TIMEOUT_MS=250 + +# Max linear memory per plugin instance, in 64 KiB WASM pages (default: 256 = 16 MiB) +#OXICLOUD_PLUGIN_MAX_MEMORY_PAGES=256 + +# Max serialized event payload handed to a plugin, in bytes (default: 262144 = 256 KiB) +#OXICLOUD_PLUGIN_MAX_INPUT_BYTES=262144 + # ----------------------------------------------------------------------------- # STORAGE BACKEND # ----------------------------------------------------------------------------- diff --git a/justfile b/justfile index f16df0fa..f223fea9 100644 --- a/justfile +++ b/justfile @@ -65,6 +65,21 @@ wasm-check: wasm-test: cd wasm/oxicloud-hash; cargo test --release +# Run the host plugin-runtime tests (compiles the Extism/wasmtime runtime). +test-plugins: + cargo test --features plugins + +# Rebuild the committed plugin .wasm fixtures from wasm/oxicloud-plugin-hello/. +# Requires the wasm32 target (devenv provides it; else `rustup target add +# wasm32-unknown-unknown`). Commit the regenerated files; CI fails on drift. +plugin-build: + bash scripts/build-plugin-hello.sh + +# fmt + clippy the example plugin crate (standalone workspace, wasm32 target). +plugin-check: + cd wasm/oxicloud-plugin-hello; cargo fmt --all + cd wasm/oxicloud-plugin-hello; cargo clippy --target wasm32-unknown-unknown --release -- -D warnings + # audit security (condition: cargo install cargo-audit) audit: cargo audit diff --git a/scripts/build-plugin-hello.sh b/scripts/build-plugin-hello.sh new file mode 100755 index 00000000..4e7212fd --- /dev/null +++ b/scripts/build-plugin-hello.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Rebuild the committed plugin .wasm fixtures used by the plugin-runtime tests +# (src/infrastructure/services/plugins/runtime_test.rs). +# +# The generated artifacts ARE committed — like the vendored BLAKE3 module — so +# regular builds and `cargo test --features plugins` never need the wasm +# toolchain. Re-run this only when wasm/oxicloud-plugin-hello/ changes, and +# commit the regenerated files. CI rebuilds them and fails on any diff. +# +# Requirements (one-time): +# - the wasm32-unknown-unknown target. In the devenv this is provided by +# `languages.rust.targets` in devenv.nix; otherwise: +# rustup target add wasm32-unknown-unknown + +set -euo pipefail +cd "$(dirname "$0")/.." + +CRATE=wasm/oxicloud-plugin-hello +OUT=tests/fixtures/plugins +ARTIFACT="$CRATE/target/wasm32-unknown-unknown/release/oxicloud_plugin_hello.wasm" + +# Needs the wasm32-unknown-unknown target's std. In the devenv this comes from +# `languages.rust.targets` in devenv.nix; otherwise run +# `rustup target add wasm32-unknown-unknown`. cargo emits a clear "can't find +# crate for `std`" error below if it is missing. + +mkdir -p "$OUT" + +build() { + local variant="$1"; shift + echo "building $variant.wasm ${*:+(features: ${*#--features })}" + cargo build \ + --manifest-path "$CRATE/Cargo.toml" \ + --target wasm32-unknown-unknown \ + --release "$@" + cp "$ARTIFACT" "$OUT/$variant.wasm" +} + +build hello +build panic --features panic +build sleep --features sleep +build net --features net +build wrong_abi --features wrong_abi + +echo "Built fixtures:" +ls -la "$OUT"/*.wasm | awk '{print " " $9 " (" $5 " bytes)"}' diff --git a/src/application/adapters/mod.rs b/src/application/adapters/mod.rs index 08fbab7f..89a7dd00 100644 --- a/src/application/adapters/mod.rs +++ b/src/application/adapters/mod.rs @@ -2,6 +2,7 @@ pub mod caldav_adapter; pub mod carddav_adapter; +pub mod plugin_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..2892db56 --- /dev/null +++ b/src/application/adapters/plugin_lifecycle_hook.rs @@ -0,0 +1,100 @@ +//! Bridges the existing [`FileLifecycleHook`] fan-out to the plugin runtime. +//! +//! `FileLifecycleService` already notifies hooks on every file create/update. +//! This adapter turns those notifications into `file.uploaded` plugin events. +//! Because the hook signature carries only `file_id` (not path/size), it looks +//! the metadata up via [`FileRetrievalUseCase::get_file`] — off the request +//! path, and skipped entirely when no plugin subscribes. + +use std::sync::Arc; + +use uuid::Uuid; + +use crate::application::ports::file_lifecycle::FileLifecycleHook; +use crate::application::ports::file_ports::FileRetrievalUseCase; +use crate::application::ports::plugin_ports::{ + EVENT_FILE_UPLOADED, FileUploadedEvent, PluginDispatchPort, +}; +use crate::application::services::FileRetrievalService; + +/// Lifecycle hook that forwards file create/update events to subscribed plugins. +pub struct PluginLifecycleHook { + dispatch: Arc, + 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_file_uploaded(FileUploadedEvent { + path: dto.path, + size: dto.size, + mime: dto.mime_type.to_string(), + user_id: dto.owner_id, + invocation_id: Uuid::new_v4().to_string(), + }); + }); + } +} + +impl FileLifecycleHook for PluginLifecycleHook { + fn on_file_created( + &self, + file_id: &str, + _blob_hash: &str, + _content_type: &str, + _is_new_blob: bool, + ) { + self.dispatch_upload(file_id); + } + + fn on_file_updated(&self, file_id: &str, _blob_hash: &str, _content_type: &str) { + self.dispatch_upload(file_id); + } + + // A copy creates a new file record, but its content already existed and was + // already observed on its original upload; M0 does not re-dispatch for it. + fn on_file_copied( + &self, + _file_id: &str, + _blob_hash: &str, + _content_type: &str, + _source_file_id: &str, + ) { + } + + fn on_file_deleted(&self, _file_id: &str) {} +} 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..475fcdac --- /dev/null +++ b/src/application/ports/plugin_ports.rs @@ -0,0 +1,80 @@ +//! WASM plugin runtime ports (ABI v0 — M0 walking skeleton). +//! +//! This module is the *entire* contract surface the rest of the application +//! talks to. Concrete Extism types live in the infrastructure layer behind +//! [`PluginDispatchPort`], keeping the hexagonal boundary intact: nothing in +//! `application/` or `domain/` depends on the WASM runtime. +//! +//! The ABI is intentionally tiny (see the M0 spec): +//! - constant [`OXICLOUD_PLUGIN_ABI`] / namespace [`HOST_NAMESPACE`]; +//! - plugin exports `abi_version` + `handle`; +//! - one host import `log` (observe-only — the only authority a plugin has). + +use serde::{Deserialize, Serialize}; + +/// The single ABI version this host speaks. A breaking change bumps this and +/// the namespace suffix ([`HOST_NAMESPACE`]); plugins built against a different +/// value are rejected at load, never silently mis-run. +pub const OXICLOUD_PLUGIN_ABI: u32 = 0; + +/// Namespace of the host functions a plugin may import. The `:v0` suffix is +/// part of the import path so a future `v1` is a *different* symbol. +pub const HOST_NAMESPACE: &str = "oxicloud:host:v0"; + +/// The only event emitted in M0. +pub const EVENT_FILE_UPLOADED: &str = "file.uploaded"; + +/// Outbound port: the application asks the (infrastructure) plugin runtime to +/// dispatch an event to every subscribed plugin. Dispatch is fire-and-forget — +/// the implementation owns all isolation, timeouts, and fault handling, and the +/// caller (a `FileLifecycleHook`) never awaits it. +pub trait PluginDispatchPort: Send + Sync + 'static { + /// Dispatch a `file.uploaded` event (metadata only) to subscribed plugins. + fn dispatch_file_uploaded(&self, event: FileUploadedEvent); + + /// Cheap predicate so the bridge hook can skip the metadata lookup entirely + /// when no plugin subscribes to `event`. + fn has_subscribers(&self, event: &str) -> bool; +} + +/// Metadata describing a freshly committed file. Carries **no file contents** — +/// only path, size, and MIME (privacy goal). +#[derive(Debug, Clone)] +pub struct FileUploadedEvent { + pub path: String, + pub size: u64, + pub mime: String, + /// Opaque owner id of the file, when known. + pub user_id: Option, + /// Unique id minted per dispatch, correlating host logs with plugin output. + pub invocation_id: String, +} + +// ---- Wire DTOs (ABI v0 JSON shapes, §3.4 of the spec) ---------------------- + +/// Serialized host → plugin and handed to `handle` as a UTF-8 JSON string. +#[derive(Debug, Clone, Serialize)] +pub struct PluginInput { + pub abi: u32, + pub event: String, + pub context: PluginContext, + pub payload: serde_json::Value, +} + +/// Invocation context. `user_id` is the owner of the event; because each +/// invocation is a fresh instance, a plugin never sees two users at once. +#[derive(Debug, Clone, Serialize)] +pub struct PluginContext { + pub plugin_id: String, + pub user_id: Option, + 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..a4a4c7eb 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -936,6 +936,45 @@ impl Default for ContentSearchConfig { } } +/// WASM plugin runtime configuration (M0 walking skeleton). +/// +/// The runtime is doubly gated: it is only compiled when the `plugins` cargo +/// feature is enabled, and only activated when `enabled` is `true`. The limits +/// below are conservative starting defaults, not part of the plugin ABI — each +/// deployment may tune them. +#[derive(Debug, Clone)] +pub struct PluginConfig { + /// Master switch. When disabled, no plugins are loaded and the lifecycle + /// bridge hook is never registered. Env: `OXICLOUD_ENABLE_PLUGINS`. + pub enabled: bool, + /// Directory scanned for plugins at startup; each plugin is a subdirectory + /// containing `plugin.toml` + its `.wasm`. Default: `{storage_path}/.plugins`. + /// Env: `OXICLOUD_PLUGINS_DIR`. + pub plugins_dir: Option, + /// Wall-clock timeout for a single `handle` invocation. A runaway plugin + /// cannot stall the upload path beyond this. Default: 250. + /// Env: `OXICLOUD_PLUGIN_TIMEOUT_MS`. + pub invocation_timeout_ms: u64, + /// Max linear memory per plugin instance, in WASM pages (64 KiB each). + /// Default: 256 (≈ 16 MiB). Env: `OXICLOUD_PLUGIN_MAX_MEMORY_PAGES`. + pub max_memory_pages: u32, + /// Hard cap on the serialized event payload handed to a plugin. Default: + /// 256 KiB. Env: `OXICLOUD_PLUGIN_MAX_INPUT_BYTES`. + pub max_input_bytes: usize, +} + +impl Default for PluginConfig { + fn default() -> Self { + Self { + enabled: false, + plugins_dir: None, + invocation_timeout_ms: 250, + max_memory_pages: 256, + max_input_bytes: 256 * 1024, + } + } +} + /// Global application configuration #[derive(Debug, Clone)] pub struct AppConfig { @@ -977,6 +1016,8 @@ pub struct AppConfig { pub i18n: I18nConfig, /// Content-search configuration (embedded full-text index) pub content_search: ContentSearchConfig, + /// WASM plugin runtime configuration + pub plugins: PluginConfig, } /// Server-side i18n knobs. @@ -1029,6 +1070,7 @@ impl Default for AppConfig { magic_link: MagicLinkConfig::default(), i18n: I18nConfig::default(), content_search: ContentSearchConfig::default(), + plugins: PluginConfig::default(), } } } @@ -1318,6 +1360,33 @@ impl AppConfig { config.content_search.max_text_bytes = val; } + // WASM plugin runtime + if let Ok(v) = env::var("OXICLOUD_ENABLE_PLUGINS").map(|v| v.parse::()) + && 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(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..259a8158 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -457,13 +457,28 @@ impl AppServiceFactory { authz.clone(), )); + // Built before the upload/management services so the plugin lifecycle + // bridge (which looks file metadata up by id) can be wired into the + // dispatcher they receive. It depends only on repos + core, never on + // the upload service, so the reorder is safe. + let file_retrieval_service = Arc::new(FileRetrievalService::new_with_cache( + repos.file_read_repository.clone(), + core.file_content_cache.clone(), + core.image_transcode_service.clone(), + authz.clone(), + )); + + // Effective lifecycle dispatcher: the core hooks (thumbnails, metadata) + // plus, when the plugins feature is enabled, the WASM plugin bridge. + let file_lifecycle = self.effective_file_lifecycle(core, &file_retrieval_service); + let file_upload_service = Arc::new( FileUploadService::new_with_read( repos.file_write_repository.clone(), repos.file_read_repository.clone(), ) .with_content_cache(core.file_content_cache.clone()) - .with_file_lifecycle_hook(core.file_lifecycle.clone()) + .with_file_lifecycle_hook(file_lifecycle.clone()) .with_instant_upload( authz.clone(), core.dedup_service.clone(), @@ -485,13 +500,6 @@ impl AppServiceFactory { ), ); - let file_retrieval_service = Arc::new(FileRetrievalService::new_with_cache( - repos.file_read_repository.clone(), - core.file_content_cache.clone(), - core.image_transcode_service.clone(), - authz.clone(), - )); - // FileManagementService — ref_count handled by PG trigger, no dedup port needed let file_management_service = Arc::new( FileManagementService::with_trash( @@ -502,7 +510,7 @@ impl AppServiceFactory { Some(core.file_content_cache.clone()), authz.clone(), ) - .with_file_lifecycle_hook(core.file_lifecycle.clone()), + .with_file_lifecycle_hook(file_lifecycle.clone()), ); let file_use_case_factory = Arc::new(AppFileUseCaseFactory::new( @@ -549,6 +557,64 @@ impl AppServiceFactory { } } + /// Builds the file lifecycle dispatcher handed to the upload/management + /// services. By default this is just the core dispatcher (thumbnails, + /// metadata). When the `plugins` feature is built and enabled, it wraps the + /// core dispatcher together with the WASM plugin bridge so plugins observe + /// `file.uploaded` events without any of the core hooks being aware of them. + fn effective_file_lifecycle( + &self, + core: &CoreServices, + file_retrieval: &Arc, + ) -> Arc { + #[cfg(feature = "plugins")] + if self.config.plugins.enabled + && let Some(manager) = self.create_plugin_manager() + { + use crate::application::adapters::plugin_lifecycle_hook::PluginLifecycleHook; + use crate::application::ports::plugin_ports::PluginDispatchPort; + + let dispatch: Arc = manager; + let bridge = Arc::new(PluginLifecycleHook::new(dispatch, file_retrieval.clone())); + let composite = FileLifecycleService::new() + .with_hook(core.file_lifecycle.clone()) + .with_hook(bridge); + return Arc::new(composite); + } + + let _ = file_retrieval; + core.file_lifecycle.clone() + } + + /// Discovers and loads WASM plugins when the `plugins` feature is enabled and + /// `OXICLOUD_ENABLE_PLUGINS=true`. Plugins live under + /// `OXICLOUD_PLUGINS_DIR` (default `{storage_path}/.plugins`); a missing or + /// empty directory simply yields no plugins. + #[cfg(feature = "plugins")] + fn create_plugin_manager( + &self, + ) -> Option> { + if !self.config.plugins.enabled { + return None; + } + let dir = self + .config + .plugins + .plugins_dir + .clone() + .unwrap_or_else(|| self.config.storage_path.join(".plugins")); + let manager = crate::infrastructure::services::plugins::ExtismPluginManager::load_from_dir( + self.config.plugins.clone(), + &dir, + ); + tracing::info!( + target: "oxicloud::plugins", + loaded = manager.loaded_count(), + "plugin manager initialized" + ); + Some(Arc::new(manager)) + } + /// Creates the audio metadata service (extracts ID3 tags from audio files) pub fn create_audio_metadata_service( &self, 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/manager.rs b/src/infrastructure/services/plugins/manager.rs new file mode 100644 index 00000000..4d6e9118 --- /dev/null +++ b/src/infrastructure/services/plugins/manager.rs @@ -0,0 +1,193 @@ +//! Plugin discovery + dispatch. Implements [`PluginDispatchPort`] over the +//! Extism [`PluginRuntime`]. +//! +//! Discovery scans a directory of plugin subdirectories (each `plugin.toml` + +//! `.wasm`) at startup; a plugin that fails validation or load is audit-logged +//! and skipped, never fatal. Dispatch builds a fresh sandbox per invocation on +//! the blocking pool, so a slow or hostile plugin never stalls async workers or +//! the upload path that triggered it. + +use std::collections::HashSet; +use std::path::Path; +use std::sync::Arc; + +use serde_json::json; + +use super::manifest; +use super::runtime::{InvokeOutcome, PluginRuntime}; +use crate::application::ports::plugin_ports::{ + EVENT_FILE_UPLOADED, FileUploadedEvent, OXICLOUD_PLUGIN_ABI, PluginContext, PluginDispatchPort, + PluginInput, +}; +use crate::common::config::PluginConfig; + +/// A validated, loadable plugin held in memory. +struct LoadedPlugin { + id: String, + subscribe: HashSet, + runtime: Arc, +} + +/// Owns all loaded plugins and dispatches events to them. +pub struct ExtismPluginManager { + config: PluginConfig, + plugins: Vec, +} + +impl ExtismPluginManager { + /// Scan `dir` for plugins and build a manager from those that validate and + /// load. Returns an empty manager (logging the cause) if `dir` is absent or + /// unreadable — a missing plugins directory is normal, not an error. + pub fn load_from_dir(config: PluginConfig, dir: &Path) -> Self { + let mut plugins = Vec::new(); + let mut rejected = 0usize; + + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) => { + tracing::info!( + target: "oxicloud::plugins", + dir = %dir.display(), + error = %e, + "plugins directory not readable; no plugins loaded" + ); + return Self { config, plugins }; + } + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + match Self::load_one(&config, &path) { + Ok(loaded) => { + tracing::info!( + target: "oxicloud::plugins", + plugin_id = %loaded.id, + dir = %path.display(), + "plugin loaded" + ); + plugins.push(loaded); + } + Err(reason) => { + rejected += 1; + tracing::warn!( + target: "audit", + event = "plugin.load_rejected", + reason = reason, + plugin_dir = %path.display(), + "👮🏻‍♂️ plugin rejected at load" + ); + } + } + } + + tracing::info!( + target: "oxicloud::plugins", + loaded = plugins.len(), + rejected, + dir = %dir.display(), + "plugin discovery complete" + ); + Self { config, plugins } + } + + /// Validate and load a single plugin directory. Returns a stable audit + /// `reason` key on rejection. + fn load_one(config: &PluginConfig, dir: &Path) -> Result { + let manifest_path = dir.join("plugin.toml"); + if !manifest_path.exists() { + return Err("no_manifest"); + } + let toml_str = + std::fs::read_to_string(&manifest_path).map_err(|_| "manifest_unreadable")?; + let manifest = manifest::parse_and_validate(&toml_str).map_err(|e| e.reason())?; + + let wasm_path = dir.join(&manifest.plugin.entrypoint); + let wasm_bytes = std::fs::read(&wasm_path).map_err(|_| "wasm_unreadable")?; + + let runtime = PluginRuntime::new(manifest.plugin.id.clone(), wasm_bytes); + // Probe abi_version on a throwaway instance; rejects lying/unloadable wasm. + match runtime.check_loadable(config) { + InvokeOutcome::Ok => {} + InvokeOutcome::AbiMismatch { .. } => return Err("abi_mismatch"), + _ => return Err("not_loadable"), + } + + Ok(LoadedPlugin { + id: manifest.plugin.id, + subscribe: manifest.events.subscribe.into_iter().collect(), + runtime: Arc::new(runtime), + }) + } + + /// Number of successfully loaded plugins (used by DI for the startup summary + /// and by tests). + pub fn loaded_count(&self) -> usize { + self.plugins.len() + } +} + +impl PluginDispatchPort for ExtismPluginManager { + fn dispatch_file_uploaded(&self, event: FileUploadedEvent) { + for plugin in &self.plugins { + if !plugin.subscribe.contains(EVENT_FILE_UPLOADED) { + continue; + } + + let input = PluginInput { + abi: OXICLOUD_PLUGIN_ABI, + event: EVENT_FILE_UPLOADED.to_string(), + context: PluginContext { + plugin_id: plugin.id.clone(), + user_id: event.user_id.clone(), + invocation_id: event.invocation_id.clone(), + }, + payload: json!({ + "path": event.path, + "size": event.size, + "mime": event.mime, + }), + }; + let input_json = match serde_json::to_string(&input) { + Ok(j) => j, + Err(e) => { + tracing::warn!( + target: "oxicloud::plugins", + plugin_id = %plugin.id, + error = %e, + "failed to serialize plugin input; skipping" + ); + continue; + } + }; + + let runtime = plugin.runtime.clone(); + let config = self.config.clone(); + let plugin_id = plugin.id.clone(); + let invocation_id = event.invocation_id.clone(); + + // Run the synchronous wasm call off the async workers. Fire-and-forget: + // the upload already succeeded; plugins are post-hoc observers. + tokio::task::spawn_blocking(move || { + let result = runtime.invoke(&config, &invocation_id, &input_json); + if !result.outcome.is_ok() { + tracing::warn!( + target: "audit", + event = "plugin.invocation_failed", + reason = result.outcome.reason(), + plugin_id = %plugin_id, + invocation_id = %invocation_id, + detail = ?result.outcome, + "👮🏻‍♂️ plugin invocation failed" + ); + } + }); + } + } + + fn has_subscribers(&self, event: &str) -> bool { + self.plugins.iter().any(|p| p.subscribe.contains(event)) + } +} diff --git a/src/infrastructure/services/plugins/manifest.rs b/src/infrastructure/services/plugins/manifest.rs new file mode 100644 index 00000000..9f4b4456 --- /dev/null +++ b/src/infrastructure/services/plugins/manifest.rs @@ -0,0 +1,103 @@ +//! `plugin.toml` parsing + load-time validation (ABI v0). +//! +//! The manifest is the host's source of truth for *what to load and when to +//! call it*. Validation fails closed: unknown sections/keys, a mismatched ABI, +//! an unknown subscribed event, or any non-empty `[permissions]` (M0 grants +//! none) all reject the plugin. A rejected plugin is skipped, never fatal. + +use std::collections::BTreeMap; + +use crate::application::ports::plugin_ports::{EVENT_FILE_UPLOADED, OXICLOUD_PLUGIN_ABI}; + +/// Parsed `plugin.toml`. `#[serde(deny_unknown_fields)]` on every struct turns +/// stray keys into load errors rather than silently ignored config. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginManifest { + pub plugin: PluginSection, + pub events: EventsSection, + /// M0: must be empty. Any key here rejects the plugin (no grantable + /// permissions exist yet). Kept as a free map so future keys are *detected*, + /// not parsed. + #[serde(default)] + pub permissions: BTreeMap, +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginSection { + /// Reverse-DNS, unique per instance. + pub id: String, + pub name: String, + /// The plugin's own semver. + pub version: String, + /// Must equal [`OXICLOUD_PLUGIN_ABI`]. + pub abi: u32, + /// Path to the `.wasm`, relative to the manifest. + pub entrypoint: String, +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EventsSection { + /// Events this plugin wants. M0 accepts only `"file.uploaded"`. + pub subscribe: Vec, +} + +/// 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 event != EVENT_FILE_UPLOADED { + return Err(ManifestError::UnknownEvent(event.clone())); + } + } + + if let Some((key, _)) = manifest.permissions.iter().next() { + return Err(ManifestError::PermissionsNotEmpty(key.clone())); + } + + Ok(manifest) +} diff --git a/src/infrastructure/services/plugins/mod.rs b/src/infrastructure/services/plugins/mod.rs new file mode 100644 index 00000000..76a2dc24 --- /dev/null +++ b/src/infrastructure/services/plugins/mod.rs @@ -0,0 +1,15 @@ +//! WASM plugin runtime (Extism) — M0 walking skeleton. +//! +//! Compiled only under the `plugins` cargo feature. The application layer talks +//! to [`manager::ExtismPluginManager`] through the +//! [`crate::application::ports::plugin_ports::PluginDispatchPort`] trait, so the +//! Extism types here never leak past the infrastructure boundary. + +pub mod manager; +pub mod manifest; +pub mod runtime; + +pub use manager::ExtismPluginManager; + +#[cfg(test)] +mod runtime_test; diff --git a/src/infrastructure/services/plugins/runtime.rs b/src/infrastructure/services/plugins/runtime.rs new file mode 100644 index 00000000..09e526ef --- /dev/null +++ b/src/infrastructure/services/plugins/runtime.rs @@ -0,0 +1,230 @@ +//! The Extism runtime wrapper — one sandboxed, per-invocation WASM instance. +//! +//! Isolation is the point: no WASI, no filesystem, no network, a memory cap, and +//! a wall-clock timeout. The only authority a plugin has is the host `log` +//! function. Every boundary crossing is wrapped so a trap/timeout/OOM/malformed +//! output is captured as an [`InvokeOutcome`] and never propagates to the caller. + +use std::time::Duration; + +use extism::{Manifest as ExtismManifest, PTR, PluginBuilder, UserData, Wasm}; + +use crate::application::ports::plugin_ports::{HOST_NAMESPACE, OXICLOUD_PLUGIN_ABI, PluginOutput}; +use crate::common::config::PluginConfig; + +/// Per-invocation host state: the plugin's identity (for log attribution) plus +/// the buffer the `log` host function appends to. Shared with the running +/// instance via [`UserData`]; read back after the call via [`drain`]. +#[derive(Default)] +pub struct LogContext { + pub plugin_id: String, + pub invocation_id: String, + pub lines: Vec<(String, String)>, +} + +// The entire authority surface: log(level, message) -> (). Observe-only — it +// reads nothing and mutates no host state. Unknown levels clamp to "info". +extism::host_fn!(oxi_log(user_data: LogContext; level: String, message: String) { + let level = match level.as_str() { + "debug" | "info" | "warn" | "error" => level, + _ => "info".to_string(), + }; + let ud = user_data.get()?; + let mut ctx = ud.lock().unwrap(); + tracing::info!( + target: "oxicloud::plugins", + plugin_id = %ctx.plugin_id, + invocation_id = %ctx.invocation_id, + plugin_level = %level, + "plugin log: {message}" + ); + ctx.lines.push((level, message)); + Ok(()) +}); + +/// The result of one boundary crossing. Only `Ok` is a success; every other +/// variant is a contained failure the host audit-logs and moves past. +#[derive(Debug)] +pub enum InvokeOutcome { + /// `handle` returned `{"ok": true}`. + Ok, + /// `handle` returned `{"ok": false, "error": ...}`. + PluginError(String), + /// A wasm trap (panic/`unreachable`/OOM/etc.). + Trap(String), + /// The wall-clock timeout cancelled the call. + Timeout, + /// The instance could not be built (bad/unloadable wasm, unresolved import). + LoadError(String), + /// `abi_version` returned a value the host does not speak. + AbiMismatch { got: u32 }, + /// `handle` returned bytes that are not a valid `PluginOutput`. + MalformedOutput(String), + /// The serialized input exceeded the configured cap; nothing was invoked. + MalformedInput { size: usize, max: usize }, +} + +impl InvokeOutcome { + pub fn is_ok(&self) -> bool { + matches!(self, InvokeOutcome::Ok) + } + + /// Stable, machine-readable key for audit logs. + pub fn reason(&self) -> &'static str { + match self { + InvokeOutcome::Ok => "ok", + InvokeOutcome::PluginError(_) => "plugin_error", + InvokeOutcome::Trap(_) => "trap", + InvokeOutcome::Timeout => "timeout", + InvokeOutcome::LoadError(_) => "load_error", + InvokeOutcome::AbiMismatch { .. } => "abi_mismatch", + InvokeOutcome::MalformedOutput(_) => "malformed_output", + InvokeOutcome::MalformedInput { .. } => "malformed_input", + } + } +} + +/// Outcome plus whatever the plugin logged (for tests and tracing). +pub struct InvokeResult { + pub outcome: InvokeOutcome, + pub logs: Vec<(String, String)>, +} + +/// A loaded-but-not-instantiated plugin: the wasm bytes plus identity. A fresh +/// instance is built for every invocation (no reuse → no cross-user state). +pub struct PluginRuntime { + plugin_id: String, + wasm_bytes: Vec, +} + +impl PluginRuntime { + pub fn new(plugin_id: impl Into, wasm_bytes: Vec) -> Self { + Self { + plugin_id: plugin_id.into(), + wasm_bytes, + } + } + + pub fn plugin_id(&self) -> &str { + &self.plugin_id + } + + /// Build a fresh, fully locked-down instance for one invocation. + fn build( + &self, + cfg: &PluginConfig, + logs: UserData, + ) -> 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], [], logs, oxi_log) + .build() + } + + /// Probe `abi_version` on a throwaway instance. Used at load time so a lying + /// or unloadable plugin is rejected before it is ever registered. + pub fn check_loadable(&self, cfg: &PluginConfig) -> InvokeOutcome { + let logs = UserData::new(LogContext::default()); + let mut plugin = match self.build(cfg, logs) { + Ok(p) => p, + Err(e) => return InvokeOutcome::LoadError(e.to_string()), + }; + match plugin.call::<(), u32>("abi_version", ()) { + Ok(v) if v == OXICLOUD_PLUGIN_ABI => InvokeOutcome::Ok, + Ok(v) => InvokeOutcome::AbiMismatch { got: v }, + Err(e) => classify_call_error(e), + } + } + + /// Run one `handle` invocation, fully fault-isolated. + pub fn invoke( + &self, + cfg: &PluginConfig, + invocation_id: &str, + input_json: &str, + ) -> InvokeResult { + if input_json.len() > cfg.max_input_bytes { + return InvokeResult { + outcome: InvokeOutcome::MalformedInput { + size: input_json.len(), + max: cfg.max_input_bytes, + }, + logs: Vec::new(), + }; + } + + let logs = UserData::new(LogContext { + plugin_id: self.plugin_id.clone(), + invocation_id: invocation_id.to_string(), + lines: Vec::new(), + }); + + let mut plugin = match self.build(cfg, logs.clone()) { + Ok(p) => p, + Err(e) => { + return InvokeResult { + outcome: InvokeOutcome::LoadError(e.to_string()), + logs: drain(&logs), + }; + } + }; + + // Version negotiation at the door. + match plugin.call::<(), u32>("abi_version", ()) { + Ok(v) if v == OXICLOUD_PLUGIN_ABI => {} + Ok(v) => { + return InvokeResult { + outcome: InvokeOutcome::AbiMismatch { got: v }, + logs: drain(&logs), + }; + } + Err(e) => { + return InvokeResult { + outcome: classify_call_error(e), + logs: drain(&logs), + }; + } + } + + // The actual call. Traps, timeouts, and OOM all surface here as Err. + let outcome = match plugin.call::<&str, String>("handle", input_json) { + Ok(out) => match serde_json::from_str::(&out) { + Ok(parsed) if parsed.ok => InvokeOutcome::Ok, + Ok(parsed) => { + InvokeOutcome::PluginError(parsed.error.unwrap_or_else(|| "unspecified".into())) + } + Err(e) => InvokeOutcome::MalformedOutput(e.to_string()), + }, + Err(e) => classify_call_error(e), + }; + + InvokeResult { + outcome, + logs: drain(&logs), + } + // `plugin` dropped here -> sandbox memory reclaimed. + } +} + +/// Extism signals a wall-clock timeout with `Error::msg("timeout")`; everything +/// else from a `call` is a trap (panic, `unreachable`, OOM, etc.). +fn classify_call_error(e: extism::Error) -> InvokeOutcome { + let msg = e.to_string(); + if msg.to_ascii_lowercase().contains("timeout") { + InvokeOutcome::Timeout + } else { + InvokeOutcome::Trap(msg) + } +} + +fn drain(logs: &UserData) -> Vec<(String, String)> { + logs.get() + .ok() + .map(|m| m.lock().unwrap().lines.clone()) + .unwrap_or_default() +} diff --git a/src/infrastructure/services/plugins/runtime_test.rs b/src/infrastructure/services/plugins/runtime_test.rs new file mode 100644 index 00000000..c377aa1e --- /dev/null +++ b/src/infrastructure/services/plugins/runtime_test.rs @@ -0,0 +1,219 @@ +//! Plugin-runtime acceptance + failure-isolation tests, plus manifest-validation +//! unit tests. +//! +//! The `.wasm` fixtures are built and committed by `scripts/build-plugin-hello.sh` +//! from `wasm/oxicloud-plugin-hello/`. Run with `cargo test --features plugins`. + +use std::time::{Duration, Instant}; + +use super::ExtismPluginManager; +use super::manifest; +use super::runtime::{InvokeOutcome, PluginRuntime}; +use crate::common::config::PluginConfig; + +fn cfg() -> PluginConfig { + PluginConfig::default() +} + +/// Load a committed `.wasm` fixture, failing with a build hint if it's missing. +fn fixture(name: &str) -> Vec { + let path = format!( + "{}/tests/fixtures/plugins/{}", + env!("CARGO_MANIFEST_DIR"), + name + ); + std::fs::read(&path).unwrap_or_else(|e| { + panic!("missing fixture {path}: {e}\n run scripts/build-plugin-hello.sh to (re)build it") + }) +} + +fn sample_input() -> String { + serde_json::json!({ + "abi": 0, + "event": "file.uploaded", + "context": { + "plugin_id": "com.example.hello", + "user_id": "u_test", + "invocation_id": "inv_test_0001" + }, + "payload": { "path": "/photos/2026/cat.jpg", "size": 81234, "mime": "image/jpeg" } + }) + .to_string() +} + +// ---- The M0 exit criterion: the full loop ----------------------------------- + +#[test] +fn acceptance_hello_returns_ok_and_calls_host_log() { + let rt = PluginRuntime::new("com.example.hello", fixture("hello.wasm")); + let result = rt.invoke(&cfg(), "inv_test_0001", &sample_input()); + + // 1. handle returned a well-formed PluginOutput with ok = true. + assert!( + result.outcome.is_ok(), + "plugin did not complete: {:?}", + result.outcome + ); + + // 2. The plugin called the host `log` function (plugin -> host). + assert!( + result.logs.iter().any(|(level, msg)| level == "info" + && msg.contains("hello plugin saw upload: /photos/2026/cat.jpg")), + "expected the plugin's host log line, got: {:?}", + result.logs + ); +} + +// ---- The guarantees, not just the happy path -------------------------------- + +#[test] +fn rejects_wrong_abi() { + let rt = PluginRuntime::new("com.example.wrong-abi", fixture("wrong_abi.wasm")); + assert!( + matches!( + rt.check_loadable(&cfg()), + InvokeOutcome::AbiMismatch { got: 1 } + ), + "wrong-abi plugin should be rejected at load" + ); +} + +#[test] +fn contains_a_panicking_plugin() { + let rt = PluginRuntime::new("com.example.panic", fixture("panic.wasm")); + let result = rt.invoke(&cfg(), "inv", &sample_input()); + assert!( + matches!(result.outcome, InvokeOutcome::Trap(_)), + "expected a contained trap, got {:?}", + result.outcome + ); + // Reaching this line at all proves the host process survived the trap. +} + +#[test] +fn enforces_timeout() { + let rt = PluginRuntime::new("com.example.sleep", fixture("sleep.wasm")); + let start = Instant::now(); + let result = rt.invoke(&cfg(), "inv", &sample_input()); + let elapsed = start.elapsed(); + + assert!( + matches!(result.outcome, InvokeOutcome::Timeout), + "expected a timeout, got {:?}", + result.outcome + ); + assert!( + elapsed < Duration::from_secs(2), + "timeout took too long to fire: {elapsed:?}" + ); +} + +#[test] +fn no_network() { + let rt = PluginRuntime::new("com.example.net", fixture("net.wasm")); + let result = rt.invoke(&cfg(), "inv", &sample_input()); + // No allowed_hosts are granted, so the outbound call is denied and the + // plugin cannot complete successfully. + assert!( + !result.outcome.is_ok(), + "network access should be denied, got {:?}", + result.outcome + ); +} + +#[tokio::test] +async fn manager_loads_and_dispatches() { + use crate::application::ports::plugin_ports::{FileUploadedEvent, PluginDispatchPort}; + + let tmp = tempfile::tempdir().unwrap(); + let plugin_dir = tmp.path().join("hello"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::write(plugin_dir.join("hello.wasm"), fixture("hello.wasm")).unwrap(); + std::fs::write( + plugin_dir.join("plugin.toml"), + r#" +[plugin] +id = "com.example.hello" +name = "Hello" +version = "0.1.0" +abi = 0 +entrypoint = "hello.wasm" + +[events] +subscribe = ["file.uploaded"] +"#, + ) + .unwrap(); + + let manager = ExtismPluginManager::load_from_dir(cfg(), tmp.path()); + assert_eq!(manager.loaded_count(), 1, "the valid plugin should load"); + assert!(manager.has_subscribers("file.uploaded")); + assert!(!manager.has_subscribers("file.deleted")); + + // Dispatch runs the plugin on the blocking pool; it must not panic or block. + manager.dispatch_file_uploaded(FileUploadedEvent { + path: "/a.txt".into(), + size: 3, + mime: "text/plain".into(), + user_id: Some("u_test".into()), + invocation_id: "inv_dispatch".into(), + }); + // Give the spawned task time to complete before the test runtime shuts down. + tokio::time::sleep(Duration::from_millis(300)).await; +} + +// ---- Manifest validation (no wasm needed) ----------------------------------- + +const VALID_MANIFEST: &str = r#" +[plugin] +id = "com.example.hello" +name = "Hello" +version = "0.1.0" +abi = 0 +entrypoint = "hello.wasm" + +[events] +subscribe = ["file.uploaded"] +"#; + +#[test] +fn manifest_accepts_valid() { + let m = manifest::parse_and_validate(VALID_MANIFEST).expect("valid manifest"); + assert_eq!(m.plugin.id, "com.example.hello"); +} + +#[test] +fn manifest_rejects_unknown_field() { + let toml = format!("{VALID_MANIFEST}\nbogus_top_level = true\n"); + assert_eq!( + manifest::parse_and_validate(&toml).unwrap_err().reason(), + "parse_error" + ); +} + +#[test] +fn manifest_rejects_abi_mismatch() { + let toml = VALID_MANIFEST.replace("abi = 0", "abi = 1"); + assert_eq!( + manifest::parse_and_validate(&toml).unwrap_err().reason(), + "abi_mismatch" + ); +} + +#[test] +fn manifest_rejects_unknown_event() { + let toml = VALID_MANIFEST.replace(r#"["file.uploaded"]"#, r#"["file.deleted"]"#); + assert_eq!( + manifest::parse_and_validate(&toml).unwrap_err().reason(), + "unknown_event" + ); +} + +#[test] +fn manifest_rejects_nonempty_permissions() { + let toml = format!("{VALID_MANIFEST}\n[permissions]\nfs = \"/tmp\"\n"); + assert_eq!( + manifest::parse_and_validate(&toml).unwrap_err().reason(), + "permissions_not_empty" + ); +} diff --git a/tests/fixtures/plugins/hello.wasm b/tests/fixtures/plugins/hello.wasm new file mode 100755 index 00000000..8502b66d 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..c1f832a5 Binary files /dev/null and b/tests/fixtures/plugins/net.wasm differ diff --git a/tests/fixtures/plugins/panic.wasm b/tests/fixtures/plugins/panic.wasm new file mode 100755 index 00000000..a220960e 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..18c4ef8e 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..c9b163f6 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..0ad59401 --- /dev/null +++ b/wasm/oxicloud-plugin-hello/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "oxicloud-plugin-hello" +version = "0.1.0" +edition = "2021" +description = "Example OxiCloud plugin (ABI v0) — the artifact the plugin-runtime acceptance test loads. Cargo features build the misbehaving variants used by the failure-isolation tests." +publish = false + +# Standalone workspace root: built only by scripts/build-plugin-hello.sh on the +# wasm32 target, never by the server's `cargo test --workspace` / clippy. +[workspace] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +extism-pdk = "1" +serde_json = "1" + +[features] +default = [] +# Misbehaving variants for the failure-isolation tests. Each builds a separate +# fixture under tests/fixtures/plugins/ via the build script. +panic = [] # `handle` panics -> host must contain the trap +sleep = [] # `handle` busy-loops past the timeout +net = [] # `handle` attempts an outbound HTTP call (denied: no allowed_hosts) +wrong_abi = [] # `abi_version` returns 1 -> host must reject at load + +[profile.release] +opt-level = "s" +lto = true +strip = true diff --git a/wasm/oxicloud-plugin-hello/src/lib.rs b/wasm/oxicloud-plugin-hello/src/lib.rs new file mode 100644 index 00000000..3e9c2983 --- /dev/null +++ b/wasm/oxicloud-plugin-hello/src/lib.rs @@ -0,0 +1,75 @@ +//! Example OxiCloud plugin — ABI v0 (M0 walking skeleton). +//! +//! The default build is the well-behaved "hello" plugin: it reads the +//! `file.uploaded` event metadata, calls the host `log` function (the only +//! authority a plugin has), and returns `{"ok": true}`. +//! +//! Cargo features select the misbehaving variants the host's failure-isolation +//! tests load (`panic`, `sleep`, `net`, `wrong_abi`). See +//! `scripts/build-plugin-hello.sh`. + +use extism_pdk::*; + +/// The one host function OxiCloud exposes, imported from its namespaced module. +#[host_fn("oxicloud:host:v0")] +extern "ExtismHost" { + fn log(level: String, message: String); +} + +/// Required export: which ABI this plugin was built against. The host rejects +/// the plugin at load if this does not equal its own `OXICLOUD_PLUGIN_ABI`. +#[cfg(not(feature = "wrong_abi"))] +#[plugin_fn] +pub fn abi_version() -> FnResult { + 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) +} + +/// Required export: the single event entry point. +#[plugin_fn] +pub fn handle(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 `handle`. + let req = HttpRequest::new("https://example.com/"); + let _ = http::request::<()>(&req, None)?; + } + + // --- well-behaved "hello" path ------------------------------------------ + let ev: serde_json::Value = serde_json::from_str(&input)?; + let path = ev["payload"]["path"].as_str().unwrap_or(""); + let size = ev["payload"]["size"].as_u64().unwrap_or(0); + + // Prove plugin -> host: call the only authority we have. + unsafe { + log( + "info".to_string(), + format!("hello plugin saw upload: {path} ({size} bytes)"), + )?; + } + + // Prove plugin -> host return path. + Ok(serde_json::json!({ "ok": true }).to_string()) +}