diff --git a/.gitignore b/.gitignore index 88099657..18e0d184 100644 --- a/.gitignore +++ b/.gitignore @@ -277,3 +277,6 @@ tmp/ plugin-catalog # Hugo build output docs/public/ +crates/cpex-wasm-host/wasm/*.wasm +crates/cpex-wasm-plugin/plugin.wasm +crates/cpex-wasm-plugin/Cargo.lock diff --git a/Cargo.lock b/Cargo.lock index 9a49b7d6..31ea6e41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +dependencies = [ + "gimli", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -17,6 +26,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" @@ -26,6 +41,18 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "antlr4rust" version = "0.5.2" @@ -45,9 +72,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "apl-cmf" @@ -71,7 +98,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", ] @@ -88,7 +115,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2 0.10.9", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", ] @@ -99,9 +126,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" dependencies = [ - "object", + "object 0.37.3", ] +[[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.2" @@ -194,18 +227,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.2", ] [[package]] @@ -222,9 +255,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -233,14 +266,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -344,7 +378,7 @@ dependencies = [ "pkcs8 0.9.0", "prost 0.10.4", "prost-types 0.10.1", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "regex", "serde_json", @@ -398,9 +432,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" @@ -447,7 +481,7 @@ dependencies = [ "log", "num", "pin-project-lite", - "rand 0.9.4", + "rand 0.9.5", "rustls", "rustls-native-certs", "rustls-pemfile", @@ -457,7 +491,7 @@ dependencies = [ "serde_json", "serde_repr", "serde_urlencoded", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-stream", "tokio-util", @@ -499,9 +533,9 @@ dependencies = [ [[package]] name = "borsh" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" dependencies = [ "bytes", "cfg_aliases", @@ -521,6 +555,9 @@ name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] [[package]] name = "byteorder" @@ -530,15 +567,89 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[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-net-ext" +version = "3.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "20a158160765c6a7d0d8c072a53d772e4cb243f38b04bfcf6b4939cfbe7482e7" +dependencies = [ + "cap-primitives", + "cap-std", + "rustix", + "smallvec", +] + +[[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", + "rustix-linux-procfs", + "windows-sys 0.59.0", + "winx", +] + +[[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", +] + +[[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", + "winx", +] + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.65" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" dependencies = [ "find-msvc-tools", "jobserver", @@ -563,7 +674,7 @@ dependencies = [ "serde_json", "serde_with", "smol_str", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -590,7 +701,7 @@ dependencies = [ "serde_with", "smol_str", "stacker", - "thiserror 2.0.18", + "thiserror 2.0.19", "unicode-security", ] @@ -633,9 +744,20 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] [[package]] name = "chrono" @@ -657,6 +779,58 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fb99565819980999fb7b4a1796046a5c949e6d4ff132cf5fadf5a641e20d776" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.58" @@ -666,6 +840,15 @@ dependencies = [ "cc", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.19", +] + [[package]] name = "colored" version = "3.1.1" @@ -761,7 +944,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-util", "tracing", @@ -818,7 +1001,7 @@ dependencies = [ "serde_json", "serde_yaml", "stacker", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", ] @@ -833,7 +1016,7 @@ dependencies = [ "cel", "cpex-core", "serde_yaml", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", ] @@ -907,7 +1090,7 @@ dependencies = [ "futures", "jsonwebtoken", "mockito", - "rand 0.8.6", + "rand 0.8.7", "reqwest", "rsa", "serde", @@ -963,7 +1146,7 @@ dependencies = [ "sha2 0.10.9", "testcontainers", "testcontainers-modules", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", "url", @@ -984,202 +1167,485 @@ dependencies = [ ] [[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +name = "cpex-wasm-host" +version = "0.1.0" dependencies = [ - "libc", + "anyhow", + "async-trait", + "chrono", + "cpex-core", + "criterion", + "hyper", + "serde", + "serde_json", + "serde_yaml", + "tokio", + "tracing", + "tracing-subscriber", + "wasmtime", + "wasmtime-wasi", + "wasmtime-wasi-http", ] [[package]] -name = "crossbeam-utils" -version = "0.8.21" +name = "cpp_demangle" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] [[package]] -name = "crypto-bigint" -version = "0.5.5" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", + "libc", ] [[package]] -name = "crypto-common" -version = "0.1.6" +name = "cpufeatures" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ - "generic-array", - "typenum", + "libc", ] [[package]] -name = "curve25519-dalek" -version = "4.1.3" +name = "cranelift-assembler-x64" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +checksum = "3d521bdbc6098937af83ef4ab6d5c07398126bc71878f7ef4ea9499977978ef5" dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "digest 0.10.7", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", + "cranelift-assembler-x64-meta", ] [[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" +name = "cranelift-assembler-x64-meta" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +checksum = "3dde0b83164d4a497860af4236271178bc640512b067101e0853e4f74eaa4df5" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", + "cranelift-srcgen", ] [[package]] -name = "darling" -version = "0.23.0" +name = "cranelift-bforest" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "0111d110b72b4efad69a372e29e21628652fd0bcab66967e5c8350ab679affd5" dependencies = [ - "darling_core", - "darling_macro", + "cranelift-entity", + "wasmtime-internal-core", ] [[package]] -name = "darling_core" -version = "0.23.0" +name = "cranelift-bitset" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "cf01ecc92fc5499789d79c3b817299d5a8ddd828d31dcf0f9cc3cc66f38dbb36" dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.118", + "serde", + "serde_derive", + "wasmtime-internal-core", ] [[package]] -name = "darling_macro" -version = "0.23.0" +name = "cranelift-codegen" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "6cd2563bead0090c3879a7ff7327f7550c9d59bb5d05ecc8cd7886977aa3125a" dependencies = [ - "darling_core", - "quote", - "syn 2.0.118", + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.17.1", + "libm", + "log", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "smallvec", + "target-lexicon", + "wasmtime-internal-core", ] [[package]] -name = "deadpool" -version = "0.13.0" +name = "cranelift-codegen-meta" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883466cb8db62725aee5f4a6011e8a5d42912b42632df32aad57fc91127c6e04" +checksum = "9640d250d26f9381a73dc7f9862b27928d4809119aec73be0e556612e67b6a99" dependencies = [ - "deadpool-runtime", - "num_cpus", - "tokio", + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", ] [[package]] -name = "deadpool-redis" -version = "0.23.0" +name = "cranelift-codegen-shared" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bafa30c49dafe086d10116074e422ad7fc1c3cf554697e744a3ab112599ebd09" -dependencies = [ - "deadpool", - "redis", -] +checksum = "a07f156b90efc94371ddb3536f76e4ab671ad2e093bfa5511e10198cadbb0c47" [[package]] -name = "deadpool-runtime" -version = "0.3.1" +name = "cranelift-control" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae" +checksum = "d75a76fd9dd37dcbc3d2d2e00abe3281a7e88adc035fba3b8114cc69981576ec" dependencies = [ - "tokio", + "arbitrary", ] [[package]] -name = "der" -version = "0.6.1" +name = "cranelift-entity" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +checksum = "2eea22522144ba08c7e7ef94bfc25d474ef016c2771974b8ab1986734ac85965" dependencies = [ - "const-oid", + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", ] [[package]] -name = "der" -version = "0.7.10" +name = "cranelift-frontend" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +checksum = "efa2826c80dff1d93b19b3cfbcaf9fae44c78d739a98d146dd5bd89c51e14367" dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", ] [[package]] -name = "deranged" -version = "0.5.8" +name = "cranelift-isle" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "serde_core", -] +checksum = "e238a69b95c5415456f22189494a12db94f313bb72b6ec9cc88ddf2f1056e28e" [[package]] -name = "digest" -version = "0.9.0" +name = "cranelift-native" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +checksum = "eceb0ebd8d6aef6bb287e8d532a164b0db1c0062961211e9c22a91f67521c098" dependencies = [ - "generic-array", + "cranelift-codegen", + "libc", + "target-lexicon", ] [[package]] -name = "digest" -version = "0.10.7" +name = "cranelift-srcgen" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "const-oid", - "crypto-common", - "subtle", -] +checksum = "004643f39a7bec553de5263d650db30e5b9caec1d5cbe065fd732b7ec9ae40d0" [[package]] -name = "displaydoc" -version = "0.2.6" +name = "crc32fast" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", + "cfg-if", ] [[package]] -name = "docker_credential" -version = "1.4.0" +name = "criterion" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" dependencies = [ - "base64 0.22.1", - "serde", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "futures", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "tokio", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deadpool" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883466cb8db62725aee5f4a6011e8a5d42912b42632df32aad57fc91127c6e04" +dependencies = [ + "deadpool-runtime", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-redis" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bafa30c49dafe086d10116074e422ad7fc1c3cf554697e744a3ab112599ebd09" +dependencies = [ + "deadpool", + "redis", +] + +[[package]] +name = "deadpool-runtime" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae" +dependencies = [ + "tokio", +] + +[[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.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid", + "crypto-common", + "subtle", +] + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "docker_credential" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" +dependencies = [ + "base64 0.22.1", + "serde", "serde_json", ] @@ -1244,7 +1710,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1274,6 +1740,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "ena" version = "0.14.4" @@ -1283,6 +1761,15 @@ dependencies = [ "log", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enum-ordinalize" version = "4.4.1" @@ -1300,7 +1787,7 @@ checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1352,9 +1839,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "ferroid" @@ -1363,7 +1850,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb330bbd4cb7a5b9f559427f06f98a4f853a137c8298f3bd3f8ca57663e21986" dependencies = [ "portable-atomic", - "rand 0.9.4", + "rand 0.9.5", "web-time", ] @@ -1401,7 +1888,13 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fixedbitset" -version = "0.5.7" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fixedbitset" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" @@ -1426,6 +1919,17 @@ dependencies = [ "percent-encoding", ] +[[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", + "windows-sys 0.59.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1434,9 +1938,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -1449,9 +1953,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -1459,15 +1963,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -1476,38 +1980,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -1520,6 +2024,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.9" @@ -1551,11 +2069,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -1565,8 +2081,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[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 2.14.0", + "stable_deref_trait", ] [[package]] @@ -1599,12 +2130,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "hashbrown" version = "0.17.1" @@ -1614,6 +2162,8 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash", + "serde", + "serde_core", ] [[package]] @@ -1664,9 +2214,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1674,9 +2224,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -1699,9 +2249,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -1721,9 +2271,9 @@ dependencies = [ [[package]] name = "hyper-named-pipe" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" dependencies = [ "hex", "hyper", @@ -1731,7 +2281,6 @@ dependencies = [ "pin-project-lite", "tokio", "tower-service", - "winapi", ] [[package]] @@ -1747,7 +2296,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.9", ] [[package]] @@ -1907,6 +2456,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1957,12 +2512,39 @@ dependencies = [ "serde_core", ] +[[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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "itertools" version = "0.10.5" @@ -1987,13 +2569,33 @@ 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" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] @@ -2032,7 +2634,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -2046,7 +2648,7 @@ dependencies = [ "ena", "itertools 0.14.0", "lalrpop-util", - "petgraph", + "petgraph 0.7.1", "pico-args", "regex", "regex-syntax", @@ -2076,11 +2678,23 @@ dependencies = [ "spin", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.186" +version = "0.2.188" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b" [[package]] name = "libm" @@ -2088,6 +2702,15 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -2153,7 +2776,7 @@ dependencies = [ "quote", "regex-automata", "regex-syntax", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2171,17 +2794,50 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" 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 = "memchr" -version = "2.8.2" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memfd" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix", +] [[package]] name = "miette" @@ -2203,7 +2859,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2220,9 +2876,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -2246,7 +2902,7 @@ dependencies = [ "hyper-util", "log", "pin-project-lite", - "rand 0.9.4", + "rand 0.9.5", "regex", "serde_json", "serde_urlencoded", @@ -2288,6 +2944,15 @@ dependencies = [ "serde", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num" version = "0.4.3" @@ -2304,9 +2969,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -2323,7 +2988,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.6", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -2354,11 +3019,10 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -2403,12 +3067,30 @@ dependencies = [ "memchr", ] +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "crc32fast", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -2484,7 +3166,7 @@ dependencies = [ "regex", "regex-syntax", "structmeta", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2518,13 +3200,23 @@ 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 0.4.2", + "indexmap 2.14.0", +] + [[package]] name = "petgraph" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ - "fixedbitset", + "fixedbitset 0.5.7", "indexmap 2.14.0", ] @@ -2560,7 +3252,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2600,11 +3292,57 @@ dependencies = [ "spki 0.7.3", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "postcard" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] [[package]] name = "potential_utf" @@ -2675,14 +3413,14 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -2730,7 +3468,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2762,6 +3500,29 @@ dependencies = [ "cc", ] +[[package]] +name = "pulley-interpreter" +version = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04adf8f93264685f2c70a3edb8f828c791e71b22659253319c33f29d1165aef" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c120a6af1a574a42efcd9df2ad27cb78bc04118c5e0c69e90599f17301a1c7a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pyo3" version = "0.29.0" @@ -2818,7 +3579,7 @@ dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2830,7 +3591,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2847,7 +3608,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", "web-time", @@ -2855,20 +3616,21 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.19", "tinyvec", "tracing", "web-time", @@ -2876,23 +3638,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2911,9 +3673,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -2922,14 +3684,25 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2968,11 +3741,46 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redis" -version = "1.3.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fa6f8e4b491d7a8ef3a9550a4d71969bd0064f46e32b8dbbcc7fc60dad94fed" +checksum = "b0b9503711b03773e43b31668c7b5bd279ee7cd9b7d18cff7c23a42cc1d08e5a" dependencies = [ "arc-swap", "arcstr", @@ -3006,43 +3814,68 @@ 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 = "ref-cast" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.2", ] [[package]] -name = "regex" -version = "1.12.4" +name = "regalloc2" +version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85" dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", + "allocator-api2", + "bumpalo", + "hashbrown 0.17.1", + "log", + "rustc-hash", + "smallvec", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -3090,7 +3923,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.9", ] [[package]] @@ -3156,11 +3989,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc-literal-escaper" @@ -3190,11 +4029,21 @@ dependencies = [ "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", +] + [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "log", "once_cell", @@ -3249,9 +4098,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -3350,12 +4199,16 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3373,29 +4226,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.2", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "indexmap 2.14.0", "itoa", @@ -3407,13 +4260,22 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.2", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", ] [[package]] @@ -3457,7 +4319,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3491,7 +4353,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.9.0", "opaque-debug", ] @@ -3503,7 +4365,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -3517,6 +4379,15 @@ dependencies = [ "keccak", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3557,7 +4428,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.18", + "thiserror 2.0.19", "time", ] @@ -3578,6 +4449,9 @@ name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] [[package]] name = "smol_str" @@ -3591,9 +4465,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3601,9 +4475,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" [[package]] name = "spki" @@ -3670,7 +4544,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta-derive", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3681,7 +4555,7 @@ checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3703,9 +4577,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" dependencies = [ "proc-macro2", "quote", @@ -3729,7 +4614,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3738,6 +4623,19 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "term" version = "1.2.1" @@ -3747,6 +4645,15 @@ dependencies = [ "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 = "testcontainers" version = "0.26.3" @@ -3770,7 +4677,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-stream", "tokio-util", @@ -3797,11 +4704,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.19", ] [[package]] @@ -3812,25 +4719,34 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.2", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", ] [[package]] name = "time" -version = "0.3.53" +version = "0.3.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" dependencies = [ "deranged", "num-conv", @@ -3848,9 +4764,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.31" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -3878,11 +4794,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -3895,9 +4821,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -3912,13 +4838,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3944,18 +4870,58 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", "futures-util", + "libc", "pin-project-lite", "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 2.14.0", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[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_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.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tonic" version = "0.14.6" @@ -4064,7 +5030,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4074,6 +5040,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -4177,147 +5173,579 @@ dependencies = [ ] [[package]] -name = "ureq-proto" -version = "0.6.0" +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", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-compose" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96ba953e2b9b4b4b52a31cf4e3ee1c1374c872b6e012cf2138d1c37cba00bfd6" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "log", + "petgraph 0.6.5", + "smallvec", + "wasm-encoder 0.248.0", + "wasmparser 0.248.0", + "wat", +] + +[[package]] +name = "wasm-encoder" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac92cf547bc18d27ecc521015c08c353b4f18b84ab388bb6d1b6b682c620d9b6" +dependencies = [ + "leb128fmt", + "wasmparser 0.248.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09480d646178e5fdd12bb06e812d0af9a3a191dbc9cd697fdc86687beade7393" +dependencies = [ + "leb128fmt", + "wasmparser 0.254.0", +] + +[[package]] +name = "wasmparser" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5769a29f799fbab136aaf65b4fe5384cd7d93fe6fc9ba0dcb6c8382a1f16e27" +dependencies = [ + "bitflags", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b264a5410b008d4d199a92bf536eae703cbd614482fc1ec53831cf19e1c183" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.248.0", +] + +[[package]] +name = "wasmtime" +version = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a83ef84ac8bab735c89e27b0268b0586099fbe81281ae45be2b8e4f407b2daa" +dependencies = [ + "addr2line", + "async-trait", + "bitflags", + "bumpalo", + "cc", + "cfg-if", + "encoding_rs", + "futures", + "fxprof-processed-profile", + "gimli", + "ittapi", + "libc", + "log", + "mach2", + "memfd", + "object 0.39.1", + "once_cell", + "postcard", + "pulley-interpreter", + "rayon", + "rustix", + "semver", + "serde", + "serde_derive", + "serde_json", + "smallvec", + "target-lexicon", + "tempfile", + "wasm-compose", + "wasm-encoder 0.248.0", + "wasmparser 0.248.0", + "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 = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439686d3deb38525c86b88bbc90f7ba669f70c8edc7d6b35147c738bbef5ff76" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "log", + "object 0.39.1", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "sha2 0.10.9", + "smallvec", + "target-lexicon", + "wasm-encoder 0.248.0", + "wasmparser 0.248.0", + "wasmprinter", + "wasmtime-internal-component-util", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-cache" +version = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2799e6c35393c2a38999f13e4c80ab5d5d9756082bb554cbd1f60485a18293" +dependencies = [ + "base64 0.22.1", + "directories-next", + "log", + "postcard", + "rustix", + "serde", + "serde_derive", + "sha2 0.10.9", + "toml", + "wasmtime-environ", + "windows-sys 0.61.2", + "zstd", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81d2c5850fb8c448792453765d97f6f01096a32708f3c36798e86220763c90c0" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ed79c4e9ab416dcc5e46b9d1ec9b7c2e3c730deab525996b0de45a35fc8b2c3" + +[[package]] +name = "wasmtime-internal-core" +version = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3073c03f97f871fe6400e68621c863b93ba79296f8e570285231952d23fcc804" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +checksum = "7a15981261d361f9c93b42929f446b4009840853ceea181ad6e17730f4016a0b" dependencies = [ - "base64 0.22.1", - "http", - "httparse", + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools 0.14.0", "log", + "object 0.39.1", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.19", + "wasmparser 0.248.0", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", ] [[package]] -name = "url" -version = "2.5.8" +name = "wasmtime-internal-fiber" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +checksum = "33aed9d91d54c9f861ba7e3c3130bded8a7a63e01fe58c3a9a36b45d6ee0fb57" dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", + "cc", + "cfg-if", + "libc", + "rustix", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", ] [[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" +name = "wasmtime-internal-jit-debug" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +checksum = "944942118aab67e7b4febfe88a65d0af4cfd10af8ed0e79fd6cf39d75359ab7e" +dependencies = [ + "cc", + "object 0.39.1", + "rustix", + "wasmtime-internal-versioned-export-macros", +] [[package]] -name = "uuid" -version = "1.23.4" +name = "wasmtime-internal-jit-icache-coherence" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "37dee05e8c35759826f6b926cd949c51f771b6a58619d8e60c63c3f3d3e5e59b" dependencies = [ - "getrandom 0.4.3", - "js-sys", - "serde_core", - "wasm-bindgen", + "cfg-if", + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", ] [[package]] -name = "version_check" -version = "0.9.5" +name = "wasmtime-internal-unwinder" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +checksum = "a83f7ebe911a6f1605ff0d3a678472ddb1fcc57c3e2f6bae5dd4d170b625b3ed" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "log", + "object 0.39.1", + "wasmtime-environ", +] [[package]] -name = "walkdir" -version = "2.5.0" +name = "wasmtime-internal-versioned-export-macros" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +checksum = "d75980644456dc003238766254b0e5f002704630237ccd0728747991fe2739d7" dependencies = [ - "same-file", - "winapi-util", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "want" -version = "0.3.1" +name = "wasmtime-internal-winch" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "81af36995b4720b32e3d667541b548a27184a859d09a6ee745eaba095f5a6f6e" dependencies = [ - "try-lock", + "cranelift-codegen", + "gimli", + "log", + "object 0.39.1", + "target-lexicon", + "wasmparser 0.248.0", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "winch-codegen", ] [[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +name = "wasmtime-internal-wit-bindgen" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "ada06667b7948892703fcc9f0b9b337c2e78c334d74d4fa62e2f75f07fbb3659" +dependencies = [ + "anyhow", + "bitflags", + "heck", + "indexmap 2.14.0", + "wit-parser", +] [[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" +name = "wasmtime-wasi" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +checksum = "fd8e9db77f7b60f4c5f6f72847f55eb646ed7ed2f68e362559ee07dc543fb408" dependencies = [ - "wit-bindgen", + "async-trait", + "bitflags", + "bytes", + "cap-fs-ext", + "cap-net-ext", + "cap-std", + "cap-time-ext", + "cfg-if", + "fs-set-times", + "futures", + "io-extras", + "io-lifetimes", + "rand 0.10.2", + "rustix", + "thiserror 2.0.19", + "tokio", + "tracing", + "url", + "wasmtime", + "wasmtime-wasi-io", + "wiggle", + "windows-sys 0.61.2", ] [[package]] -name = "wasm-bindgen" -version = "0.2.126" +name = "wasmtime-wasi-http" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "c3b7f2587ebe31df3d143baebe3aa9f6712955507e15778fe00097eb77da9ebe" dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", + "async-trait", + "bytes", + "futures", + "http", + "http-body", + "http-body-util", + "hyper", + "rustls", + "tokio", + "tokio-rustls", + "tracing", + "wasmtime", + "wasmtime-wasi", + "wasmtime-wasi-io", + "webpki-roots 0.26.11", ] [[package]] -name = "wasm-bindgen-futures" -version = "0.4.76" +name = "wasmtime-wasi-io" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "3805f02be91374a4fd02c0f947daf07bbaa6fbebb5909de5305fa5b0e9568f30" dependencies = [ - "js-sys", - "wasm-bindgen", + "async-trait", + "bytes", + "futures", + "tracing", + "wasmtime", ] [[package]] -name = "wasm-bindgen-macro" -version = "0.2.126" +name = "wast" +version = "35.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" dependencies = [ - "quote", - "wasm-bindgen-macro-support", + "leb128", ] [[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.126" +name = "wast" +version = "254.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e7ed4dfc8f6b9fc38b231065e2cdfbf7359af5ab945990abf09658dcc63c3e32" dependencies = [ "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.118", - "wasm-bindgen-shared", + "leb128fmt", + "memchr", + "unicode-width 0.2.2", + "wasm-encoder 0.254.0", ] [[package]] -name = "wasm-bindgen-shared" -version = "0.2.126" +name = "wat" +version = "1.254.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7127f7f9b8f127c879991cecd35f494e4628bae1b0874c681414d8d8831e952c" dependencies = [ - "unicode-ident", + "wast 254.0.0", ] [[package]] @@ -4342,13 +5770,62 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.8" +version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] +[[package]] +name = "wiggle" +version = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aca130e25a57e29bbb541441b6e261970a8469580bffebe904d96f0df67e41d" +dependencies = [ + "bitflags", + "thiserror 2.0.19", + "tracing", + "wasmtime", + "wasmtime-environ", + "wiggle-macro", +] + +[[package]] +name = "wiggle-generate" +version = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f0a45b47e893521cad81819f2e0db18a8b05086fd4bfb35369ed8830a8f0148" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasmtime-environ", + "witx", +] + +[[package]] +name = "wiggle-macro" +version = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e297e0325cffa6d368386b6e672e9d6c6a7ad34bdb8a5f49319db4decef2a990" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "wiggle-generate", +] + [[package]] name = "wildmatch" version = "2.6.1" @@ -4386,6 +5863,25 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "winch-codegen" +version = "45.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d73bc25d27222248f9bafc7e9945f61e74275360c3ea0d34008810d436140a53" +dependencies = [ + "cranelift-assembler-x64", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon", + "thiserror 2.0.19", + "wasmparser 0.248.0", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -4407,7 +5903,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4418,7 +5914,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4451,16 +5947,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] name = "windows-sys" -version = "0.60.2" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -4478,31 +5974,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -4511,60 +5990,30 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4572,34 +6021,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" +name = "windows_x86_64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] -name = "windows_x86_64_gnullvm" +name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" +name = "winnow" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" +name = "winnow" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" [[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" +name = "winx" +version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.59.0", +] [[package]] name = "wit-bindgen" @@ -4607,6 +6060,37 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wit-parser" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "247ad505da2915a082fe13204c5ba8788425aea1de54f43b284818cf82637856" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.248.0", +] + +[[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]] name = "writeable" version = "0.6.3" @@ -4625,9 +6109,9 @@ dependencies = [ [[package]] name = "xxhash-rust" -version = "0.8.16" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" [[package]] name = "yoke" @@ -4648,28 +6132,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4689,7 +6173,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -4710,7 +6194,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4743,11 +6227,39 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 11f2bd51..ece5697a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/cpex-sdk", "crates/cpex-builtins", "crates/cpex-ffi", + "crates/cpex-wasm-host", "crates/apl-core", "crates/apl-cmf", "crates/apl-cpex", @@ -33,6 +34,9 @@ members = [ # or `maturin develop` to build this crate explicitly. "bindings/python", ] +exclude = [ + "crates/cpex-wasm-plugin", +] # `default-members` controls what `cargo build` / `cargo test` (with no # `-p` or `--workspace` flag) picks up. `cpex-session-valkey` pulls a redis diff --git a/crates/cpex-core/Cargo.toml b/crates/cpex-core/Cargo.toml index 1f0d90aa..9ec6a188 100644 --- a/crates/cpex-core/Cargo.toml +++ b/crates/cpex-core/Cargo.toml @@ -20,9 +20,12 @@ keywords.workspace = true categories.workspace = true rust-version.workspace = true +[features] +default = ["runtime"] +runtime = ["dep:tokio", "dep:tokio-util", "dep:cpex-orchestration", "dep:arc-swap"] + [dependencies] -tokio = { workspace = true } -tokio-util = { workspace = true } +# --- Always-on dependencies (WASM-compatible) --- serde = { workspace = true } serde_yaml = { workspace = true } serde_json = { workspace = true } @@ -31,17 +34,15 @@ thiserror = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } hashbrown = { workspace = true } -arc-swap = { workspace = true } wildmatch = { workspace = true } chrono = { workspace = true } -# Zeroizing wrapper for raw credential material in RawCredentialsExtension. -# `derive` feature pulls the proc-macro so we can `#[derive(Zeroize)]` on -# token-bearing structs in a future slice; for now only the -# `Zeroizing` wrapper is used directly. zeroize = { version = "1.8", features = ["zeroize_derive"] } -# Shared concurrency primitive used by `executor::run_concurrent_phase` -# (and apl-core's `Effect::Parallel`). Leaf crate, no cycles back here. -cpex-orchestration = { workspace = true } + +# --- Runtime-only dependencies (require tokio, not WASM-compatible) --- +tokio = { workspace = true, optional = true } +tokio-util = { workspace = true, optional = true } +arc-swap = { workspace = true, optional = true } +cpex-orchestration = { workspace = true, optional = true } [lints] workspace = true diff --git a/crates/cpex-core/src/cmf/message.rs b/crates/cpex-core/src/cmf/message.rs index 4eb7678c..68a5d2db 100644 --- a/crates/cpex-core/src/cmf/message.rs +++ b/crates/cpex-core/src/cmf/message.rs @@ -219,6 +219,7 @@ pub struct MessagePayload { } crate::impl_plugin_payload!(MessagePayload); +crate::impl_wasm_payload!(MessagePayload, "cmf.message"); crate::define_hook! { /// CMF message evaluation hook. diff --git a/crates/cpex-core/src/config.rs b/crates/cpex-core/src/config.rs index 30adf128..a6ced4a5 100644 --- a/crates/cpex-core/src/config.rs +++ b/crates/cpex-core/src/config.rs @@ -19,6 +19,7 @@ // enabled, conditions on individual plugins are ignored. use std::collections::{HashMap, HashSet}; +#[cfg(feature = "runtime")] use std::path::Path; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -568,6 +569,11 @@ impl StringOrList { } } +// --------------------------------------------------------------------------- +// Config Loading +// --------------------------------------------------------------------------- + +#[cfg(feature = "runtime")] /// Load and parse a CPEX config from a YAML file. pub fn load_config(path: &Path) -> Result> { let content = std::fs::read_to_string(path).map_err(|e| PluginError::Config { diff --git a/crates/cpex-core/src/delegation/payload.rs b/crates/cpex-core/src/delegation/payload.rs index f2a9bc44..4f0a9e37 100644 --- a/crates/cpex-core/src/delegation/payload.rs +++ b/crates/cpex-core/src/delegation/payload.rs @@ -56,6 +56,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; +#[cfg(feature = "runtime")] use crate::executor::PipelineResult; use crate::extensions::raw_credentials::DelegationMode; use crate::extensions::{ @@ -348,6 +349,9 @@ impl DelegationPayload { } } + // -------- Host-side application helpers -------- + + #[cfg(feature = "runtime")] /// Pull the resolved `DelegationPayload` out of a `PipelineResult` /// returned by `mgr.invoke_named::(...)`. /// Returns `None` when the pipeline was denied or when the result's @@ -438,6 +442,13 @@ impl DelegationPayload { impl_plugin_payload!(DelegationPayload); +// WASM transport: `bearer_token` and `delegated_token.token` are +// `#[serde(skip)]`, so raw credential material never crosses the +// sandbox boundary. A WASM handler can attenuate scopes and populate +// `delegation_update` / `metadata`, but token minting that must +// return the raw token stays in-process. +crate::impl_wasm_payload!(DelegationPayload, "cpex.delegation"); + #[cfg(test)] mod tests { use super::*; diff --git a/crates/cpex-core/src/elicitation/payload.rs b/crates/cpex-core/src/elicitation/payload.rs index 5c7f00d3..af48f5e7 100644 --- a/crates/cpex-core/src/elicitation/payload.rs +++ b/crates/cpex-core/src/elicitation/payload.rs @@ -55,6 +55,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; +#[cfg(feature = "runtime")] use crate::executor::PipelineResult; use crate::impl_plugin_payload; @@ -266,6 +267,9 @@ impl ElicitationPayload { self.channel.as_deref() } + // -------- Host-side application helper -------- + + #[cfg(feature = "runtime")] /// Pull the resolved `ElicitationPayload` out of a `PipelineResult` /// returned by `mgr.invoke_entries::(...)`. Returns /// `None` when the pipeline was denied or the result's payload wasn't diff --git a/crates/cpex-core/src/hooks/mod.rs b/crates/cpex-core/src/hooks/mod.rs index 8ad5115e..499ee3c9 100644 --- a/crates/cpex-core/src/hooks/mod.rs +++ b/crates/cpex-core/src/hooks/mod.rs @@ -16,18 +16,21 @@ // // Hook types are open — hosts define their own using define_hook! alongside the built-ins. -pub mod adapter; pub mod macros; pub mod metadata; pub mod payload; pub mod trait_def; pub mod types; +#[cfg(feature = "runtime")] +pub mod adapter; + // Re-export core types at the hooks level +#[cfg(feature = "runtime")] pub use adapter::TypedHandlerAdapter; pub use metadata::{ lookup as lookup_hook_metadata, register_hook_metadata, HookMetadata, HookPhase, }; -pub use payload::{Extensions, PluginPayload}; +pub use payload::{Extensions, PluginPayload, WasmSerializablePayload}; pub use trait_def::{HookHandler, HookTypeDef, PluginResult}; pub use types::{builtin_hook_types, hook_type_from_str, HookType}; diff --git a/crates/cpex-core/src/hooks/payload.rs b/crates/cpex-core/src/hooks/payload.rs index ef3e6950..f4d7a4be 100644 --- a/crates/cpex-core/src/hooks/payload.rs +++ b/crates/cpex-core/src/hooks/payload.rs @@ -123,3 +123,81 @@ macro_rules! impl_plugin_payload { } }; } + +// --------------------------------------------------------------------------- +// WasmSerializablePayload — opt-in WASM transport trait +// --------------------------------------------------------------------------- + +/// Opt-in trait for payload types that can cross the WASM serialization boundary. +/// +/// Implement this (via [`impl_wasm_payload!`]) for any payload type that WASM +/// plugins should be able to receive or return. The type discriminator string +/// is embedded in the WIT `custom-payload` record so the host and guest can +/// agree on which concrete type to deserialize. +/// +/// # Example +/// +/// ``` +/// use serde::{Deserialize, Serialize}; +/// use cpex_core::{impl_plugin_payload, impl_wasm_payload}; +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] +/// struct ToolInvokePayload { +/// tool_name: String, +/// user: String, +/// } +/// +/// impl_plugin_payload!(ToolInvokePayload); +/// impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); +/// ``` +pub trait WasmSerializablePayload: PluginPayload { + /// Type discriminator used in the WIT `custom-payload` record. + /// + /// Must be unique across all payload types registered with the host. + /// Convention: `"."` (e.g. `"cmf.message"`, `"cpex.tool_invoke"`). + fn payload_type_name() -> &'static str + where + Self: Sized; + + /// Serialize this payload to JSON bytes for WASM transport. + fn to_wasm_bytes(&self) -> Result, serde_json::Error>; + + /// Deserialize a payload from JSON bytes received from WASM. + fn from_wasm_bytes(bytes: &[u8]) -> Result + where + Self: Sized; +} + +/// Implements [`WasmSerializablePayload`] for a type that is `Serialize + Deserialize`. +/// +/// The type must already implement [`PluginPayload`] (via [`impl_plugin_payload!`]). +/// +/// ``` +/// use serde::{Deserialize, Serialize}; +/// use cpex_core::{impl_plugin_payload, impl_wasm_payload}; +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] +/// struct ToolInvokePayload { +/// tool_name: String, +/// user: String, +/// } +/// +/// impl_plugin_payload!(ToolInvokePayload); +/// impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); +/// ``` +#[macro_export] +macro_rules! impl_wasm_payload { + ($ty:ty, $name:literal) => { + impl $crate::hooks::payload::WasmSerializablePayload for $ty { + fn payload_type_name() -> &'static str { + $name + } + fn to_wasm_bytes(&self) -> Result, serde_json::Error> { + serde_json::to_vec(self) + } + fn from_wasm_bytes(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes) + } + } + }; +} diff --git a/crates/cpex-core/src/identity/payload.rs b/crates/cpex-core/src/identity/payload.rs index 4048f1d4..42d881ef 100644 --- a/crates/cpex-core/src/identity/payload.rs +++ b/crates/cpex-core/src/identity/payload.rs @@ -63,6 +63,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; +#[cfg(feature = "runtime")] use crate::executor::PipelineResult; use crate::extensions::{ ClientExtension, DelegationExtension, Extensions, RawCredentialsExtension, SecurityExtension, @@ -275,6 +276,9 @@ impl IdentityPayload { } } + // -------- Host-side application helpers -------- + + #[cfg(feature = "runtime")] /// Pull the resolved `IdentityPayload` out of a `PipelineResult` /// returned by `mgr.invoke_named::(...)`. Returns /// `None` when the pipeline was denied (no `modified_payload`) @@ -357,6 +361,11 @@ impl IdentityPayload { impl_plugin_payload!(IdentityPayload); +// WASM transport: `raw_token` is `#[serde(skip)]`, so a WASM handler +// receives every input except the raw credential bytes — it resolves +// identity from `headers` / claims and returns the output fields. +crate::impl_wasm_payload!(IdentityPayload, "cpex.identity"); + #[cfg(test)] mod tests { use super::*; diff --git a/crates/cpex-core/src/lib.rs b/crates/cpex-core/src/lib.rs index fae7bd25..24a29aee 100644 --- a/crates/cpex-core/src/lib.rs +++ b/crates/cpex-core/src/lib.rs @@ -34,12 +34,20 @@ pub mod context; pub mod delegation; pub mod elicitation; pub mod error; -pub mod executor; pub mod extensions; -pub mod factory; pub mod hooks; pub mod identity; -pub mod manager; pub mod plugin; + +// Runtime-only modules — require tokio, task spawning, orchestration. +// Excluded when building for WASM targets (use `default-features = false`). +#[cfg(feature = "runtime")] +pub mod executor; +#[cfg(feature = "runtime")] +pub mod factory; +#[cfg(feature = "runtime")] +pub mod manager; +#[cfg(feature = "runtime")] pub mod registry; +#[cfg(feature = "runtime")] pub mod visitor; diff --git a/crates/cpex-wasm-host/Cargo.toml b/crates/cpex-wasm-host/Cargo.toml new file mode 100644 index 00000000..fa97365b --- /dev/null +++ b/crates/cpex-wasm-host/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "cpex-wasm-host" +version = "0.1.0" +edition = "2021" +description = "WASM plugin host runtime for the CPEX framework — sandboxed wasmtime execution with capability-based access control" +license = "Apache-2.0" +authors = ["Shriti Priya"] + +[dependencies] +tokio = { version = "1", features = ["sync", "macros", "io-util", "rt", "rt-multi-thread", "time", "signal", "net"] } +wasmtime = { version = "45.0", features = ["component-model", "async"] } +wasmtime-wasi = "45.0" +wasmtime-wasi-http = { version = "45.0", features = ["default-send-request"] } +hyper = "1" +anyhow = "1.0" +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" +serde_json = { workspace = true } +cpex-core = { workspace = true } +async-trait = "0.1" +chrono = { version = "0.4", features = ["serde"] } +tracing = { workspace = true } + +[dev-dependencies] +criterion = { version = "0.5", features = ["async_tokio"] } +async-trait = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } + +[[bench]] +name = "invocation" +harness = false +path = "benchmarking/invocation.rs" + +[[bench]] +name = "comprehensive" +harness = false +path = "benchmarking/comprehensive.rs" diff --git a/crates/cpex-wasm-host/Makefile b/crates/cpex-wasm-host/Makefile new file mode 100644 index 00000000..877c3ea4 --- /dev/null +++ b/crates/cpex-wasm-host/Makefile @@ -0,0 +1,123 @@ +.PHONY: all build build-plugin build-all-plugins build-test-plugins build-bench-plugins bench-all test run-demos run-plugin-demo run-capabilities-demo clean clean-all help + +PLUGIN_DIR := ../cpex-wasm-plugin +WASM_DIR := wasm + +# --------------------------------------------------------------------------- +# Build +# --------------------------------------------------------------------------- + +all: build ## Build default plugin + host (default) + +build-plugin: ## Build the default WASM plugin and stage it + $(MAKE) -C $(PLUGIN_DIR) all + +DEMO_PLUGINS := tool-invoke-checker pii-guard audit-logger-custom remote-authz +BENCH_PLUGINS := compute-bench + +build-all-plugins: ## Build all WASM plugin binaries (capabilities + custom-payload demo) + $(MAKE) -C $(PLUGIN_DIR) build-all + @for plugin in $(DEMO_PLUGINS); do \ + echo "Building $$plugin..."; \ + cargo build --manifest-path $(PLUGIN_DIR)/Cargo.toml \ + --target wasm32-wasip2 --release \ + --features $$plugin --no-default-features; \ + cp $(PLUGIN_TARGET) $(WASM_DIR)/$$plugin.wasm; \ + done + @echo "All demo plugins built and staged." + +build: build-plugin ## Build everything (default plugin + host examples) + cargo build --examples + +# --------------------------------------------------------------------------- +# Run demos +# --------------------------------------------------------------------------- + +run-demos: build-all-plugins ## Build all plugins and run all demos + cargo build --examples + @echo "\n========== Plugin Demo (custom payload, pre/post invoke) ==========\n" + cargo run --example wasm_plugin_demo + @echo "\n========== Capabilities Demo (3 plugins, capability isolation) ==========\n" + cargo run --example wasm_capabilities_demo + @echo "\n========== All demos passed ==========\n" + +run-plugin-demo: build-all-plugins ## Build tool-invoke-checker plugin and run the custom-payload demo + cargo build --examples + cargo run --example wasm_plugin_demo + +run-capabilities-demo: build-all-plugins ## Build all plugins and run the capabilities demo + cargo build --examples + cargo run --example wasm_capabilities_demo + +# --------------------------------------------------------------------------- +# Benchmarks +# --------------------------------------------------------------------------- + +build-bench-plugins: ## Build benchmark WASM plugins (compute-bench, noop) + @for plugin in $(BENCH_PLUGINS); do \ + echo "Building $$plugin..."; \ + cargo build --manifest-path $(PLUGIN_DIR)/Cargo.toml \ + --target wasm32-wasip2 --release \ + --features $$plugin --no-default-features; \ + cp $(PLUGIN_TARGET) $(WASM_DIR)/$$plugin.wasm; \ + done + @echo "Benchmark plugins built and staged." + +bench-all: build-all-plugins build-bench-plugins build-test-plugins ## Build all plugins and run all benchmarks + generate chart + cargo bench + @echo "\n========== Generating performance chart ==========\n" + cd benchmarking && python3 plot_results.py + @echo "\n========== Benchmarks complete ==========\n" + +# --------------------------------------------------------------------------- +# Test +# --------------------------------------------------------------------------- + +PLUGIN_TARGET := $(PLUGIN_DIR)/target/wasm32-wasip2/release/cpex_wasm_plugin.wasm +TEST_PLUGINS := fs-test net-test env-test noop + +build-test-plugins: ## Build all test WASM plugins (fs-test, net-test, env-test, noop) + @for plugin in $(TEST_PLUGINS); do \ + echo "Building $$plugin..."; \ + cargo build --manifest-path $(PLUGIN_DIR)/Cargo.toml \ + --target wasm32-wasip2 --release \ + --features $$plugin --no-default-features; \ + cp $(PLUGIN_TARGET) $(WASM_DIR)/$$plugin.wasm; \ + done + @echo "All test plugins built and staged." + +test: clean-all build-all-plugins build-test-plugins ## Clean all, rebuild all plugins, run all tests + @echo "\n========== Running host tests (46 tests) ==========\n" + cargo test + @echo "\n========== Running plugin unit tests ==========\n" + cargo test --manifest-path $(PLUGIN_DIR)/Cargo.toml --features identity-checker --no-default-features + cargo test --manifest-path $(PLUGIN_DIR)/Cargo.toml --features header-injector --no-default-features + cargo test --manifest-path $(PLUGIN_DIR)/Cargo.toml --features audit-logger --no-default-features + cargo test --manifest-path $(PLUGIN_DIR)/Cargo.toml --features token-attenuator --no-default-features + cargo test --manifest-path $(PLUGIN_DIR)/Cargo.toml --features tool-invoke-checker --no-default-features + cargo test --manifest-path $(PLUGIN_DIR)/Cargo.toml --features pii-guard --no-default-features + cargo test --manifest-path $(PLUGIN_DIR)/Cargo.toml --features audit-logger-custom --no-default-features + cargo test --manifest-path $(PLUGIN_DIR)/Cargo.toml --features remote-authz --no-default-features + @echo "\n========== All tests passed ==========\n" + +# --------------------------------------------------------------------------- +# Cleanup +# --------------------------------------------------------------------------- + +clean: ## Remove host build artifacts and default plugin.wasm + cargo clean + rm -f $(WASM_DIR)/plugin.wasm + +clean-all: ## Remove host + plugin build artifacts and ALL .wasm files + cargo clean + cargo clean --manifest-path $(PLUGIN_DIR)/Cargo.toml + cargo clean --manifest-path $(PLUGIN_DIR)/Cargo.toml --target wasm32-wasip2 + rm -f $(WASM_DIR)/*.wasm + +# --------------------------------------------------------------------------- +# Help +# --------------------------------------------------------------------------- + +help: ## Show available targets + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' diff --git a/crates/cpex-wasm-host/README.md b/crates/cpex-wasm-host/README.md new file mode 100644 index 00000000..0a553dc1 --- /dev/null +++ b/crates/cpex-wasm-host/README.md @@ -0,0 +1,545 @@ +# cpex-wasm-host + +Run plugins in sandboxed WebAssembly instead of trusting them with full process access. This crate loads `.wasm` plugin binaries, enforces resource limits and capabilities, and plugs into the same `PluginManager` pipeline as native plugins — no changes to your invocation code. + +--- + +## Table of Contents + +1. [What Is This?](#what-is-this) +2. [Prerequisites](#prerequisites) +3. [Quick Start (See It Work in 2 Minutes)](#quick-start-see-it-work-in-2-minutes) +4. [How It Works](#how-it-works) +5. [Using WASM Plugins in Your Code](#using-wasm-plugins-in-your-code) +6. [Using Custom Payload Types](#using-custom-payload-types) +7. [Configuring Plugins (YAML)](#configuring-plugins-yaml) +8. [Running the Demos](#running-the-demos) +9. [Running Tests](#running-tests) +10. [Performance](#performance) +11. [Security Model](#security-model) +12. [Error Handling](#error-handling) +13. [API Reference](#api-reference) +14. [Project Structure](#project-structure) +15. [Troubleshooting](#troubleshooting) +16. [Known Limitations & Future Work](#known-limitations--future-work) + +--- + +## What Is This? + +CPEX plugins inspect and modify requests flowing through your system (tool calls, LLM messages, identity checks). Normally these plugins run as native Rust code in the same process — fast but fully trusted. + +**cpex-wasm-host** lets you run plugins in a **WebAssembly sandbox** instead. Each plugin: +- Runs in isolated memory (can't read your process's data) +- Has no filesystem access unless you explicitly grant it +- Has no network access unless you explicitly allow specific hosts +- Can't exceed a CPU budget (fuel limit) or wall-clock timeout +- Can only see the extension data you declare in its capabilities + +The plugin author writes the same Rust code (same `HookHandler` trait, same `PluginResult`). The only difference is it compiles to `.wasm` instead of linking into your binary. + +--- + +## Prerequisites + +You need one extra Rust target installed: + +```bash +rustup target add wasm32-wasip2 +``` + +That's it. Everything else (wasmtime, WIT bindings) is handled by Cargo dependencies. + +--- + +## Quick Start (See It Work in 2 Minutes) + +```bash +# From the repository root: +cd crates/cpex-wasm-host + +# This builds all WASM plugins and runs both demos end-to-end: +make run-demos +``` + +You'll see output showing 4 plugins processing requests in a pipeline — identity checks, PII access control, remote authorization, and audit logging — all running inside WASM sandboxes. + +If you want to reset everything and start fresh: + +```bash +make clean-all && make run-demos +``` + +--- + +## How It Works + +``` +Your Code + │ + ▼ +PluginManager::invoke::(payload, extensions, context) + │ + ▼ (same API whether plugin is native or WASM) +┌─────────────────────────────────────────────────────┐ +│ WasmBridgeHandler │ +│ │ +│ 1. Convert payload + extensions → WIT types │ +│ 2. Reset fuel + timeout for this call │ +│ 3. Call into the WASM sandbox │ +│ 4. Convert result back to native types │ +│ 5. Validate: did the plugin modify things it's │ +│ not allowed to? If so, reject the changes. │ +└─────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Wasmtime Sandbox (per plugin) │ +│ │ +│ - Isolated linear memory (can't see host memory) │ +│ - Fuel budget (instruction limit per call) │ +│ - Epoch timeout (wall-clock limit per call) │ +│ - Capability-filtered extensions (only sees what │ +│ it declared) │ +│ - Network/filesystem/env access only if allowed │ +└─────────────────────────────────────────────────────┘ +``` + +From the caller's perspective, WASM and native plugins are identical. You use the same `PluginManager`, same `invoke()` call, same result handling. The sandbox is invisible to the calling code. + +--- + +## Using WASM Plugins in Your Code + +### Step 1: Add the dependency + +```toml +[dependencies] +cpex-wasm-host = { path = "../cpex-wasm-host" } +cpex-core = { path = "../cpex-core" } +``` + +### Step 2: Register a WASM factory with the PluginManager + +```rust +use std::path::PathBuf; +use cpex_core::manager::PluginManager; +use cpex_wasm_host::factory::WasmPluginFactory; + +let mgr = PluginManager::default(); + +// Point at the directory containing your .wasm files +let wasm_dir = PathBuf::from("wasm"); + +// Register a factory for each plugin kind in your YAML config. +// "wasm://my-plugin.wasm" matches the `kind:` field in config. +mgr.register_factory( + "wasm://my-plugin.wasm", + Box::new(WasmPluginFactory::with_builtin_payloads(wasm_dir)), +); +``` + +### Step 3: Load config and invoke (same as native) + +```rust +use cpex_core::cmf::CmfHook; +use cpex_core::config::parse_config; + +// Load YAML config (declares which plugins, hooks, capabilities) +let config = parse_config(&yaml_string)?; +mgr.load_config(config)?; +mgr.initialize().await?; + +// Invoke a hook — WASM plugins participate transparently +let (result, bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", payload, extensions, None) + .await; + +// Check the result +if !result.continue_processing { + println!("Denied: {}", result.violation.unwrap().reason); +} + +// Wait for fire-and-forget plugins to finish +bg.wait_for_background_tasks().await; +``` + +That's it. The plugin runs in a sandbox, but your invocation code is identical to native. + +--- + +## Using Custom Payload Types + +The built-in payloads (CMF `MessagePayload`, `IdentityPayload`, `DelegationPayload`) work out of the box with `WasmPluginFactory::with_builtin_payloads()`. + +If you want to define **your own** payload type (like `ToolInvokePayload` in the demo), you need two extra steps: + +### Host side (your binary): + +```rust +use serde::{Deserialize, Serialize}; +use cpex_wasm_host::factory::WasmPluginFactory; +use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; + +// 1. Define your payload struct +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ToolInvokePayload { + tool_name: String, + user: String, + arguments: String, +} + +// 2. Implement the required traits (these macros do it for you) +cpex_core::impl_plugin_payload!(ToolInvokePayload); +cpex_core::impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); +// ^^^^^^^^^^^^^^^^ +// This string must match exactly on host and guest side + +// 3. Define a hook type that uses your payload +struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +// 4. Register your payload with the factory +let mut registry = PayloadSerializerRegistry::new(); +registry.register::(); +let factory = WasmPluginFactory::new(wasm_dir, Arc::new(registry)); +``` + +### Guest side (your plugin — see cpex-wasm-plugin README for full tutorial): + +Define the **exact same struct** with the same field names and types, the same `impl_wasm_payload!` discriminator string, and implement `HookHandler`. The payload crosses the boundary automatically as JSON bytes. + +**Working examples:** See `examples/wasm_plugin_demo.rs` (host) and `cpex-wasm-plugin/src/plugins/tool_invoke_checker.rs` (guest). + +--- + +## Configuring Plugins (YAML) + +Each WASM plugin is declared in YAML with its hooks, mode, priority, capabilities, and sandbox policy: + +```yaml +plugins: + - name: my-plugin + kind: wasm://my-plugin.wasm # "wasm://" prefix tells the manager to use WasmPluginFactory + hooks: [cmf.tool_pre_invoke] # which hooks this plugin runs on + mode: sequential # sequential | transform | audit | concurrent | fire_and_forget + priority: 50 # lower number = runs earlier within same mode + on_error: fail # fail | ignore | disable (circuit breaker) + capabilities: # what extension data the plugin can see/modify + - read_labels + - read_subject + - read_roles + config: + sandbox_policy: + allowed_filesystem: [] # empty = no filesystem access + allowed_network: [] # empty = no network access + allowed_env: [] # empty = no environment variables visible + resources: + max_memory_bytes: 10485760 # 10 MB linear memory cap + max_fuel: 1000000000 # ~1 billion instructions per call + max_execution_time_ms: 5000 # 5 second wall-clock timeout per call + max_instances: 10 + max_tables: 10 +``` + +**Defaults** (if you omit `sandbox_policy` entirely): deny-all filesystem, deny-all network, deny-all env vars, 5-second timeout, unlimited fuel/memory. + +### Routing plugins to specific tools + +```yaml +global: + policies: + all: # these plugins fire on EVERY invocation + plugins: [identity-checker] + pii: # these fire only when a route has the "pii" tag + plugins: [pii-guard] + +routes: + - tool: get_compensation # specific tool + meta: + tags: [pii, hr] # activates the "pii" policy group + plugins: + - audit-logger # plus any route-specific plugins + + - tool: "*" # wildcard catch-all + plugins: + - audit-logger +``` + +--- + +## Running the Demos + +| Demo | What it shows | Command | +|------|---------------|---------| +| **Plugin Demo** | 4 WASM plugins, custom payload, 7 scenarios, policy routing | `make run-plugin-demo` | +| **Capabilities Demo** | 3 WASM plugins, capability-gated extension visibility | `make run-capabilities-demo` | +| **Both** | Everything above | `make run-demos` | + +All commands should be run from `crates/cpex-wasm-host`. + +The Plugin Demo mirrors the native `cpex-core/examples/plugin_demo.rs` exactly — same plugins, same scenarios, same results — but all running in WASM sandboxes. See the [detailed Plugin Demo section](#plugin-demo-details) at the end for expected output. + +--- + +## Running Tests + +```bash +# From the repository root: +cargo test -p cpex-wasm-host +``` + +This runs all tests that have their `.wasm` binaries pre-built. For the full suite including sandbox isolation tests: + +```bash +# One command that cleans, rebuilds everything, and runs all tests: +cd crates/cpex-wasm-host +make test +``` + +### What the tests cover + +| Category | Count | What it proves | +|----------|:-----:|----------------| +| Security enforcement | 15 | Capability filtering, immutable tier, monotonic labels, write authorization, slot preservation | +| Custom payload pipeline | 8 | 4 WASM plugins with user-defined payload through the full PluginManager pipeline (E2E) | +| Sandbox isolation | 6 | Real plugins attempt filesystem/network/env access — sandbox blocks them | +| Policy loader | 8 | YAML config parsing, context building, deny-all defaults | +| Config integration | 8 | Config structure, resource limits validation | +| Error classification | 6 | Timeout, fuel, memory, trap, network errors classified correctly | +| Conversions | 3 | Payload round-trips, extension immutability | + +--- + +## Performance + +**Benchmark environment:** Apple M4 Max, 64 GB RAM, macOS 15.5, Rust 1.96.0, wasmtime 45.0, release profile. + +![Performance Comparison](benchmarking/performance_comparison.png) + +| Scenario | Latency | vs Native | +|----------|:-------:|:---------:| +| Native handler (noop) | 87 ns | 1x | +| Native compute (real work) | 874 ns | 10x | +| WASM noop (sandbox overhead only) | 5.1 µs | 58x | +| WASM compute (same real work) | 10.5 µs | 120x | +| Custom payload (JSON serde path) | 5.3 µs | 61x | +| Structured payload (WIT types) | 5.6 µs | 64x | +| Cold start (first load + compile) | 547 ms | one-time | + +### The bottom line + +| | Native | WASM | +|---|---|---| +| **Per-call latency** | 87ns - 874ns | 5-10µs (12-120x slower) | +| **Cold start** | Zero (compiled in) | ~550ms (one-time WASM compilation) | +| **Concurrency** | Lock-free | Mutex-serialized per plugin | +| **Isolation** | None (same process, trusted) | Full sandbox (fuel, memory, timeout, network, filesystem) | + +**For typical CPEX usage** (2-4 plugin calls per LLM request at 200ms+), the WASM overhead is 20-40µs total — **0.01-0.02% of request latency**. Effectively invisible. + +**When to use WASM:** untrusted/third-party plugins, multi-tenant environments, or anywhere you need the guarantee that a plugin can't read files, exfiltrate data, or crash the host. + +**When to use native:** your own code, performance-critical hot paths, or plugins that need async I/O or cross-invocation mutable state beyond what `OnceLock` provides. + +### Running benchmarks yourself + +```bash +cd crates/cpex-wasm-host +make bench-all +``` + +This builds all required plugins, runs both benchmark suites (Criterion), and generates the performance comparison chart. See `benchmarking/README.md` for a detailed step-by-step guide. + +--- + +## Security Model + +Five layers of defense-in-depth at the WASM trust boundary: + +| Layer | What It Does | On Violation | +|-------|-------------|--------------| +| **Capability filtering** | Strips extension slots the plugin isn't authorized to read | Plugin never receives unauthorized data | +| **Immutable tier** | Verifies Arc pointer identity on immutable slots after return | Rejects all extension modifications | +| **Monotonic labels** | Verifies security labels can only be added, never removed | Rejects extension modifications | +| **Write authorization** | Verifies mutations only on slots with write capability | Rejects extension modifications | +| **Slot preservation** | Hidden slots preserved unchanged during writeback | Pipeline data integrity maintained | + +Additionally, the sandbox enforces: +- **Fuel limits** — per-invocation instruction budget (prevents infinite loops) +- **Execution timeout** — epoch-based wall-clock limit (prevents hanging) +- **Memory caps** — linear memory growth limited (prevents OOM) +- **Network allowlist** — outbound HTTP only to declared hosts +- **Filesystem preopens** — only declared paths accessible + +--- + +## Error Handling + +WASM runtime errors are classified into proper `PluginError` variants so the executor can apply the correct `on_error` policy: + +| Error | Variant | What happened | +|-------|---------|---------------| +| Epoch deadline exceeded | `Timeout` | Plugin took too long (wall-clock) | +| Fuel exhausted | `Execution { code: "fuel_exhausted" }` | Plugin used too many instructions | +| Memory limit | `Execution { code: "memory_limit" }` | Plugin tried to allocate too much memory | +| Plugin trap/panic | `Execution { code: "wasm_trap" }` | Plugin crashed (unreachable, divide by zero, etc.) | +| Network denied | `Execution { code: "network_denied" }` | Plugin tried to access a non-allowed host | + +With `on_error: disable`, the executor permanently disables a failing plugin (circuit breaker pattern). + +--- + +## API Reference + +### `WasmPluginFactory` + +The main entry point. Implements `cpex_core::factory::PluginFactory` so WASM plugins register with `PluginManager` the same way native plugins do. + +```rust +use cpex_wasm_host::factory::WasmPluginFactory; + +// For built-in payloads (CMF, Identity, Delegation): +let factory = WasmPluginFactory::with_builtin_payloads(PathBuf::from("wasm")); + +// For custom payloads (you must register your types): +let factory = WasmPluginFactory::new(wasm_dir, Arc::new(registry)); +``` + +### `PayloadSerializerRegistry` + +Tells the host how to serialize/deserialize your custom payload types for the WASM boundary. + +```rust +use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; + +let mut registry = PayloadSerializerRegistry::new(); +registry.register::(); // MyPayload must impl WasmSerializablePayload +``` + +### `SharedEngine` + +One wasmtime engine + one epoch ticker thread, shared across all plugins from the same factory. You don't create this directly — `WasmPluginFactory` manages it internally. + +### `SandboxManager` + +Per-plugin isolated Store. Also managed internally by the factory. If you need direct access (e.g., for benchmarks), see `benchmarking/invocation.rs` for usage patterns. + +--- + +## Project Structure + +``` +cpex-wasm-host/ +├── Cargo.toml +├── README.md +├── Makefile # Build, test, run, bench targets +├── config/ +│ ├── config.yaml # Test fixture (policy loader tests) +│ ├── config_plugin_demo.yaml # 4-plugin custom payload demo +│ └── config_capabilities.yaml # 3-plugin capabilities demo +├── examples/ +│ ├── wasm_plugin_demo.rs # 4 plugins, custom payload, 7 scenarios +│ └── wasm_capabilities_demo.rs # 3 plugins, capability isolation +├── benchmarking/ +│ ├── README.md # Step-by-step benchmarking guide +│ ├── invocation.rs # Benchmark: sandbox overhead +│ ├── comprehensive.rs # Benchmark: real work, cold start, contention +│ ├── plot_results.py # Generates performance_comparison.png +│ └── performance_comparison.png # Chart (committed for README display) +├── tests/ +│ ├── test_security_enforcement.rs # 15 tests: 5-layer security validation +│ ├── test_custom_payload_pipeline.rs # 8 tests: E2E custom payload pipeline +│ ├── test_sandbox_isolation.rs # 2 tests: filesystem denied +│ ├── test_sandbox_network.rs # 2 tests: network denied +│ ├── test_sandbox_env.rs # 2 tests: env vars hidden +│ └── test_policy_loader.rs # 8 tests: config parsing +├── src/ +│ ├── lib.rs # Crate docs + module re-exports +│ ├── factory.rs # WasmPluginFactory, WasmBridgeHandler +│ ├── sandbox_manager.rs # SharedEngine, SandboxManager, host-logging +│ ├── conversions.rs # Native ↔ WIT type conversions +│ ├── policy_loader.rs # SandboxPolicy parsing, WASI context +│ └── payload_registry.rs # Custom payload serialization +├── wasm/ # Compiled .wasm binaries (gitignored) +└── wit/ + ├── world.wit # WIT interface definition + └── deps/ # WASI P2 interface dependencies +``` + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `target 'wasm32-wasip2' not found` | WASM target not installed | `rustup target add wasm32-wasip2` | +| `failed to load wasm from .../plugin.wasm` | Binary not built | `cd crates/cpex-wasm-host && make build-all-plugins` | +| `failed to instantiate plugin` | Stale `.wasm` (WIT mismatch after code changes) | Rebuild: `make clean-all && make build-all-plugins` | +| `WASM invocation failed: epoch deadline` | Plugin took too long | Increase `max_execution_time_ms` in YAML config | +| `WASM invocation failed: all fuel consumed` | Plugin too compute-heavy | Increase `max_fuel` in YAML config | +| `block_in_place` panic | Single-threaded tokio runtime | Use `tokio::runtime::Builder::new_multi_thread()` or `#[tokio::main]` (multi-thread is default) | +| Benchmark says "SKIP: noop.wasm not found" | Benchmark plugins not built | `make build-bench-plugins` | +| Sandbox tests fail with "not found" | Test plugins not built | `make build-test-plugins` | + +--- + +## Known Limitations & Future Work + +### Current Limitations + +| Limitation | Impact | Workaround | +|-----------|--------|------------| +| **Single-threaded per plugin** | Mutex serializes all calls to one plugin — no parallel invocations | Acceptable at ~126K calls/sec/core; bottleneck only under extreme concurrency | +| **One plugin per `.wasm` binary** | WIT single-export constraint | Use feature flags; minor build-time cost | +| **Cold start ~550ms** | First load compiles WASM to native code | Load all plugins at startup, not per-request | +| **No raw credential access** | Bearer tokens never cross the boundary | By design — plugins needing credentials should run natively | +| **No streaming** | Full payload buffering, no per-token hooks | Use native plugins for streaming paths | +| **No WIT schema versioning** | WIT changes are breaking — all plugins must be recompiled | Pin WIT version across releases | +| **Rust-only guest SDK** | No Go/TypeScript/Python convenience wrappers | Any `wasm32-wasip2` language works; Rust SDK is just the ergonomic layer | + +### Future Work + +| Priority | Feature | Benefit | +|:--------:|---------|---------| +| High | **Instance pooling** | N pre-warmed Stores per plugin, round-robin dispatch. Eliminates mutex contention. | +| High | **Pre-compilation cache** | Serialize compiled modules to disk. Reduces cold start from ~550ms to <5ms. | +| Medium | **Per-token streaming hooks** | `HookPayload::Stream` variant for LLM token-level processing. | +| Medium | **Hot reload** | Replace `.wasm` binary without restarting the host process. | +| Medium | **Multi-language guest SDKs** | TypeScript/Go/Python wrappers for the guest-side plugin API. | +| Low | **WIT version negotiation** | Host and guest report versions at load time. Enables rolling upgrades. | +| Low | **Resource usage metrics** | Expose fuel consumed, memory high-water mark per plugin. | +| Low | **Plugin-to-plugin communication** | Shared memory or message-passing between plugins in the same pipeline. | + +--- + +## Appendix: Plugin Demo Details + + + +The `wasm_plugin_demo` example runs 4 WASM plugins through 7 scenarios, demonstrating the full feature set: + +| Plugin | Binary | Priority | Mode | Role | +|--------|--------|:--------:|------|------| +| identity-resolver | `tool-invoke-checker.wasm` | 10 | sequential | Checks user identity is present | +| pii-guard | `pii-guard.wasm` | 20 | sequential | Blocks PII tools without clearance | +| remote-authz | `remote-authz.wasm` | 30 | sequential | ACL-based authorization (state persists across calls) | +| audit-logger | `audit-logger-custom.wasm` | 100 | fire_and_forget | Logs all tool invocations | + +**Expected output:** + +``` +Scenario 1: get_compensation (no clearance) → DENIED by pii-guard +Scenario 2: get_compensation (with clearance) → ALLOWED (+ post-invoke) +Scenario 3: list_departments (non-PII) → ALLOWED +Scenario 4: some_other_tool (wildcard) → ALLOWED +Scenario 5: query_external_data (alice in ACL) → ALLOWED +Scenario 6: query_external_data (charlie) → DENIED by remote-authz +Scenario 7: list_departments (no user) → DENIED by identity-resolver +``` + +Run with: `cd crates/cpex-wasm-host && make run-plugin-demo` diff --git a/crates/cpex-wasm-host/benchmarking/README.md b/crates/cpex-wasm-host/benchmarking/README.md new file mode 100644 index 00000000..5a1bff77 --- /dev/null +++ b/crates/cpex-wasm-host/benchmarking/README.md @@ -0,0 +1,236 @@ +# Benchmarking Guide + +Step-by-step guide to running the CPEX WASM plugin performance benchmarks. This measures how much overhead the WASM sandbox adds compared to native plugin execution. + +--- + +## What You'll Learn + +After running these benchmarks, you'll know: + +1. **How much slower is WASM than native?** — for identical workloads (noop, real computation, full extensions) +2. **How long does the first plugin load take?** — cold start includes WASM compilation +3. **Does the payload format matter?** — custom JSON payload vs structured WIT types +4. **How does concurrency scale?** — what happens when multiple requests hit the same plugin simultaneously + +--- + +## Prerequisites + +Before you start, make sure you have these installed: + +```bash +# 1. Rust with the WASM target +rustup target add wasm32-wasip2 + +# 2. Python 3 + matplotlib (for chart generation — optional) +pip3 install matplotlib + +# 3. Verify you're in the repository root +cd /path/to/contextforge-plugins-framework +``` + +--- + +## Step 1: Build the WASM Plugin Binaries + +The benchmarks load pre-compiled `.wasm` binaries from the `wasm/` directory. You need to build them first. + +**Easiest way (builds everything):** + +```bash +cd crates/cpex-wasm-host +make build-all-plugins build-bench-plugins build-test-plugins +``` + +This builds: +- `noop.wasm` — does nothing, returns immediately (measures pure sandbox overhead) +- `compute-bench.wasm` — does real work: JSON parsing, string manipulation, hash computation +- `tool-invoke-checker.wasm` — handles a custom payload type (measures the JSON serde path) +- All other plugin binaries needed for the full benchmark suite + +**Manual way (if you only want specific benchmarks):** + +```bash +cd crates/cpex-wasm-plugin + +# For invocation.rs benchmarks (overhead measurement) +cargo build --target wasm32-wasip2 --release --features noop --no-default-features +cp target/wasm32-wasip2/release/cpex_wasm_plugin.wasm ../cpex-wasm-host/wasm/noop.wasm + +# For comprehensive.rs benchmarks (real-work comparison) +cargo build --target wasm32-wasip2 --release --features compute-bench --no-default-features +cp target/wasm32-wasip2/release/cpex_wasm_plugin.wasm ../cpex-wasm-host/wasm/compute-bench.wasm + +# For custom payload benchmark +cargo build --target wasm32-wasip2 --release --features tool-invoke-checker --no-default-features +cp target/wasm32-wasip2/release/cpex_wasm_plugin.wasm ../cpex-wasm-host/wasm/tool-invoke-checker.wasm +``` + +--- + +## Step 2: Run the Benchmarks + +From the repository root (or `crates/cpex-wasm-host`): + +```bash +# Run ALL benchmarks (both suites) +cargo bench -p cpex-wasm-host +``` + +This runs two benchmark suites: + +### Suite 1: `invocation` — Measures sandbox overhead + +| Benchmark | What it measures | +|-----------|-----------------| +| `native_noop` | Baseline: calling a native handler directly (no sandbox) | +| `native_with_full_extensions` | Native handler with realistic extensions (security + HTTP) | +| `conversion_native_to_wit` | Just the type conversion cost (native types → WIT types) | +| `wasm_noop` | Full WASM round-trip with minimal payload (pure overhead) | +| `wasm_with_full_extensions` | Full WASM round-trip with realistic extensions | + +### Suite 2: `comprehensive` — Measures real-world scenarios + +| Benchmark | What it measures | +|-----------|-----------------| +| `cold_start_wasm` | Time to load a `.wasm` file + compile it + run the first call | +| `compute_native` | Native handler doing real work (JSON parse + string ops + hash) | +| `compute_wasm` | Same real work through the WASM sandbox | +| `custom_payload_wasm` | User-defined payload via JSON serde (`HookPayload::Custom`) | +| `structured_payload_wasm` | Built-in CMF payload via WIT types (`HookPayload::Cmf`) | +| `concurrent_contention/1` | 1 task calling the sandbox (baseline) | +| `concurrent_contention/4` | 4 tasks contending on the same sandbox mutex | +| `concurrent_contention/8` | 8 tasks contending on the same sandbox mutex | + +**To run just one suite:** + +```bash +cargo bench -p cpex-wasm-host --bench invocation +cargo bench -p cpex-wasm-host --bench comprehensive +``` + +**To run a specific benchmark:** + +```bash +cargo bench -p cpex-wasm-host -- compute_native +cargo bench -p cpex-wasm-host -- cold_start +``` + +--- + +## Step 3: Read the Results + +Criterion prints results to the terminal like this: + +``` +compute_native time: [870 ns 874 ns 878 ns] +compute_wasm time: [10.4 µs 10.5 µs 10.6 µs] +``` + +The three numbers are: [lower bound, median, upper bound] of the 95% confidence interval. + +**How to interpret:** +- `compute_native: 874 ns` = calling the handler natively takes ~874 nanoseconds +- `compute_wasm: 10.5 µs` = same work through WASM takes ~10,500 nanoseconds +- Ratio: 10,500 / 874 = **~12x overhead** for the sandbox + +### HTML Reports + +Criterion also generates detailed HTML reports with histograms and regression analysis: + +```bash +open target/criterion/report/index.html +``` + +Each benchmark has its own page with: +- Distribution plot (how spread out the measurements are) +- Regression comparison (if you've run before, shows improvement/regression) +- Outlier analysis + +--- + +## Step 4: Generate the Comparison Chart (Optional) + +A Python script reads Criterion's JSON output and produces a visual bar chart: + +```bash +cd crates/cpex-wasm-host/benchmarking +python3 plot_results.py +``` + +This produces `performance_comparison.png` in the same directory — a 3-panel chart showing: +1. Native vs WASM for identical workloads (log scale) +2. Payload path cost + cold start +3. Mutex contention scaling + +**Requirements:** Python 3 + matplotlib (`pip3 install matplotlib`) + +--- + +## Step 5: One-Command Full Run (Recommended) + +If you just want to do everything at once: + +```bash +cd crates/cpex-wasm-host +make bench-all +``` + +This: +1. Builds all WASM plugin binaries (demo + bench + test plugins) +2. Runs both benchmark suites +3. Generates the performance comparison chart + +--- + +## Understanding the Results + +### What the numbers mean for your use case + +| Your scenario | Plugin calls per request | WASM overhead | Request latency | Impact | +|---------------|:-----------------------:|:-------------:|:---------------:|:------:| +| LLM tool invoke | 2-4 calls | 20-40 µs | 200ms - 2s | **< 0.02%** | +| HTTP API gateway | 3-6 calls | 30-60 µs | 5-50ms | **0.06-1.2%** | +| Real-time streaming | 100+ calls | 500µs+ | per-token | **Consider native** | + +### Key takeaways + +- **WASM is 12-120x slower than native** depending on workload and extensions size +- **Cold start is ~550ms** — load plugins at startup, not per-request +- **Custom payload vs structured**: nearly identical cost (~5µs each) — JSON serde is not a bottleneck +- **Mutex contention**: per-call latency stays flat as tasks increase (the mutex serializes, but each call is fast) +- **For typical usage** (2-4 plugin calls per 200ms+ LLM request): overhead is invisible + +### When to choose WASM vs native + +| Choose WASM when... | Choose native when... | +|--------------------|-----------------------| +| Running untrusted/third-party plugins | Running your own trusted code | +| Multi-tenant isolation is required | Per-call latency is critical (<1µs) | +| You need resource limits (fuel, memory, timeout) | You need async I/O in the plugin | +| Plugins should not access filesystem/network | Plugins need streaming/per-token hooks | + +--- + +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| `SKIP: noop.wasm not found` | Run `make build-all-plugins build-bench-plugins build-test-plugins` | +| `SKIP: compute-bench.wasm not found` | Run `make build-bench-plugins` | +| Benchmark numbers vary wildly | Close other apps, disable Turbo Boost, run multiple times | +| `cargo bench` shows "0 benchmarks" | Make sure you're running from the workspace root or using `-p cpex-wasm-host` | +| `python3 plot_results.py` fails | Install matplotlib: `pip3 install matplotlib` | +| Chart shows "No benchmark results found" | Run `cargo bench -p cpex-wasm-host` first to generate Criterion data | + +--- + +## File Reference + +| File | Purpose | +|------|---------| +| `invocation.rs` | Benchmark suite 1: measures framework overhead (sandbox crossing cost) | +| `comprehensive.rs` | Benchmark suite 2: measures real-world scenarios (compute, cold start, contention) | +| `plot_results.py` | Reads Criterion JSON output, generates `performance_comparison.png` | +| `performance_comparison.png` | Generated chart — commit this to show results in README | diff --git a/crates/cpex-wasm-host/benchmarking/comprehensive.rs b/crates/cpex-wasm-host/benchmarking/comprehensive.rs new file mode 100644 index 00000000..91d43b93 --- /dev/null +++ b/crates/cpex-wasm-host/benchmarking/comprehensive.rs @@ -0,0 +1,482 @@ +// Location: ./crates/cpex-wasm-host/benchmarking/comprehensive.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +//! Comprehensive benchmarks: cold start, real computation, custom payload, concurrency. +//! +//! Complements invocation.rs (which measures isolation overhead) with benchmarks +//! that answer: "how does WASM compare for real work?" and "what's the custom +//! payload path cost?". +//! +//! Run: cargo bench -p cpex-wasm-host --bench comprehensive + +use std::path::PathBuf; +use std::sync::Arc; + +use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; +use tokio::runtime::Runtime; +use tokio::sync::Mutex; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use cpex_core::cmf::constants::SCHEMA_VERSION; +use cpex_core::cmf::message::MessagePayload; +use cpex_core::cmf::{CmfHook, ContentPart, Message, Role, ToolCall}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::http::HttpExtension; +use cpex_core::extensions::security::SecurityExtension; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use cpex_wasm_host::conversions::{ + native_context_to_wit, native_extensions_to_wit, native_payload_to_wit, +}; +use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; +use cpex_wasm_host::sandbox_manager::{SandboxManager, SharedEngine}; + +// --------------------------------------------------------------------------- +// Native compute plugin (identical logic to the WASM compute-bench plugin) +// --------------------------------------------------------------------------- + +struct NativeComputePlugin; + +static COMPUTE_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for NativeComputePlugin { + fn config(&self) -> &PluginConfig { + COMPUTE_CONFIG.get_or_init(|| PluginConfig { + name: "native-compute-bench".into(), + kind: "builtin".into(), + hooks: vec!["cmf.tool_pre_invoke".into()], + ..Default::default() + }) + } + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for NativeComputePlugin { + async fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + let args_json = payload + .message + .get_tool_calls() + .first() + .map(|tc| serde_json::to_string(&tc.arguments).unwrap_or_default()) + .unwrap_or_default(); + + let mut summary = String::with_capacity(256); + summary.push_str("tool="); + summary.push_str( + payload + .message + .get_tool_calls() + .first() + .map(|tc| tc.name.as_str()) + .unwrap_or("?"), + ); + if let Some(ref sec) = extensions.security { + for label in sec.labels.iter() { + summary.push_str(",label="); + summary.push_str(label); + } + } + if let Some(ref http) = extensions.http { + if let Some(req_id) = http.get_header("X-Request-ID") { + summary.push_str(",req_id="); + summary.push_str(req_id); + } + } + + let hash: u64 = args_json.bytes().fold(14695981039346656037u64, |acc, b| { + acc.wrapping_mul(1099511628211).wrapping_add(b as u64) + }); + + ctx.set_local("hash", serde_json::json!(hash)); + ctx.set_local("summary_len", serde_json::json!(summary.len())); + + PluginResult::allow() + } +} + +// --------------------------------------------------------------------------- +// Custom payload types (for payload path comparison) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ToolInvokePayload { + tool_name: String, + user: String, + arguments: String, +} + +cpex_core::impl_plugin_payload!(ToolInvokePayload); +cpex_core::impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); + +#[allow(dead_code)] +struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +fn make_payload() -> MessagePayload { + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_001".into(), + name: "get_compensation".into(), + arguments: [ + ("employee_id".to_string(), serde_json::json!(42)), + ("department".to_string(), serde_json::json!("engineering")), + ("include_bonus".to_string(), serde_json::json!(true)), + ] + .into(), + namespace: None, + }, + }], + channel: None, + }, + } +} + +fn make_full_extensions() -> Extensions { + let mut security = SecurityExtension::default(); + security.add_label("PII"); + security.add_label("HR_DATA"); + security.add_label("CONFIDENTIAL"); + + let mut http = HttpExtension::default(); + http.set_header( + "Authorization", + "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + ); + http.set_header("X-Request-ID", "req-bench-001"); + http.set_header("Content-Type", "application/json"); + http.set_header("X-Correlation-ID", "corr-12345"); + + Extensions { + security: Some(Arc::new(security)), + http: Some(Arc::new(http)), + ..Default::default() + } +} + +fn wasm_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm") +} + +fn compute_wasm_path() -> PathBuf { + wasm_dir().join("compute-bench.wasm") +} + +fn noop_wasm_path() -> PathBuf { + wasm_dir().join("noop.wasm") +} + +fn tool_invoke_checker_path() -> PathBuf { + wasm_dir().join("tool-invoke-checker.wasm") +} + +// --------------------------------------------------------------------------- +// Benchmark: Cold Start (load + first invocation) +// --------------------------------------------------------------------------- + +fn bench_cold_start(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let wasm_path = noop_wasm_path(); + + if !wasm_path.exists() { + eprintln!("SKIP bench_cold_start: noop.wasm not found"); + return; + } + + let shared = Arc::new(SharedEngine::new().unwrap()); + + c.bench_function("cold_start_wasm", |b| { + b.to_async(&rt).iter(|| { + let shared = shared.clone(); + let path = wasm_path.clone(); + async move { + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&path, None, "cold-start-bench") + .await + .unwrap(); + + let wit_payload = cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf( + native_payload_to_wit(&make_payload()), + ); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + black_box(result); + } + }); + }); +} + +// --------------------------------------------------------------------------- +// Benchmark: Real Computation (native vs WASM) +// --------------------------------------------------------------------------- + +fn bench_compute_native(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let payload = make_payload(); + let ext = make_full_extensions(); + + c.bench_function("compute_native", |b| { + b.to_async(&rt).iter(|| async { + let mut ctx = PluginContext::default(); + let result = NativeComputePlugin + .handle(black_box(&payload), black_box(&ext), &mut ctx) + .await; + black_box(result); + }); + }); +} + +fn bench_compute_wasm(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let wasm_path = compute_wasm_path(); + + if !wasm_path.exists() { + eprintln!("SKIP bench_compute_wasm: compute-bench.wasm not found"); + return; + } + + let sandbox = rt.block_on(async { + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&wasm_path, None, "compute-bench") + .await + .unwrap(); + Arc::new(Mutex::new(mgr)) + }); + + let payload = make_payload(); + let ext = make_full_extensions(); + + c.bench_function("compute_wasm", |b| { + b.to_async(&rt).iter(|| { + let sandbox = sandbox.clone(); + let wit_payload = cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf( + native_payload_to_wit(&payload), + ); + let wit_ext = native_extensions_to_wit(&ext); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + async move { + let mut mgr = sandbox.lock().await; + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + black_box(result); + } + }); + }); +} + +// --------------------------------------------------------------------------- +// Benchmark: Custom Payload vs Structured Payload +// --------------------------------------------------------------------------- + +fn bench_custom_payload_wasm(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let wasm_path = tool_invoke_checker_path(); + + if !wasm_path.exists() { + eprintln!("SKIP bench_custom_payload_wasm: tool-invoke-checker.wasm not found"); + return; + } + + let registry = Arc::new({ + let mut r = PayloadSerializerRegistry::new(); + r.register::(); + r + }); + + let sandbox = rt.block_on(async { + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&wasm_path, None, "custom-payload-bench") + .await + .unwrap(); + Arc::new(Mutex::new(mgr)) + }); + + let custom_payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + + c.bench_function("custom_payload_wasm", |b| { + b.to_async(&rt).iter(|| { + let sandbox = sandbox.clone(); + let (type_name, bytes) = registry.serialize(&custom_payload).unwrap(); + let wit_payload = cpex_wasm_host::sandbox_manager::types::HookPayload::Custom( + cpex_wasm_host::sandbox_manager::types::CustomPayload { + payload_type: type_name.to_string(), + payload_data: bytes, + }, + ); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + async move { + let mut mgr = sandbox.lock().await; + let result = mgr + .invoke("tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + black_box(result); + } + }); + }); +} + +fn bench_structured_payload_wasm(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let wasm_path = noop_wasm_path(); + + if !wasm_path.exists() { + eprintln!("SKIP bench_structured_payload_wasm: noop.wasm not found"); + return; + } + + let sandbox = rt.block_on(async { + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&wasm_path, None, "structured-bench") + .await + .unwrap(); + Arc::new(Mutex::new(mgr)) + }); + + let payload = make_payload(); + + c.bench_function("structured_payload_wasm", |b| { + b.to_async(&rt).iter(|| { + let sandbox = sandbox.clone(); + let wit_payload = cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf( + native_payload_to_wit(&payload), + ); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + async move { + let mut mgr = sandbox.lock().await; + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + black_box(result); + } + }); + }); +} + +// --------------------------------------------------------------------------- +// Benchmark: Mutex Contention (concurrent access) +// --------------------------------------------------------------------------- + +fn bench_concurrent_contention(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let wasm_path = noop_wasm_path(); + + if !wasm_path.exists() { + eprintln!("SKIP bench_concurrent_contention: noop.wasm not found"); + return; + } + + let sandbox = rt.block_on(async { + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&wasm_path, None, "contention-bench") + .await + .unwrap(); + Arc::new(Mutex::new(mgr)) + }); + + let payload = make_payload(); + let ext = Extensions::default(); + + let mut group = c.benchmark_group("concurrent_contention"); + + for num_tasks in [1, 4, 8] { + group.bench_with_input( + BenchmarkId::from_parameter(num_tasks), + &num_tasks, + |b, &n| { + b.to_async(&rt).iter(|| { + let sandbox = sandbox.clone(); + let payload = payload.clone(); + let ext = ext.clone(); + async move { + let mut handles = Vec::with_capacity(n); + for _ in 0..n { + let sandbox = sandbox.clone(); + let payload = payload.clone(); + let ext = ext.clone(); + handles.push(tokio::spawn(async move { + let wit_payload = + cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf( + native_payload_to_wit(&payload), + ); + let wit_ext = native_extensions_to_wit(&ext); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + let mut mgr = sandbox.lock().await; + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + black_box(result); + })); + } + for h in handles { + h.await.unwrap(); + } + } + }); + }, + ); + } + group.finish(); +} + +// --------------------------------------------------------------------------- +// Criterion groups +// --------------------------------------------------------------------------- + +criterion_group!( + benches, + bench_cold_start, + bench_compute_native, + bench_compute_wasm, + bench_custom_payload_wasm, + bench_structured_payload_wasm, + bench_concurrent_contention, +); +criterion_main!(benches); diff --git a/crates/cpex-wasm-host/benchmarking/invocation.rs b/crates/cpex-wasm-host/benchmarking/invocation.rs new file mode 100644 index 00000000..8e05c398 --- /dev/null +++ b/crates/cpex-wasm-host/benchmarking/invocation.rs @@ -0,0 +1,266 @@ +// Location: ./crates/cpex-wasm-host/benchmarking/invocation.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +//! Performance benchmarks: WASM plugin invocation vs native handler. +//! +//! Measures the isolation tax of running the same logic through the +//! WASM sandbox vs calling the handler directly. +//! +//! Run: cargo bench -p cpex-wasm-host + +use std::path::PathBuf; +use std::sync::Arc; + +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use tokio::runtime::Runtime; +use tokio::sync::Mutex; + +use async_trait::async_trait; +use cpex_core::cmf::constants::SCHEMA_VERSION; +use cpex_core::cmf::message::MessagePayload; +use cpex_core::cmf::{CmfHook, ContentPart, Message, Role, ToolCall}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::http::HttpExtension; +use cpex_core::extensions::request::RequestExtension; +use cpex_core::extensions::security::SecurityExtension; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use cpex_wasm_host::conversions::{ + native_context_to_wit, native_extensions_to_wit, native_payload_to_wit, +}; +use cpex_wasm_host::sandbox_manager::{SandboxManager, SharedEngine}; + +// --------------------------------------------------------------------------- +// Native noop handler (baseline) +// --------------------------------------------------------------------------- + +struct NativeNoopPlugin; + +static BENCH_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for NativeNoopPlugin { + fn config(&self) -> &PluginConfig { + BENCH_CONFIG.get_or_init(|| PluginConfig { + name: "native-noop-bench".into(), + kind: "builtin".into(), + hooks: vec!["cmf.tool_pre_invoke".into()], + ..Default::default() + }) + } + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for NativeNoopPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +fn make_payload() -> MessagePayload { + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_001".into(), + name: "get_data".into(), + arguments: [("id".to_string(), serde_json::json!(42))].into(), + namespace: None, + }, + }], + channel: None, + }, + } +} + +fn make_minimal_extensions() -> Extensions { + Extensions::default() +} + +fn make_full_extensions() -> Extensions { + let mut security = SecurityExtension::default(); + security.add_label("PII"); + security.add_label("HR_DATA"); + + let mut http = HttpExtension::default(); + http.set_header("Authorization", "Bearer eyJ..."); + http.set_header("X-Request-ID", "req-abc-123"); + http.set_header("Content-Type", "application/json"); + + Extensions { + request: Some(Arc::new(RequestExtension { + request_id: Some("req-abc-123".into()), + environment: Some("production".into()), + ..Default::default() + })), + security: Some(Arc::new(security)), + http: Some(Arc::new(http)), + ..Default::default() + } +} + +// --------------------------------------------------------------------------- +// Benchmarks +// --------------------------------------------------------------------------- + +fn bench_native_noop(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let payload = make_payload(); + let ext = make_minimal_extensions(); + + c.bench_function("native_noop", |b| { + b.to_async(&rt).iter(|| async { + let mut ctx = PluginContext::default(); + let result = NativeNoopPlugin + .handle(black_box(&payload), black_box(&ext), &mut ctx) + .await; + black_box(result); + }); + }); +} + +fn bench_wasm_noop(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let wasm_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm/noop.wasm"); + + if !wasm_path.exists() { + eprintln!( + "SKIP: noop.wasm not found at {}. Build with:", + wasm_path.display() + ); + eprintln!(" cd crates/cpex-wasm-plugin && cargo build --target wasm32-wasip2 --release --features noop --no-default-features"); + return; + } + + let sandbox = rt.block_on(async { + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&wasm_path, None, "noop-bench") + .await + .unwrap(); + Arc::new(Mutex::new(mgr)) + }); + + let payload = make_payload(); + let ext = make_minimal_extensions(); + + c.bench_function("wasm_noop", |b| { + b.to_async(&rt).iter(|| { + let sandbox = sandbox.clone(); + let wit_payload = cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf( + native_payload_to_wit(&payload), + ); + let wit_ext = native_extensions_to_wit(&ext); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + async move { + let mut mgr = sandbox.lock().await; + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + black_box(result); + } + }); + }); +} + +fn bench_wasm_with_extensions(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let wasm_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm/noop.wasm"); + + if !wasm_path.exists() { + return; + } + + let sandbox = rt.block_on(async { + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&wasm_path, None, "noop-bench-ext") + .await + .unwrap(); + Arc::new(Mutex::new(mgr)) + }); + + let payload = make_payload(); + let ext = make_full_extensions(); + + c.bench_function("wasm_with_full_extensions", |b| { + b.to_async(&rt).iter(|| { + let sandbox = sandbox.clone(); + let wit_payload = cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf( + native_payload_to_wit(&payload), + ); + let wit_ext = native_extensions_to_wit(&ext); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + async move { + let mut mgr = sandbox.lock().await; + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + black_box(result); + } + }); + }); +} + +fn bench_conversion_only(c: &mut Criterion) { + let payload = make_payload(); + let ext = make_full_extensions(); + let ctx = PluginContext::default(); + + c.bench_function("conversion_native_to_wit", |b| { + b.iter(|| { + let _wp = native_payload_to_wit(black_box(&payload)); + let _we = native_extensions_to_wit(black_box(&ext)); + let _wc = native_context_to_wit(black_box(&ctx)); + }); + }); +} + +fn bench_native_with_extensions(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let payload = make_payload(); + let ext = make_full_extensions(); + + c.bench_function("native_with_full_extensions", |b| { + b.to_async(&rt).iter(|| async { + let mut ctx = PluginContext::default(); + let result = NativeNoopPlugin + .handle(black_box(&payload), black_box(&ext), &mut ctx) + .await; + black_box(result); + }); + }); +} + +criterion_group!( + benches, + bench_native_noop, + bench_native_with_extensions, + bench_conversion_only, + bench_wasm_noop, + bench_wasm_with_extensions, +); +criterion_main!(benches); diff --git a/crates/cpex-wasm-host/benchmarking/performance_comparison.png b/crates/cpex-wasm-host/benchmarking/performance_comparison.png new file mode 100644 index 00000000..78556d75 Binary files /dev/null and b/crates/cpex-wasm-host/benchmarking/performance_comparison.png differ diff --git a/crates/cpex-wasm-host/benchmarking/plot_results.py b/crates/cpex-wasm-host/benchmarking/plot_results.py new file mode 100644 index 00000000..2cf0bb6c --- /dev/null +++ b/crates/cpex-wasm-host/benchmarking/plot_results.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +# Location: ./crates/cpex-wasm-host/benchmarking/plot_results.py +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Shriti Priya +""" +Performance comparison chart generator for CPEX WASM benchmarks. + +Reads Criterion JSON output and produces a grouped bar chart comparing +native vs WASM performance across scenarios. + +Usage: + cd crates/cpex-wasm-host/benchmarking + python3 plot_results.py + +Requires: matplotlib (pip install matplotlib) + +Output: performance_comparison.png +""" + +import json +import os +import sys +from pathlib import Path + +try: + import matplotlib.pyplot as plt + import matplotlib.ticker as ticker +except ImportError: + print("Error: matplotlib is required. Install with: pip install matplotlib") + sys.exit(1) + + +def find_criterion_dir(): + """Find the Criterion output directory.""" + candidates = [ + Path("../../../target/criterion"), + Path("../../target/criterion"), + Path("target/criterion"), + ] + for c in candidates: + if c.exists(): + return c.resolve() + return None + + +def read_estimate(criterion_dir, bench_name): + """Read the median estimate (in nanoseconds) for a benchmark.""" + estimate_path = criterion_dir / bench_name / "new" / "estimates.json" + if not estimate_path.exists(): + # Try group format (concurrent_contention/N) + return None + with open(estimate_path) as f: + data = json.load(f) + return data["median"]["point_estimate"] + + +def read_group_estimates(criterion_dir, group_name): + """Read estimates for a benchmark group (e.g., concurrent_contention/1, /4, /8).""" + results = {} + group_dir = criterion_dir / group_name + if not group_dir.exists(): + return results + for entry in sorted(group_dir.iterdir()): + if entry.is_dir() and entry.name != "report": + estimate_path = entry / "new" / "estimates.json" + if estimate_path.exists(): + with open(estimate_path) as f: + data = json.load(f) + results[entry.name] = data["median"]["point_estimate"] + return results + + +def format_time(ns): + """Format nanoseconds to human-readable string.""" + if ns >= 1_000_000: + return f"{ns / 1_000_000:.1f} ms" + elif ns >= 1_000: + return f"{ns / 1_000:.1f} µs" + else: + return f"{ns:.0f} ns" + + +def main(): + criterion_dir = find_criterion_dir() + if criterion_dir is None: + print("Error: Criterion output not found. Run benchmarks first:") + print(" cargo bench -p cpex-wasm-host") + sys.exit(1) + + print(f"Reading from: {criterion_dir}\n") + + # --- Collect results --- + benchmarks = { + # From invocation.rs + "native_noop": read_estimate(criterion_dir, "native_noop"), + "native_with_full_extensions": read_estimate(criterion_dir, "native_with_full_extensions"), + "conversion_native_to_wit": read_estimate(criterion_dir, "conversion_native_to_wit"), + "wasm_noop": read_estimate(criterion_dir, "wasm_noop"), + "wasm_with_full_extensions": read_estimate(criterion_dir, "wasm_with_full_extensions"), + # From comprehensive.rs + "cold_start_wasm": read_estimate(criterion_dir, "cold_start_wasm"), + "compute_native": read_estimate(criterion_dir, "compute_native"), + "compute_wasm": read_estimate(criterion_dir, "compute_wasm"), + "custom_payload_wasm": read_estimate(criterion_dir, "custom_payload_wasm"), + "structured_payload_wasm": read_estimate(criterion_dir, "structured_payload_wasm"), + } + + contention = read_group_estimates(criterion_dir, "concurrent_contention") + + # Filter out None values + available = {k: v for k, v in benchmarks.items() if v is not None} + + if not available: + print("Error: No benchmark results found. Run benchmarks first:") + print(" cargo bench -p cpex-wasm-host") + sys.exit(1) + + # --- Print ASCII table --- + print("=" * 70) + print(f"{'Benchmark':<35} {'Latency':>12} {'vs Native':>12}") + print("-" * 70) + + native_noop = available.get("native_noop", 84) + for name, ns in sorted(available.items(), key=lambda x: x[1]): + ratio = ns / native_noop if native_noop else 0 + print(f" {name:<33} {format_time(ns):>12} {ratio:>10.1f}x") + + if contention: + print() + print(" Concurrent contention:") + for tasks, ns in sorted(contention.items(), key=lambda x: int(x[0])): + print(f" {tasks} tasks: {format_time(ns):>12}") + + print("=" * 70) + + # --- Generate chart --- + fig, axes = plt.subplots(1, 3, figsize=(16, 6)) + fig.suptitle("CPEX WASM Plugin Performance", fontsize=14, fontweight="bold") + + # Panel 1: "How much slower is WASM than native?" + ax1 = axes[0] + comparison_pairs = [] + + if "native_noop" in available and "wasm_noop" in available: + comparison_pairs.append(("Noop\n(sandbox overhead)", available["native_noop"], available["wasm_noop"])) + if "compute_native" in available and "compute_wasm" in available: + comparison_pairs.append(("Real Work\n(JSON + hash)", available["compute_native"], available["compute_wasm"])) + if "native_with_full_extensions" in available and "wasm_with_full_extensions" in available: + comparison_pairs.append(("Full Extensions\n(production-like)", available["native_with_full_extensions"], available["wasm_with_full_extensions"])) + + if comparison_pairs: + labels = [p[0] for p in comparison_pairs] + native_vals = [p[1] / 1000 for p in comparison_pairs] # convert to µs + wasm_vals = [p[2] / 1000 for p in comparison_pairs] + + x = range(len(labels)) + width = 0.35 + bars1 = ax1.bar([i - width/2 for i in x], native_vals, width, label="Native", color="#2196F3", alpha=0.85) + bars2 = ax1.bar([i + width/2 for i in x], wasm_vals, width, label="WASM", color="#FF9800", alpha=0.85) + + ax1.set_ylabel("Latency (µs, log scale)") + ax1.set_title("Native vs WASM\n(same workload, lower is better)") + ax1.set_xticks(list(x)) + ax1.set_xticklabels(labels, fontsize=9) + ax1.set_yscale("log") + ax1.legend(loc="upper left") + ax1.grid(axis="y", alpha=0.3) + + for bar in bars1: + h = bar.get_height() + ax1.annotate(f"{h:.2f}µs", xy=(bar.get_x() + bar.get_width()/2, h), + xytext=(0, 3), textcoords="offset points", ha="center", fontsize=8) + for bar in bars2: + h = bar.get_height() + ax1.annotate(f"{h:.1f}µs", xy=(bar.get_x() + bar.get_width()/2, h), + xytext=(0, 3), textcoords="offset points", ha="center", fontsize=8) + + # Panel 2: "Custom JSON payload vs structured WIT payload + cold start" + ax2 = axes[1] + payload_bars = [] + + if "custom_payload_wasm" in available: + payload_bars.append(("Custom Payload\n(JSON serde)", available["custom_payload_wasm"] / 1000, "#FF9800")) + if "structured_payload_wasm" in available: + payload_bars.append(("Structured Payload\n(WIT types)", available["structured_payload_wasm"] / 1000, "#4CAF50")) + if "cold_start_wasm" in available: + payload_bars.append(("Cold Start\n(first load + compile)", available["cold_start_wasm"] / 1000, "#F44336")) + + if payload_bars: + labels = [b[0] for b in payload_bars] + vals = [b[1] for b in payload_bars] + colors = [b[2] for b in payload_bars] + + bars = ax2.bar(range(len(labels)), vals, color=colors, alpha=0.85) + ax2.set_ylabel("Latency (µs, log scale)") + ax2.set_title("Payload Path Cost & Cold Start\n(one-time vs per-call)") + ax2.set_xticks(range(len(labels))) + ax2.set_xticklabels(labels, fontsize=9) + ax2.set_yscale("log") + ax2.grid(axis="y", alpha=0.3) + + for bar in bars: + h = bar.get_height() + if h > 1000: + label = f"{h/1000:.0f}ms" + else: + label = f"{h:.1f}µs" + ax2.annotate(label, xy=(bar.get_x() + bar.get_width()/2, h), + xytext=(0, 3), textcoords="offset points", ha="center", fontsize=9, fontweight="bold") + + # Panel 3: "How does mutex contention scale?" + ax3 = axes[2] + if contention: + tasks = sorted(contention.keys(), key=int) + vals = [contention[t] / 1000 for t in tasks] # convert to µs + per_call = [contention[t] / 1000 / int(t) for t in tasks] # per-call latency + + ax3.bar(range(len(tasks)), vals, color="#9C27B0", alpha=0.85, label="Total (all tasks)") + ax3.plot(range(len(tasks)), per_call, "o-", color="#E91E63", linewidth=2, markersize=8, label="Per-call effective") + + ax3.set_ylabel("Latency (µs)") + ax3.set_title("Mutex Contention\n(N tasks sharing 1 sandbox)") + ax3.set_xticks(range(len(tasks))) + ax3.set_xticklabels([f"{t} tasks" for t in tasks], fontsize=9) + ax3.legend(loc="upper left") + ax3.grid(axis="y", alpha=0.3) + + for i, (v, pc) in enumerate(zip(vals, per_call)): + ax3.annotate(f"{v:.0f}µs", xy=(i, v), + xytext=(0, 3), textcoords="offset points", ha="center", fontsize=8) + ax3.annotate(f"{pc:.0f}µs/call", xy=(i, pc), + xytext=(5, -10), textcoords="offset points", ha="left", fontsize=8, color="#E91E63") + + plt.tight_layout() + output_path = Path(__file__).parent / "performance_comparison.png" + plt.savefig(output_path, dpi=150, bbox_inches="tight") + print(f"\nChart saved: {output_path}") + + +if __name__ == "__main__": + main() diff --git a/crates/cpex-wasm-host/config/config.yaml b/crates/cpex-wasm-host/config/config.yaml new file mode 100644 index 00000000..f3e9854d --- /dev/null +++ b/crates/cpex-wasm-host/config/config.yaml @@ -0,0 +1,43 @@ +# CMF Capabilities Demo Configuration +# +# Three plugins with different capabilities see different views +# of the same extensions. Demonstrates capability-gated access +# across pre-invoke and post-invoke hooks. + +plugin_settings: + routing_enabled: true + +global: + policies: + all: + plugins: [identity-checker] + +plugins: + - name: identity-checker + kind: wasm://plugin.wasm + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke, tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + capabilities: + - read_labels + - read_subject + - read_roles + config: + # Sandbox policy controls what host resources the WASM plugin can access. + # Empty lists = full lockdown (deny-all): no filesystem, no network, no env vars. + # The plugin can only operate on data passed via handle-hook arguments. + sandbox_policy: + allowed_filesystem: [] # No host filesystem access + allowed_network: [] # No outbound HTTP allowed + allowed_env: [] # No host environment variables exposed + resources: + max_memory_bytes: 10485760 # 10 MB linear memory cap + max_fuel: 1000000000 # ~1 billion instructions (session-level budget) + max_execution_time_ms: 5000 # 5 seconds per invocation timeout + max_instances: 10 # Max WASM module instances + max_tables: 10 # Max WASM tables + +routes: + - tool: "*" + plugins: [] diff --git a/crates/cpex-wasm-host/config/config_capabilities.yaml b/crates/cpex-wasm-host/config/config_capabilities.yaml new file mode 100644 index 00000000..110a02e0 --- /dev/null +++ b/crates/cpex-wasm-host/config/config_capabilities.yaml @@ -0,0 +1,83 @@ +# WASM Capabilities Demo Configuration +# +# Three WASM plugins with different capabilities see different views +# of the same extensions. Demonstrates capability-gated access +# across pre-invoke and post-invoke hooks with sandboxed WASM plugins. + +plugin_settings: + routing_enabled: true + +global: + policies: + all: + plugins: [identity-checker, header-injector, audit-logger] + +plugins: + - name: identity-checker + kind: wasm://identity-checker.wasm + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + mode: sequential + priority: 10 + on_error: fail + capabilities: + - read_labels + - read_subject + - read_roles + config: + sandbox_policy: + allowed_filesystem: [] + allowed_network: [] + allowed_env: [] + resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 + max_execution_time_ms: 5000 + max_instances: 10 + max_tables: 10 + + - name: header-injector + kind: wasm://header-injector.wasm + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 20 + on_error: fail + capabilities: + - read_headers + - write_headers + - append_labels + config: + sandbox_policy: + allowed_filesystem: [] + allowed_network: [] + allowed_env: [] + resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 + max_execution_time_ms: 5000 + max_instances: 10 + max_tables: 10 + + - name: audit-logger + kind: wasm://audit-logger.wasm + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + mode: audit + priority: 100 + on_error: ignore + capabilities: + - read_headers + - read_labels + config: + sandbox_policy: + allowed_filesystem: [] + allowed_network: [] + allowed_env: [] + resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 + max_execution_time_ms: 5000 + max_instances: 10 + max_tables: 10 + +routes: + - tool: "*" + plugins: [] diff --git a/crates/cpex-wasm-host/config/config_plugin_demo.yaml b/crates/cpex-wasm-host/config/config_plugin_demo.yaml new file mode 100644 index 00000000..5287328d --- /dev/null +++ b/crates/cpex-wasm-host/config/config_plugin_demo.yaml @@ -0,0 +1,121 @@ +# WASM Plugin Demo Configuration +# +# Four WASM plugins using custom ToolInvokePayload, policy groups with +# tag-based activation, and routes that map tools to different plugin +# combinations. Mirrors the native cpex-core/examples/plugin_demo.yaml. + +plugin_settings: + routing_enabled: true + plugin_timeout: 30 + +global: + policies: + # "all" is reserved — these plugins fire on every invocation + all: + plugins: [identity-resolver] + # "pii" group — activated when a route has the "pii" tag + pii: + plugins: [pii-guard] + # "external_authz" group — activated when a route has the + # "needs_remote_authz" tag. Fires the RemoteAuthz WASM plugin. + external_authz: + plugins: [remote-authz] + +plugins: + - name: identity-resolver + kind: wasm://tool-invoke-checker.wasm + hooks: [tool_pre_invoke, tool_post_invoke] + mode: sequential + priority: 10 + on_error: fail + config: + sandbox_policy: + allowed_filesystem: [] + allowed_network: [] + allowed_env: [] + resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 + max_execution_time_ms: 5000 + max_instances: 10 + max_tables: 10 + + - name: pii-guard + kind: wasm://pii-guard.wasm + hooks: [tool_pre_invoke] + mode: sequential + priority: 20 + on_error: fail + config: + sandbox_policy: + allowed_filesystem: [] + allowed_network: [] + allowed_env: [] + resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 + max_execution_time_ms: 5000 + max_instances: 10 + max_tables: 10 + + - name: remote-authz + kind: wasm://remote-authz.wasm + hooks: [tool_pre_invoke] + mode: sequential + priority: 30 + on_error: fail + config: + sandbox_policy: + allowed_filesystem: [] + allowed_network: [] + allowed_env: [] + resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 + max_execution_time_ms: 5000 + max_instances: 10 + max_tables: 10 + + - name: audit-logger + kind: wasm://audit-logger-custom.wasm + hooks: [tool_pre_invoke, tool_post_invoke] + mode: fire_and_forget + priority: 100 + on_error: ignore + config: + sandbox_policy: + allowed_filesystem: [] + allowed_network: [] + allowed_env: [] + resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 + max_execution_time_ms: 5000 + max_instances: 10 + max_tables: 10 + +routes: + # HR compensation tool — contains PII, gets full security stack + - tool: get_compensation + meta: + tags: [pii, hr] + plugins: + - audit-logger + + # Public department listing — standard security only + - tool: list_departments + plugins: + - audit-logger + + # Tool that requires remote authz — triggers the async plugin + # on top of the standard "all" policy stack. + - tool: query_external_data + meta: + tags: [external_authz] + plugins: + - audit-logger + + # Wildcard — catch-all for unmatched tools + - tool: "*" + plugins: + - audit-logger diff --git a/crates/cpex-wasm-host/examples/wasm_capabilities_demo.rs b/crates/cpex-wasm-host/examples/wasm_capabilities_demo.rs new file mode 100644 index 00000000..50ee3e39 --- /dev/null +++ b/crates/cpex-wasm-host/examples/wasm_capabilities_demo.rs @@ -0,0 +1,217 @@ +// Location: ./crates/cpex-wasm-host/examples/wasm_capabilities_demo.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// WASM Capabilities Demo +// +// Demonstrates: +// 1. Three WASM plugins with different capability profiles +// 2. Capability-gated extension visibility across the WASM sandbox boundary +// 3. Extension modification by a WASM plugin (add label + inject header) +// 4. Multi-plugin pipeline with priority ordering +// +// Prerequisites: build all plugin binaries: +// cd crates/cpex-wasm-plugin && make build-all +// +// Run: +// cargo run -p cpex-wasm-host --example wasm_capabilities_demo + +use std::path::PathBuf; +use std::sync::Arc; + +use cpex_core::cmf::{CmfHook, ContentPart, Message, MessagePayload, Role, ToolCall, ToolResult}; +use cpex_core::config::parse_config; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::http::HttpExtension; +use cpex_core::extensions::meta::MetaExtension; +use cpex_core::extensions::request::RequestExtension; +use cpex_core::extensions::security::{SecurityExtension, SubjectExtension, SubjectType}; +use cpex_core::manager::PluginManager; + +use cpex_wasm_host::factory::WasmPluginFactory; + +#[tokio::main] +async fn main() { + // Initialize tracing so plugin cpex_log! calls are visible. + // Set RUST_LOG=info (or debug/trace) to control verbosity. + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("info".parse().unwrap()), + ) + .init(); + + println!("=== WASM Capabilities Demo ===\n"); + + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let config_path = crate_dir.join("config/config_capabilities.yaml"); + let wasm_dir = crate_dir.join("wasm"); + + println!("Loading config: {}", config_path.display()); + let yaml = std::fs::read_to_string(&config_path) + .unwrap_or_else(|e| panic!("failed to read {}: {}", config_path.display(), e)); + let cpex_config = parse_config(&yaml).unwrap(); + + let mgr = PluginManager::default(); + + // Register the WASM factory for each plugin kind. + // The factory strips "wasm://" and looks for the .wasm file in the wasm/ dir. + mgr.register_factory( + "wasm://identity-checker.wasm", + Box::new(WasmPluginFactory::with_builtin_payloads(wasm_dir.clone())), + ); + mgr.register_factory( + "wasm://header-injector.wasm", + Box::new(WasmPluginFactory::with_builtin_payloads(wasm_dir.clone())), + ); + mgr.register_factory( + "wasm://audit-logger.wasm", + Box::new(WasmPluginFactory::with_builtin_payloads(wasm_dir)), + ); + + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // --- Build CMF Message: assistant requesting a tool call --- + let pre_payload = MessagePayload { + message: Message { + schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ + ContentPart::Text { + text: "Looking up compensation data.".into(), + }, + ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_001".into(), + name: "get_compensation".into(), + arguments: [("employee_id".to_string(), serde_json::json!(42))].into(), + namespace: None, + }, + }, + ], + channel: None, + }, + }; + + let ext = build_extensions(); + + // --- Phase 1: Pre-invoke --- + println!("=== Phase 1: cmf.tool_pre_invoke ===\n"); + + let (pre_result, pre_bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", pre_payload, ext, None) + .await; + + println!(); + if pre_result.continue_processing { + println!("Pre-invoke result: ALLOWED"); + if let Some(ref modified_ext) = pre_result.modified_extensions { + if let Some(ref sec) = modified_ext.security { + let labels: Vec<&String> = sec.labels.iter().collect(); + println!(" Labels after pre-invoke: {:?}", labels); + } + if let Some(ref http) = modified_ext.http { + println!(" Headers after pre-invoke: {:?}", http.request_headers); + } + } + } else { + let reason = pre_result + .violation + .as_ref() + .map(|v| v.reason.as_str()) + .unwrap_or("unknown"); + println!("Pre-invoke result: DENIED — {}", reason); + pre_bg.wait_for_background_tasks().await; + println!("\n=== Demo complete ==="); + return; + } + pre_bg.wait_for_background_tasks().await; + + // --- Simulate tool execution --- + println!("\n--- Tool 'get_compensation' executes... ---"); + println!(" Result: {{\"salary\": 150000, \"currency\": \"USD\"}}\n"); + + // --- Phase 2: Post-invoke with tool result --- + println!("=== Phase 2: cmf.tool_post_invoke ===\n"); + + let post_payload = MessagePayload { + message: Message { + schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(), + role: Role::Tool, + content: vec![ContentPart::ToolResult { + content: ToolResult { + tool_call_id: "tc_001".into(), + tool_name: "get_compensation".into(), + content: serde_json::json!({"salary": 150000, "currency": "USD"}), + is_error: false, + }, + }], + channel: None, + }, + }; + + let post_ext = pre_result + .modified_extensions + .unwrap_or_else(build_extensions); + + let (post_result, post_bg) = mgr + .invoke_named::( + "cmf.tool_post_invoke", + post_payload, + post_ext, + Some(pre_result.context_table), + ) + .await; + + println!(); + if post_result.continue_processing { + println!("Post-invoke result: ALLOWED"); + } else { + let reason = post_result + .violation + .as_ref() + .map(|v| v.reason.as_str()) + .unwrap_or("unknown"); + println!("Post-invoke result: DENIED — {}", reason); + } + + post_bg.wait_for_background_tasks().await; + println!("\n=== Demo complete ==="); +} + +fn build_extensions() -> Extensions { + let mut security = SecurityExtension::default(); + security.add_label("PII"); + security.add_label("HR_DATA"); + security.classification = Some("confidential".into()); + security.subject = Some(SubjectExtension { + id: Some("alice".into()), + subject_type: Some(SubjectType::User), + roles: ["hr_admin".to_string()].into(), + permissions: ["read_compensation".to_string()].into(), + ..Default::default() + }); + + let mut http = HttpExtension::default(); + http.set_header("Authorization", "Bearer eyJ..."); + http.set_header("X-Request-ID", "req-abc-123"); + + Extensions { + request: Some(Arc::new(RequestExtension { + environment: Some("production".into()), + request_id: Some("req-abc-123".into()), + ..Default::default() + })), + security: Some(Arc::new(security)), + http: Some(Arc::new(http)), + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some("get_compensation".into()), + tags: ["pii".to_string(), "hr".to_string()].into(), + ..Default::default() + })), + ..Default::default() + } +} diff --git a/crates/cpex-wasm-host/examples/wasm_plugin_demo.rs b/crates/cpex-wasm-host/examples/wasm_plugin_demo.rs new file mode 100644 index 00000000..f4fb03e9 --- /dev/null +++ b/crates/cpex-wasm-host/examples/wasm_plugin_demo.rs @@ -0,0 +1,302 @@ +// Location: ./crates/cpex-wasm-host/examples/wasm_plugin_demo.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// WASM Plugin Demo +// +// End-to-end demo mirroring cpex-core/examples/plugin_demo.rs, but all 4 +// plugins run as sandboxed WASM binaries using a custom (user-defined) +// payload type. +// +// Demonstrates: +// 1. Define a custom payload + hook types (same as native plugin_demo) +// 2. Register the payload with PayloadSerializerRegistry +// 3. Load 4 WASM plugins via WasmPluginFactory (one per .wasm binary) +// 4. Multi-plugin pipeline with priority ordering and different modes +// 5. Policy groups with tag-based activation (pii, external_authz) +// 6. Context table threading across pre/post-invoke hooks +// 7. Fire-and-forget audit logging through WASM sandbox +// +// Prerequisites: +// cd crates/cpex-wasm-host && make build-all-plugins +// +// Run: +// cargo run -p cpex-wasm-host --example wasm_plugin_demo + +use std::path::PathBuf; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use cpex_core::config::parse_config; +use cpex_core::executor::PipelineResult; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::meta::MetaExtension; +use cpex_core::hooks::trait_def::{HookTypeDef, PluginResult}; +use cpex_core::manager::PluginManager; + +use cpex_wasm_host::factory::WasmPluginFactory; +use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; + +// --------------------------------------------------------------------------- +// Step 1: Define a custom payload and hook types +// +// This is the user-defined payload — it can be anything serializable. +// The same struct exists on each guest plugin for JSON serde roundtrip. +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ToolInvokePayload { + tool_name: String, + user: String, + arguments: String, +} + +cpex_core::impl_plugin_payload!(ToolInvokePayload); +cpex_core::impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); + +struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +struct ToolPostInvoke; +impl HookTypeDef for ToolPostInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_post_invoke"; +} + +// --------------------------------------------------------------------------- +// Step 2: Build extensions with MetaExtension for route resolution +// --------------------------------------------------------------------------- + +fn make_tool_extensions(tool_name: &str, tags: &[&str]) -> Extensions { + Extensions { + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some(tool_name.into()), + tags: tags.iter().map(|s| s.to_string()).collect(), + ..Default::default() + })), + ..Default::default() + } +} + +// --------------------------------------------------------------------------- +// Helper to print results +// --------------------------------------------------------------------------- + +fn print_result(_label: &str, result: &PipelineResult) { + if result.continue_processing { + println!(" Result: ALLOWED"); + } else { + let violation = result.violation.as_ref().unwrap(); + println!( + " Result: DENIED by '{}' — {} [{}]", + violation.plugin_name.as_deref().unwrap_or("unknown"), + violation.reason, + violation.code, + ); + } + println!(); +} + +// --------------------------------------------------------------------------- +// Step 3: Main — load config, register WASM factories, invoke hooks +// --------------------------------------------------------------------------- + +#[tokio::main] +async fn main() { + // Initialize tracing so plugin cpex_log! calls are visible + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("info".parse().unwrap()), + ) + .init(); + + println!("=== WASM Plugin Demo ===\n"); + + // --- Load config from YAML --- + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let config_path = crate_dir.join("config/config_plugin_demo.yaml"); + let wasm_dir = crate_dir.join("wasm"); + + println!("--- Loading config from {} ---\n", config_path.display()); + let yaml = std::fs::read_to_string(&config_path) + .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path.display(), e)); + let cpex_config = parse_config(&yaml).unwrap(); + + // --- Build payload registry with our custom type --- + let registry = Arc::new({ + let mut r = PayloadSerializerRegistry::new(); + r.register::(); + r + }); + + // --- Create manager and register a WASM factory per plugin kind --- + let mgr = PluginManager::default(); + mgr.register_factory( + "wasm://tool-invoke-checker.wasm", + Box::new(WasmPluginFactory::new(wasm_dir.clone(), registry.clone())), + ); + mgr.register_factory( + "wasm://pii-guard.wasm", + Box::new(WasmPluginFactory::new(wasm_dir.clone(), registry.clone())), + ); + mgr.register_factory( + "wasm://remote-authz.wasm", + Box::new(WasmPluginFactory::new(wasm_dir.clone(), registry.clone())), + ); + mgr.register_factory( + "wasm://audit-logger-custom.wasm", + Box::new(WasmPluginFactory::new(wasm_dir, registry)), + ); + + mgr.load_config(cpex_config).unwrap(); + + println!("\n--- Initializing plugins ---\n"); + mgr.initialize().await.unwrap(); + + println!("\nPlugins loaded: {}", mgr.plugin_count()); + println!( + "Hooks registered: tool_pre_invoke={}, tool_post_invoke={}\n", + mgr.has_hooks_for("tool_pre_invoke"), + mgr.has_hooks_for("tool_post_invoke"), + ); + + // ========================================================================= + // Scenario 1: PII tool without clearance + // Expected: identity-resolver allows → pii-guard DENIES + // ========================================================================= + println!("=== Scenario 1: get_compensation (PII tool, no clearance) ===\n"); + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = make_tool_extensions("get_compensation", &[]); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + print_result("get_compensation (no clearance)", &result); + bg.wait_for_background_tasks().await; + + // ========================================================================= + // Scenario 2: PII tool with clearance + // Expected: identity-resolver allows → pii-guard allows → audit-logger logs + // ========================================================================= + println!("=== Scenario 2: get_compensation (PII tool, with clearance) ===\n"); + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = make_tool_extensions("get_compensation", &[]); + let mut ctx_table = cpex_core::context::PluginContextTable::new(); + ctx_table + .global_state + .insert("pii_clearance".into(), serde_json::Value::Bool(true)); + let (result, bg) = mgr + .invoke::(payload, ext, Some(ctx_table)) + .await; + print_result("get_compensation (with clearance)", &result); + bg.wait_for_background_tasks().await; + + // Thread context table into post-invoke + println!(" --- post-invoke for get_compensation ---\n"); + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = make_tool_extensions("get_compensation", &[]); + let (post_result, bg) = mgr + .invoke::(payload, ext, Some(result.context_table)) + .await; + print_result("get_compensation post-invoke", &post_result); + bg.wait_for_background_tasks().await; + + // ========================================================================= + // Scenario 3: Non-PII tool + // Expected: identity-resolver allows → audit-logger logs → ALLOWED + // ========================================================================= + println!("=== Scenario 3: list_departments (non-PII tool) ===\n"); + let payload = ToolInvokePayload { + tool_name: "list_departments".into(), + user: "bob".into(), + arguments: "".into(), + }; + let ext = make_tool_extensions("list_departments", &[]); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + print_result("list_departments", &result); + bg.wait_for_background_tasks().await; + + // ========================================================================= + // Scenario 4: Unknown tool (wildcard route) + // Expected: identity-resolver allows → audit-logger logs → ALLOWED + // ========================================================================= + println!("=== Scenario 4: some_other_tool (wildcard route) ===\n"); + let payload = ToolInvokePayload { + tool_name: "some_other_tool".into(), + user: "charlie".into(), + arguments: "foo=bar".into(), + }; + let ext = make_tool_extensions("some_other_tool", &[]); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + print_result("some_other_tool (wildcard)", &result); + bg.wait_for_background_tasks().await; + + // ========================================================================= + // Scenario 5: Remote authz — cache hit (alice is in ACL) + // Expected: identity-resolver allows → remote-authz allows → ALLOWED + // ========================================================================= + println!("=== Scenario 5: query_external_data (remote authz, ACL hit) ===\n"); + let payload = ToolInvokePayload { + tool_name: "query_external_data".into(), + user: "alice".into(), + arguments: "dataset=sales".into(), + }; + let ext = make_tool_extensions("query_external_data", &[]); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + print_result("query_external_data (alice — in ACL)", &result); + bg.wait_for_background_tasks().await; + + // ========================================================================= + // Scenario 6: Remote authz — cache miss (charlie is NOT in ACL) + // Expected: identity-resolver allows → remote-authz DENIES + // ========================================================================= + println!("=== Scenario 6: query_external_data (remote authz, ACL miss) ===\n"); + let payload = ToolInvokePayload { + tool_name: "query_external_data".into(), + user: "charlie".into(), + arguments: "dataset=sales".into(), + }; + let ext = make_tool_extensions("query_external_data", &[]); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + print_result("query_external_data (charlie — not in ACL)", &result); + bg.wait_for_background_tasks().await; + + // ========================================================================= + // Scenario 7: No user identity + // Expected: identity-resolver DENIES (first in pipeline) + // ========================================================================= + println!("=== Scenario 7: list_departments (no user identity) ===\n"); + let payload = ToolInvokePayload { + tool_name: "list_departments".into(), + user: "".into(), + arguments: "".into(), + }; + let ext = make_tool_extensions("list_departments", &[]); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + print_result("list_departments (no user)", &result); + bg.wait_for_background_tasks().await; + + // --- Shutdown --- + println!("--- Shutting down ---\n"); + mgr.shutdown().await; + + println!("=== Demo complete ==="); +} diff --git a/crates/cpex-wasm-host/src/conversions.rs b/crates/cpex-wasm-host/src/conversions.rs new file mode 100644 index 00000000..d2ced38f --- /dev/null +++ b/crates/cpex-wasm-host/src/conversions.rs @@ -0,0 +1,1453 @@ +// Location: ./crates/cpex-wasm-host/src/conversions.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// Host-side type conversions: native cpex-core types ↔ WIT types. +// Used by WasmBridgeHandler to translate between the PluginManager's native +// types and the WIT types that the WASM sandbox expects. + +use chrono::DateTime; + +use cpex_core::cmf::content as native_content; +use cpex_core::cmf::enums as native_enums; +use cpex_core::cmf::message as native_msg; +use cpex_core::context::PluginContext as NativePluginContext; +use cpex_core::delegation::payload::{ + AttenuationConfig as NativeAttenuationConfig, AuthEnforcedBy as NativeAuthEnforcedBy, + DelegationPayload as NativeDelegationPayload, TargetType as NativeTargetType, +}; +use cpex_core::error::PluginViolation as NativePluginViolation; +use cpex_core::executor::ErasedResultFields; +use cpex_core::extensions::agent::AgentExtension as NativeAgentExtension; +use cpex_core::extensions::authorization::AuthorizationDetail as NativeAuthDetail; +use cpex_core::extensions::completion::{ + CompletionExtension as NativeCompletionExtension, StopReason as NativeStopReason, +}; +use cpex_core::extensions::container::{ + Extensions as NativeExtensions, OwnedExtensions as NativeOwnedExtensions, +}; +use cpex_core::extensions::delegation::{ + DelegationExtension as NativeDelegationExtension, DelegationHop as NativeDelegationHop, + DelegationStrategy as NativeDelegationStrategy, +}; +use cpex_core::extensions::framework::FrameworkExtension as NativeFrameworkExtension; +use cpex_core::extensions::http::HttpExtension as NativeHttpExtension; +use cpex_core::extensions::llm::LLMExtension as NativeLLMExtension; +use cpex_core::extensions::mcp::{ + MCPExtension as NativeMCPExtension, PromptMetadata as NativePromptMetadata, + ResourceMetadata as NativeResourceMetadata, ToolMetadata as NativeToolMetadata, +}; +use cpex_core::extensions::meta::MetaExtension as NativeMetaExtension; +use cpex_core::extensions::provenance::ProvenanceExtension as NativeProvenanceExtension; +use cpex_core::extensions::raw_credentials::{ + DelegationMode as NativeDelegationMode, RawDelegatedToken as NativeRawDelegatedToken, +}; +use cpex_core::extensions::request::RequestExtension as NativeRequestExtension; +use cpex_core::extensions::security::{ + ClientExtension as NativeClientExtension, ClientTrustLevel as NativeClientTrustLevel, + DataPolicy as NativeDataPolicy, ObjectSecurityProfile as NativeObjectSecurityProfile, + RetentionPolicy as NativeRetentionPolicy, SecurityExtension as NativeSecurityExtension, + SubjectExtension as NativeSubjectExtension, SubjectType as NativeSubjectType, + WorkloadIdentity as NativeWorkloadIdentity, +}; +use cpex_core::hooks::payload::PluginPayload; +use cpex_core::identity::payload::{ + IdentityPayload as NativeIdentityPayload, TokenSource as NativeTokenSource, +}; + +use crate::payload_registry::PayloadSerializerRegistry; +use crate::sandbox_manager::types::*; + +// --------------------------------------------------------------------------- +// Native → WIT: MessagePayload +// --------------------------------------------------------------------------- + +pub fn native_payload_to_wit(payload: &native_msg::MessagePayload) -> MessagePayload { + MessagePayload { + message: native_message_to_wit(&payload.message), + } +} + +fn native_message_to_wit(msg: &native_msg::Message) -> Message { + Message { + schema_version: msg.schema_version.clone(), + role: native_role_to_wit(msg.role), + content: msg.content.iter().map(native_content_part_to_wit).collect(), + channel: msg.channel.map(native_channel_to_wit), + } +} + +fn native_role_to_wit(role: native_enums::Role) -> Role { + match role { + native_enums::Role::System => Role::System, + native_enums::Role::Developer => Role::Developer, + native_enums::Role::User => Role::User, + native_enums::Role::Assistant => Role::Assistant, + native_enums::Role::Tool => Role::Tool, + } +} + +fn native_channel_to_wit(channel: native_enums::Channel) -> Channel { + match channel { + native_enums::Channel::Analysis => Channel::Analysis, + native_enums::Channel::Commentary => Channel::Commentary, + native_enums::Channel::Final => Channel::Final, + } +} + +fn native_content_part_to_wit(part: &native_content::ContentPart) -> ContentPart { + match part { + native_content::ContentPart::Text { text } => ContentPart::Text(text.clone()), + native_content::ContentPart::Thinking { text } => ContentPart::Thinking(text.clone()), + native_content::ContentPart::ToolCall { content } => { + ContentPart::ToolCall(native_tool_call_to_wit(content)) + }, + native_content::ContentPart::ToolResult { content } => { + ContentPart::ToolResult(native_tool_result_to_wit(content)) + }, + native_content::ContentPart::Resource { content } => { + ContentPart::CmfResource(native_resource_to_wit(content)) + }, + native_content::ContentPart::ResourceRef { content } => { + ContentPart::ResourceRef(native_resource_ref_to_wit(content)) + }, + native_content::ContentPart::PromptRequest { content } => { + ContentPart::PromptRequest(native_prompt_request_to_wit(content)) + }, + native_content::ContentPart::PromptResult { content } => { + ContentPart::PromptResult(native_prompt_result_to_wit(content)) + }, + native_content::ContentPart::Image { content } => ContentPart::Image(ImageSource { + source_type: content.source_type.clone(), + data: content.data.clone(), + media_type: content.media_type.clone(), + }), + native_content::ContentPart::Video { content } => ContentPart::Video(VideoSource { + source_type: content.source_type.clone(), + data: content.data.clone(), + media_type: content.media_type.clone(), + duration_ms: content.duration_ms, + }), + native_content::ContentPart::Audio { content } => ContentPart::Audio(AudioSource { + source_type: content.source_type.clone(), + data: content.data.clone(), + media_type: content.media_type.clone(), + duration_ms: content.duration_ms, + }), + native_content::ContentPart::Document { content } => { + ContentPart::Document(DocumentSource { + source_type: content.source_type.clone(), + data: content.data.clone(), + media_type: content.media_type.clone(), + title: content.title.clone(), + }) + }, + } +} + +fn native_tool_call_to_wit(tc: &native_content::ToolCall) -> ToolCall { + ToolCall { + tool_call_id: tc.tool_call_id.clone(), + name: tc.name.clone(), + arguments: serde_json::to_string(&tc.arguments).unwrap_or_else(|_| "{}".to_string()), + namespace: tc.namespace.clone(), + } +} + +fn native_tool_result_to_wit(tr: &native_content::ToolResult) -> ToolResult { + ToolResult { + tool_call_id: tr.tool_call_id.clone(), + tool_name: tr.tool_name.clone(), + content: serde_json::to_string(&tr.content).unwrap_or_default(), + is_error: tr.is_error, + } +} + +fn native_resource_to_wit(r: &native_content::Resource) -> CmfResource { + CmfResource { + resource_request_id: r.resource_request_id.clone(), + uri: r.uri.clone(), + name: r.name.clone(), + description: r.description.clone(), + resource_type: native_resource_type_to_wit(r.resource_type), + content: r.content.clone(), + blob: r.blob.clone(), + mime_type: r.mime_type.clone(), + size_bytes: r.size_bytes, + annotations: serde_json::to_string(&r.annotations).unwrap_or_else(|_| "{}".to_string()), + version: r.version.clone(), + } +} + +fn native_resource_type_to_wit(rt: native_enums::ResourceType) -> ResourceType { + match rt { + native_enums::ResourceType::File => ResourceType::File, + native_enums::ResourceType::Blob => ResourceType::Blob, + native_enums::ResourceType::Uri => ResourceType::Uri, + native_enums::ResourceType::Database => ResourceType::Database, + native_enums::ResourceType::Api => ResourceType::Api, + native_enums::ResourceType::Memory => ResourceType::Memory, + native_enums::ResourceType::Artifact => ResourceType::Artifact, + } +} + +fn native_resource_ref_to_wit(rr: &native_content::ResourceReference) -> ResourceReference { + ResourceReference { + resource_request_id: rr.resource_request_id.clone(), + uri: rr.uri.clone(), + name: rr.name.clone(), + resource_type: native_resource_type_to_wit(rr.resource_type), + range_start: rr.range_start, + range_end: rr.range_end, + selector: rr.selector.clone(), + } +} + +fn native_prompt_request_to_wit(pr: &native_content::PromptRequest) -> PromptRequest { + PromptRequest { + prompt_request_id: pr.prompt_request_id.clone(), + name: pr.name.clone(), + arguments: serde_json::to_string(&pr.arguments).unwrap_or_else(|_| "{}".to_string()), + server_id: pr.server_id.clone(), + } +} + +fn native_prompt_result_to_wit(pr: &native_content::PromptResult) -> PromptResult { + PromptResult { + prompt_request_id: pr.prompt_request_id.clone(), + prompt_name: pr.prompt_name.clone(), + messages: serde_json::to_string(&pr.messages).unwrap_or_else(|_| "[]".to_string()), + content: pr.content.clone(), + is_error: pr.is_error, + error_message: pr.error_message.clone(), + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: IdentityPayload +// --------------------------------------------------------------------------- + +pub fn native_identity_payload_to_wit(p: &NativeIdentityPayload) -> IdentityPayload { + let (source, source_custom) = match p.source() { + NativeTokenSource::Bearer => (TokenSource::Bearer, None), + NativeTokenSource::UserToken => (TokenSource::UserToken, None), + NativeTokenSource::Mtls => (TokenSource::Mtls, None), + NativeTokenSource::SpiffeJwtSvid => (TokenSource::SpiffeJwtSvid, None), + NativeTokenSource::ApiKey => (TokenSource::ApiKey, None), + NativeTokenSource::Custom(s) => (TokenSource::Custom, Some(s.clone())), + _ => (TokenSource::Bearer, None), + }; + IdentityPayload { + source, + source_custom, + source_header: p.source_header().map(str::to_owned), + headers: p + .headers() + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + client_host: p.client_host().map(str::to_owned), + client_port: p.client_port(), + subject: p.subject.as_ref().map(native_subject_to_wit), + client: p.client.as_ref().map(native_client_to_wit), + caller_workload: p.caller_workload.as_ref().map(native_workload_to_wit), + delegation: p.delegation.as_ref().map(native_delegation_to_wit), + resolved_at: p.resolved_at.map(|dt| dt.to_rfc3339()), + raw_claims: if p.raw_claims.is_empty() { + None + } else { + serde_json::to_string(&p.raw_claims).ok() + }, + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: DelegationPayload +// --------------------------------------------------------------------------- + +pub fn native_delegation_payload_to_wit(p: &NativeDelegationPayload) -> DelegationPayload { + let (target_type, target_type_custom) = match p.target_type() { + NativeTargetType::Tool => (TargetType::Tool, None), + NativeTargetType::Agent => (TargetType::Agent, None), + NativeTargetType::Resource => (TargetType::Resource, None), + NativeTargetType::Service => (TargetType::Service, None), + NativeTargetType::Custom(s) => (TargetType::Custom, Some(s.clone())), + _ => (TargetType::Tool, None), + }; + let auth_enforced_by = match p.auth_enforced_by() { + NativeAuthEnforcedBy::Caller => AuthEnforcedBy::Caller, + NativeAuthEnforcedBy::Target => AuthEnforcedBy::Target, + NativeAuthEnforcedBy::Both => AuthEnforcedBy::Both, + _ => AuthEnforcedBy::Caller, + }; + DelegationPayload { + target_name: p.target_name().to_owned(), + target_type, + target_type_custom, + target_audience: p.target_audience().map(str::to_owned), + required_permissions: p.required_permissions().to_vec(), + trust_domain: p.trust_domain().map(str::to_owned), + auth_enforced_by, + route_attenuation: p.route_attenuation().map(native_attenuation_to_wit), + // token bytes are #[serde(skip)] — omit from WIT + delegated_token: p + .delegated_token + .as_ref() + .map(native_raw_delegated_token_to_wit), + delegation_update: p.delegation_update.as_ref().map(native_delegation_to_wit), + delegation_mode: p.delegation_mode.as_ref().map(|m| match m { + NativeDelegationMode::OnBehalfOfUser => DelegationMode::OnBehalfOfUser, + NativeDelegationMode::AsGateway => DelegationMode::AsGateway, + _ => DelegationMode::OnBehalfOfUser, + }), + minted_at: p.minted_at.map(|dt| dt.to_rfc3339()), + metadata: if p.metadata.is_empty() { + None + } else { + serde_json::to_string(&p.metadata).ok() + }, + } +} + +fn native_attenuation_to_wit(a: &NativeAttenuationConfig) -> AttenuationConfig { + AttenuationConfig { + capabilities: a.capabilities.clone(), + resource_template: a.resource_template.clone(), + actions: a.actions.clone(), + ttl_seconds: a.ttl_seconds, + } +} + +fn native_raw_delegated_token_to_wit(t: &NativeRawDelegatedToken) -> RawDelegatedToken { + RawDelegatedToken { + outbound_header: t.outbound_header.clone(), + audience: t.audience.clone(), + scopes: t.scopes.clone(), + expires_at: t.expires_at.to_rfc3339(), + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: Extensions +// --------------------------------------------------------------------------- + +pub fn native_extensions_to_wit(ext: &NativeExtensions) -> Extensions { + Extensions { + request: ext.request.as_ref().map(|r| native_request_to_wit(r)), + security: ext.security.as_ref().map(|s| native_security_to_wit(s)), + http: ext.http.as_ref().map(|h| native_http_to_wit(h)), + meta: ext.meta.as_ref().map(|m| native_meta_to_wit(m)), + agent: ext.agent.as_ref().map(|a| native_agent_to_wit(a)), + mcp: ext.mcp.as_ref().map(|m| native_mcp_to_wit(m)), + completion: ext.completion.as_ref().map(|c| native_completion_to_wit(c)), + provenance: ext.provenance.as_ref().map(|p| native_provenance_to_wit(p)), + llm: ext.llm.as_ref().map(|l| native_llm_to_wit(l)), + framework: ext.framework.as_ref().map(|f| native_framework_to_wit(f)), + delegation: ext.delegation.as_ref().map(|d| native_delegation_to_wit(d)), + custom: ext + .custom + .as_ref() + .and_then(|c| serde_json::to_string(c.as_ref()).ok()), + } +} + +fn native_request_to_wit(r: &NativeRequestExtension) -> RequestExtension { + RequestExtension { + environment: r.environment.clone(), + request_id: r.request_id.clone(), + timestamp: r.timestamp.clone(), + trace_id: r.trace_id.clone(), + span_id: r.span_id.clone(), + } +} + +fn native_security_to_wit(s: &NativeSecurityExtension) -> SecurityExtension { + SecurityExtension { + labels: s.labels.iter().cloned().collect(), + classification: s.classification.clone(), + subject: s.subject.as_ref().map(native_subject_to_wit), + client: s.client.as_ref().map(native_client_to_wit), + caller_workload: s.caller_workload.as_ref().map(native_workload_to_wit), + this_workload: s.this_workload.as_ref().map(native_workload_to_wit), + auth_method: s.auth_method.clone(), + objects: s + .objects + .iter() + .map(|(k, v)| (k.clone(), native_object_profile_to_wit(v))) + .collect(), + data: s + .data + .iter() + .map(|(k, v)| (k.clone(), native_data_policy_to_wit(v))) + .collect(), + } +} + +fn native_subject_to_wit(s: &NativeSubjectExtension) -> SubjectExtension { + SubjectExtension { + id: s.id.clone(), + subject_type: s.subject_type.as_ref().map(native_subject_type_to_wit), + roles: s.roles.iter().cloned().collect(), + permissions: s.permissions.iter().cloned().collect(), + teams: s.teams.iter().cloned().collect(), + claims: s + .claims + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + } +} + +fn native_subject_type_to_wit(st: &NativeSubjectType) -> SubjectType { + match st { + NativeSubjectType::User => SubjectType::User, + NativeSubjectType::Agent => SubjectType::Agent, + NativeSubjectType::Service => SubjectType::Service, + NativeSubjectType::System => SubjectType::System, + } +} + +fn native_client_to_wit(c: &NativeClientExtension) -> ClientExtension { + let (trust_level, trust_level_custom) = match &c.trust_level { + NativeClientTrustLevel::FirstParty => (ClientTrustLevel::FirstParty, None), + NativeClientTrustLevel::ThirdParty => (ClientTrustLevel::ThirdParty, None), + NativeClientTrustLevel::Internal => (ClientTrustLevel::Internal, None), + NativeClientTrustLevel::Custom(s) => (ClientTrustLevel::ThirdParty, Some(s.clone())), + _ => (ClientTrustLevel::ThirdParty, None), + }; + ClientExtension { + client_id: c.client_id.clone(), + client_name: c.client_name.clone(), + trust_level, + trust_level_custom, + authorized_scopes: c.authorized_scopes.clone(), + authorized_audiences: c.authorized_audiences.clone(), + roles: c.roles.clone(), + permissions: c.permissions.clone(), + teams: c.teams.clone(), + claims: c + .claims + .iter() + .map(|(k, v)| (k.clone(), serde_json::to_string(v).unwrap_or_default())) + .collect(), + } +} + +fn native_workload_to_wit(w: &NativeWorkloadIdentity) -> WorkloadIdentity { + WorkloadIdentity { + spiffe_id: w.spiffe_id.clone(), + trust_domain: w.trust_domain.clone(), + attested_at: w.attested_at.map(|dt| dt.to_rfc3339()), + attestor: w.attestor.clone(), + selectors: w.selectors.clone(), + client_id: w.client_id.clone(), + } +} + +fn native_object_profile_to_wit(o: &NativeObjectSecurityProfile) -> ObjectSecurityProfile { + ObjectSecurityProfile { + managed_by: o.managed_by.clone(), + permissions: o.permissions.clone(), + trust_domain: o.trust_domain.clone(), + data_scope: o.data_scope.clone(), + } +} + +fn native_data_policy_to_wit(d: &NativeDataPolicy) -> DataPolicy { + DataPolicy { + apply_labels: d.apply_labels.clone(), + allowed_actions: d.allowed_actions.clone(), + denied_actions: d.denied_actions.clone(), + retention: d.retention.as_ref().map(|r| RetentionPolicy { + max_age_seconds: r.max_age_seconds, + policy: r.policy.clone(), + delete_after: r.delete_after.clone(), + }), + } +} + +fn native_http_to_wit(h: &NativeHttpExtension) -> HttpExtension { + HttpExtension { + request_headers: h + .request_headers + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + response_headers: h + .response_headers + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + method: h.method.clone(), + path: h.path.clone(), + host: h.host.clone(), + scheme: h.scheme.clone(), + } +} + +fn native_meta_to_wit(m: &NativeMetaExtension) -> MetaExtension { + MetaExtension { + entity_type: m.entity_type.clone(), + entity_name: m.entity_name.clone(), + tags: m.tags.iter().cloned().collect(), + scope: m.scope.clone(), + properties: m + .properties + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + } +} + +fn native_agent_to_wit(a: &NativeAgentExtension) -> AgentExtension { + AgentExtension { + input: a.input.clone(), + session_id: a.session_id.clone(), + conversation_id: a.conversation_id.clone(), + turn: a.turn, + agent_id: a.agent_id.clone(), + parent_agent_id: a.parent_agent_id.clone(), + conversation: a.conversation.as_ref().map(|c| ConversationContext { + history: c + .history + .iter() + .map(|v| serde_json::to_string(v).unwrap_or_default()) + .collect(), + summary: c.summary.clone(), + topics: c.topics.clone(), + }), + } +} + +fn native_mcp_to_wit(m: &NativeMCPExtension) -> McpExtension { + McpExtension { + tool: m.tool.as_ref().map(native_tool_metadata_to_wit), + resource_info: m.resource.as_ref().map(native_resource_metadata_to_wit), + prompt: m.prompt.as_ref().map(native_prompt_metadata_to_wit), + } +} + +fn native_tool_metadata_to_wit(t: &NativeToolMetadata) -> ToolMetadata { + ToolMetadata { + name: t.name.clone(), + title: t.title.clone(), + description: t.description.clone(), + input_schema: t + .input_schema + .as_ref() + .and_then(|v| serde_json::to_string(v).ok()), + output_schema: t + .output_schema + .as_ref() + .and_then(|v| serde_json::to_string(v).ok()), + server_id: t.server_id.clone(), + namespace: t.namespace.clone(), + annotations: t + .annotations + .iter() + .map(|(k, v)| (k.clone(), serde_json::to_string(v).unwrap_or_default())) + .collect(), + } +} + +fn native_resource_metadata_to_wit(r: &NativeResourceMetadata) -> ResourceMetadata { + ResourceMetadata { + uri: r.uri.clone(), + name: r.name.clone(), + description: r.description.clone(), + mime_type: r.mime_type.clone(), + server_id: r.server_id.clone(), + annotations: r + .annotations + .iter() + .map(|(k, v)| (k.clone(), serde_json::to_string(v).unwrap_or_default())) + .collect(), + } +} + +fn native_prompt_metadata_to_wit(p: &NativePromptMetadata) -> PromptMetadata { + PromptMetadata { + name: p.name.clone(), + description: p.description.clone(), + arguments: p + .arguments + .as_ref() + .and_then(|v| serde_json::to_string(v).ok()), + server_id: p.server_id.clone(), + annotations: p + .annotations + .iter() + .map(|(k, v)| (k.clone(), serde_json::to_string(v).unwrap_or_default())) + .collect(), + } +} + +fn native_completion_to_wit(c: &NativeCompletionExtension) -> CompletionExtension { + CompletionExtension { + stop_reason: c.stop_reason.map(|r| match r { + NativeStopReason::End => StopReason::End, + NativeStopReason::Return => StopReason::ReturnComplete, + NativeStopReason::Call => StopReason::Call, + NativeStopReason::MaxTokens => StopReason::MaxTokens, + NativeStopReason::StopSequence => StopReason::StopSequence, + }), + tokens: c.tokens.as_ref().map(|t| TokenUsage { + input_tokens: t.input_tokens, + output_tokens: t.output_tokens, + total_tokens: t.total_tokens, + }), + model: c.model.clone(), + raw_format: c.raw_format.clone(), + created_at: c.created_at.clone(), + latency_ms: c.latency_ms, + } +} + +fn native_provenance_to_wit(p: &NativeProvenanceExtension) -> ProvenanceExtension { + ProvenanceExtension { + source: p.source.clone(), + message_id: p.message_id.clone(), + parent_id: p.parent_id.clone(), + } +} + +fn native_llm_to_wit(l: &NativeLLMExtension) -> LlmExtension { + LlmExtension { + model_id: l.model_id.clone(), + provider: l.provider.clone(), + capabilities: l.capabilities.clone(), + } +} + +fn native_framework_to_wit(f: &NativeFrameworkExtension) -> FrameworkExtension { + FrameworkExtension { + framework: f.framework.clone(), + framework_version: f.framework_version.clone(), + node_id: f.node_id.clone(), + graph_id: f.graph_id.clone(), + metadata: if f.metadata.is_empty() { + None + } else { + serde_json::to_string(&f.metadata).ok() + }, + } +} + +fn native_delegation_to_wit(d: &NativeDelegationExtension) -> DelegationExtension { + DelegationExtension { + chain: d.chain.iter().map(native_delegation_hop_to_wit).collect(), + depth: d.depth, + origin_subject_id: d.origin_subject_id.clone(), + actor_subject_id: d.actor_subject_id.clone(), + delegated: d.delegated, + age_seconds: d.age_seconds.to_string(), + } +} + +fn native_delegation_hop_to_wit(hop: &NativeDelegationHop) -> DelegationHop { + let (strategy, strategy_custom) = match &hop.strategy { + None => (None, None), + Some(NativeDelegationStrategy::TokenExchange) => { + (Some(DelegationStrategy::TokenExchange), None) + }, + Some(NativeDelegationStrategy::ClientCredentials) => { + (Some(DelegationStrategy::ClientCredentials), None) + }, + Some(NativeDelegationStrategy::SpiffeSvid) => (Some(DelegationStrategy::SpiffeSvid), None), + Some(NativeDelegationStrategy::Passthrough) => { + (Some(DelegationStrategy::Passthrough), None) + }, + Some(NativeDelegationStrategy::Ucan) => (Some(DelegationStrategy::Ucan), None), + Some(NativeDelegationStrategy::TransactionToken) => { + (Some(DelegationStrategy::TransactionToken), None) + }, + Some(NativeDelegationStrategy::Custom(s)) => (None, Some(s.clone())), + Some(_) => (None, None), + }; + DelegationHop { + subject_id: hop.subject_id.clone(), + subject_type: hop.subject_type.as_ref().map(native_subject_type_to_wit), + audience: hop.audience.clone(), + scopes_granted: hop.scopes_granted.clone(), + authorization_details: hop + .authorization_details + .iter() + .map(native_auth_detail_to_wit) + .collect(), + timestamp: hop.timestamp.to_rfc3339(), + ttl_seconds: hop.ttl_seconds, + strategy, + strategy_custom, + from_cache: hop.from_cache, + } +} + +fn native_auth_detail_to_wit(a: &NativeAuthDetail) -> AuthorizationDetail { + AuthorizationDetail { + detail_type: a.detail_type.clone(), + locations: a.locations.clone(), + actions: a.actions.clone(), + datatypes: a.datatypes.clone(), + identifier: a.identifier.clone(), + privileges: a.privileges.clone(), + extra: if a.extra.is_empty() { + None + } else { + serde_json::to_string(&a.extra).ok() + }, + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: PluginContext +// --------------------------------------------------------------------------- + +pub fn native_context_to_wit(ctx: &NativePluginContext) -> PluginContext { + PluginContext { + local_state: ctx + .local_state + .iter() + .map(|(k, v)| ContextEntry { + key: k.clone(), + value: serde_json::to_string(v).unwrap_or_default(), + }) + .collect(), + global_state: ctx + .global_state + .iter() + .map(|(k, v)| ContextEntry { + key: k.clone(), + value: serde_json::to_string(v).unwrap_or_default(), + }) + .collect(), + } +} + +// --------------------------------------------------------------------------- +// WIT → Native: HookResult +// --------------------------------------------------------------------------- + +/// Converts a WIT HookResult into the executor's type-erased result fields. +/// +/// The modified payload stays type-erased (`Box`) so a +/// generic hook (e.g. identity_resolve carrying `IdentityPayload`) gets its +/// modification back as the concrete type the pipeline expects — the registry +/// reconstructs it from the type discriminator. Result `metadata` has no slot +/// in `ErasedResultFields` and is dropped, same as the native erasure path. +pub fn wit_hook_result_to_native( + result: crate::sandbox_manager::types::HookResult, + registry: &PayloadSerializerRegistry, + original_extensions: &NativeExtensions, +) -> (ErasedResultFields, Option) { + wit_hook_result_to_native_filtered(result, registry, original_extensions, None) +} + +/// Same as `wit_hook_result_to_native` but accepts the filtered extensions +/// that were actually sent to the WASM guest. When provided, mutable slots +/// that were hidden from the guest (filtered to `None`) are preserved from +/// the original rather than being nulled by the guest's empty return. +pub fn wit_hook_result_to_native_filtered( + result: crate::sandbox_manager::types::HookResult, + registry: &PayloadSerializerRegistry, + original_extensions: &NativeExtensions, + filtered_extensions: Option<&NativeExtensions>, +) -> (ErasedResultFields, Option) { + let modified_payload: Option> = + result.modified_payload.and_then(|hp| match hp { + HookPayload::Cmf(mp) => { + Some(Box::new(wit_cmf_payload_to_native(mp)) as Box) + }, + HookPayload::Identity(ip) => { + Some(Box::new(wit_identity_payload_to_native(ip)) as Box) + }, + HookPayload::Delegation(dp) => { + Some(Box::new(wit_delegation_payload_to_native(dp)) as Box) + }, + HookPayload::Custom(gp) => { + match registry.deserialize(&gp.payload_type, &gp.payload_data) { + Ok(boxed) => Some(boxed), + Err(e) => { + tracing::warn!( + "custom payload writeback failed for '{}': {}", + gp.payload_type, + e + ); + None + }, + } + }, + }); + + let fields = ErasedResultFields { + continue_processing: result.continue_processing, + modified_payload, + modified_extensions: result + .modified_extensions + .map(|e| wit_extensions_to_owned(e, original_extensions, filtered_extensions)), + violation: result.violation.map(wit_violation_to_native), + }; + + let modified_ctx = result.modified_context.map(wit_context_to_native); + (fields, modified_ctx) +} + +fn wit_violation_to_native(v: PluginViolation) -> NativePluginViolation { + NativePluginViolation { + code: v.code, + reason: v.reason, + description: v.description, + details: serde_json::from_str(&v.details).unwrap_or_default(), + plugin_name: v.plugin_name, + proto_error_code: v.proto_error_code, + } +} + +pub fn wit_context_to_native(ctx: PluginContext) -> NativePluginContext { + NativePluginContext { + local_state: ctx + .local_state + .into_iter() + .map(|e| { + ( + e.key, + serde_json::from_str(&e.value).unwrap_or(serde_json::Value::String(e.value)), + ) + }) + .collect(), + global_state: ctx + .global_state + .into_iter() + .map(|e| { + ( + e.key, + serde_json::from_str(&e.value).unwrap_or(serde_json::Value::String(e.value)), + ) + }) + .collect(), + } +} + +// --------------------------------------------------------------------------- +// WIT → Native: MessagePayload (for modified_payload in results) +// --------------------------------------------------------------------------- + +pub fn wit_cmf_payload_to_native(payload: MessagePayload) -> native_msg::MessagePayload { + native_msg::MessagePayload { + message: wit_message_to_native(payload.message), + } +} + +fn wit_message_to_native(msg: Message) -> native_msg::Message { + native_msg::Message { + schema_version: msg.schema_version, + role: wit_role_to_native(msg.role), + content: msg + .content + .into_iter() + .map(wit_content_part_to_native) + .collect(), + channel: msg.channel.map(wit_channel_to_native), + } +} + +fn wit_role_to_native(role: Role) -> native_enums::Role { + match role { + Role::System => native_enums::Role::System, + Role::Developer => native_enums::Role::Developer, + Role::User => native_enums::Role::User, + Role::Assistant => native_enums::Role::Assistant, + Role::Tool => native_enums::Role::Tool, + } +} + +fn wit_channel_to_native(channel: Channel) -> native_enums::Channel { + match channel { + Channel::Analysis => native_enums::Channel::Analysis, + Channel::Commentary => native_enums::Channel::Commentary, + Channel::Final => native_enums::Channel::Final, + } +} + +fn wit_content_part_to_native(part: ContentPart) -> native_content::ContentPart { + match part { + ContentPart::Text(text) => native_content::ContentPart::Text { text }, + ContentPart::Thinking(text) => native_content::ContentPart::Thinking { text }, + ContentPart::ToolCall(tc) => native_content::ContentPart::ToolCall { + content: native_content::ToolCall { + tool_call_id: tc.tool_call_id, + name: tc.name, + arguments: serde_json::from_str(&tc.arguments).unwrap_or_default(), + namespace: tc.namespace, + }, + }, + ContentPart::ToolResult(tr) => native_content::ContentPart::ToolResult { + content: native_content::ToolResult { + tool_call_id: tr.tool_call_id, + tool_name: tr.tool_name, + content: serde_json::from_str(&tr.content) + .unwrap_or(serde_json::Value::String(tr.content)), + is_error: tr.is_error, + }, + }, + ContentPart::CmfResource(r) => native_content::ContentPart::Resource { + content: native_content::Resource { + resource_request_id: r.resource_request_id, + uri: r.uri, + name: r.name, + description: r.description, + resource_type: wit_resource_type_to_native(r.resource_type), + content: r.content, + blob: r.blob, + mime_type: r.mime_type, + size_bytes: r.size_bytes, + annotations: serde_json::from_str(&r.annotations).unwrap_or_default(), + version: r.version, + }, + }, + ContentPart::ResourceRef(rr) => native_content::ContentPart::ResourceRef { + content: native_content::ResourceReference { + resource_request_id: rr.resource_request_id, + uri: rr.uri, + name: rr.name, + resource_type: wit_resource_type_to_native(rr.resource_type), + range_start: rr.range_start, + range_end: rr.range_end, + selector: rr.selector, + }, + }, + ContentPart::PromptRequest(pr) => native_content::ContentPart::PromptRequest { + content: native_content::PromptRequest { + prompt_request_id: pr.prompt_request_id, + name: pr.name, + arguments: serde_json::from_str(&pr.arguments).unwrap_or_default(), + server_id: pr.server_id, + }, + }, + ContentPart::PromptResult(pr) => native_content::ContentPart::PromptResult { + content: native_content::PromptResult { + prompt_request_id: pr.prompt_request_id, + prompt_name: pr.prompt_name, + messages: serde_json::from_str(&pr.messages).unwrap_or_default(), + content: pr.content, + is_error: pr.is_error, + error_message: pr.error_message, + }, + }, + ContentPart::Image(img) => native_content::ContentPart::Image { + content: native_content::ImageSource { + source_type: img.source_type, + data: img.data, + media_type: img.media_type, + }, + }, + ContentPart::Video(v) => native_content::ContentPart::Video { + content: native_content::VideoSource { + source_type: v.source_type, + data: v.data, + media_type: v.media_type, + duration_ms: v.duration_ms, + }, + }, + ContentPart::Audio(a) => native_content::ContentPart::Audio { + content: native_content::AudioSource { + source_type: a.source_type, + data: a.data, + media_type: a.media_type, + duration_ms: a.duration_ms, + }, + }, + ContentPart::Document(d) => native_content::ContentPart::Document { + content: native_content::DocumentSource { + source_type: d.source_type, + data: d.data, + media_type: d.media_type, + title: d.title, + }, + }, + } +} + +// --------------------------------------------------------------------------- +// WIT → Native: IdentityPayload +// --------------------------------------------------------------------------- + +pub fn wit_identity_payload_to_native(p: IdentityPayload) -> NativeIdentityPayload { + let source = match p.source { + TokenSource::Bearer => NativeTokenSource::Bearer, + TokenSource::UserToken => NativeTokenSource::UserToken, + TokenSource::Mtls => NativeTokenSource::Mtls, + TokenSource::SpiffeJwtSvid => NativeTokenSource::SpiffeJwtSvid, + TokenSource::ApiKey => NativeTokenSource::ApiKey, + TokenSource::Custom => NativeTokenSource::Custom(p.source_custom.unwrap_or_default()), + }; + let mut out = NativeIdentityPayload::new("", source); + if let Some(h) = p.source_header { + out = out.with_source_header(h); + } + out = out.with_headers(p.headers.into_iter().collect()); + if let Some(h) = p.client_host { + out = out.with_client_host(h); + } + if let Some(port) = p.client_port { + out = out.with_client_port(port); + } + out.subject = p.subject.map(|s| NativeSubjectExtension { + id: s.id, + subject_type: s.subject_type.map(|st| match st { + SubjectType::User => NativeSubjectType::User, + SubjectType::Agent => NativeSubjectType::Agent, + SubjectType::Service => NativeSubjectType::Service, + SubjectType::System => NativeSubjectType::System, + }), + roles: s.roles.into_iter().collect(), + permissions: s.permissions.into_iter().collect(), + teams: s.teams.into_iter().collect(), + claims: s.claims.into_iter().collect(), + }); + out.client = p.client.map(wit_client_to_native); + out.caller_workload = p.caller_workload.map(wit_workload_to_native); + out.delegation = p.delegation.map(wit_delegation_to_native); + out.resolved_at = p + .resolved_at + .as_deref() + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)); + out.raw_claims = p + .raw_claims + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + out +} + +// --------------------------------------------------------------------------- +// WIT → Native: DelegationPayload +// --------------------------------------------------------------------------- + +pub fn wit_delegation_payload_to_native(p: DelegationPayload) -> NativeDelegationPayload { + let target_type = match p.target_type { + TargetType::Tool => NativeTargetType::Tool, + TargetType::Agent => NativeTargetType::Agent, + TargetType::Resource => NativeTargetType::Resource, + TargetType::Service => NativeTargetType::Service, + TargetType::Custom => NativeTargetType::Custom(p.target_type_custom.unwrap_or_default()), + }; + let auth_enforced_by = match p.auth_enforced_by { + AuthEnforcedBy::Caller => NativeAuthEnforcedBy::Caller, + AuthEnforcedBy::Target => NativeAuthEnforcedBy::Target, + AuthEnforcedBy::Both => NativeAuthEnforcedBy::Both, + }; + // bearer_token is never sent across the boundary; reconstruct with empty string + let mut out = NativeDelegationPayload::new("", p.target_name) + .with_target_type(target_type) + .with_auth_enforced_by(auth_enforced_by); + if let Some(aud) = p.target_audience { + out = out.with_target_audience(aud); + } + if !p.required_permissions.is_empty() { + out = out.with_required_permissions(p.required_permissions); + } + if let Some(td) = p.trust_domain { + out = out.with_trust_domain(td); + } + if let Some(att) = p.route_attenuation { + out = out.with_route_attenuation(NativeAttenuationConfig { + capabilities: att.capabilities, + resource_template: att.resource_template, + actions: att.actions, + ttl_seconds: att.ttl_seconds, + }); + } + out.delegated_token = p.delegated_token.map(|t| { + let expires_at = DateTime::parse_from_rfc3339(&t.expires_at) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .unwrap_or_else(|_| chrono::Utc::now()); + // token bytes are not sent across — reconstructed empty + NativeRawDelegatedToken::new("", t.outbound_header, t.audience, t.scopes, expires_at) + }); + out.delegation_update = p.delegation_update.map(wit_delegation_to_native); + out.delegation_mode = p.delegation_mode.map(|m| match m { + DelegationMode::OnBehalfOfUser => NativeDelegationMode::OnBehalfOfUser, + DelegationMode::AsGateway => NativeDelegationMode::AsGateway, + }); + out.minted_at = p + .minted_at + .as_deref() + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)); + out.metadata = p + .metadata + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + out +} + +fn wit_resource_type_to_native(rt: ResourceType) -> native_enums::ResourceType { + match rt { + ResourceType::File => native_enums::ResourceType::File, + ResourceType::Blob => native_enums::ResourceType::Blob, + ResourceType::Uri => native_enums::ResourceType::Uri, + ResourceType::Database => native_enums::ResourceType::Database, + ResourceType::Api => native_enums::ResourceType::Api, + ResourceType::Memory => native_enums::ResourceType::Memory, + ResourceType::Artifact => native_enums::ResourceType::Artifact, + } +} + +// --------------------------------------------------------------------------- +// WIT → Native: Extensions (writeback from guest) +// --------------------------------------------------------------------------- + +fn wit_extensions_to_owned( + ext: Extensions, + original: &NativeExtensions, + filtered: Option<&NativeExtensions>, +) -> NativeOwnedExtensions { + use cpex_core::extensions::guarded::Guarded; + + // Seed from cow_copy() of the pipeline's extensions so the immutable + // slots keep their original Arc pointers — `validate_immutable` checks + // pointer equality, and slots rebuilt from WIT data would always be + // rejected as tampering. Only the mutable slots (http, security, + // delegation, custom) are overlaid with what the guest returned; + // those are the only slots `merge_owned` consumes. + let mut owned = original.cow_copy(); + + // Only overwrite a mutable slot if the guest was authorized to see it + // (i.e., it was present in the filtered view sent to the guest). + // If the slot was hidden by capability filtering, preserve the original + // value from cow_copy() — otherwise the guest's empty return would + // null out the pipeline's slot. + let guest_saw_security = filtered.is_none_or(|f| f.security.is_some()); + let guest_saw_http = filtered.is_none_or(|f| f.http.is_some()); + let guest_saw_delegation = filtered.is_none_or(|f| f.delegation.is_some()); + + if guest_saw_security { + owned.security = ext.security.map(wit_security_to_native); + } + if guest_saw_http { + owned.http = ext.http.map(|h| { + Guarded::new(NativeHttpExtension { + request_headers: h.request_headers.into_iter().collect(), + response_headers: h.response_headers.into_iter().collect(), + method: h.method, + path: h.path, + host: h.host, + scheme: h.scheme, + }) + }); + } + if guest_saw_delegation { + owned.delegation = ext.delegation.map(wit_delegation_to_native); + } + // Custom is unrestricted — always writable + owned.custom = ext.custom.and_then(|s| serde_json::from_str(&s).ok()); + + owned +} + +fn wit_security_to_native(s: SecurityExtension) -> NativeSecurityExtension { + NativeSecurityExtension { + labels: cpex_core::extensions::monotonic::MonotonicSet::from_set( + s.labels.into_iter().collect(), + ), + classification: s.classification, + subject: s.subject.map(|sub| NativeSubjectExtension { + id: sub.id, + subject_type: sub.subject_type.map(|st| match st { + SubjectType::User => NativeSubjectType::User, + SubjectType::Agent => NativeSubjectType::Agent, + SubjectType::Service => NativeSubjectType::Service, + SubjectType::System => NativeSubjectType::System, + }), + roles: sub.roles.into_iter().collect(), + permissions: sub.permissions.into_iter().collect(), + teams: sub.teams.into_iter().collect(), + claims: sub.claims.into_iter().collect(), + }), + client: s.client.map(wit_client_to_native), + caller_workload: s.caller_workload.map(wit_workload_to_native), + this_workload: s.this_workload.map(wit_workload_to_native), + auth_method: s.auth_method, + objects: s + .objects + .into_iter() + .map(|(k, v)| { + ( + k, + NativeObjectSecurityProfile { + managed_by: v.managed_by, + permissions: v.permissions, + trust_domain: v.trust_domain, + data_scope: v.data_scope, + }, + ) + }) + .collect(), + data: s + .data + .into_iter() + .map(|(k, v)| { + ( + k, + NativeDataPolicy { + apply_labels: v.apply_labels, + allowed_actions: v.allowed_actions, + denied_actions: v.denied_actions, + retention: v.retention.map(|r| NativeRetentionPolicy { + max_age_seconds: r.max_age_seconds, + policy: r.policy, + delete_after: r.delete_after, + }), + }, + ) + }) + .collect(), + } +} + +fn wit_client_to_native(c: ClientExtension) -> NativeClientExtension { + let trust_level = match c.trust_level_custom { + Some(s) => NativeClientTrustLevel::Custom(s), + None => match c.trust_level { + ClientTrustLevel::FirstParty => NativeClientTrustLevel::FirstParty, + ClientTrustLevel::ThirdParty => NativeClientTrustLevel::ThirdParty, + ClientTrustLevel::Internal => NativeClientTrustLevel::Internal, + }, + }; + NativeClientExtension { + client_id: c.client_id, + client_name: c.client_name, + trust_level, + authorized_scopes: c.authorized_scopes, + authorized_audiences: c.authorized_audiences, + roles: c.roles, + permissions: c.permissions, + teams: c.teams, + claims: c + .claims + .into_iter() + .map(|(k, v)| { + ( + k, + serde_json::from_str(&v).unwrap_or(serde_json::Value::String(v)), + ) + }) + .collect(), + } +} + +fn wit_workload_to_native(w: WorkloadIdentity) -> NativeWorkloadIdentity { + NativeWorkloadIdentity { + spiffe_id: w.spiffe_id, + trust_domain: w.trust_domain, + attested_at: w + .attested_at + .as_deref() + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)), + attestor: w.attestor, + selectors: w.selectors, + client_id: w.client_id, + } +} + +fn wit_delegation_to_native(d: DelegationExtension) -> NativeDelegationExtension { + NativeDelegationExtension { + chain: d + .chain + .into_iter() + .map(|hop| { + let strategy = match (hop.strategy, hop.strategy_custom) { + (Some(DelegationStrategy::TokenExchange), _) => { + Some(NativeDelegationStrategy::TokenExchange) + }, + (Some(DelegationStrategy::ClientCredentials), _) => { + Some(NativeDelegationStrategy::ClientCredentials) + }, + (Some(DelegationStrategy::SpiffeSvid), _) => { + Some(NativeDelegationStrategy::SpiffeSvid) + }, + (Some(DelegationStrategy::Passthrough), _) => { + Some(NativeDelegationStrategy::Passthrough) + }, + (Some(DelegationStrategy::Ucan), _) => Some(NativeDelegationStrategy::Ucan), + (Some(DelegationStrategy::TransactionToken), _) => { + Some(NativeDelegationStrategy::TransactionToken) + }, + (None, Some(s)) => Some(NativeDelegationStrategy::Custom(s)), + (None, None) => None, + }; + NativeDelegationHop { + subject_id: hop.subject_id, + subject_type: hop.subject_type.map(|st| match st { + SubjectType::User => NativeSubjectType::User, + SubjectType::Agent => NativeSubjectType::Agent, + SubjectType::Service => NativeSubjectType::Service, + SubjectType::System => NativeSubjectType::System, + }), + audience: hop.audience, + scopes_granted: hop.scopes_granted, + authorization_details: hop + .authorization_details + .into_iter() + .map(|a| NativeAuthDetail { + detail_type: a.detail_type, + locations: a.locations, + actions: a.actions, + datatypes: a.datatypes, + identifier: a.identifier, + privileges: a.privileges, + extra: a + .extra + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(), + }) + .collect(), + timestamp: DateTime::parse_from_rfc3339(&hop.timestamp) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .unwrap_or_else(|_| chrono::Utc::now()), + ttl_seconds: hop.ttl_seconds, + strategy, + from_cache: hop.from_cache, + } + }) + .collect(), + depth: d.depth, + origin_subject_id: d.origin_subject_id, + actor_subject_id: d.actor_subject_id, + delegated: d.delegated, + age_seconds: d.age_seconds.parse().unwrap_or(0.0), + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Arc; + + use cpex_core::hooks::payload::WasmSerializablePayload; + use cpex_core::identity::{IdentityPayload, TokenSource}; + + use super::*; + + fn empty_hook_result() -> HookResult { + HookResult { + continue_processing: true, + modified_payload: None, + modified_extensions: None, + modified_context: None, + violation: None, + metadata: None, + } + } + + #[test] + fn test_generic_modified_payload_writeback_preserves_concrete_type() { + // A guest returning a modified IdentityPayload must come back as + // IdentityPayload, not be silently dropped for not being CMF. + let mut registry = PayloadSerializerRegistry::new(); + registry.register::(); + + let mut headers = HashMap::new(); + headers.insert("x-user-id".to_string(), "alice".to_string()); + let mut payload = + IdentityPayload::new("secret-token", TokenSource::Bearer).with_headers(headers); + payload.subject = Some(NativeSubjectExtension { + id: Some("alice".to_string()), + ..Default::default() + }); + + let result = HookResult { + modified_payload: Some(HookPayload::Custom(CustomPayload { + payload_type: "cpex.identity".to_string(), + payload_data: payload.to_wasm_bytes().unwrap(), + })), + ..empty_hook_result() + }; + + let (fields, _) = + wit_hook_result_to_native(result, ®istry, &NativeExtensions::default()); + + let boxed = fields.modified_payload.expect("modified payload dropped"); + let roundtripped = boxed + .as_any() + .downcast_ref::() + .expect("payload lost its concrete type across the boundary"); + assert_eq!( + roundtripped.subject.as_ref().and_then(|s| s.id.as_deref()), + Some("alice") + ); + // The raw token is #[serde(skip)] — it must NOT survive transport. + assert_eq!(roundtripped.raw_token(), ""); + } + + #[test] + fn test_cmf_modified_payload_writeback() { + let registry = PayloadSerializerRegistry::new(); + let native = native_msg::MessagePayload { + message: native_msg::Message::text(native_enums::Role::User, "hello"), + }; + + let result = HookResult { + modified_payload: Some(HookPayload::Cmf(native_payload_to_wit(&native))), + ..empty_hook_result() + }; + + let (fields, _) = + wit_hook_result_to_native(result, ®istry, &NativeExtensions::default()); + let boxed = fields.modified_payload.expect("modified payload dropped"); + assert!(boxed + .as_any() + .downcast_ref::() + .is_some()); + } + + #[test] + fn test_modified_extensions_pass_validate_immutable() { + // Guest-returned extensions are rebuilt from WIT data. The owned + // copy must share the original's Arcs on immutable slots or the + // executor rejects the whole modification as tampering. + let mut security = NativeSecurityExtension::default(); + security.add_label("PII"); + + let original = NativeExtensions { + request: Some(Arc::new(NativeRequestExtension { + request_id: Some("req-1".to_string()), + ..Default::default() + })), + meta: Some(Arc::new(NativeMetaExtension { + entity_type: Some("tool".to_string()), + ..Default::default() + })), + security: Some(Arc::new(security)), + ..Default::default() + }; + + // Simulate the guest echoing extensions back (with a label added). + let mut wit_ext = native_extensions_to_wit(&original); + if let Some(ref mut s) = wit_ext.security { + s.labels.push("CHECKED".to_string()); + } + + let result = HookResult { + modified_extensions: Some(wit_ext), + ..empty_hook_result() + }; + + let registry = PayloadSerializerRegistry::new(); + let (fields, _) = wit_hook_result_to_native(result, ®istry, &original); + let owned = fields.modified_extensions.expect("extensions dropped"); + + assert!( + original.validate_immutable(&owned), + "writeback extensions flagged as tampering" + ); + + // Monotonic label check must also pass: original labels preserved. + let new_sec = owned.security.as_ref().unwrap(); + let orig_sec = original.security.as_ref().unwrap(); + assert!(new_sec.labels.is_superset(&orig_sec.labels)); + assert!(new_sec.has_label("CHECKED")); + } +} diff --git a/crates/cpex-wasm-host/src/factory.rs b/crates/cpex-wasm-host/src/factory.rs new file mode 100644 index 00000000..4c334b73 --- /dev/null +++ b/crates/cpex-wasm-host/src/factory.rs @@ -0,0 +1,551 @@ +// Location: ./crates/cpex-wasm-host/src/factory.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// WasmPluginFactory — bridges cpex-core's PluginFactory trait to the +// SandboxManager. Implements PluginFactory so WASM plugins can be +// registered with the PluginManager and participate in the hook pipeline. +// Each plugin gets its own SandboxManager instance (isolated engine + store). + +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::Mutex; +use tracing::warn; + +use cpex_core::cmf::message::MessagePayload; +use cpex_core::context::PluginContext; +use cpex_core::delegation::DelegationPayload; +use cpex_core::error::PluginError; +use cpex_core::extensions::{Extensions, OwnedExtensions}; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::payload::PluginPayload; +use cpex_core::identity::IdentityPayload; +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_core::registry::AnyHookHandler; + +use crate::conversions::{ + native_context_to_wit, native_delegation_payload_to_wit, native_extensions_to_wit, + native_identity_payload_to_wit, native_payload_to_wit, wit_hook_result_to_native_filtered, +}; +use crate::payload_registry::PayloadSerializerRegistry; +use crate::sandbox_manager::SandboxManager; + +// --------------------------------------------------------------------------- +// WasmPluginFactory +// --------------------------------------------------------------------------- + +/// Factory that creates WASM plugin instances using a shared wasmtime engine. +/// All plugins from the same factory share one engine and one epoch ticker thread. +/// Each plugin gets its own Store (isolated memory, fuel, state) — no contention. +pub struct WasmPluginFactory { + wasm_dir: PathBuf, + registry: Arc, + shared_engine: Arc, +} + +impl WasmPluginFactory { + /// Create a factory with a pre-built payload registry and shared engine. + pub fn new(wasm_dir: PathBuf, registry: Arc) -> Self { + let shared_engine = Arc::new( + crate::sandbox_manager::SharedEngine::new() + .expect("failed to create shared wasmtime engine"), + ); + Self { + wasm_dir, + registry, + shared_engine, + } + } + + /// Convenience constructor that pre-registers all built-in payload types: + /// `MessagePayload` (CMF hooks), `IdentityPayload` (identity_resolve), + /// and `DelegationPayload` (token_delegate). + /// Credential fields marked `#[serde(skip)]` on the identity and + /// delegation payloads never cross the sandbox boundary. + pub fn with_builtin_payloads(wasm_dir: PathBuf) -> Self { + let mut registry = PayloadSerializerRegistry::new(); + registry.register::(); + registry.register::(); + registry.register::(); + Self::new(wasm_dir, Arc::new(registry)) + } +} + +impl PluginFactory for WasmPluginFactory { + fn create(&self, config: &PluginConfig) -> Result> { + // Parse wasm path from kind (e.g., "wasm://plugin.wasm" → "plugin.wasm") + let wasm_filename = config.kind.strip_prefix("wasm://").ok_or_else(|| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}': kind '{}' must start with 'wasm://'", + config.name, config.kind + ), + }) + })?; + + let wasm_path = self.wasm_dir.join(wasm_filename); + + // Extract sandbox policy from plugin's config field. + // If absent, deny-by-default applies (no filesystem, no network, no env vars). + let sandbox_policy = config + .config + .as_ref() + .and_then(|v| v.get("sandbox_policy")) + .and_then(|v| { + serde_json::from_value::(v.clone()).ok() + }); + + // Create a SandboxManager backed by the shared engine (one epoch thread for all plugins) + let sandbox = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let mut mgr = SandboxManager::with_shared_engine(&self.shared_engine); + mgr.load_wasmplugin(&wasm_path, sandbox_policy.as_ref(), &config.name) + .await + .map_err(|e| format!("failed to load WASM: {}", e))?; + Ok::<_, String>(mgr) + }) + }) + .map_err(|e| { + Box::new(PluginError::Config { + message: format!("plugin '{}': {}", config.name, e), + }) + })?; + + let sandbox = Arc::new(Mutex::new(sandbox)); + + let timeout_ms = sandbox_policy + .as_ref() + .and_then(|p| p.resources.max_execution_time_ms) + .unwrap_or(5_000); + + let plugin: Arc = Arc::new(WasmBridgePlugin { + config: config.clone(), + }); + + // Register a separate handler per hook so each carries its own hook_name + let hooks: Vec<(&'static str, Arc)> = config + .hooks + .iter() + .map(|hook_name| { + let leaked: &'static str = Box::leak(hook_name.clone().into_boxed_str()); + let handler: Arc = Arc::new(WasmBridgeHandler { + plugin_name: config.name.clone(), + hook_name: hook_name.clone(), + sandbox: sandbox.clone(), + registry: self.registry.clone(), + capabilities: config.capabilities.clone(), + timeout_ms, + }); + (leaked, handler) + }) + .collect(); + + Ok(PluginInstance { + plugin, + handlers: hooks, + }) + } +} + +// --------------------------------------------------------------------------- +// WasmBridgePlugin — lifecycle wrapper +// --------------------------------------------------------------------------- + +/// Implements the Plugin trait for WASM plugins. Handles lifecycle. +struct WasmBridgePlugin { + config: PluginConfig, +} + +#[async_trait] +impl Plugin for WasmBridgePlugin { + fn config(&self) -> &PluginConfig { + &self.config + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// WasmBridgeHandler — hook dispatch through the WASM sandbox +// --------------------------------------------------------------------------- + +/// Implements AnyHookHandler by converting native types to WIT, +/// invoking the WASM sandbox, and converting the result back. +/// Each handler owns its own SandboxManager — no contention with other plugins. +struct WasmBridgeHandler { + plugin_name: String, + hook_name: String, + sandbox: Arc>, + registry: Arc, + capabilities: HashSet, + timeout_ms: u64, +} + +#[async_trait] +impl AnyHookHandler for WasmBridgeHandler { + async fn invoke( + &self, + payload: &dyn PluginPayload, + extensions: &Extensions, + ctx: &mut PluginContext, + ) -> Result, Box> { + // Build the WIT payload: structured fast-path for known types, generic fallback via registry + let wit_hook_payload = if let Some(cmf) = payload.as_any().downcast_ref::() + { + crate::sandbox_manager::types::HookPayload::Cmf(native_payload_to_wit(cmf)) + } else if let Some(identity) = payload.as_any().downcast_ref::() { + crate::sandbox_manager::types::HookPayload::Identity(native_identity_payload_to_wit( + identity, + )) + } else if let Some(delegation) = payload.as_any().downcast_ref::() { + crate::sandbox_manager::types::HookPayload::Delegation( + native_delegation_payload_to_wit(delegation), + ) + } else if self.registry.contains_type_id(payload.as_any().type_id()) { + let (type_name, bytes) = self.registry.serialize(payload).map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}': payload serialization failed: {}", + self.plugin_name, e + ), + }) + })?; + crate::sandbox_manager::types::HookPayload::Custom( + crate::sandbox_manager::types::CustomPayload { + payload_type: type_name.to_string(), + payload_data: bytes, + }, + ) + } else { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': payload type not registered in PayloadSerializerRegistry", + self.plugin_name, + ), + })); + }; + + // The executor already filters extensions by capabilities before calling + // this handler. We convert directly — no redundant re-filtering. + let wit_extensions = native_extensions_to_wit(extensions); + let wit_ctx = native_context_to_wit(ctx); + + // Invoke the WASM plugin through its dedicated sandbox + let wit_result = { + let mut mgr = self.sandbox.lock().await; + mgr.invoke(&self.hook_name, wit_hook_payload, wit_extensions, wit_ctx) + .await + .map_err(|e| classify_wasm_error(&self.plugin_name, self.timeout_ms, e))? + }; + + // Convert WIT HookResult → type-erased result fields + optional + // context writeback. Pass `extensions` as the filtered reference so + // hidden slots are preserved during writeback. + let (mut erased_fields, modified_ctx) = wit_hook_result_to_native_filtered( + wit_result, + &self.registry, + extensions, + Some(extensions), + ); + + // Defense-in-depth: validate extension modifications returned by WASM + if let Some(ref owned) = erased_fields.modified_extensions { + if !validate_extension_modifications( + owned, + extensions, + &self.capabilities, + &self.plugin_name, + &self.hook_name, + ) { + erased_fields.modified_extensions = None; + } + } + + if let Some(new_ctx) = modified_ctx { + ctx.local_state = new_ctx.local_state; + ctx.global_state = new_ctx.global_state; + } + + Ok(Box::new(erased_fields)) + } + + fn hook_type_name(&self) -> &'static str { + "cmf" + } +} + +// --------------------------------------------------------------------------- +// Post-invocation validation — defense-in-depth at the WASM trust boundary +// --------------------------------------------------------------------------- + +/// Validate extension modifications returned by a WASM plugin. +/// +/// Checks immutable tier integrity, monotonic label enforcement, and +/// write authorization. Returns `true` if the modifications should be +/// accepted, `false` if they must be rejected. +fn validate_extension_modifications( + owned: &OwnedExtensions, + original: &Extensions, + capabilities: &HashSet, + plugin_name: &str, + hook_name: &str, +) -> bool { + // Check 1: Immutable tier — Arc pointers must be identical + if !original.validate_immutable(owned) { + warn!( + "[WASM] plugin '{}' hook '{}': violated immutable tier — \ + modified an immutable extension slot. Extension changes rejected.", + plugin_name, hook_name + ); + return false; + } + + // Check 2: Monotonic labels — can only add, never remove + if capabilities.contains("read_labels") { + if let (Some(ref orig_sec), Some(ref new_sec)) = (&original.security, &owned.security) { + if !new_sec.labels.is_superset(&orig_sec.labels) { + warn!( + "[WASM] plugin '{}' hook '{}': violated monotonic tier — \ + removed a security label. Extension changes rejected.", + plugin_name, hook_name + ); + return false; + } + } + } + + // Check 3: Write authorization — reject mutations on slots without write cap + if !capabilities.contains("write_headers") { + if let Some(ref http_guarded) = owned.http { + let new_http = http_guarded.read(); + let http_changed = match original.http.as_ref() { + Some(orig) => { + new_http.request_headers != orig.request_headers + || new_http.response_headers != orig.response_headers + }, + None => { + !new_http.request_headers.is_empty() || !new_http.response_headers.is_empty() + }, + }; + if http_changed { + warn!( + "[WASM] plugin '{}' hook '{}': modified HTTP headers \ + without write_headers capability. Extension changes rejected.", + plugin_name, hook_name + ); + return false; + } + } + } + + if !capabilities.contains("append_labels") { + if let Some(ref new_sec) = owned.security { + let labels_changed = match original.security.as_ref() { + Some(orig) => new_sec.labels.len() != orig.labels.len(), + None => !new_sec.labels.is_empty(), + }; + if labels_changed { + warn!( + "[WASM] plugin '{}' hook '{}': modified security labels \ + without append_labels capability. Extension changes rejected.", + plugin_name, hook_name + ); + return false; + } + } + } + + if !capabilities.contains("append_delegation") { + if let Some(ref new_deleg) = owned.delegation { + let delegation_changed = match original.delegation.as_ref() { + Some(orig) => { + new_deleg.chain.len() != orig.chain.len() + || new_deleg.depth != orig.depth + || new_deleg.delegated != orig.delegated + }, + None => new_deleg.delegated || !new_deleg.chain.is_empty(), + }; + if delegation_changed { + warn!( + "[WASM] plugin '{}' hook '{}': modified delegation \ + without append_delegation capability. Extension changes rejected.", + plugin_name, hook_name + ); + return false; + } + } + } + + true +} + +// --------------------------------------------------------------------------- +// WASM error classification — maps wasmtime errors to PluginError variants +// --------------------------------------------------------------------------- + +/// Classify a wasmtime invocation error into the appropriate `PluginError` +/// variant based on the error message content. +/// +/// The executor uses error variants to apply different `OnError` policies +/// and produce accurate diagnostic records. Using the correct variant +/// ensures circuit breakers, timeout logging, and error aggregation all +/// work as designed. +fn classify_wasm_error(plugin_name: &str, timeout_ms: u64, err: anyhow::Error) -> Box { + let msg = err.to_string(); + let msg_lower = msg.to_lowercase(); + + if msg_lower.contains("epoch deadline") { + Box::new(PluginError::Timeout { + plugin_name: plugin_name.to_string(), + timeout_ms, + proto_error_code: None, + }) + } else if msg_lower.contains("all fuel consumed") || msg_lower.contains("fuel") { + Box::new(PluginError::Execution { + plugin_name: plugin_name.to_string(), + message: format!("WASM fuel exhausted: {}", msg), + source: None, + code: Some("fuel_exhausted".into()), + details: HashMap::new(), + proto_error_code: None, + }) + } else if msg_lower.contains("memory") + && (msg_lower.contains("grow") || msg_lower.contains("limit")) + { + Box::new(PluginError::Execution { + plugin_name: plugin_name.to_string(), + message: format!("WASM memory limit exceeded: {}", msg), + source: None, + code: Some("memory_limit".into()), + details: HashMap::new(), + proto_error_code: None, + }) + } else if msg_lower.contains("unreachable") + || msg_lower.contains("wasm trap") + || msg_lower.contains("panic") + { + Box::new(PluginError::Execution { + plugin_name: plugin_name.to_string(), + message: format!("WASM trap: {}", msg), + source: None, + code: Some("wasm_trap".into()), + details: HashMap::new(), + proto_error_code: None, + }) + } else if msg_lower.contains("request denied") || msg_lower.contains("http_request_denied") { + Box::new(PluginError::Execution { + plugin_name: plugin_name.to_string(), + message: format!("WASM network access denied: {}", msg), + source: None, + code: Some("network_denied".into()), + details: HashMap::new(), + proto_error_code: None, + }) + } else { + Box::new(PluginError::Execution { + plugin_name: plugin_name.to_string(), + message: format!("WASM invocation failed: {}", msg), + source: None, + code: None, + details: HashMap::new(), + proto_error_code: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_classify_epoch_deadline_as_timeout() { + let err = anyhow::anyhow!("wasm trap: epoch deadline has elapsed"); + let result = classify_wasm_error("test-plugin", 5000, err); + match *result { + PluginError::Timeout { + ref plugin_name, + timeout_ms, + .. + } => { + assert_eq!(plugin_name, "test-plugin"); + assert_eq!(timeout_ms, 5000); + }, + _ => panic!("expected Timeout, got {:?}", result), + } + } + + #[test] + fn test_classify_fuel_exhaustion() { + let err = anyhow::anyhow!("all fuel consumed by wasm"); + let result = classify_wasm_error("test-plugin", 5000, err); + match *result { + PluginError::Execution { ref code, .. } => { + assert_eq!(code.as_deref(), Some("fuel_exhausted")); + }, + _ => panic!("expected Execution with fuel_exhausted, got {:?}", result), + } + } + + #[test] + fn test_classify_memory_limit() { + let err = anyhow::anyhow!("memory growth failed: memory limit exceeded"); + let result = classify_wasm_error("test-plugin", 5000, err); + match *result { + PluginError::Execution { ref code, .. } => { + assert_eq!(code.as_deref(), Some("memory_limit")); + }, + _ => panic!("expected Execution with memory_limit, got {:?}", result), + } + } + + #[test] + fn test_classify_wasm_trap() { + let err = anyhow::anyhow!("wasm trap: unreachable instruction executed"); + let result = classify_wasm_error("test-plugin", 5000, err); + match *result { + PluginError::Execution { ref code, .. } => { + assert_eq!(code.as_deref(), Some("wasm_trap")); + }, + _ => panic!("expected Execution with wasm_trap, got {:?}", result), + } + } + + #[test] + fn test_classify_network_denied() { + let err = anyhow::anyhow!("outbound HTTP request denied: host not in allowlist"); + let result = classify_wasm_error("test-plugin", 5000, err); + match *result { + PluginError::Execution { ref code, .. } => { + assert_eq!(code.as_deref(), Some("network_denied")); + }, + _ => panic!("expected Execution with network_denied, got {:?}", result), + } + } + + #[test] + fn test_classify_unknown_error_as_execution() { + let err = anyhow::anyhow!("something unexpected happened in the component"); + let result = classify_wasm_error("test-plugin", 5000, err); + match *result { + PluginError::Execution { + ref code, + ref plugin_name, + .. + } => { + assert_eq!(plugin_name, "test-plugin"); + assert!(code.is_none()); + }, + _ => panic!("expected generic Execution, got {:?}", result), + } + } +} diff --git a/crates/cpex-wasm-host/src/lib.rs b/crates/cpex-wasm-host/src/lib.rs new file mode 100644 index 00000000..5be86796 --- /dev/null +++ b/crates/cpex-wasm-host/src/lib.rs @@ -0,0 +1,48 @@ +// Location: ./crates/cpex-wasm-host/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya + +//! WASM plugin host runtime for the CPEX framework. +//! +//! Loads WebAssembly Component Model plugins into sandboxed wasmtime environments, +//! enforces resource limits and capability-based access control, and bridges to +//! cpex-core's [`PluginManager`](cpex_core::manager::PluginManager) for seamless +//! integration with native plugins. +//! +//! # Usage +//! +//! ```ignore +//! use cpex_wasm_host::factory::WasmPluginFactory; +//! use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; +//! +//! // For built-in payloads (CMF, Identity, Delegation): +//! let factory = WasmPluginFactory::with_builtin_payloads(wasm_dir); +//! +//! // For custom payloads: +//! let mut registry = PayloadSerializerRegistry::new(); +//! registry.register::(); +//! let factory = WasmPluginFactory::new(wasm_dir, Arc::new(registry)); +//! +//! // Register with PluginManager — WASM plugins participate transparently +//! mgr.register_factory("wasm://my-plugin.wasm", Box::new(factory)); +//! ``` + +/// Native ↔ WIT type conversions for payloads, extensions, and context. +pub mod conversions; + +/// [`WasmPluginFactory`](factory::WasmPluginFactory) — bridges cpex-core's +/// `PluginFactory` trait to the sandbox runtime. Implements `PluginFactory` so +/// WASM plugins can be registered with `PluginManager`. +pub mod factory; + +/// Type-erased serialization registry for custom payload types crossing the +/// WASM boundary via `HookPayload::Custom`. +pub mod payload_registry; + +/// Sandbox policy parsing and WASI context construction from YAML config. +pub mod policy_loader; + +/// Wasmtime sandbox runtime — `SharedEngine`, `SandboxManager`, and the +/// `host-logging` WIT import implementation. +pub mod sandbox_manager; diff --git a/crates/cpex-wasm-host/src/payload_registry.rs b/crates/cpex-wasm-host/src/payload_registry.rs new file mode 100644 index 00000000..862038cd --- /dev/null +++ b/crates/cpex-wasm-host/src/payload_registry.rs @@ -0,0 +1,127 @@ +// Location: ./crates/cpex-wasm-host/src/payload_registry.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// PayloadSerializerRegistry — maps payload TypeId ↔ (type_name, serialize, deserialize). +// +// The host registers every payload type it wants to route through the WASM +// boundary here. WasmBridgeHandler calls serialize() to build HookPayload::Custom +// before invocation, and deserialize() to reconstruct a Box +// from the guest's returned custom payload. + +use std::any::TypeId; +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::{anyhow, Result}; + +use cpex_core::hooks::payload::{PluginPayload, WasmSerializablePayload}; + +// --------------------------------------------------------------------------- +// Internal codec — per-type serialize + deserialize closures +// --------------------------------------------------------------------------- + +type SerializeFn = Arc Result> + Send + Sync>; +type DeserializeFn = Arc Result> + Send + Sync>; + +struct PayloadCodec { + type_name: &'static str, + serialize: SerializeFn, + deserialize: DeserializeFn, +} + +// --------------------------------------------------------------------------- +// PayloadSerializerRegistry +// --------------------------------------------------------------------------- + +/// Registry that maps payload types to their WASM serialization codecs. +/// +/// Register every payload type that WASM plugins should be able to receive +/// or return. The registry is built once (at factory creation time) and then +/// shared read-only across all handler invocations via `Arc`. +/// +/// # Example +/// +/// ```ignore +/// let mut registry = PayloadSerializerRegistry::new(); +/// registry.register::(); +/// registry.register::(); +/// let registry = Arc::new(registry); +/// ``` +#[derive(Default)] +pub struct PayloadSerializerRegistry { + by_type_id: HashMap, + by_type_name: HashMap<&'static str, TypeId>, +} + +impl PayloadSerializerRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Register a payload type. `T` must implement both `PluginPayload` and + /// `WasmSerializablePayload`. Calling `register` twice for the same type + /// is idempotent — the second call overwrites the first. + pub fn register(&mut self) + where + T: WasmSerializablePayload + 'static, + { + let type_id = TypeId::of::(); + let type_name = T::payload_type_name(); + + let codec = PayloadCodec { + type_name, + serialize: Arc::new(|payload| { + let concrete = payload + .as_any() + .downcast_ref::() + .ok_or_else(|| anyhow!("serialize: TypeId matched but downcast failed"))?; + concrete.to_wasm_bytes().map_err(|e| anyhow!(e)) + }), + deserialize: Arc::new(|bytes| { + let value = T::from_wasm_bytes(bytes).map_err(|e| anyhow!(e))?; + Ok(Box::new(value) as Box) + }), + }; + + self.by_type_name.insert(type_name, type_id); + self.by_type_id.insert(type_id, codec); + } + + /// Serialize a type-erased payload to `(type_name, json_bytes)`. + /// + /// Returns an error if the payload's concrete type has not been registered. + pub fn serialize(&self, payload: &dyn PluginPayload) -> Result<(&'static str, Vec)> { + let type_id = payload.as_any().type_id(); + let codec = self + .by_type_id + .get(&type_id) + .ok_or_else(|| anyhow!("payload type not registered in PayloadSerializerRegistry"))?; + let bytes = (codec.serialize)(payload)?; + Ok((codec.type_name, bytes)) + } + + /// Deserialize a payload from `(type_name, json_bytes)` back to a + /// `Box`. + /// + /// Returns an error if `type_name` has not been registered. + pub fn deserialize(&self, type_name: &str, bytes: &[u8]) -> Result> { + let type_id = self.by_type_name.get(type_name).ok_or_else(|| { + anyhow!( + "unknown payload type '{}' in PayloadSerializerRegistry", + type_name + ) + })?; + let codec = self + .by_type_id + .get(type_id) + .ok_or_else(|| anyhow!("codec missing for type '{}'", type_name))?; + (codec.deserialize)(bytes) + } + + /// Returns true if the given `TypeId` has a registered codec. + pub fn contains_type_id(&self, type_id: TypeId) -> bool { + self.by_type_id.contains_key(&type_id) + } +} diff --git a/crates/cpex-wasm-host/src/policy_loader.rs b/crates/cpex-wasm-host/src/policy_loader.rs new file mode 100644 index 00000000..fb41623d --- /dev/null +++ b/crates/cpex-wasm-host/src/policy_loader.rs @@ -0,0 +1,263 @@ +// Location: ./crates/cpex-wasm-host/src/policy_loader.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// PolicyLoader — defines the SandboxPolicy schema and builds a WASI context +// from it. The sandbox policy controls what host resources a WASM plugin can +// access: filesystem paths, network hosts, and environment variables. +// When no policy is provided (or all lists are empty), the plugin runs in a +// fully locked-down sandbox with no access to the outside world. + +use std::{path::Path, sync::Arc}; + +use anyhow::Result; +use serde::Deserialize; +use wasmtime_wasi::{DirPerms, FilePerms, WasiCtx, WasiCtxBuilder}; +use wasmtime_wasi_http::WasiHttpCtx; + +/// Declarative sandbox policy deserialized from the plugin's config.sandbox_policy YAML key. +/// Controls filesystem, network, and environment access for the WASM plugin. +/// All fields default to empty/deny — a missing or empty policy means full lockdown. +#[derive(Debug, Clone, Default, Deserialize, serde::Serialize)] +pub struct SandboxPolicy { + /// Directories/files the plugin may access (empty = no filesystem access) + #[serde(default)] + pub allowed_filesystem: Vec, + /// Host names the plugin may make outbound HTTP requests to (empty = no network) + #[serde(default)] + pub allowed_network: Vec, + /// Environment variable names the plugin may read from the host (empty = no env access) + #[serde(default)] + pub allowed_env: Vec, + /// Resource limits (memory, fuel, execution time) for the WASM store + #[serde(default)] + pub resources: ResourceLimits, +} + +/// Resource limits enforced on the WASM store. +/// None means unlimited (wasmtime defaults apply). +#[derive(Debug, Clone, Default, Deserialize, serde::Serialize)] +pub struct ResourceLimits { + /// Maximum linear memory the plugin can allocate (bytes) + #[serde(default)] + pub max_memory_bytes: Option, + /// Maximum instructions (fuel units) the plugin can execute across all invocations + #[serde(default)] + pub max_fuel: Option, + /// Maximum wall-clock time for a single invocation (milliseconds) + #[serde(default)] + pub max_execution_time_ms: Option, + /// Maximum number of WASM module instances + #[serde(default)] + pub max_instances: Option, + /// Maximum number of WASM tables + #[serde(default)] + pub max_tables: Option, +} + +/// A single filesystem access rule — grants access to a directory or file with a permission level. +#[derive(Debug, Clone, Deserialize, serde::Serialize)] +pub struct FilesystemRule { + /// Directory path to preopen into the WASM sandbox + #[serde(default)] + pub dir: Option, + /// File path (its parent directory is preopened) + #[serde(default)] + pub file: Option, + /// Permission level: "read" or "write"/"mutate" + pub permission: String, +} + +/// The constructed WASI + HTTP context ready to be installed into a wasmtime Store. +pub struct PluginWasiContext { + pub wasi_ctx: WasiCtx, + pub http_ctx: WasiHttpCtx, + /// Network allow-list passed to the NetworkPolicy hook for outbound HTTP filtering + pub allowed_hosts: Arc>, +} + +/// Builds a WASI context from the given sandbox policy. +/// Preopens filesystem paths, injects allowed env vars, and captures the network allow-list. +/// If sandbox_policy is None, the context grants no host access (full lockdown). +pub fn build_wasi_context(sandbox_policy: Option<&SandboxPolicy>) -> Result { + let mut builder = WasiCtxBuilder::new(); + + if let Some(policy) = sandbox_policy { + for rule in &policy.allowed_filesystem { + let (dir_perms, file_perms) = match rule.permission.as_str() { + "read" => (DirPerms::READ, FilePerms::READ), + "write" | "mutate" => ( + DirPerms::READ | DirPerms::MUTATE, + FilePerms::READ | FilePerms::WRITE, + ), + other => anyhow::bail!("unknown filesystem permission: {}", other), + }; + + if let Some(dir) = &rule.dir { + builder + .preopened_dir(dir, dir, dir_perms, file_perms) + .map_err(|e| anyhow::anyhow!("failed to preopen dir '{}': {}", dir, e))?; + } else if let Some(file) = &rule.file { + let parent = Path::new(file) + .parent() + .ok_or_else(|| anyhow::anyhow!("file '{}' has no parent directory", file))?; + builder + .preopened_dir( + parent, + parent.to_string_lossy().as_ref(), + dir_perms, + file_perms, + ) + .map_err(|e| { + anyhow::anyhow!("failed to preopen parent dir for file '{}': {}", file, e) + })?; + } + } + + for key in &policy.allowed_env { + if let Ok(val) = std::env::var(key) { + builder.env(key, &val); + } + } + } + + builder.inherit_stdio(); + + let wasi_ctx = builder.build(); + let http_ctx = WasiHttpCtx::new(); + let allowed_hosts = Arc::new( + sandbox_policy + .map(|p| p.allowed_network.clone()) + .unwrap_or_default(), + ); + + Ok(PluginWasiContext { + wasi_ctx, + http_ctx, + allowed_hosts, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_parse_sandbox_policy_from_config_file() { + let config_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("config/config.yaml"); + let raw = fs::read_to_string(&config_path).expect("failed to read config file"); + let config: serde_yaml::Value = serde_yaml::from_str(&raw).expect("failed to parse YAML"); + + let sandbox_policy_value = config["plugins"][0]["config"]["sandbox_policy"].clone(); + let policy: SandboxPolicy = serde_yaml::from_value(sandbox_policy_value) + .expect("failed to deserialize sandbox_policy"); + + assert!(policy.allowed_filesystem.is_empty()); + assert!(policy.allowed_network.is_empty()); + assert!(policy.allowed_env.is_empty()); + assert_eq!(policy.resources.max_memory_bytes, Some(10485760)); + assert_eq!(policy.resources.max_fuel, Some(1000000000)); + assert_eq!(policy.resources.max_execution_time_ms, Some(5000)); + assert_eq!(policy.resources.max_instances, Some(10)); + assert_eq!(policy.resources.max_tables, Some(10)); + } + + #[test] + fn test_deserialize_sandbox_policy() { + let yaml = r#" +allowed_filesystem: + - dir: /tmp/data + permission: "read" +allowed_network: + - "httpbin.org" +allowed_env: + - "API_KEY" +resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 +"#; + let policy: SandboxPolicy = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(policy.allowed_network, vec!["httpbin.org"]); + assert_eq!(policy.allowed_env, vec!["API_KEY"]); + assert_eq!(policy.allowed_filesystem.len(), 1); + assert_eq!(policy.resources.max_memory_bytes, Some(10485760)); + assert_eq!(policy.resources.max_fuel, Some(1000000000)); + } + + #[test] + fn test_default_sandbox_policy_denies_all() { + let policy = SandboxPolicy::default(); + assert!(policy.allowed_filesystem.is_empty()); + assert!(policy.allowed_network.is_empty()); + assert!(policy.allowed_env.is_empty()); + assert!(policy.resources.max_memory_bytes.is_none()); + } + + #[test] + fn test_no_policy_builds_context_with_no_filesystem() { + let ctx = build_wasi_context(None); + assert!(ctx.is_ok(), "no-policy context should build successfully"); + let ctx = ctx.unwrap(); + assert!(ctx.allowed_hosts.is_empty()); + } + + #[test] + fn test_empty_policy_builds_context_with_no_filesystem() { + let policy = SandboxPolicy::default(); + let ctx = build_wasi_context(Some(&policy)); + assert!(ctx.is_ok()); + let ctx = ctx.unwrap(); + assert!(ctx.allowed_hosts.is_empty()); + } + + #[test] + fn test_nonexistent_directory_fails_to_preopen() { + let policy = SandboxPolicy { + allowed_filesystem: vec![FilesystemRule { + dir: Some("/nonexistent_path_that_does_not_exist_xyz".to_string()), + file: None, + permission: "read".to_string(), + }], + ..Default::default() + }; + let result = build_wasi_context(Some(&policy)); + assert!( + result.is_err(), + "preopening a non-existent directory should fail" + ); + } + + #[test] + fn test_invalid_permission_rejected() { + let policy = SandboxPolicy { + allowed_filesystem: vec![FilesystemRule { + dir: Some("/tmp".to_string()), + file: None, + permission: "execute".to_string(), + }], + ..Default::default() + }; + let result = build_wasi_context(Some(&policy)); + assert!( + result.is_err(), + "invalid permission 'execute' should be rejected" + ); + } + + #[test] + fn test_network_allowlist_populated_from_policy() { + let policy = SandboxPolicy { + allowed_network: vec![ + "api.internal.svc".to_string(), + "auth.example.com".to_string(), + ], + ..Default::default() + }; + let ctx = build_wasi_context(Some(&policy)).unwrap(); + assert_eq!(ctx.allowed_hosts.len(), 2); + assert!(ctx.allowed_hosts.contains(&"api.internal.svc".to_string())); + assert!(ctx.allowed_hosts.contains(&"auth.example.com".to_string())); + } +} diff --git a/crates/cpex-wasm-host/src/sandbox_manager.rs b/crates/cpex-wasm-host/src/sandbox_manager.rs new file mode 100644 index 00000000..ede395aa --- /dev/null +++ b/crates/cpex-wasm-host/src/sandbox_manager.rs @@ -0,0 +1,308 @@ +// Location: ./crates/cpex-wasm-host/src/sandbox_manager.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// SandboxManager — loads and invokes a single WASM plugin in a wasmtime sandbox. +// Enforces resource limits (fuel, memory, execution time) and network policy. + +use std::path::Path; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use wasmtime::component::{Component, Linker, ResourceTable}; +use wasmtime::{Config, Engine, Store, StoreLimits, StoreLimitsBuilder}; +use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; +use wasmtime_wasi_http::p2::bindings::http::types::ErrorCode; +use wasmtime_wasi_http::p2::body::HyperOutgoingBody; +use wasmtime_wasi_http::p2::types::{HostFutureIncomingResponse, OutgoingRequestConfig}; +use wasmtime_wasi_http::p2::{default_send_request, HttpResult, WasiHttpHooks}; +use wasmtime_wasi_http::p2::{WasiHttpCtxView, WasiHttpView}; +use wasmtime_wasi_http::WasiHttpCtx; + +use crate::policy_loader::{build_wasi_context, ResourceLimits, SandboxPolicy}; + +// Generate Rust bindings from the WIT interface definition. +// This creates the `Plugin` struct with `call_handle_hook` and the WIT types. +wasmtime::component::bindgen!({ + path: "wit", + world: "plugin", + exports: { default: async }, +}); + +/// Re-export WIT-generated types for use by the factory and conversions modules. +pub mod types { + pub use super::cpex::plugin::types::*; +} + +/// Intercepts outbound HTTP requests from the WASM plugin and enforces the network allow-list. +/// Only requests to explicitly allowed hosts (or their subdomains) are permitted. +struct NetworkPolicy { + allowed_hosts: Arc>, +} + +impl WasiHttpHooks for NetworkPolicy { + fn send_request( + &mut self, + request: hyper::Request, + config: OutgoingRequestConfig, + ) -> HttpResult { + // Extract the target host from the request URI + let authority = request + .uri() + .authority() + .map(|a| a.host().to_string()) + .unwrap_or_default(); + + // Check exact match or subdomain match (e.g., "api.example.com" matches "example.com") + let is_allowed = self + .allowed_hosts + .iter() + .any(|allowed| authority == *allowed || authority.ends_with(&format!(".{}", allowed))); + + if !is_allowed { + return Err(ErrorCode::HttpRequestDenied.into()); + } + + Ok(default_send_request(request, config)) + } +} + +/// Per-plugin state held in the wasmtime Store. +/// Contains WASI context, HTTP context, network policy, resource table, and store limits. +struct WasmPluginState { + wasi: WasiCtx, + http: WasiHttpCtx, + network: NetworkPolicy, + table: ResourceTable, + limits: StoreLimits, + plugin_name: String, +} + +impl cpex::plugin::host_logging::Host for WasmPluginState { + fn log(&mut self, level: cpex::plugin::host_logging::LogLevel, message: String) { + use cpex::plugin::host_logging::LogLevel; + match level { + LogLevel::Trace => tracing::trace!(plugin = %self.plugin_name, "{}", message), + LogLevel::Debug => tracing::debug!(plugin = %self.plugin_name, "{}", message), + LogLevel::Info => tracing::info!(plugin = %self.plugin_name, "{}", message), + LogLevel::Warn => tracing::warn!(plugin = %self.plugin_name, "{}", message), + LogLevel::Error => tracing::error!(plugin = %self.plugin_name, "{}", message), + } + } +} + +impl WasiView for WasmPluginState { + fn ctx(&mut self) -> WasiCtxView<'_> { + WasiCtxView { + ctx: &mut self.wasi, + table: &mut self.table, + } + } +} + +impl WasiHttpView for WasmPluginState { + fn http(&mut self) -> WasiHttpCtxView<'_> { + WasiHttpCtxView { + ctx: &mut self.http, + table: &mut self.table, + hooks: &mut self.network, + } + } +} + +/// A loaded and instantiated WASM plugin, ready for invocation. +struct WasmPluginInstance { + store: Store, + plugin: Plugin, + /// Epoch ticks before the store traps (used to reset timeout per invocation) + epoch_deadline: u64, + /// Fuel budget per invocation (reset at the start of each call) + fuel_per_invocation: u64, +} + +/// Manages a single WASM plugin in a sandboxed wasmtime environment. +/// Enforces resource limits (fuel, memory, execution time) and network policy. +pub struct SandboxManager { + engine: Engine, + linker: Linker, + instance: Option, +} + +/// A shared engine + linker + epoch ticker that multiple `SandboxManager` instances +/// can use. Create one per factory, not one per plugin. +pub struct SharedEngine { + engine: Engine, + linker: Linker, +} + +impl SharedEngine { + /// Create a shared engine with WASI, HTTP, and host-logging linked. + /// Spawns a single epoch ticker thread for all plugins using this engine. + pub fn new() -> Result { + let mut config = Config::new(); + config.wasm_component_model(true); + config.consume_fuel(true); + config.epoch_interruption(true); + let engine = Engine::new(&config)?; + + let mut linker = Linker::new(&engine); + wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; + wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; + cpex::plugin::host_logging::add_to_linker::< + WasmPluginState, + wasmtime::component::HasSelf, + >(&mut linker, |state| state)?; + + let engine_clone = engine.clone(); + #[allow(clippy::disallowed_methods)] + std::thread::spawn(move || loop { + std::thread::sleep(std::time::Duration::from_millis(1)); + engine_clone.increment_epoch(); + }); + + Ok(Self { engine, linker }) + } +} + +impl SandboxManager { + /// Create a new SandboxManager with its own engine and epoch ticker. + /// Prefer `with_shared_engine` when loading multiple plugins. + pub fn new() -> Result { + let shared = SharedEngine::new()?; + Ok(Self { + engine: shared.engine, + linker: shared.linker, + instance: None, + }) + } + + /// Create a SandboxManager backed by a shared engine. + /// All plugins sharing an engine use one epoch ticker thread. + pub fn with_shared_engine(shared: &SharedEngine) -> Self { + Self { + engine: shared.engine.clone(), + linker: shared.linker.clone(), + instance: None, + } + } + + /// Load a plugin from a WASM file with the given sandbox policy. + /// Replaces any previously loaded plugin. + pub async fn load_wasmplugin( + &mut self, + wasm_path: &Path, + sandbox_policy: Option<&SandboxPolicy>, + plugin_name: &str, + ) -> Result<()> { + tracing::debug!(plugin = %plugin_name, path = %wasm_path.display(), "loading WASM plugin"); + let ctx = build_wasi_context(sandbox_policy)?; + let default_resources = ResourceLimits::default(); + let resources = sandbox_policy + .map(|p| &p.resources) + .unwrap_or(&default_resources); + + // Build store limits from resource config + let mut limits_builder = StoreLimitsBuilder::new(); + if let Some(max_mem) = resources.max_memory_bytes { + limits_builder = limits_builder.memory_size(max_mem); + } + if let Some(max_instances) = resources.max_instances { + limits_builder = limits_builder.instances(max_instances); + } + if let Some(max_tables) = resources.max_tables { + limits_builder = limits_builder.tables(max_tables); + } + let limits = limits_builder.trap_on_grow_failure(true).build(); + + let component = Component::from_file(&self.engine, wasm_path).map_err(|e| { + anyhow::anyhow!("failed to load wasm from {}: {}", wasm_path.display(), e) + })?; + + let mut store = Store::new( + &self.engine, + WasmPluginState { + wasi: ctx.wasi_ctx, + http: ctx.http_ctx, + network: NetworkPolicy { + allowed_hosts: ctx.allowed_hosts, + }, + table: ResourceTable::new(), + limits, + plugin_name: plugin_name.to_string(), + }, + ); + + // Apply memory/table limits + store.limiter(|state| &mut state.limits); + + // Fuel is set per-invocation (reset at the start of each call) to + // prevent long-lived plugins from silently degrading. The initial + // budget is set here so the plugin can instantiate. + let fuel_per_invocation = resources.max_fuel.unwrap_or(u64::MAX); + store + .set_fuel(fuel_per_invocation) + .map_err(|e| anyhow::anyhow!("failed to set fuel: {}", e))?; + + // Apply execution timeout via epoch deadline. + // Default is 5 seconds — safe for synchronous hooks. Plugins that + // perform outbound HTTP should set a higher value explicitly. + let epoch_deadline = resources.max_execution_time_ms.unwrap_or(5_000); + if resources.max_execution_time_ms.is_none() { + tracing::warn!( + plugin = %plugin_name, + "no explicit max_execution_time_ms configured — using default 5000ms" + ); + } + store.set_epoch_deadline(epoch_deadline); + store.epoch_deadline_trap(); + + let plugin = Plugin::instantiate_async(&mut store, &component, &self.linker) + .await + .map_err(|e| anyhow::anyhow!("failed to instantiate plugin: {}", e))?; + tracing::debug!(plugin = %plugin_name, "plugin instantiated"); + + self.instance = Some(WasmPluginInstance { + store, + plugin, + epoch_deadline, + fuel_per_invocation, + }); + + Ok(()) + } + + /// Invoke the loaded plugin's handle-hook function. + /// Both fuel and epoch deadline are reset per invocation so each call + /// gets a fresh budget — no silent degradation over time. + pub async fn invoke( + &mut self, + hook_name: &str, + payload: types::HookPayload, + extensions: types::Extensions, + ctx: types::PluginContext, + ) -> Result { + let instance = self.instance.as_mut().with_context(|| "no plugin loaded")?; + + // Reset fuel per invocation — each call gets a fresh budget + instance + .store + .set_fuel(instance.fuel_per_invocation) + .map_err(|e| anyhow::anyhow!("failed to reset fuel: {}", e))?; + + // Reset epoch deadline per invocation (timeout is per-call) + instance.store.set_epoch_deadline(instance.epoch_deadline); + + let result = instance + .plugin + .call_handle_hook(&mut instance.store, hook_name, &payload, &extensions, &ctx) + .await; + + result.map_err(|e| anyhow::anyhow!("plugin invocation failed: {}", e)) + } + + /// Returns whether a plugin is currently loaded. + pub fn is_loaded(&self) -> bool { + self.instance.is_some() + } +} diff --git a/crates/cpex-wasm-host/tests/test_custom_payload_pipeline.rs b/crates/cpex-wasm-host/tests/test_custom_payload_pipeline.rs new file mode 100644 index 00000000..3b868d99 --- /dev/null +++ b/crates/cpex-wasm-host/tests/test_custom_payload_pipeline.rs @@ -0,0 +1,322 @@ +// Location: ./crates/cpex-wasm-host/tests/test_custom_payload_pipeline.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +//! Integration test: verifies the custom-payload WASM plugin pipeline end-to-end. +//! +//! Loads 4 real `.wasm` plugins (tool-invoke-checker, pii-guard, remote-authz, +//! audit-logger-custom) that use a user-defined `ToolInvokePayload` routed +//! through HookPayload::Custom. Tests the full PluginManager → WasmPluginFactory +//! → SandboxManager → guest handler → result path. +//! +//! Requires: all 4 custom-payload `.wasm` binaries built and staged. +//! Run `make build-all-plugins` from crates/cpex-wasm-host first. + +use std::path::PathBuf; +use std::sync::{Arc, Once}; + +use serde::{Deserialize, Serialize}; + +use cpex_core::config::parse_config; +use cpex_core::context::PluginContextTable; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::meta::MetaExtension; +use cpex_core::hooks::trait_def::{HookTypeDef, PluginResult}; +use cpex_core::manager::PluginManager; + +use cpex_wasm_host::factory::WasmPluginFactory; +use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; + +static INIT: Once = Once::new(); +fn init_tracing() { + INIT.call_once(|| { + tracing_subscriber::fmt() + .with_test_writer() + .with_env_filter("info") + .init(); + }); +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ToolInvokePayload { + tool_name: String, + user: String, + arguments: String, +} + +cpex_core::impl_plugin_payload!(ToolInvokePayload); +cpex_core::impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); + +struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +struct ToolPostInvoke; +impl HookTypeDef for ToolPostInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_post_invoke"; +} + +fn wasm_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm") +} + +fn check_binaries_exist() { + let dir = wasm_dir(); + for name in &[ + "tool-invoke-checker.wasm", + "pii-guard.wasm", + "remote-authz.wasm", + "audit-logger-custom.wasm", + ] { + let path = dir.join(name); + assert!( + path.exists(), + "WASM binary not found: {}. Run `make build-all-plugins` from crates/cpex-wasm-host first.", + path.display() + ); + } +} + +fn make_extensions(tool_name: &str) -> Extensions { + Extensions { + meta: Some(Arc::new(MetaExtension { + entity_type: Some("tool".into()), + entity_name: Some(tool_name.into()), + ..Default::default() + })), + ..Default::default() + } +} + +async fn setup_manager() -> PluginManager { + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let config_path = crate_dir.join("config/config_plugin_demo.yaml"); + let wasm_dir = crate_dir.join("wasm"); + + let yaml = std::fs::read_to_string(&config_path).unwrap(); + let cpex_config = parse_config(&yaml).unwrap(); + + let registry = Arc::new({ + let mut r = PayloadSerializerRegistry::new(); + r.register::(); + r + }); + + let mgr = PluginManager::default(); + mgr.register_factory( + "wasm://tool-invoke-checker.wasm", + Box::new(WasmPluginFactory::new(wasm_dir.clone(), registry.clone())), + ); + mgr.register_factory( + "wasm://pii-guard.wasm", + Box::new(WasmPluginFactory::new(wasm_dir.clone(), registry.clone())), + ); + mgr.register_factory( + "wasm://remote-authz.wasm", + Box::new(WasmPluginFactory::new(wasm_dir.clone(), registry.clone())), + ); + mgr.register_factory( + "wasm://audit-logger-custom.wasm", + Box::new(WasmPluginFactory::new(wasm_dir, registry)), + ); + + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + mgr +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_identity_resolver_denies_empty_user() { + init_tracing(); + check_binaries_exist(); + let mgr = setup_manager().await; + + let payload = ToolInvokePayload { + tool_name: "list_departments".into(), + user: "".into(), + arguments: "".into(), + }; + let ext = make_extensions("list_departments"); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + bg.wait_for_background_tasks().await; + + assert!(!result.continue_processing); + let violation = result.violation.as_ref().unwrap(); + assert_eq!(violation.code, "no_identity"); + assert_eq!(violation.plugin_name.as_deref(), Some("identity-resolver")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_pii_guard_denies_without_clearance() { + init_tracing(); + check_binaries_exist(); + let mgr = setup_manager().await; + + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = make_extensions("get_compensation"); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + bg.wait_for_background_tasks().await; + + assert!(!result.continue_processing); + let violation = result.violation.as_ref().unwrap(); + assert_eq!(violation.code, "pii_access_denied"); + assert_eq!(violation.plugin_name.as_deref(), Some("pii-guard")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_pii_guard_allows_with_clearance() { + init_tracing(); + check_binaries_exist(); + let mgr = setup_manager().await; + + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = make_extensions("get_compensation"); + let mut ctx_table = PluginContextTable::new(); + ctx_table + .global_state + .insert("pii_clearance".into(), serde_json::Value::Bool(true)); + + let (result, bg) = mgr + .invoke::(payload, ext, Some(ctx_table)) + .await; + bg.wait_for_background_tasks().await; + + assert!(result.continue_processing); + assert!(result.violation.is_none()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_remote_authz_allows_user_in_acl() { + init_tracing(); + check_binaries_exist(); + let mgr = setup_manager().await; + + let payload = ToolInvokePayload { + tool_name: "query_external_data".into(), + user: "alice".into(), + arguments: "dataset=sales".into(), + }; + let ext = make_extensions("query_external_data"); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + bg.wait_for_background_tasks().await; + + assert!(result.continue_processing); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_remote_authz_denies_user_not_in_acl() { + init_tracing(); + check_binaries_exist(); + let mgr = setup_manager().await; + + let payload = ToolInvokePayload { + tool_name: "query_external_data".into(), + user: "charlie".into(), + arguments: "dataset=sales".into(), + }; + let ext = make_extensions("query_external_data"); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + bg.wait_for_background_tasks().await; + + assert!(!result.continue_processing); + let violation = result.violation.as_ref().unwrap(); + assert_eq!(violation.code, "remote_authz_denied"); + assert_eq!(violation.plugin_name.as_deref(), Some("remote-authz")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_non_pii_tool_allowed() { + init_tracing(); + check_binaries_exist(); + let mgr = setup_manager().await; + + let payload = ToolInvokePayload { + tool_name: "list_departments".into(), + user: "bob".into(), + arguments: "".into(), + }; + let ext = make_extensions("list_departments"); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + bg.wait_for_background_tasks().await; + + assert!(result.continue_processing); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_context_table_threads_across_hooks() { + init_tracing(); + check_binaries_exist(); + let mgr = setup_manager().await; + + // Pre-invoke with clearance + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = make_extensions("get_compensation"); + let mut ctx_table = PluginContextTable::new(); + ctx_table + .global_state + .insert("pii_clearance".into(), serde_json::Value::Bool(true)); + + let (pre_result, bg) = mgr + .invoke::(payload, ext, Some(ctx_table)) + .await; + bg.wait_for_background_tasks().await; + assert!(pre_result.continue_processing); + + // Post-invoke with threaded context table + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = make_extensions("get_compensation"); + let (post_result, bg) = mgr + .invoke::(payload, ext, Some(pre_result.context_table)) + .await; + bg.wait_for_background_tasks().await; + + assert!(post_result.continue_processing); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires pre-built WASM plugins — run `make build-all-plugins` first"] +async fn test_wildcard_route_allowed() { + init_tracing(); + check_binaries_exist(); + let mgr = setup_manager().await; + + let payload = ToolInvokePayload { + tool_name: "unknown_tool_xyz".into(), + user: "bob".into(), + arguments: "x=1".into(), + }; + let ext = make_extensions("unknown_tool_xyz"); + let (result, bg) = mgr.invoke::(payload, ext, None).await; + bg.wait_for_background_tasks().await; + + assert!(result.continue_processing); +} diff --git a/crates/cpex-wasm-host/tests/test_policy_loader.rs b/crates/cpex-wasm-host/tests/test_policy_loader.rs new file mode 100644 index 00000000..4e00a4ee --- /dev/null +++ b/crates/cpex-wasm-host/tests/test_policy_loader.rs @@ -0,0 +1,109 @@ +// Location: ./crates/cpex-wasm-host/tests/test_policy_loader.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// Integration tests for policy_loader. +// Validates that the sandbox policy in config/config.yaml deserializes correctly +// and matches the expected deny-all posture with resource limits. + +use std::path::Path; + +use cpex_wasm_host::policy_loader::SandboxPolicy; + +/// Helper: reads config.yaml and extracts the sandbox_policy for a named plugin. +fn load_sandbox_policy_from_config(plugin_name: &str) -> SandboxPolicy { + let config_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("config/config.yaml"); + let raw = std::fs::read_to_string(&config_path).expect("failed to read config.yaml"); + let config: serde_yaml::Value = serde_yaml::from_str(&raw).expect("failed to parse YAML"); + + let plugin = config["plugins"] + .as_sequence() + .expect("plugins should be a list") + .iter() + .find(|p| p["name"].as_str() == Some(plugin_name)) + .unwrap_or_else(|| panic!("plugin '{}' not found in config", plugin_name)); + + let sandbox_value = plugin["config"]["sandbox_policy"].clone(); + serde_yaml::from_value(sandbox_value).expect("failed to deserialize sandbox_policy") +} + +#[test] +fn config_file_is_valid_yaml() { + let config_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("config/config.yaml"); + let raw = std::fs::read_to_string(&config_path).expect("failed to read config.yaml"); + let _: serde_yaml::Value = serde_yaml::from_str(&raw).expect("config.yaml is not valid YAML"); +} + +#[test] +fn config_has_plugins_list() { + let config_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("config/config.yaml"); + let raw = std::fs::read_to_string(&config_path).expect("failed to read config.yaml"); + let config: serde_yaml::Value = serde_yaml::from_str(&raw).unwrap(); + + let plugins = config["plugins"] + .as_sequence() + .expect("plugins should be a list"); + assert!(!plugins.is_empty(), "plugins list should not be empty"); +} + +#[test] +fn identity_checker_plugin_exists_with_correct_kind() { + let config_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("config/config.yaml"); + let raw = std::fs::read_to_string(&config_path).expect("failed to read config.yaml"); + let config: serde_yaml::Value = serde_yaml::from_str(&raw).unwrap(); + + let plugin = config["plugins"] + .as_sequence() + .unwrap() + .iter() + .find(|p| p["name"].as_str() == Some("identity-checker")) + .expect("identity-checker plugin not found"); + + assert_eq!(plugin["kind"].as_str(), Some("wasm://plugin.wasm")); +} + +#[test] +fn sandbox_policy_allowed_network_is_empty() { + let policy = load_sandbox_policy_from_config("identity-checker"); + assert!(policy.allowed_network.is_empty()); +} + +#[test] +fn sandbox_policy_allowed_env_is_empty() { + let policy = load_sandbox_policy_from_config("identity-checker"); + assert!(policy.allowed_env.is_empty()); +} + +#[test] +fn sandbox_policy_allowed_filesystem_is_empty() { + let policy = load_sandbox_policy_from_config("identity-checker"); + assert!(policy.allowed_filesystem.is_empty()); +} + +#[test] +fn sandbox_policy_resource_limits() { + let policy = load_sandbox_policy_from_config("identity-checker"); + + assert_eq!(policy.resources.max_memory_bytes, Some(10_485_760)); + assert_eq!(policy.resources.max_fuel, Some(1_000_000_000)); + assert_eq!(policy.resources.max_execution_time_ms, Some(5000)); + assert_eq!(policy.resources.max_instances, Some(10)); + assert_eq!(policy.resources.max_tables, Some(10)); +} + +#[test] +fn sandbox_policy_deserializes_to_same_type_used_by_factory() { + let policy = load_sandbox_policy_from_config("identity-checker"); + let json = serde_json::to_value(&policy).expect("SandboxPolicy should serialize to JSON"); + + let roundtripped: SandboxPolicy = + serde_json::from_value(json).expect("SandboxPolicy should roundtrip through JSON"); + + assert_eq!(roundtripped.allowed_network, policy.allowed_network); + assert_eq!(roundtripped.allowed_env, policy.allowed_env); + assert_eq!( + roundtripped.resources.max_memory_bytes, + policy.resources.max_memory_bytes + ); +} diff --git a/crates/cpex-wasm-host/tests/test_sandbox_env.rs b/crates/cpex-wasm-host/tests/test_sandbox_env.rs new file mode 100644 index 00000000..edca4b0d --- /dev/null +++ b/crates/cpex-wasm-host/tests/test_sandbox_env.rs @@ -0,0 +1,204 @@ +// Location: ./crates/cpex-wasm-host/tests/test_sandbox_env.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +//! Integration test: verifies WASM sandbox environment variable isolation. +//! +//! Loads a real `.wasm` plugin that reads env vars (HOME, PATH, SECRET_API_KEY). +//! With no env policy, all must be empty. With a selective policy, only the +//! explicitly allowed variable should be visible. +//! +//! Requires: `wasm/env-test.wasm` built from cpex-wasm-plugin with `--features env-test` + +use std::path::PathBuf; +use std::sync::Once; + +use cpex_core::cmf::constants::SCHEMA_VERSION; +use cpex_core::cmf::{ContentPart, Message, MessagePayload, Role, ToolCall}; +use cpex_core::context::PluginContext; +use cpex_core::extensions::container::Extensions; + +use cpex_wasm_host::conversions::{ + native_context_to_wit, native_extensions_to_wit, native_payload_to_wit, +}; +use cpex_wasm_host::policy_loader::SandboxPolicy; +use cpex_wasm_host::sandbox_manager::{SandboxManager, SharedEngine}; + +static INIT: Once = Once::new(); +fn init_tracing() { + INIT.call_once(|| { + tracing_subscriber::fmt() + .with_test_writer() + .with_env_filter("info") + .init(); + }); +} + +fn wasm_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm/env-test.wasm") +} + +fn make_payload() -> MessagePayload { + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_001".into(), + name: "env_check".into(), + arguments: Default::default(), + namespace: None, + }, + }], + channel: None, + }, + } +} + +fn extract_context( + result: &cpex_wasm_host::sandbox_manager::types::HookResult, +) -> std::collections::HashMap { + result + .modified_context + .as_ref() + .expect("plugin should write context") + .local_state + .iter() + .map(|e| (e.key.clone(), e.value.clone())) + .collect() +} + +#[tokio::test] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_plugin_cannot_see_env_vars_without_policy() { + init_tracing(); + let path = wasm_path(); + assert!(path.exists(), + "WASM binary not found: {}. Run `make build-test-plugins` from crates/cpex-wasm-host first.", + path.display()); + + // Set a host env var that the plugin will try to read + std::env::set_var("SECRET_API_KEY", "super-secret-value"); + + // Load with NO env policy (deny-all) + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&path, None, "env-test").await.unwrap(); + + let payload = make_payload(); + let wit_payload = + cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf(native_payload_to_wit(&payload)); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + + assert!(result.continue_processing); + + let entries = extract_context(&result); + + // HOME must be empty (not exposed) + let home = entries.get("env_HOME").unwrap_or(&String::new()).clone(); + assert_eq!( + home, "\"\"", + "SANDBOX ESCAPE: plugin can see HOME='{}'", + home + ); + + // PATH must be empty + let path_val = entries.get("env_PATH").unwrap_or(&String::new()).clone(); + assert_eq!( + path_val, "\"\"", + "SANDBOX ESCAPE: plugin can see PATH='{}'", + path_val + ); + + // SECRET_API_KEY must be empty + let secret = entries + .get("env_SECRET_API_KEY") + .unwrap_or(&String::new()) + .clone(); + assert_eq!( + secret, "\"\"", + "SANDBOX ESCAPE: plugin can see SECRET_API_KEY='{}'", + secret + ); + + // Clean up + std::env::remove_var("SECRET_API_KEY"); +} + +#[tokio::test] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_plugin_sees_only_allowed_env_var() { + init_tracing(); + let path = wasm_path(); + assert!(path.exists(), + "WASM binary not found: {}. Run `make build-test-plugins` from crates/cpex-wasm-host first.", + path.display()); + + // Set the allowed var and a secret var on the host + std::env::set_var("CPEX_TEST_ALLOWED", "hello-from-host"); + std::env::set_var("SECRET_API_KEY", "super-secret-value"); + + // Policy allows ONLY CPEX_TEST_ALLOWED + let policy = SandboxPolicy { + allowed_env: vec!["CPEX_TEST_ALLOWED".to_string()], + ..Default::default() + }; + + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&path, Some(&policy), "env-test-selective") + .await + .unwrap(); + + let payload = make_payload(); + let wit_payload = + cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf(native_payload_to_wit(&payload)); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + + assert!(result.continue_processing); + + let entries = extract_context(&result); + + // CPEX_TEST_ALLOWED should be visible + let allowed = entries + .get("env_CPEX_TEST_ALLOWED") + .unwrap_or(&String::new()) + .clone(); + assert_eq!( + allowed, "\"hello-from-host\"", + "allowed env var should be visible, got: {}", + allowed + ); + + // HOME must still be empty (not in allowed list) + let home = entries.get("env_HOME").unwrap_or(&String::new()).clone(); + assert_eq!(home, "\"\"", "HOME should be hidden, got: {}", home); + + // SECRET_API_KEY must still be empty + let secret = entries + .get("env_SECRET_API_KEY") + .unwrap_or(&String::new()) + .clone(); + assert_eq!( + secret, "\"\"", + "SANDBOX ESCAPE: plugin sees SECRET_API_KEY despite not being in allowed_env" + ); + + // Clean up + std::env::remove_var("CPEX_TEST_ALLOWED"); + std::env::remove_var("SECRET_API_KEY"); +} diff --git a/crates/cpex-wasm-host/tests/test_sandbox_isolation.rs b/crates/cpex-wasm-host/tests/test_sandbox_isolation.rs new file mode 100644 index 00000000..9e845abf --- /dev/null +++ b/crates/cpex-wasm-host/tests/test_sandbox_isolation.rs @@ -0,0 +1,183 @@ +// Location: ./crates/cpex-wasm-host/tests/test_sandbox_isolation.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +//! Integration test: verifies WASM sandbox filesystem isolation. +//! +//! Loads a real `.wasm` plugin that attempts to read `/etc/passwd`. +//! With no filesystem policy, the read must fail (sandbox denies access). +//! This proves the isolation isn't just config-level — it's enforced at runtime. +//! +//! Requires: `wasm/fs-test.wasm` built from cpex-wasm-plugin with `--features fs-test` + +use std::path::PathBuf; +use std::sync::Once; + +use cpex_core::cmf::constants::SCHEMA_VERSION; +use cpex_core::cmf::{ContentPart, Message, MessagePayload, Role, ToolCall}; +use cpex_core::context::PluginContext; +use cpex_core::extensions::container::Extensions; + +use cpex_wasm_host::conversions::{ + native_context_to_wit, native_extensions_to_wit, native_payload_to_wit, +}; +use cpex_wasm_host::sandbox_manager::{SandboxManager, SharedEngine}; + +static INIT: Once = Once::new(); +fn init_tracing() { + INIT.call_once(|| { + tracing_subscriber::fmt() + .with_test_writer() + .with_env_filter("info") + .init(); + }); +} + +fn wasm_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm/fs-test.wasm") +} + +fn make_payload() -> MessagePayload { + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_001".into(), + name: "read_file".into(), + arguments: Default::default(), + namespace: None, + }, + }], + channel: None, + }, + } +} + +#[tokio::test] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_plugin_cannot_read_etc_passwd_without_filesystem_policy() { + init_tracing(); + let path = wasm_path(); + assert!(path.exists(), + "WASM binary not found: {}. Run `make build-test-plugins` from crates/cpex-wasm-host first.", + path.display()); + + // Load plugin with NO filesystem policy (deny-all) + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&path, None, "fs-test").await.unwrap(); + + let payload = make_payload(); + let wit_payload = + cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf(native_payload_to_wit(&payload)); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + + // The plugin should have executed (continue_processing = true) + assert!(result.continue_processing, "plugin should return allow"); + + // Check the context — plugin writes fs_read_success into local_state + let ctx = result + .modified_context + .expect("plugin should write context"); + let local_entries: std::collections::HashMap = ctx + .local_state + .into_iter() + .map(|e| (e.key, e.value)) + .collect(); + + let success_value = local_entries + .get("fs_read_success") + .expect("plugin should set fs_read_success in context"); + + // The read MUST have failed — sandbox denied it + assert_eq!( + success_value, + "false", + "SANDBOX ESCAPE: plugin successfully read /etc/passwd! fs_read_success={}, error={}", + success_value, + local_entries + .get("fs_read_error") + .unwrap_or(&"".to_string()) + ); + + // Verify the error message indicates permission/access denial + let error_msg = local_entries + .get("fs_read_error") + .expect("plugin should set fs_read_error"); + assert!( + error_msg.contains("denied") + || error_msg.contains("permission") + || error_msg.contains("not found") + || error_msg.contains("No such") + || error_msg.contains("Capabilities insufficient"), + "unexpected error message: {}", + error_msg + ); +} + +#[tokio::test] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_plugin_cannot_read_etc_passwd_with_unrelated_filesystem_policy() { + init_tracing(); + let path = wasm_path(); + assert!(path.exists(), + "WASM binary not found: {}. Run `make build-test-plugins` from crates/cpex-wasm-host first.", + path.display()); + + // Load plugin with filesystem policy that only allows /tmp + let policy = cpex_wasm_host::policy_loader::SandboxPolicy { + allowed_filesystem: vec![cpex_wasm_host::policy_loader::FilesystemRule { + dir: Some("/tmp".to_string()), + file: None, + permission: "read".to_string(), + }], + ..Default::default() + }; + + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&path, Some(&policy), "fs-test-restricted") + .await + .unwrap(); + + let payload = make_payload(); + let wit_payload = + cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf(native_payload_to_wit(&payload)); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + + assert!(result.continue_processing); + + let ctx = result + .modified_context + .expect("plugin should write context"); + let local_entries: std::collections::HashMap = ctx + .local_state + .into_iter() + .map(|e| (e.key, e.value)) + .collect(); + + let success_value = local_entries + .get("fs_read_success") + .expect("plugin should set fs_read_success"); + + // Even with /tmp allowed, /etc/passwd must still be denied + assert_eq!( + success_value, "false", + "SANDBOX ESCAPE: plugin read /etc/passwd despite only /tmp being allowed!" + ); +} diff --git a/crates/cpex-wasm-host/tests/test_sandbox_network.rs b/crates/cpex-wasm-host/tests/test_sandbox_network.rs new file mode 100644 index 00000000..393732ff --- /dev/null +++ b/crates/cpex-wasm-host/tests/test_sandbox_network.rs @@ -0,0 +1,157 @@ +// Location: ./crates/cpex-wasm-host/tests/test_sandbox_network.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +//! Integration test: verifies WASM sandbox network isolation. +//! +//! Loads a real `.wasm` plugin that attempts DNS resolution / network access. +//! With no network policy (deny-all), the access must fail. +//! +//! Requires: `wasm/net-test.wasm` built from cpex-wasm-plugin with `--features net-test` + +use std::path::PathBuf; +use std::sync::Once; + +use cpex_core::cmf::constants::SCHEMA_VERSION; +use cpex_core::cmf::{ContentPart, Message, MessagePayload, Role, ToolCall}; +use cpex_core::context::PluginContext; +use cpex_core::extensions::container::Extensions; + +use cpex_wasm_host::conversions::{ + native_context_to_wit, native_extensions_to_wit, native_payload_to_wit, +}; +use cpex_wasm_host::sandbox_manager::{SandboxManager, SharedEngine}; + +static INIT: Once = Once::new(); +fn init_tracing() { + INIT.call_once(|| { + tracing_subscriber::fmt() + .with_test_writer() + .with_env_filter("info") + .init(); + }); +} + +fn wasm_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("wasm/net-test.wasm") +} + +fn make_payload() -> MessagePayload { + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_001".into(), + name: "net_check".into(), + arguments: Default::default(), + namespace: None, + }, + }], + channel: None, + }, + } +} + +#[tokio::test] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_plugin_cannot_access_network_without_policy() { + init_tracing(); + let path = wasm_path(); + assert!(path.exists(), + "WASM binary not found: {}. Run `make build-test-plugins` from crates/cpex-wasm-host first.", + path.display()); + + // Load with NO network policy (deny-all) + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&path, None, "net-test").await.unwrap(); + + let payload = make_payload(); + let wit_payload = + cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf(native_payload_to_wit(&payload)); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + + assert!(result.continue_processing, "plugin should return allow"); + + let ctx = result + .modified_context + .expect("plugin should write context"); + let local_entries: std::collections::HashMap = ctx + .local_state + .into_iter() + .map(|e| (e.key, e.value)) + .collect(); + + let net_access = local_entries + .get("net_access") + .expect("plugin should set net_access"); + + // Network access must be denied in sandbox + assert_eq!( + net_access, "\"denied\"", + "SANDBOX ESCAPE: plugin accessed network without allowlist! net_access={}", + net_access + ); +} + +#[tokio::test] +#[ignore = "requires pre-built WASM plugins — run `make build-test-plugins` first"] +async fn test_plugin_cannot_access_network_with_unrelated_allowlist() { + init_tracing(); + let path = wasm_path(); + assert!(path.exists(), + "WASM binary not found: {}. Run `make build-test-plugins` from crates/cpex-wasm-host first.", + path.display()); + + // Allow only "internal.example.com" — httpbin.org should still be denied + let policy = cpex_wasm_host::policy_loader::SandboxPolicy { + allowed_network: vec!["internal.example.com".to_string()], + ..Default::default() + }; + + let shared = SharedEngine::new().unwrap(); + let mut mgr = SandboxManager::with_shared_engine(&shared); + mgr.load_wasmplugin(&path, Some(&policy), "net-test-restricted") + .await + .unwrap(); + + let payload = make_payload(); + let wit_payload = + cpex_wasm_host::sandbox_manager::types::HookPayload::Cmf(native_payload_to_wit(&payload)); + let wit_ext = native_extensions_to_wit(&Extensions::default()); + let wit_ctx = native_context_to_wit(&PluginContext::default()); + + let result = mgr + .invoke("cmf.tool_pre_invoke", wit_payload, wit_ext, wit_ctx) + .await + .unwrap(); + + assert!(result.continue_processing); + + let ctx = result + .modified_context + .expect("plugin should write context"); + let local_entries: std::collections::HashMap = ctx + .local_state + .into_iter() + .map(|e| (e.key, e.value)) + .collect(); + + let net_access = local_entries + .get("net_access") + .expect("plugin should set net_access"); + + assert_eq!( + net_access, "\"denied\"", + "SANDBOX ESCAPE: plugin resolved DNS for httpbin.org despite allowlist being [internal.example.com]!" + ); +} diff --git a/crates/cpex-wasm-host/tests/test_security_enforcement.rs b/crates/cpex-wasm-host/tests/test_security_enforcement.rs new file mode 100644 index 00000000..444165cf --- /dev/null +++ b/crates/cpex-wasm-host/tests/test_security_enforcement.rs @@ -0,0 +1,541 @@ +// Location: ./crates/cpex-wasm-host/tests/test_security_enforcement.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// Tests for defense-in-depth security enforcement in WasmBridgeHandler. +// +// These tests validate the four security checks: +// 1. Pre-invocation capability-based extension filtering +// 2. Post-invocation immutable tier validation +// 3. Post-invocation monotonic label enforcement +// 4. Post-invocation write authorization checking + +use std::collections::HashSet; +use std::sync::Arc; + +use cpex_core::extensions::{ + filter_extensions, DelegationExtension, Extensions, Guarded, HttpExtension, MonotonicSet, + OwnedExtensions, RequestExtension, SecurityExtension, +}; + +// --------------------------------------------------------------------------- +// Fix 1: Pre-invocation capability filtering +// --------------------------------------------------------------------------- + +#[test] +fn test_pre_filter_strips_ungated_extensions() { + let ext = build_full_extensions(); + let caps: HashSet = ["read_headers"].iter().map(|s| s.to_string()).collect(); + + let filtered = filter_extensions(&ext, &caps); + + // HTTP is visible (has read_headers) + assert!(filtered.http.is_some()); + // Security labels require read_labels — not granted + assert!(filtered.security.is_none() || filtered.security.as_ref().unwrap().labels.is_empty()); + // Agent requires read_agent — not granted + assert!(filtered.agent.is_none()); + // Delegation requires read_delegation — not granted + assert!(filtered.delegation.is_none()); + // Unrestricted immutable slots are always visible + assert!(filtered.request.is_some()); +} + +#[test] +fn test_pre_filter_allows_all_with_full_capabilities() { + let ext = build_full_extensions(); + let caps: HashSet = [ + "read_headers", + "read_labels", + "read_subject", + "read_agent", + "read_delegation", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + + let filtered = filter_extensions(&ext, &caps); + + assert!(filtered.http.is_some()); + assert!(filtered.security.is_some()); + assert!(filtered.agent.is_some()); + assert!(filtered.delegation.is_some()); + assert!(filtered.request.is_some()); +} + +// --------------------------------------------------------------------------- +// Fix 2: Immutable tier validation +// --------------------------------------------------------------------------- + +#[test] +fn test_immutable_tier_violation_detected() { + let ext = build_full_extensions(); + let mut owned = ext.cow_copy(); + + // Tamper with an immutable slot by replacing the Arc pointer + owned.request = Some(Arc::new(RequestExtension { + request_id: Some("tampered".into()), + ..Default::default() + })); + + assert!(!ext.validate_immutable(&owned)); +} + +#[test] +fn test_immutable_tier_passes_when_arcs_preserved() { + let ext = build_full_extensions(); + let owned = ext.cow_copy(); + + assert!(ext.validate_immutable(&owned)); +} + +// --------------------------------------------------------------------------- +// Fix 3: Monotonic label enforcement +// --------------------------------------------------------------------------- + +#[test] +fn test_monotonic_violation_when_label_removed() { + let ext = build_extensions_with_labels(&["PII", "HIPAA"]); + let mut owned = ext.cow_copy(); + + // Replace security with only one label (removed HIPAA) + let new_sec = SecurityExtension { + labels: MonotonicSet::from_set(["PII".to_string()].into_iter().collect()), + ..Default::default() + }; + owned.security = Some(new_sec); + + // With read_labels capability, this should be detected + let caps: HashSet = ["read_labels"].iter().map(|s| s.to_string()).collect(); + + let result = validate_monotonic(&ext, &owned, &caps); + assert!(!result, "should reject when label removed"); +} + +#[test] +fn test_monotonic_passes_when_labels_only_added() { + let ext = build_extensions_with_labels(&["PII"]); + let mut owned = ext.cow_copy(); + + // Add a label (superset of original) + let new_sec = SecurityExtension { + labels: MonotonicSet::from_set( + ["PII".to_string(), "HIPAA".to_string()] + .into_iter() + .collect(), + ), + ..Default::default() + }; + owned.security = Some(new_sec); + + let caps: HashSet = ["read_labels"].iter().map(|s| s.to_string()).collect(); + + let result = validate_monotonic(&ext, &owned, &caps); + assert!(result, "should accept when labels only added"); +} + +#[test] +fn test_monotonic_not_enforced_without_read_labels() { + let ext = build_extensions_with_labels(&["PII", "HIPAA"]); + let mut owned = ext.cow_copy(); + + // Remove a label + let new_sec = SecurityExtension { + labels: MonotonicSet::from_set(["PII".to_string()].into_iter().collect()), + ..Default::default() + }; + owned.security = Some(new_sec); + + // Without read_labels capability, plugin never saw labels — not a violation + let caps: HashSet = HashSet::new(); + + let result = validate_monotonic(&ext, &owned, &caps); + assert!( + result, + "should not enforce monotonic without read_labels cap" + ); +} + +// --------------------------------------------------------------------------- +// Fix 4: Write authorization checking +// --------------------------------------------------------------------------- + +#[test] +fn test_unauthorized_http_write_detected() { + let ext = build_full_extensions(); + let mut owned = ext.cow_copy(); + + // Modify HTTP headers + let mut new_http = HttpExtension::default(); + new_http + .request_headers + .insert("X-Injected".into(), "evil".into()); + owned.http = Some(Guarded::new(new_http)); + + // Plugin lacks write_headers capability + let caps: HashSet = ["read_headers"].iter().map(|s| s.to_string()).collect(); + + let result = validate_write_auth_http(&ext, &owned, &caps); + assert!( + !result, + "should reject HTTP write without write_headers cap" + ); +} + +#[test] +fn test_authorized_http_write_passes() { + let ext = build_full_extensions(); + let mut owned = ext.cow_copy(); + + // Modify HTTP headers + let mut new_http = HttpExtension::default(); + new_http + .request_headers + .insert("X-Processed".into(), "ok".into()); + owned.http = Some(Guarded::new(new_http)); + + // Plugin HAS write_headers capability + let caps: HashSet = ["read_headers", "write_headers"] + .iter() + .map(|s| s.to_string()) + .collect(); + + let result = validate_write_auth_http(&ext, &owned, &caps); + assert!(result, "should accept HTTP write with write_headers cap"); +} + +#[test] +fn test_unauthorized_labels_write_detected() { + let ext = build_extensions_with_labels(&["PII"]); + let mut owned = ext.cow_copy(); + + // Add a label without append_labels capability + let new_sec = SecurityExtension { + labels: MonotonicSet::from_set( + ["PII".to_string(), "NEW".to_string()].into_iter().collect(), + ), + ..Default::default() + }; + owned.security = Some(new_sec); + + let caps: HashSet = ["read_labels"].iter().map(|s| s.to_string()).collect(); + + let result = validate_write_auth_labels(&ext, &owned, &caps); + assert!( + !result, + "should reject label write without append_labels cap" + ); +} + +#[test] +fn test_authorized_labels_write_passes() { + let ext = build_extensions_with_labels(&["PII"]); + let mut owned = ext.cow_copy(); + + // Add a label WITH append_labels capability + let new_sec = SecurityExtension { + labels: MonotonicSet::from_set( + ["PII".to_string(), "NEW".to_string()].into_iter().collect(), + ), + ..Default::default() + }; + owned.security = Some(new_sec); + + let caps: HashSet = ["read_labels", "append_labels"] + .iter() + .map(|s| s.to_string()) + .collect(); + + let result = validate_write_auth_labels(&ext, &owned, &caps); + assert!(result, "should accept label write with append_labels cap"); +} + +#[test] +fn test_unauthorized_delegation_write_detected() { + let ext = build_full_extensions(); + let mut owned = ext.cow_copy(); + + // Modify delegation + let new_deleg = DelegationExtension { + delegated: true, + depth: 1, + ..Default::default() + }; + owned.delegation = Some(new_deleg); + + // Plugin lacks append_delegation capability + let caps: HashSet = ["read_delegation"].iter().map(|s| s.to_string()).collect(); + + let result = validate_write_auth_delegation(&ext, &owned, &caps); + assert!( + !result, + "should reject delegation write without append_delegation cap" + ); +} + +#[test] +fn test_no_modifications_skips_validation() { + let ext = build_full_extensions(); + let owned = ext.cow_copy(); + + // No changes — all checks should pass + let caps: HashSet = HashSet::new(); + + assert!(ext.validate_immutable(&owned)); + // Monotonic: no caps means no enforcement + assert!(validate_monotonic(&ext, &owned, &caps)); +} + +// --------------------------------------------------------------------------- +// Helpers — mirror the validation logic from factory.rs +// --------------------------------------------------------------------------- + +fn validate_monotonic( + original: &Extensions, + owned: &OwnedExtensions, + capabilities: &HashSet, +) -> bool { + if capabilities.contains("read_labels") { + if let (Some(ref orig_sec), Some(ref new_sec)) = (&original.security, &owned.security) { + if !new_sec.labels.is_superset(&orig_sec.labels) { + return false; + } + } + } + true +} + +fn validate_write_auth_http( + original: &Extensions, + owned: &OwnedExtensions, + capabilities: &HashSet, +) -> bool { + if !capabilities.contains("write_headers") { + if let Some(ref http_guarded) = owned.http { + let new_http = http_guarded.read(); + let http_changed = match original.http.as_ref() { + Some(orig) => { + new_http.request_headers != orig.request_headers + || new_http.response_headers != orig.response_headers + }, + None => { + !new_http.request_headers.is_empty() || !new_http.response_headers.is_empty() + }, + }; + if http_changed { + return false; + } + } + } + true +} + +fn validate_write_auth_labels( + original: &Extensions, + owned: &OwnedExtensions, + capabilities: &HashSet, +) -> bool { + if !capabilities.contains("append_labels") { + if let Some(ref new_sec) = owned.security { + let labels_changed = match original.security.as_ref() { + Some(orig) => new_sec.labels.len() != orig.labels.len(), + None => !new_sec.labels.is_empty(), + }; + if labels_changed { + return false; + } + } + } + true +} + +fn validate_write_auth_delegation( + original: &Extensions, + owned: &OwnedExtensions, + capabilities: &HashSet, +) -> bool { + if !capabilities.contains("append_delegation") { + if let Some(ref new_deleg) = owned.delegation { + let delegation_changed = match original.delegation.as_ref() { + Some(orig) => { + new_deleg.chain.len() != orig.chain.len() + || new_deleg.depth != orig.depth + || new_deleg.delegated != orig.delegated + }, + None => new_deleg.delegated || !new_deleg.chain.is_empty(), + }; + if delegation_changed { + return false; + } + } + } + true +} + +// --------------------------------------------------------------------------- +// Fix 5: Filtered slot preservation (hidden slots not nulled on writeback) +// --------------------------------------------------------------------------- + +#[test] +fn test_hidden_http_slot_preserved_when_plugin_modifies_labels() { + use cpex_core::extensions::filter_extensions; + use cpex_wasm_host::conversions::{ + native_extensions_to_wit, wit_hook_result_to_native_filtered, + }; + use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; + + // Pipeline has HTTP headers + let ext = build_full_extensions(); + assert!(ext.http.is_some(), "precondition: HTTP exists in pipeline"); + + // Plugin only has label capabilities — no read_headers + let caps: HashSet = ["read_labels", "append_labels"] + .iter() + .map(|s| s.to_string()) + .collect(); + let filtered = filter_extensions(&ext, &caps); + assert!(filtered.http.is_none(), "precondition: HTTP filtered out"); + + // Simulate guest modifying labels and returning extensions + let mut wit_ext = native_extensions_to_wit(&filtered); + if let Some(ref mut s) = wit_ext.security { + s.labels.push("NEW_LABEL".to_string()); + } + + // Build a HookResult with modified extensions + let result = cpex_wasm_host::sandbox_manager::types::HookResult { + continue_processing: true, + modified_payload: None, + modified_extensions: Some(wit_ext), + modified_context: None, + violation: None, + metadata: None, + }; + + let registry = PayloadSerializerRegistry::new(); + let (fields, _) = wit_hook_result_to_native_filtered(result, ®istry, &ext, Some(&filtered)); + + let owned = fields + .modified_extensions + .expect("extensions should be present"); + + // HTTP must be preserved (not nulled) because the guest never saw it + assert!( + owned.http.is_some(), + "HTTP slot was nulled even though guest never received it" + ); + let http = owned.http.as_ref().unwrap().read(); + assert_eq!( + http.request_headers + .get("Authorization") + .map(|s| s.as_str()), + Some("Bearer token"), + "HTTP headers lost during writeback" + ); + + // Labels should reflect the guest's modification + let sec = owned.security.as_ref().unwrap(); + assert!(sec.has_label("NEW_LABEL")); + assert!(sec.has_label("PII")); +} + +#[test] +fn test_hidden_delegation_preserved_when_plugin_modifies_http() { + use cpex_core::extensions::filter_extensions; + use cpex_wasm_host::conversions::{ + native_extensions_to_wit, wit_hook_result_to_native_filtered, + }; + use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; + + // Pipeline has delegation + let ext = build_full_extensions(); + assert!(ext.delegation.is_some()); + + // Plugin has HTTP caps but not delegation + let caps: HashSet = ["read_headers", "write_headers"] + .iter() + .map(|s| s.to_string()) + .collect(); + let filtered = filter_extensions(&ext, &caps); + assert!( + filtered.delegation.is_none(), + "delegation should be filtered" + ); + assert!(filtered.http.is_some(), "HTTP should be visible"); + + // Guest modifies HTTP and returns extensions + let mut wit_ext = native_extensions_to_wit(&filtered); + if let Some(ref mut h) = wit_ext.http { + h.request_headers + .push(("X-Added".to_string(), "value".to_string())); + } + + let result = cpex_wasm_host::sandbox_manager::types::HookResult { + continue_processing: true, + modified_payload: None, + modified_extensions: Some(wit_ext), + modified_context: None, + violation: None, + metadata: None, + }; + + let registry = PayloadSerializerRegistry::new(); + let (fields, _) = wit_hook_result_to_native_filtered(result, ®istry, &ext, Some(&filtered)); + + let owned = fields + .modified_extensions + .expect("extensions should be present"); + + // Delegation must be preserved + assert!( + owned.delegation.is_some(), + "Delegation slot nulled even though guest never received it" + ); +} + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +fn build_full_extensions() -> Extensions { + let security = SecurityExtension { + labels: MonotonicSet::from_set(["PII".to_string()].into_iter().collect()), + ..Default::default() + }; + + let mut http = HttpExtension::default(); + http.request_headers + .insert("Authorization".into(), "Bearer token".into()); + + Extensions { + request: Some(Arc::new(RequestExtension { + request_id: Some("req-001".into()), + ..Default::default() + })), + security: Some(Arc::new(security)), + http: Some(Arc::new(http)), + delegation: Some(Arc::new(DelegationExtension::default())), + agent: Some(Arc::new(cpex_core::extensions::AgentExtension::default())), + ..Default::default() + } +} + +fn build_extensions_with_labels(labels: &[&str]) -> Extensions { + let security = SecurityExtension { + labels: MonotonicSet::from_set(labels.iter().map(|s| s.to_string()).collect()), + ..Default::default() + }; + + Extensions { + request: Some(Arc::new(RequestExtension { + request_id: Some("req-001".into()), + ..Default::default() + })), + security: Some(Arc::new(security)), + http: Some(Arc::new(HttpExtension::default())), + delegation: Some(Arc::new(DelegationExtension::default())), + ..Default::default() + } +} diff --git a/crates/cpex-wasm-host/wit/deps/cli.wit b/crates/cpex-wasm-host/wit/deps/cli.wit new file mode 100644 index 00000000..d7a3ca4d --- /dev/null +++ b/crates/cpex-wasm-host/wit/deps/cli.wit @@ -0,0 +1,261 @@ +package wasi:cli@0.2.6; + +@since(version = 0.2.0) +interface environment { + /// Get the POSIX-style environment variables. + /// + /// Each environment variable is provided as a pair of string variable names + /// and string value. + /// + /// Morally, these are a value import, but until value imports are available + /// in the component model, this import function should return the same + /// values each time it is called. + @since(version = 0.2.0) + get-environment: func() -> list>; + + /// Get the POSIX-style arguments to the program. + @since(version = 0.2.0) + get-arguments: func() -> list; + + /// Return a path that programs should use as their initial current working + /// directory, interpreting `.` as shorthand for this. + @since(version = 0.2.0) + initial-cwd: func() -> option; +} + +@since(version = 0.2.0) +interface exit { + /// Exit the current instance and any linked instances. + @since(version = 0.2.0) + exit: func(status: result); + + /// Exit the current instance and any linked instances, reporting the + /// specified status code to the host. + /// + /// The meaning of the code depends on the context, with 0 usually meaning + /// "success", and other values indicating various types of failure. + /// + /// This function does not return; the effect is analogous to a trap, but + /// without the connotation that something bad has happened. + @unstable(feature = cli-exit-with-code) + exit-with-code: func(status-code: u8); +} + +@since(version = 0.2.0) +interface run { + /// Run the program. + @since(version = 0.2.0) + run: func() -> result; +} + +@since(version = 0.2.0) +interface stdin { + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{input-stream}; + + @since(version = 0.2.0) + get-stdin: func() -> input-stream; +} + +@since(version = 0.2.0) +interface stdout { + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{output-stream}; + + @since(version = 0.2.0) + get-stdout: func() -> output-stream; +} + +@since(version = 0.2.0) +interface stderr { + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{output-stream}; + + @since(version = 0.2.0) + get-stderr: func() -> output-stream; +} + +/// Terminal input. +/// +/// In the future, this may include functions for disabling echoing, +/// disabling input buffering so that keyboard events are sent through +/// immediately, querying supported features, and so on. +@since(version = 0.2.0) +interface terminal-input { + /// The input side of a terminal. + @since(version = 0.2.0) + resource terminal-input; +} + +/// Terminal output. +/// +/// In the future, this may include functions for querying the terminal +/// size, being notified of terminal size changes, querying supported +/// features, and so on. +@since(version = 0.2.0) +interface terminal-output { + /// The output side of a terminal. + @since(version = 0.2.0) + resource terminal-output; +} + +/// An interface providing an optional `terminal-input` for stdin as a +/// link-time authority. +@since(version = 0.2.0) +interface terminal-stdin { + @since(version = 0.2.0) + use terminal-input.{terminal-input}; + + /// If stdin is connected to a terminal, return a `terminal-input` handle + /// allowing further interaction with it. + @since(version = 0.2.0) + get-terminal-stdin: func() -> option; +} + +/// An interface providing an optional `terminal-output` for stdout as a +/// link-time authority. +@since(version = 0.2.0) +interface terminal-stdout { + @since(version = 0.2.0) + use terminal-output.{terminal-output}; + + /// If stdout is connected to a terminal, return a `terminal-output` handle + /// allowing further interaction with it. + @since(version = 0.2.0) + get-terminal-stdout: func() -> option; +} + +/// An interface providing an optional `terminal-output` for stderr as a +/// link-time authority. +@since(version = 0.2.0) +interface terminal-stderr { + @since(version = 0.2.0) + use terminal-output.{terminal-output}; + + /// If stderr is connected to a terminal, return a `terminal-output` handle + /// allowing further interaction with it. + @since(version = 0.2.0) + get-terminal-stderr: func() -> option; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import environment; + @since(version = 0.2.0) + import exit; + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import stdin; + @since(version = 0.2.0) + import stdout; + @since(version = 0.2.0) + import stderr; + @since(version = 0.2.0) + import terminal-input; + @since(version = 0.2.0) + import terminal-output; + @since(version = 0.2.0) + import terminal-stdin; + @since(version = 0.2.0) + import terminal-stdout; + @since(version = 0.2.0) + import terminal-stderr; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.6; + @unstable(feature = clocks-timezone) + import wasi:clocks/timezone@0.2.6; + @since(version = 0.2.0) + import wasi:filesystem/types@0.2.6; + @since(version = 0.2.0) + import wasi:filesystem/preopens@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/network@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/instance-network@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/udp@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/udp-create-socket@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/tcp@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/tcp-create-socket@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/ip-name-lookup@0.2.6; + @since(version = 0.2.0) + import wasi:random/random@0.2.6; + @since(version = 0.2.0) + import wasi:random/insecure@0.2.6; + @since(version = 0.2.0) + import wasi:random/insecure-seed@0.2.6; +} +@since(version = 0.2.0) +world command { + @since(version = 0.2.0) + import environment; + @since(version = 0.2.0) + import exit; + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import stdin; + @since(version = 0.2.0) + import stdout; + @since(version = 0.2.0) + import stderr; + @since(version = 0.2.0) + import terminal-input; + @since(version = 0.2.0) + import terminal-output; + @since(version = 0.2.0) + import terminal-stdin; + @since(version = 0.2.0) + import terminal-stdout; + @since(version = 0.2.0) + import terminal-stderr; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.6; + @unstable(feature = clocks-timezone) + import wasi:clocks/timezone@0.2.6; + @since(version = 0.2.0) + import wasi:filesystem/types@0.2.6; + @since(version = 0.2.0) + import wasi:filesystem/preopens@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/network@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/instance-network@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/udp@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/udp-create-socket@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/tcp@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/tcp-create-socket@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/ip-name-lookup@0.2.6; + @since(version = 0.2.0) + import wasi:random/random@0.2.6; + @since(version = 0.2.0) + import wasi:random/insecure@0.2.6; + @since(version = 0.2.0) + import wasi:random/insecure-seed@0.2.6; + + @since(version = 0.2.0) + export run; +} diff --git a/crates/cpex-wasm-host/wit/deps/clocks.wit b/crates/cpex-wasm-host/wit/deps/clocks.wit new file mode 100644 index 00000000..d638f1a4 --- /dev/null +++ b/crates/cpex-wasm-host/wit/deps/clocks.wit @@ -0,0 +1,157 @@ +package wasi:clocks@0.2.6; + +/// WASI Monotonic Clock is a clock API intended to let users measure elapsed +/// time. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +/// +/// A monotonic clock is a clock which has an unspecified initial value, and +/// successive reads of the clock will produce non-decreasing values. +@since(version = 0.2.0) +interface monotonic-clock { + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + + /// An instant in time, in nanoseconds. An instant is relative to an + /// unspecified initial value, and can only be compared to instances from + /// the same monotonic-clock. + @since(version = 0.2.0) + type instant = u64; + + /// A duration of time, in nanoseconds. + @since(version = 0.2.0) + type duration = u64; + + /// Read the current value of the clock. + /// + /// The clock is monotonic, therefore calling this function repeatedly will + /// produce a sequence of non-decreasing values. + @since(version = 0.2.0) + now: func() -> instant; + + /// Query the resolution of the clock. Returns the duration of time + /// corresponding to a clock tick. + @since(version = 0.2.0) + resolution: func() -> duration; + + /// Create a `pollable` which will resolve once the specified instant + /// has occurred. + @since(version = 0.2.0) + subscribe-instant: func(when: instant) -> pollable; + + /// Create a `pollable` that will resolve after the specified duration has + /// elapsed from the time this function is invoked. + @since(version = 0.2.0) + subscribe-duration: func(when: duration) -> pollable; +} + +/// WASI Wall Clock is a clock API intended to let users query the current +/// time. The name "wall" makes an analogy to a "clock on the wall", which +/// is not necessarily monotonic as it may be reset. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +/// +/// A wall clock is a clock which measures the date and time according to +/// some external reference. +/// +/// External references may be reset, so this clock is not necessarily +/// monotonic, making it unsuitable for measuring elapsed time. +/// +/// It is intended for reporting the current date and time for humans. +@since(version = 0.2.0) +interface wall-clock { + /// A time and date in seconds plus nanoseconds. + @since(version = 0.2.0) + record datetime { + seconds: u64, + nanoseconds: u32, + } + + /// Read the current value of the clock. + /// + /// This clock is not monotonic, therefore calling this function repeatedly + /// will not necessarily produce a sequence of non-decreasing values. + /// + /// The returned timestamps represent the number of seconds since + /// 1970-01-01T00:00:00Z, also known as [POSIX's Seconds Since the Epoch], + /// also known as [Unix Time]. + /// + /// The nanoseconds field of the output is always less than 1000000000. + /// + /// [POSIX's Seconds Since the Epoch]: https://pubs.opengroup.org/onlinepubs/9699919799/xrat/V4_xbd_chap04.html#tag_21_04_16 + /// [Unix Time]: https://en.wikipedia.org/wiki/Unix_time + @since(version = 0.2.0) + now: func() -> datetime; + + /// Query the resolution of the clock. + /// + /// The nanoseconds field of the output is always less than 1000000000. + @since(version = 0.2.0) + resolution: func() -> datetime; +} + +@unstable(feature = clocks-timezone) +interface timezone { + @unstable(feature = clocks-timezone) + use wall-clock.{datetime}; + + /// Information useful for displaying the timezone of a specific `datetime`. + /// + /// This information may vary within a single `timezone` to reflect daylight + /// saving time adjustments. + @unstable(feature = clocks-timezone) + record timezone-display { + /// The number of seconds difference between UTC time and the local + /// time of the timezone. + /// + /// The returned value will always be less than 86400 which is the + /// number of seconds in a day (24*60*60). + /// + /// In implementations that do not expose an actual time zone, this + /// should return 0. + utc-offset: s32, + /// The abbreviated name of the timezone to display to a user. The name + /// `UTC` indicates Coordinated Universal Time. Otherwise, this should + /// reference local standards for the name of the time zone. + /// + /// In implementations that do not expose an actual time zone, this + /// should be the string `UTC`. + /// + /// In time zones that do not have an applicable name, a formatted + /// representation of the UTC offset may be returned, such as `-04:00`. + name: string, + /// Whether daylight saving time is active. + /// + /// In implementations that do not expose an actual time zone, this + /// should return false. + in-daylight-saving-time: bool, + } + + /// Return information needed to display the given `datetime`. This includes + /// the UTC offset, the time zone name, and a flag indicating whether + /// daylight saving time is active. + /// + /// If the timezone cannot be determined for the given `datetime`, return a + /// `timezone-display` for `UTC` with a `utc-offset` of 0 and no daylight + /// saving time. + @unstable(feature = clocks-timezone) + display: func(when: datetime) -> timezone-display; + + /// The same as `display`, but only return the UTC offset. + @unstable(feature = clocks-timezone) + utc-offset: func(when: datetime) -> s32; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import monotonic-clock; + @since(version = 0.2.0) + import wall-clock; + @unstable(feature = clocks-timezone) + import timezone; +} diff --git a/crates/cpex-wasm-host/wit/deps/filesystem.wit b/crates/cpex-wasm-host/wit/deps/filesystem.wit new file mode 100644 index 00000000..9f4a8288 --- /dev/null +++ b/crates/cpex-wasm-host/wit/deps/filesystem.wit @@ -0,0 +1,587 @@ +package wasi:filesystem@0.2.6; + +/// WASI filesystem is a filesystem API primarily intended to let users run WASI +/// programs that access their files on their existing filesystems, without +/// significant overhead. +/// +/// It is intended to be roughly portable between Unix-family platforms and +/// Windows, though it does not hide many of the major differences. +/// +/// Paths are passed as interface-type `string`s, meaning they must consist of +/// a sequence of Unicode Scalar Values (USVs). Some filesystems may contain +/// paths which are not accessible by this API. +/// +/// The directory separator in WASI is always the forward-slash (`/`). +/// +/// All paths in WASI are relative paths, and are interpreted relative to a +/// `descriptor` referring to a base directory. If a `path` argument to any WASI +/// function starts with `/`, or if any step of resolving a `path`, including +/// `..` and symbolic link steps, reaches a directory outside of the base +/// directory, or reaches a symlink to an absolute or rooted path in the +/// underlying filesystem, the function fails with `error-code::not-permitted`. +/// +/// For more information about WASI path resolution and sandboxing, see +/// [WASI filesystem path resolution]. +/// +/// [WASI filesystem path resolution]: https://github.com/WebAssembly/wasi-filesystem/blob/main/path-resolution.md +@since(version = 0.2.0) +interface types { + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{input-stream, output-stream, error}; + @since(version = 0.2.0) + use wasi:clocks/wall-clock@0.2.6.{datetime}; + + /// File size or length of a region within a file. + @since(version = 0.2.0) + type filesize = u64; + + /// The type of a filesystem object referenced by a descriptor. + /// + /// Note: This was called `filetype` in earlier versions of WASI. + @since(version = 0.2.0) + enum descriptor-type { + /// The type of the descriptor or file is unknown or is different from + /// any of the other types specified. + unknown, + /// The descriptor refers to a block device inode. + block-device, + /// The descriptor refers to a character device inode. + character-device, + /// The descriptor refers to a directory inode. + directory, + /// The descriptor refers to a named pipe. + fifo, + /// The file refers to a symbolic link inode. + symbolic-link, + /// The descriptor refers to a regular file inode. + regular-file, + /// The descriptor refers to a socket. + socket, + } + + /// Descriptor flags. + /// + /// Note: This was called `fdflags` in earlier versions of WASI. + @since(version = 0.2.0) + flags descriptor-flags { + /// Read mode: Data can be read. + read, + /// Write mode: Data can be written to. + write, + /// Request that writes be performed according to synchronized I/O file + /// integrity completion. The data stored in the file and the file's + /// metadata are synchronized. This is similar to `O_SYNC` in POSIX. + /// + /// The precise semantics of this operation have not yet been defined for + /// WASI. At this time, it should be interpreted as a request, and not a + /// requirement. + file-integrity-sync, + /// Request that writes be performed according to synchronized I/O data + /// integrity completion. Only the data stored in the file is + /// synchronized. This is similar to `O_DSYNC` in POSIX. + /// + /// The precise semantics of this operation have not yet been defined for + /// WASI. At this time, it should be interpreted as a request, and not a + /// requirement. + data-integrity-sync, + /// Requests that reads be performed at the same level of integrity + /// requested for writes. This is similar to `O_RSYNC` in POSIX. + /// + /// The precise semantics of this operation have not yet been defined for + /// WASI. At this time, it should be interpreted as a request, and not a + /// requirement. + requested-write-sync, + /// Mutating directories mode: Directory contents may be mutated. + /// + /// When this flag is unset on a descriptor, operations using the + /// descriptor which would create, rename, delete, modify the data or + /// metadata of filesystem objects, or obtain another handle which + /// would permit any of those, shall fail with `error-code::read-only` if + /// they would otherwise succeed. + /// + /// This may only be set on directories. + mutate-directory, + } + + /// Flags determining the method of how paths are resolved. + @since(version = 0.2.0) + flags path-flags { + /// As long as the resolved path corresponds to a symbolic link, it is + /// expanded. + symlink-follow, + } + + /// Open flags used by `open-at`. + @since(version = 0.2.0) + flags open-flags { + /// Create file if it does not exist, similar to `O_CREAT` in POSIX. + create, + /// Fail if not a directory, similar to `O_DIRECTORY` in POSIX. + directory, + /// Fail if file already exists, similar to `O_EXCL` in POSIX. + exclusive, + /// Truncate file to size 0, similar to `O_TRUNC` in POSIX. + truncate, + } + + /// Number of hard links to an inode. + @since(version = 0.2.0) + type link-count = u64; + + /// File attributes. + /// + /// Note: This was called `filestat` in earlier versions of WASI. + @since(version = 0.2.0) + record descriptor-stat { + /// File type. + %type: descriptor-type, + /// Number of hard links to the file. + link-count: link-count, + /// For regular files, the file size in bytes. For symbolic links, the + /// length in bytes of the pathname contained in the symbolic link. + size: filesize, + /// Last data access timestamp. + /// + /// If the `option` is none, the platform doesn't maintain an access + /// timestamp for this file. + data-access-timestamp: option, + /// Last data modification timestamp. + /// + /// If the `option` is none, the platform doesn't maintain a + /// modification timestamp for this file. + data-modification-timestamp: option, + /// Last file status-change timestamp. + /// + /// If the `option` is none, the platform doesn't maintain a + /// status-change timestamp for this file. + status-change-timestamp: option, + } + + /// When setting a timestamp, this gives the value to set it to. + @since(version = 0.2.0) + variant new-timestamp { + /// Leave the timestamp set to its previous value. + no-change, + /// Set the timestamp to the current time of the system clock associated + /// with the filesystem. + now, + /// Set the timestamp to the given value. + timestamp(datetime), + } + + /// A directory entry. + record directory-entry { + /// The type of the file referred to by this directory entry. + %type: descriptor-type, + /// The name of the object. + name: string, + } + + /// Error codes returned by functions, similar to `errno` in POSIX. + /// Not all of these error codes are returned by the functions provided by this + /// API; some are used in higher-level library layers, and others are provided + /// merely for alignment with POSIX. + enum error-code { + /// Permission denied, similar to `EACCES` in POSIX. + access, + /// Resource unavailable, or operation would block, similar to `EAGAIN` and `EWOULDBLOCK` in POSIX. + would-block, + /// Connection already in progress, similar to `EALREADY` in POSIX. + already, + /// Bad descriptor, similar to `EBADF` in POSIX. + bad-descriptor, + /// Device or resource busy, similar to `EBUSY` in POSIX. + busy, + /// Resource deadlock would occur, similar to `EDEADLK` in POSIX. + deadlock, + /// Storage quota exceeded, similar to `EDQUOT` in POSIX. + quota, + /// File exists, similar to `EEXIST` in POSIX. + exist, + /// File too large, similar to `EFBIG` in POSIX. + file-too-large, + /// Illegal byte sequence, similar to `EILSEQ` in POSIX. + illegal-byte-sequence, + /// Operation in progress, similar to `EINPROGRESS` in POSIX. + in-progress, + /// Interrupted function, similar to `EINTR` in POSIX. + interrupted, + /// Invalid argument, similar to `EINVAL` in POSIX. + invalid, + /// I/O error, similar to `EIO` in POSIX. + io, + /// Is a directory, similar to `EISDIR` in POSIX. + is-directory, + /// Too many levels of symbolic links, similar to `ELOOP` in POSIX. + loop, + /// Too many links, similar to `EMLINK` in POSIX. + too-many-links, + /// Message too large, similar to `EMSGSIZE` in POSIX. + message-size, + /// Filename too long, similar to `ENAMETOOLONG` in POSIX. + name-too-long, + /// No such device, similar to `ENODEV` in POSIX. + no-device, + /// No such file or directory, similar to `ENOENT` in POSIX. + no-entry, + /// No locks available, similar to `ENOLCK` in POSIX. + no-lock, + /// Not enough space, similar to `ENOMEM` in POSIX. + insufficient-memory, + /// No space left on device, similar to `ENOSPC` in POSIX. + insufficient-space, + /// Not a directory or a symbolic link to a directory, similar to `ENOTDIR` in POSIX. + not-directory, + /// Directory not empty, similar to `ENOTEMPTY` in POSIX. + not-empty, + /// State not recoverable, similar to `ENOTRECOVERABLE` in POSIX. + not-recoverable, + /// Not supported, similar to `ENOTSUP` and `ENOSYS` in POSIX. + unsupported, + /// Inappropriate I/O control operation, similar to `ENOTTY` in POSIX. + no-tty, + /// No such device or address, similar to `ENXIO` in POSIX. + no-such-device, + /// Value too large to be stored in data type, similar to `EOVERFLOW` in POSIX. + overflow, + /// Operation not permitted, similar to `EPERM` in POSIX. + not-permitted, + /// Broken pipe, similar to `EPIPE` in POSIX. + pipe, + /// Read-only file system, similar to `EROFS` in POSIX. + read-only, + /// Invalid seek, similar to `ESPIPE` in POSIX. + invalid-seek, + /// Text file busy, similar to `ETXTBSY` in POSIX. + text-file-busy, + /// Cross-device link, similar to `EXDEV` in POSIX. + cross-device, + } + + /// File or memory access pattern advisory information. + @since(version = 0.2.0) + enum advice { + /// The application has no advice to give on its behavior with respect + /// to the specified data. + normal, + /// The application expects to access the specified data sequentially + /// from lower offsets to higher offsets. + sequential, + /// The application expects to access the specified data in a random + /// order. + random, + /// The application expects to access the specified data in the near + /// future. + will-need, + /// The application expects that it will not access the specified data + /// in the near future. + dont-need, + /// The application expects to access the specified data once and then + /// not reuse it thereafter. + no-reuse, + } + + /// A 128-bit hash value, split into parts because wasm doesn't have a + /// 128-bit integer type. + @since(version = 0.2.0) + record metadata-hash-value { + /// 64 bits of a 128-bit hash value. + lower: u64, + /// Another 64 bits of a 128-bit hash value. + upper: u64, + } + + /// A descriptor is a reference to a filesystem object, which may be a file, + /// directory, named pipe, special file, or other object on which filesystem + /// calls may be made. + @since(version = 0.2.0) + resource descriptor { + /// Return a stream for reading from a file, if available. + /// + /// May fail with an error-code describing why the file cannot be read. + /// + /// Multiple read, write, and append streams may be active on the same open + /// file and they do not interfere with each other. + /// + /// Note: This allows using `read-stream`, which is similar to `read` in POSIX. + @since(version = 0.2.0) + read-via-stream: func(offset: filesize) -> result; + /// Return a stream for writing to a file, if available. + /// + /// May fail with an error-code describing why the file cannot be written. + /// + /// Note: This allows using `write-stream`, which is similar to `write` in + /// POSIX. + @since(version = 0.2.0) + write-via-stream: func(offset: filesize) -> result; + /// Return a stream for appending to a file, if available. + /// + /// May fail with an error-code describing why the file cannot be appended. + /// + /// Note: This allows using `write-stream`, which is similar to `write` with + /// `O_APPEND` in POSIX. + @since(version = 0.2.0) + append-via-stream: func() -> result; + /// Provide file advisory information on a descriptor. + /// + /// This is similar to `posix_fadvise` in POSIX. + @since(version = 0.2.0) + advise: func(offset: filesize, length: filesize, advice: advice) -> result<_, error-code>; + /// Synchronize the data of a file to disk. + /// + /// This function succeeds with no effect if the file descriptor is not + /// opened for writing. + /// + /// Note: This is similar to `fdatasync` in POSIX. + @since(version = 0.2.0) + sync-data: func() -> result<_, error-code>; + /// Get flags associated with a descriptor. + /// + /// Note: This returns similar flags to `fcntl(fd, F_GETFL)` in POSIX. + /// + /// Note: This returns the value that was the `fs_flags` value returned + /// from `fdstat_get` in earlier versions of WASI. + @since(version = 0.2.0) + get-flags: func() -> result; + /// Get the dynamic type of a descriptor. + /// + /// Note: This returns the same value as the `type` field of the `fd-stat` + /// returned by `stat`, `stat-at` and similar. + /// + /// Note: This returns similar flags to the `st_mode & S_IFMT` value provided + /// by `fstat` in POSIX. + /// + /// Note: This returns the value that was the `fs_filetype` value returned + /// from `fdstat_get` in earlier versions of WASI. + @since(version = 0.2.0) + get-type: func() -> result; + /// Adjust the size of an open file. If this increases the file's size, the + /// extra bytes are filled with zeros. + /// + /// Note: This was called `fd_filestat_set_size` in earlier versions of WASI. + @since(version = 0.2.0) + set-size: func(size: filesize) -> result<_, error-code>; + /// Adjust the timestamps of an open file or directory. + /// + /// Note: This is similar to `futimens` in POSIX. + /// + /// Note: This was called `fd_filestat_set_times` in earlier versions of WASI. + @since(version = 0.2.0) + set-times: func(data-access-timestamp: new-timestamp, data-modification-timestamp: new-timestamp) -> result<_, error-code>; + /// Read from a descriptor, without using and updating the descriptor's offset. + /// + /// This function returns a list of bytes containing the data that was + /// read, along with a bool which, when true, indicates that the end of the + /// file was reached. The returned list will contain up to `length` bytes; it + /// may return fewer than requested, if the end of the file is reached or + /// if the I/O operation is interrupted. + /// + /// In the future, this may change to return a `stream`. + /// + /// Note: This is similar to `pread` in POSIX. + @since(version = 0.2.0) + read: func(length: filesize, offset: filesize) -> result, bool>, error-code>; + /// Write to a descriptor, without using and updating the descriptor's offset. + /// + /// It is valid to write past the end of a file; the file is extended to the + /// extent of the write, with bytes between the previous end and the start of + /// the write set to zero. + /// + /// In the future, this may change to take a `stream`. + /// + /// Note: This is similar to `pwrite` in POSIX. + @since(version = 0.2.0) + write: func(buffer: list, offset: filesize) -> result; + /// Read directory entries from a directory. + /// + /// On filesystems where directories contain entries referring to themselves + /// and their parents, often named `.` and `..` respectively, these entries + /// are omitted. + /// + /// This always returns a new stream which starts at the beginning of the + /// directory. Multiple streams may be active on the same directory, and they + /// do not interfere with each other. + @since(version = 0.2.0) + read-directory: func() -> result; + /// Synchronize the data and metadata of a file to disk. + /// + /// This function succeeds with no effect if the file descriptor is not + /// opened for writing. + /// + /// Note: This is similar to `fsync` in POSIX. + @since(version = 0.2.0) + sync: func() -> result<_, error-code>; + /// Create a directory. + /// + /// Note: This is similar to `mkdirat` in POSIX. + @since(version = 0.2.0) + create-directory-at: func(path: string) -> result<_, error-code>; + /// Return the attributes of an open file or directory. + /// + /// Note: This is similar to `fstat` in POSIX, except that it does not return + /// device and inode information. For testing whether two descriptors refer to + /// the same underlying filesystem object, use `is-same-object`. To obtain + /// additional data that can be used do determine whether a file has been + /// modified, use `metadata-hash`. + /// + /// Note: This was called `fd_filestat_get` in earlier versions of WASI. + @since(version = 0.2.0) + stat: func() -> result; + /// Return the attributes of a file or directory. + /// + /// Note: This is similar to `fstatat` in POSIX, except that it does not + /// return device and inode information. See the `stat` description for a + /// discussion of alternatives. + /// + /// Note: This was called `path_filestat_get` in earlier versions of WASI. + @since(version = 0.2.0) + stat-at: func(path-flags: path-flags, path: string) -> result; + /// Adjust the timestamps of a file or directory. + /// + /// Note: This is similar to `utimensat` in POSIX. + /// + /// Note: This was called `path_filestat_set_times` in earlier versions of + /// WASI. + @since(version = 0.2.0) + set-times-at: func(path-flags: path-flags, path: string, data-access-timestamp: new-timestamp, data-modification-timestamp: new-timestamp) -> result<_, error-code>; + /// Create a hard link. + /// + /// Fails with `error-code::no-entry` if the old path does not exist, + /// with `error-code::exist` if the new path already exists, and + /// `error-code::not-permitted` if the old path is not a file. + /// + /// Note: This is similar to `linkat` in POSIX. + @since(version = 0.2.0) + link-at: func(old-path-flags: path-flags, old-path: string, new-descriptor: borrow, new-path: string) -> result<_, error-code>; + /// Open a file or directory. + /// + /// If `flags` contains `descriptor-flags::mutate-directory`, and the base + /// descriptor doesn't have `descriptor-flags::mutate-directory` set, + /// `open-at` fails with `error-code::read-only`. + /// + /// If `flags` contains `write` or `mutate-directory`, or `open-flags` + /// contains `truncate` or `create`, and the base descriptor doesn't have + /// `descriptor-flags::mutate-directory` set, `open-at` fails with + /// `error-code::read-only`. + /// + /// Note: This is similar to `openat` in POSIX. + @since(version = 0.2.0) + open-at: func(path-flags: path-flags, path: string, open-flags: open-flags, %flags: descriptor-flags) -> result; + /// Read the contents of a symbolic link. + /// + /// If the contents contain an absolute or rooted path in the underlying + /// filesystem, this function fails with `error-code::not-permitted`. + /// + /// Note: This is similar to `readlinkat` in POSIX. + @since(version = 0.2.0) + readlink-at: func(path: string) -> result; + /// Remove a directory. + /// + /// Return `error-code::not-empty` if the directory is not empty. + /// + /// Note: This is similar to `unlinkat(fd, path, AT_REMOVEDIR)` in POSIX. + @since(version = 0.2.0) + remove-directory-at: func(path: string) -> result<_, error-code>; + /// Rename a filesystem object. + /// + /// Note: This is similar to `renameat` in POSIX. + @since(version = 0.2.0) + rename-at: func(old-path: string, new-descriptor: borrow, new-path: string) -> result<_, error-code>; + /// Create a symbolic link (also known as a "symlink"). + /// + /// If `old-path` starts with `/`, the function fails with + /// `error-code::not-permitted`. + /// + /// Note: This is similar to `symlinkat` in POSIX. + @since(version = 0.2.0) + symlink-at: func(old-path: string, new-path: string) -> result<_, error-code>; + /// Unlink a filesystem object that is not a directory. + /// + /// Return `error-code::is-directory` if the path refers to a directory. + /// Note: This is similar to `unlinkat(fd, path, 0)` in POSIX. + @since(version = 0.2.0) + unlink-file-at: func(path: string) -> result<_, error-code>; + /// Test whether two descriptors refer to the same filesystem object. + /// + /// In POSIX, this corresponds to testing whether the two descriptors have the + /// same device (`st_dev`) and inode (`st_ino` or `d_ino`) numbers. + /// wasi-filesystem does not expose device and inode numbers, so this function + /// may be used instead. + @since(version = 0.2.0) + is-same-object: func(other: borrow) -> bool; + /// Return a hash of the metadata associated with a filesystem object referred + /// to by a descriptor. + /// + /// This returns a hash of the last-modification timestamp and file size, and + /// may also include the inode number, device number, birth timestamp, and + /// other metadata fields that may change when the file is modified or + /// replaced. It may also include a secret value chosen by the + /// implementation and not otherwise exposed. + /// + /// Implementations are encouraged to provide the following properties: + /// + /// - If the file is not modified or replaced, the computed hash value should + /// usually not change. + /// - If the object is modified or replaced, the computed hash value should + /// usually change. + /// - The inputs to the hash should not be easily computable from the + /// computed hash. + /// + /// However, none of these is required. + @since(version = 0.2.0) + metadata-hash: func() -> result; + /// Return a hash of the metadata associated with a filesystem object referred + /// to by a directory descriptor and a relative path. + /// + /// This performs the same hash computation as `metadata-hash`. + @since(version = 0.2.0) + metadata-hash-at: func(path-flags: path-flags, path: string) -> result; + } + + /// A stream of directory entries. + @since(version = 0.2.0) + resource directory-entry-stream { + /// Read a single directory entry from a `directory-entry-stream`. + @since(version = 0.2.0) + read-directory-entry: func() -> result, error-code>; + } + + /// Attempts to extract a filesystem-related `error-code` from the stream + /// `error` provided. + /// + /// Stream operations which return `stream-error::last-operation-failed` + /// have a payload with more information about the operation that failed. + /// This payload can be passed through to this function to see if there's + /// filesystem-related information about the error to return. + /// + /// Note that this function is fallible because not all stream-related + /// errors are filesystem-related errors. + @since(version = 0.2.0) + filesystem-error-code: func(err: borrow) -> option; +} + +@since(version = 0.2.0) +interface preopens { + @since(version = 0.2.0) + use types.{descriptor}; + + /// Return the set of preopened directories, and their paths. + @since(version = 0.2.0) + get-directories: func() -> list>; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.6; + @since(version = 0.2.0) + import types; + @since(version = 0.2.0) + import preopens; +} diff --git a/crates/cpex-wasm-host/wit/deps/http.wit b/crates/cpex-wasm-host/wit/deps/http.wit new file mode 100644 index 00000000..eb1b25f0 --- /dev/null +++ b/crates/cpex-wasm-host/wit/deps/http.wit @@ -0,0 +1,733 @@ +package wasi:http@0.2.6; + +/// This interface defines all of the types and methods for implementing +/// HTTP Requests and Responses, both incoming and outgoing, as well as +/// their headers, trailers, and bodies. +@since(version = 0.2.0) +interface types { + @since(version = 0.2.0) + use wasi:clocks/monotonic-clock@0.2.6.{duration}; + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{input-stream, output-stream}; + @since(version = 0.2.0) + use wasi:io/error@0.2.6.{error as io-error}; + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + + /// This type corresponds to HTTP standard Methods. + @since(version = 0.2.0) + variant method { + get, + head, + post, + put, + delete, + connect, + options, + trace, + patch, + other(string), + } + + /// This type corresponds to HTTP standard Related Schemes. + @since(version = 0.2.0) + variant scheme { + HTTP, + HTTPS, + other(string), + } + + /// Defines the case payload type for `DNS-error` above: + @since(version = 0.2.0) + record DNS-error-payload { + rcode: option, + info-code: option, + } + + /// Defines the case payload type for `TLS-alert-received` above: + @since(version = 0.2.0) + record TLS-alert-received-payload { + alert-id: option, + alert-message: option, + } + + /// Defines the case payload type for `HTTP-response-{header,trailer}-size` above: + @since(version = 0.2.0) + record field-size-payload { + field-name: option, + field-size: option, + } + + /// These cases are inspired by the IANA HTTP Proxy Error Types: + /// + @since(version = 0.2.0) + variant error-code { + DNS-timeout, + DNS-error(DNS-error-payload), + destination-not-found, + destination-unavailable, + destination-IP-prohibited, + destination-IP-unroutable, + connection-refused, + connection-terminated, + connection-timeout, + connection-read-timeout, + connection-write-timeout, + connection-limit-reached, + TLS-protocol-error, + TLS-certificate-error, + TLS-alert-received(TLS-alert-received-payload), + HTTP-request-denied, + HTTP-request-length-required, + HTTP-request-body-size(option), + HTTP-request-method-invalid, + HTTP-request-URI-invalid, + HTTP-request-URI-too-long, + HTTP-request-header-section-size(option), + HTTP-request-header-size(option), + HTTP-request-trailer-section-size(option), + HTTP-request-trailer-size(field-size-payload), + HTTP-response-incomplete, + HTTP-response-header-section-size(option), + HTTP-response-header-size(field-size-payload), + HTTP-response-body-size(option), + HTTP-response-trailer-section-size(option), + HTTP-response-trailer-size(field-size-payload), + HTTP-response-transfer-coding(option), + HTTP-response-content-coding(option), + HTTP-response-timeout, + HTTP-upgrade-failed, + HTTP-protocol-error, + loop-detected, + configuration-error, + /// This is a catch-all error for anything that doesn't fit cleanly into a + /// more specific case. It also includes an optional string for an + /// unstructured description of the error. Users should not depend on the + /// string for diagnosing errors, as it's not required to be consistent + /// between implementations. + internal-error(option), + } + + /// This type enumerates the different kinds of errors that may occur when + /// setting or appending to a `fields` resource. + @since(version = 0.2.0) + variant header-error { + /// This error indicates that a `field-name` or `field-value` was + /// syntactically invalid when used with an operation that sets headers in a + /// `fields`. + invalid-syntax, + /// This error indicates that a forbidden `field-name` was used when trying + /// to set a header in a `fields`. + forbidden, + /// This error indicates that the operation on the `fields` was not + /// permitted because the fields are immutable. + immutable, + } + + /// Field keys are always strings. + /// + /// Field keys should always be treated as case insensitive by the `fields` + /// resource for the purposes of equality checking. + /// + /// # Deprecation + /// + /// This type has been deprecated in favor of the `field-name` type. + @since(version = 0.2.0) + @deprecated(version = 0.2.2) + type field-key = string; + + /// Field names are always strings. + /// + /// Field names should always be treated as case insensitive by the `fields` + /// resource for the purposes of equality checking. + @since(version = 0.2.1) + type field-name = field-key; + + /// Field values should always be ASCII strings. However, in + /// reality, HTTP implementations often have to interpret malformed values, + /// so they are provided as a list of bytes. + @since(version = 0.2.0) + type field-value = list; + + /// This following block defines the `fields` resource which corresponds to + /// HTTP standard Fields. Fields are a common representation used for both + /// Headers and Trailers. + /// + /// A `fields` may be mutable or immutable. A `fields` created using the + /// constructor, `from-list`, or `clone` will be mutable, but a `fields` + /// resource given by other means (including, but not limited to, + /// `incoming-request.headers`, `outgoing-request.headers`) might be + /// immutable. In an immutable fields, the `set`, `append`, and `delete` + /// operations will fail with `header-error.immutable`. + @since(version = 0.2.0) + resource fields { + /// Construct an empty HTTP Fields. + /// + /// The resulting `fields` is mutable. + @since(version = 0.2.0) + constructor(); + /// Construct an HTTP Fields. + /// + /// The resulting `fields` is mutable. + /// + /// The list represents each name-value pair in the Fields. Names + /// which have multiple values are represented by multiple entries in this + /// list with the same name. + /// + /// The tuple is a pair of the field name, represented as a string, and + /// Value, represented as a list of bytes. + /// + /// An error result will be returned if any `field-name` or `field-value` is + /// syntactically invalid, or if a field is forbidden. + @since(version = 0.2.0) + from-list: static func(entries: list>) -> result; + /// Get all of the values corresponding to a name. If the name is not present + /// in this `fields` or is syntactically invalid, an empty list is returned. + /// However, if the name is present but empty, this is represented by a list + /// with one or more empty field-values present. + @since(version = 0.2.0) + get: func(name: field-name) -> list; + /// Returns `true` when the name is present in this `fields`. If the name is + /// syntactically invalid, `false` is returned. + @since(version = 0.2.0) + has: func(name: field-name) -> bool; + /// Set all of the values for a name. Clears any existing values for that + /// name, if they have been set. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.invalid-syntax` if the `field-name` or any of + /// the `field-value`s are syntactically invalid. + @since(version = 0.2.0) + set: func(name: field-name, value: list) -> result<_, header-error>; + /// Delete all values for a name. Does nothing if no values for the name + /// exist. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.invalid-syntax` if the `field-name` is + /// syntactically invalid. + @since(version = 0.2.0) + delete: func(name: field-name) -> result<_, header-error>; + /// Append a value for a name. Does not change or delete any existing + /// values for that name. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.invalid-syntax` if the `field-name` or + /// `field-value` are syntactically invalid. + @since(version = 0.2.0) + append: func(name: field-name, value: field-value) -> result<_, header-error>; + /// Retrieve the full set of names and values in the Fields. Like the + /// constructor, the list represents each name-value pair. + /// + /// The outer list represents each name-value pair in the Fields. Names + /// which have multiple values are represented by multiple entries in this + /// list with the same name. + /// + /// The names and values are always returned in the original casing and in + /// the order in which they will be serialized for transport. + @since(version = 0.2.0) + entries: func() -> list>; + /// Make a deep copy of the Fields. Equivalent in behavior to calling the + /// `fields` constructor on the return value of `entries`. The resulting + /// `fields` is mutable. + @since(version = 0.2.0) + clone: func() -> fields; + } + + /// Headers is an alias for Fields. + @since(version = 0.2.0) + type headers = fields; + + /// Trailers is an alias for Fields. + @since(version = 0.2.0) + type trailers = fields; + + /// Represents an incoming HTTP Request. + @since(version = 0.2.0) + resource incoming-request { + /// Returns the method of the incoming request. + @since(version = 0.2.0) + method: func() -> method; + /// Returns the path with query parameters from the request, as a string. + @since(version = 0.2.0) + path-with-query: func() -> option; + /// Returns the protocol scheme from the request. + @since(version = 0.2.0) + scheme: func() -> option; + /// Returns the authority of the Request's target URI, if present. + @since(version = 0.2.0) + authority: func() -> option; + /// Get the `headers` associated with the request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// The `headers` returned are a child resource: it must be dropped before + /// the parent `incoming-request` is dropped. Dropping this + /// `incoming-request` before all children are dropped will trap. + @since(version = 0.2.0) + headers: func() -> headers; + /// Gives the `incoming-body` associated with this request. Will only + /// return success at most once, and subsequent calls will return error. + @since(version = 0.2.0) + consume: func() -> result; + } + + /// Represents an outgoing HTTP Request. + @since(version = 0.2.0) + resource outgoing-request { + /// Construct a new `outgoing-request` with a default `method` of `GET`, and + /// `none` values for `path-with-query`, `scheme`, and `authority`. + /// + /// * `headers` is the HTTP Headers for the Request. + /// + /// It is possible to construct, or manipulate with the accessor functions + /// below, an `outgoing-request` with an invalid combination of `scheme` + /// and `authority`, or `headers` which are not permitted to be sent. + /// It is the obligation of the `outgoing-handler.handle` implementation + /// to reject invalid constructions of `outgoing-request`. + @since(version = 0.2.0) + constructor(headers: headers); + /// Returns the resource corresponding to the outgoing Body for this + /// Request. + /// + /// Returns success on the first call: the `outgoing-body` resource for + /// this `outgoing-request` can be retrieved at most once. Subsequent + /// calls will return error. + @since(version = 0.2.0) + body: func() -> result; + /// Get the Method for the Request. + @since(version = 0.2.0) + method: func() -> method; + /// Set the Method for the Request. Fails if the string present in a + /// `method.other` argument is not a syntactically valid method. + @since(version = 0.2.0) + set-method: func(method: method) -> result; + /// Get the combination of the HTTP Path and Query for the Request. + /// When `none`, this represents an empty Path and empty Query. + @since(version = 0.2.0) + path-with-query: func() -> option; + /// Set the combination of the HTTP Path and Query for the Request. + /// When `none`, this represents an empty Path and empty Query. Fails is the + /// string given is not a syntactically valid path and query uri component. + @since(version = 0.2.0) + set-path-with-query: func(path-with-query: option) -> result; + /// Get the HTTP Related Scheme for the Request. When `none`, the + /// implementation may choose an appropriate default scheme. + @since(version = 0.2.0) + scheme: func() -> option; + /// Set the HTTP Related Scheme for the Request. When `none`, the + /// implementation may choose an appropriate default scheme. Fails if the + /// string given is not a syntactically valid uri scheme. + @since(version = 0.2.0) + set-scheme: func(scheme: option) -> result; + /// Get the authority of the Request's target URI. A value of `none` may be used + /// with Related Schemes which do not require an authority. The HTTP and + /// HTTPS schemes always require an authority. + @since(version = 0.2.0) + authority: func() -> option; + /// Set the authority of the Request's target URI. A value of `none` may be used + /// with Related Schemes which do not require an authority. The HTTP and + /// HTTPS schemes always require an authority. Fails if the string given is + /// not a syntactically valid URI authority. + @since(version = 0.2.0) + set-authority: func(authority: option) -> result; + /// Get the headers associated with the Request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `outgoing-request` is dropped, or its ownership is transferred to + /// another component by e.g. `outgoing-handler.handle`. + @since(version = 0.2.0) + headers: func() -> headers; + } + + /// Parameters for making an HTTP Request. Each of these parameters is + /// currently an optional timeout applicable to the transport layer of the + /// HTTP protocol. + /// + /// These timeouts are separate from any the user may use to bound a + /// blocking call to `wasi:io/poll.poll`. + @since(version = 0.2.0) + resource request-options { + /// Construct a default `request-options` value. + @since(version = 0.2.0) + constructor(); + /// The timeout for the initial connect to the HTTP Server. + @since(version = 0.2.0) + connect-timeout: func() -> option; + /// Set the timeout for the initial connect to the HTTP Server. An error + /// return value indicates that this timeout is not supported. + @since(version = 0.2.0) + set-connect-timeout: func(duration: option) -> result; + /// The timeout for receiving the first byte of the Response body. + @since(version = 0.2.0) + first-byte-timeout: func() -> option; + /// Set the timeout for receiving the first byte of the Response body. An + /// error return value indicates that this timeout is not supported. + @since(version = 0.2.0) + set-first-byte-timeout: func(duration: option) -> result; + /// The timeout for receiving subsequent chunks of bytes in the Response + /// body stream. + @since(version = 0.2.0) + between-bytes-timeout: func() -> option; + /// Set the timeout for receiving subsequent chunks of bytes in the Response + /// body stream. An error return value indicates that this timeout is not + /// supported. + @since(version = 0.2.0) + set-between-bytes-timeout: func(duration: option) -> result; + } + + /// Represents the ability to send an HTTP Response. + /// + /// This resource is used by the `wasi:http/incoming-handler` interface to + /// allow a Response to be sent corresponding to the Request provided as the + /// other argument to `incoming-handler.handle`. + @since(version = 0.2.0) + resource response-outparam { + /// Send an HTTP 1xx response. + /// + /// Unlike `response-outparam.set`, this does not consume the + /// `response-outparam`, allowing the guest to send an arbitrary number of + /// informational responses before sending the final response using + /// `response-outparam.set`. + /// + /// This will return an `HTTP-protocol-error` if `status` is not in the + /// range [100-199], or an `internal-error` if the implementation does not + /// support informational responses. + @unstable(feature = informational-outbound-responses) + send-informational: func(status: u16, headers: headers) -> result<_, error-code>; + /// Set the value of the `response-outparam` to either send a response, + /// or indicate an error. + /// + /// This method consumes the `response-outparam` to ensure that it is + /// called at most once. If it is never called, the implementation + /// will respond with an error. + /// + /// The user may provide an `error` to `response` to allow the + /// implementation determine how to respond with an HTTP error response. + @since(version = 0.2.0) + set: static func(param: response-outparam, response: result); + } + + /// This type corresponds to the HTTP standard Status Code. + @since(version = 0.2.0) + type status-code = u16; + + /// Represents an incoming HTTP Response. + @since(version = 0.2.0) + resource incoming-response { + /// Returns the status code from the incoming response. + @since(version = 0.2.0) + status: func() -> status-code; + /// Returns the headers from the incoming response. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `incoming-response` is dropped. + @since(version = 0.2.0) + headers: func() -> headers; + /// Returns the incoming body. May be called at most once. Returns error + /// if called additional times. + @since(version = 0.2.0) + consume: func() -> result; + } + + /// Represents an incoming HTTP Request or Response's Body. + /// + /// A body has both its contents - a stream of bytes - and a (possibly + /// empty) set of trailers, indicating that the full contents of the + /// body have been received. This resource represents the contents as + /// an `input-stream` and the delivery of trailers as a `future-trailers`, + /// and ensures that the user of this interface may only be consuming either + /// the body contents or waiting on trailers at any given time. + @since(version = 0.2.0) + resource incoming-body { + /// Returns the contents of the body, as a stream of bytes. + /// + /// Returns success on first call: the stream representing the contents + /// can be retrieved at most once. Subsequent calls will return error. + /// + /// The returned `input-stream` resource is a child: it must be dropped + /// before the parent `incoming-body` is dropped, or consumed by + /// `incoming-body.finish`. + /// + /// This invariant ensures that the implementation can determine whether + /// the user is consuming the contents of the body, waiting on the + /// `future-trailers` to be ready, or neither. This allows for network + /// backpressure is to be applied when the user is consuming the body, + /// and for that backpressure to not inhibit delivery of the trailers if + /// the user does not read the entire body. + @since(version = 0.2.0) + %stream: func() -> result; + /// Takes ownership of `incoming-body`, and returns a `future-trailers`. + /// This function will trap if the `input-stream` child is still alive. + @since(version = 0.2.0) + finish: static func(this: incoming-body) -> future-trailers; + } + + /// Represents a future which may eventually return trailers, or an error. + /// + /// In the case that the incoming HTTP Request or Response did not have any + /// trailers, this future will resolve to the empty set of trailers once the + /// complete Request or Response body has been received. + @since(version = 0.2.0) + resource future-trailers { + /// Returns a pollable which becomes ready when either the trailers have + /// been received, or an error has occurred. When this pollable is ready, + /// the `get` method will return `some`. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Returns the contents of the trailers, or an error which occurred, + /// once the future is ready. + /// + /// The outer `option` represents future readiness. Users can wait on this + /// `option` to become `some` using the `subscribe` method. + /// + /// The outer `result` is used to retrieve the trailers or error at most + /// once. It will be success on the first call in which the outer option + /// is `some`, and error on subsequent calls. + /// + /// The inner `result` represents that either the HTTP Request or Response + /// body, as well as any trailers, were received successfully, or that an + /// error occurred receiving them. The optional `trailers` indicates whether + /// or not trailers were present in the body. + /// + /// When some `trailers` are returned by this method, the `trailers` + /// resource is immutable, and a child. Use of the `set`, `append`, or + /// `delete` methods will return an error, and the resource must be + /// dropped before the parent `future-trailers` is dropped. + @since(version = 0.2.0) + get: func() -> option, error-code>>>; + } + + /// Represents an outgoing HTTP Response. + @since(version = 0.2.0) + resource outgoing-response { + /// Construct an `outgoing-response`, with a default `status-code` of `200`. + /// If a different `status-code` is needed, it must be set via the + /// `set-status-code` method. + /// + /// * `headers` is the HTTP Headers for the Response. + @since(version = 0.2.0) + constructor(headers: headers); + /// Get the HTTP Status Code for the Response. + @since(version = 0.2.0) + status-code: func() -> status-code; + /// Set the HTTP Status Code for the Response. Fails if the status-code + /// given is not a valid http status code. + @since(version = 0.2.0) + set-status-code: func(status-code: status-code) -> result; + /// Get the headers associated with the Request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `outgoing-request` is dropped, or its ownership is transferred to + /// another component by e.g. `outgoing-handler.handle`. + @since(version = 0.2.0) + headers: func() -> headers; + /// Returns the resource corresponding to the outgoing Body for this Response. + /// + /// Returns success on the first call: the `outgoing-body` resource for + /// this `outgoing-response` can be retrieved at most once. Subsequent + /// calls will return error. + @since(version = 0.2.0) + body: func() -> result; + } + + /// Represents an outgoing HTTP Request or Response's Body. + /// + /// A body has both its contents - a stream of bytes - and a (possibly + /// empty) set of trailers, inducating the full contents of the body + /// have been sent. This resource represents the contents as an + /// `output-stream` child resource, and the completion of the body (with + /// optional trailers) with a static function that consumes the + /// `outgoing-body` resource, and ensures that the user of this interface + /// may not write to the body contents after the body has been finished. + /// + /// If the user code drops this resource, as opposed to calling the static + /// method `finish`, the implementation should treat the body as incomplete, + /// and that an error has occurred. The implementation should propagate this + /// error to the HTTP protocol by whatever means it has available, + /// including: corrupting the body on the wire, aborting the associated + /// Request, or sending a late status code for the Response. + @since(version = 0.2.0) + resource outgoing-body { + /// Returns a stream for writing the body contents. + /// + /// The returned `output-stream` is a child resource: it must be dropped + /// before the parent `outgoing-body` resource is dropped (or finished), + /// otherwise the `outgoing-body` drop or `finish` will trap. + /// + /// Returns success on the first call: the `output-stream` resource for + /// this `outgoing-body` may be retrieved at most once. Subsequent calls + /// will return error. + @since(version = 0.2.0) + write: func() -> result; + /// Finalize an outgoing body, optionally providing trailers. This must be + /// called to signal that the response is complete. If the `outgoing-body` + /// is dropped without calling `outgoing-body.finalize`, the implementation + /// should treat the body as corrupted. + /// + /// Fails if the body's `outgoing-request` or `outgoing-response` was + /// constructed with a Content-Length header, and the contents written + /// to the body (via `write`) does not match the value given in the + /// Content-Length. + @since(version = 0.2.0) + finish: static func(this: outgoing-body, trailers: option) -> result<_, error-code>; + } + + /// Represents a future which may eventually return an incoming HTTP + /// Response, or an error. + /// + /// This resource is returned by the `wasi:http/outgoing-handler` interface to + /// provide the HTTP Response corresponding to the sent Request. + @since(version = 0.2.0) + resource future-incoming-response { + /// Returns a pollable which becomes ready when either the Response has + /// been received, or an error has occurred. When this pollable is ready, + /// the `get` method will return `some`. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Returns the incoming HTTP Response, or an error, once one is ready. + /// + /// The outer `option` represents future readiness. Users can wait on this + /// `option` to become `some` using the `subscribe` method. + /// + /// The outer `result` is used to retrieve the response or error at most + /// once. It will be success on the first call in which the outer option + /// is `some`, and error on subsequent calls. + /// + /// The inner `result` represents that either the incoming HTTP Response + /// status and headers have received successfully, or that an error + /// occurred. Errors may also occur while consuming the response body, + /// but those will be reported by the `incoming-body` and its + /// `output-stream` child. + @since(version = 0.2.0) + get: func() -> option>>; + } + + /// Attempts to extract a http-related `error` from the wasi:io `error` + /// provided. + /// + /// Stream operations which return + /// `wasi:io/stream/stream-error::last-operation-failed` have a payload of + /// type `wasi:io/error/error` with more information about the operation + /// that failed. This payload can be passed through to this function to see + /// if there's http-related information about the error to return. + /// + /// Note that this function is fallible because not all io-errors are + /// http-related errors. + @since(version = 0.2.0) + http-error-code: func(err: borrow) -> option; +} + +/// This interface defines a handler of incoming HTTP Requests. It should +/// be exported by components which can respond to HTTP Requests. +@since(version = 0.2.0) +interface incoming-handler { + @since(version = 0.2.0) + use types.{incoming-request, response-outparam}; + + /// This function is invoked with an incoming HTTP Request, and a resource + /// `response-outparam` which provides the capability to reply with an HTTP + /// Response. The response is sent by calling the `response-outparam.set` + /// method, which allows execution to continue after the response has been + /// sent. This enables both streaming to the response body, and performing other + /// work. + /// + /// The implementor of this function must write a response to the + /// `response-outparam` before returning, or else the caller will respond + /// with an error on its behalf. + @since(version = 0.2.0) + handle: func(request: incoming-request, response-out: response-outparam); +} + +/// This interface defines a handler of outgoing HTTP Requests. It should be +/// imported by components which wish to make HTTP Requests. +@since(version = 0.2.0) +interface outgoing-handler { + @since(version = 0.2.0) + use types.{outgoing-request, request-options, future-incoming-response, error-code}; + + /// This function is invoked with an outgoing HTTP Request, and it returns + /// a resource `future-incoming-response` which represents an HTTP Response + /// which may arrive in the future. + /// + /// The `options` argument accepts optional parameters for the HTTP + /// protocol's transport layer. + /// + /// This function may return an error if the `outgoing-request` is invalid + /// or not allowed to be made. Otherwise, protocol errors are reported + /// through the `future-incoming-response`. + @since(version = 0.2.0) + handle: func(request: outgoing-request, options: option) -> result; +} + +/// The `wasi:http/imports` world imports all the APIs for HTTP proxies. +/// It is intended to be `include`d in other worlds. +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.6; + @since(version = 0.2.0) + import wasi:random/random@0.2.6; + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stdout@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stderr@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stdin@0.2.6; + @since(version = 0.2.0) + import types; + @since(version = 0.2.0) + import outgoing-handler; +} +/// The `wasi:http/proxy` world captures a widely-implementable intersection of +/// hosts that includes HTTP forward and reverse proxies. Components targeting +/// this world may concurrently stream in and out any number of incoming and +/// outgoing HTTP requests. +@since(version = 0.2.0) +world proxy { + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.6; + @since(version = 0.2.0) + import wasi:random/random@0.2.6; + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stdout@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stderr@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stdin@0.2.6; + @since(version = 0.2.0) + import types; + @since(version = 0.2.0) + import outgoing-handler; + + @since(version = 0.2.0) + export incoming-handler; +} diff --git a/crates/cpex-wasm-host/wit/deps/io.wit b/crates/cpex-wasm-host/wit/deps/io.wit new file mode 100644 index 00000000..08ad78e6 --- /dev/null +++ b/crates/cpex-wasm-host/wit/deps/io.wit @@ -0,0 +1,331 @@ +package wasi:io@0.2.6; + +@since(version = 0.2.0) +interface error { + /// A resource which represents some error information. + /// + /// The only method provided by this resource is `to-debug-string`, + /// which provides some human-readable information about the error. + /// + /// In the `wasi:io` package, this resource is returned through the + /// `wasi:io/streams/stream-error` type. + /// + /// To provide more specific error information, other interfaces may + /// offer functions to "downcast" this error into more specific types. For example, + /// errors returned from streams derived from filesystem types can be described using + /// the filesystem's own error-code type. This is done using the function + /// `wasi:filesystem/types/filesystem-error-code`, which takes a `borrow` + /// parameter and returns an `option`. + /// + /// The set of functions which can "downcast" an `error` into a more + /// concrete type is open. + @since(version = 0.2.0) + resource error { + /// Returns a string that is suitable to assist humans in debugging + /// this error. + /// + /// WARNING: The returned string should not be consumed mechanically! + /// It may change across platforms, hosts, or other implementation + /// details. Parsing this string is a major platform-compatibility + /// hazard. + @since(version = 0.2.0) + to-debug-string: func() -> string; + } +} + +/// A poll API intended to let users wait for I/O events on multiple handles +/// at once. +@since(version = 0.2.0) +interface poll { + /// `pollable` represents a single I/O event which may be ready, or not. + @since(version = 0.2.0) + resource pollable { + /// Return the readiness of a pollable. This function never blocks. + /// + /// Returns `true` when the pollable is ready, and `false` otherwise. + @since(version = 0.2.0) + ready: func() -> bool; + /// `block` returns immediately if the pollable is ready, and otherwise + /// blocks until ready. + /// + /// This function is equivalent to calling `poll.poll` on a list + /// containing only this pollable. + @since(version = 0.2.0) + block: func(); + } + + /// Poll for completion on a set of pollables. + /// + /// This function takes a list of pollables, which identify I/O sources of + /// interest, and waits until one or more of the events is ready for I/O. + /// + /// The result `list` contains one or more indices of handles in the + /// argument list that is ready for I/O. + /// + /// This function traps if either: + /// - the list is empty, or: + /// - the list contains more elements than can be indexed with a `u32` value. + /// + /// A timeout can be implemented by adding a pollable from the + /// wasi-clocks API to the list. + /// + /// This function does not return a `result`; polling in itself does not + /// do any I/O so it doesn't fail. If any of the I/O sources identified by + /// the pollables has an error, it is indicated by marking the source as + /// being ready for I/O. + @since(version = 0.2.0) + poll: func(in: list>) -> list; +} + +/// WASI I/O is an I/O abstraction API which is currently focused on providing +/// stream types. +/// +/// In the future, the component model is expected to add built-in stream types; +/// when it does, they are expected to subsume this API. +@since(version = 0.2.0) +interface streams { + @since(version = 0.2.0) + use error.{error}; + @since(version = 0.2.0) + use poll.{pollable}; + + /// An error for input-stream and output-stream operations. + @since(version = 0.2.0) + variant stream-error { + /// The last operation (a write or flush) failed before completion. + /// + /// More information is available in the `error` payload. + /// + /// After this, the stream will be closed. All future operations return + /// `stream-error::closed`. + last-operation-failed(error), + /// The stream is closed: no more input will be accepted by the + /// stream. A closed output-stream will return this error on all + /// future operations. + closed, + } + + /// An input bytestream. + /// + /// `input-stream`s are *non-blocking* to the extent practical on underlying + /// platforms. I/O operations always return promptly; if fewer bytes are + /// promptly available than requested, they return the number of bytes promptly + /// available, which could even be zero. To wait for data to be available, + /// use the `subscribe` function to obtain a `pollable` which can be polled + /// for using `wasi:io/poll`. + @since(version = 0.2.0) + resource input-stream { + /// Perform a non-blocking read from the stream. + /// + /// When the source of a `read` is binary data, the bytes from the source + /// are returned verbatim. When the source of a `read` is known to the + /// implementation to be text, bytes containing the UTF-8 encoding of the + /// text are returned. + /// + /// This function returns a list of bytes containing the read data, + /// when successful. The returned list will contain up to `len` bytes; + /// it may return fewer than requested, but not more. The list is + /// empty when no bytes are available for reading at this time. The + /// pollable given by `subscribe` will be ready when more bytes are + /// available. + /// + /// This function fails with a `stream-error` when the operation + /// encounters an error, giving `last-operation-failed`, or when the + /// stream is closed, giving `closed`. + /// + /// When the caller gives a `len` of 0, it represents a request to + /// read 0 bytes. If the stream is still open, this call should + /// succeed and return an empty list, or otherwise fail with `closed`. + /// + /// The `len` parameter is a `u64`, which could represent a list of u8 which + /// is not possible to allocate in wasm32, or not desirable to allocate as + /// as a return value by the callee. The callee may return a list of bytes + /// less than `len` in size while more bytes are available for reading. + @since(version = 0.2.0) + read: func(len: u64) -> result, stream-error>; + /// Read bytes from a stream, after blocking until at least one byte can + /// be read. Except for blocking, behavior is identical to `read`. + @since(version = 0.2.0) + blocking-read: func(len: u64) -> result, stream-error>; + /// Skip bytes from a stream. Returns number of bytes skipped. + /// + /// Behaves identical to `read`, except instead of returning a list + /// of bytes, returns the number of bytes consumed from the stream. + @since(version = 0.2.0) + skip: func(len: u64) -> result; + /// Skip bytes from a stream, after blocking until at least one byte + /// can be skipped. Except for blocking behavior, identical to `skip`. + @since(version = 0.2.0) + blocking-skip: func(len: u64) -> result; + /// Create a `pollable` which will resolve once either the specified stream + /// has bytes available to read or the other end of the stream has been + /// closed. + /// The created `pollable` is a child resource of the `input-stream`. + /// Implementations may trap if the `input-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + /// An output bytestream. + /// + /// `output-stream`s are *non-blocking* to the extent practical on + /// underlying platforms. Except where specified otherwise, I/O operations also + /// always return promptly, after the number of bytes that can be written + /// promptly, which could even be zero. To wait for the stream to be ready to + /// accept data, the `subscribe` function to obtain a `pollable` which can be + /// polled for using `wasi:io/poll`. + /// + /// Dropping an `output-stream` while there's still an active write in + /// progress may result in the data being lost. Before dropping the stream, + /// be sure to fully flush your writes. + @since(version = 0.2.0) + resource output-stream { + /// Check readiness for writing. This function never blocks. + /// + /// Returns the number of bytes permitted for the next call to `write`, + /// or an error. Calling `write` with more bytes than this function has + /// permitted will trap. + /// + /// When this function returns 0 bytes, the `subscribe` pollable will + /// become ready when this function will report at least 1 byte, or an + /// error. + @since(version = 0.2.0) + check-write: func() -> result; + /// Perform a write. This function never blocks. + /// + /// When the destination of a `write` is binary data, the bytes from + /// `contents` are written verbatim. When the destination of a `write` is + /// known to the implementation to be text, the bytes of `contents` are + /// transcoded from UTF-8 into the encoding of the destination and then + /// written. + /// + /// Precondition: check-write gave permit of Ok(n) and contents has a + /// length of less than or equal to n. Otherwise, this function will trap. + /// + /// returns Err(closed) without writing if the stream has closed since + /// the last call to check-write provided a permit. + @since(version = 0.2.0) + write: func(contents: list) -> result<_, stream-error>; + /// Perform a write of up to 4096 bytes, and then flush the stream. Block + /// until all of these operations are complete, or an error occurs. + /// + /// This is a convenience wrapper around the use of `check-write`, + /// `subscribe`, `write`, and `flush`, and is implemented with the + /// following pseudo-code: + /// + /// ```text + /// let pollable = this.subscribe(); + /// while !contents.is_empty() { + /// // Wait for the stream to become writable + /// pollable.block(); + /// let Ok(n) = this.check-write(); // eliding error handling + /// let len = min(n, contents.len()); + /// let (chunk, rest) = contents.split_at(len); + /// this.write(chunk ); // eliding error handling + /// contents = rest; + /// } + /// this.flush(); + /// // Wait for completion of `flush` + /// pollable.block(); + /// // Check for any errors that arose during `flush` + /// let _ = this.check-write(); // eliding error handling + /// ``` + @since(version = 0.2.0) + blocking-write-and-flush: func(contents: list) -> result<_, stream-error>; + /// Request to flush buffered output. This function never blocks. + /// + /// This tells the output-stream that the caller intends any buffered + /// output to be flushed. the output which is expected to be flushed + /// is all that has been passed to `write` prior to this call. + /// + /// Upon calling this function, the `output-stream` will not accept any + /// writes (`check-write` will return `ok(0)`) until the flush has + /// completed. The `subscribe` pollable will become ready when the + /// flush has completed and the stream can accept more writes. + @since(version = 0.2.0) + flush: func() -> result<_, stream-error>; + /// Request to flush buffered output, and block until flush completes + /// and stream is ready for writing again. + @since(version = 0.2.0) + blocking-flush: func() -> result<_, stream-error>; + /// Create a `pollable` which will resolve once the output-stream + /// is ready for more writing, or an error has occurred. When this + /// pollable is ready, `check-write` will return `ok(n)` with n>0, or an + /// error. + /// + /// If the stream is closed, this pollable is always ready immediately. + /// + /// The created `pollable` is a child resource of the `output-stream`. + /// Implementations may trap if the `output-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Write zeroes to a stream. + /// + /// This should be used precisely like `write` with the exact same + /// preconditions (must use check-write first), but instead of + /// passing a list of bytes, you simply pass the number of zero-bytes + /// that should be written. + @since(version = 0.2.0) + write-zeroes: func(len: u64) -> result<_, stream-error>; + /// Perform a write of up to 4096 zeroes, and then flush the stream. + /// Block until all of these operations are complete, or an error + /// occurs. + /// + /// This is a convenience wrapper around the use of `check-write`, + /// `subscribe`, `write-zeroes`, and `flush`, and is implemented with + /// the following pseudo-code: + /// + /// ```text + /// let pollable = this.subscribe(); + /// while num_zeroes != 0 { + /// // Wait for the stream to become writable + /// pollable.block(); + /// let Ok(n) = this.check-write(); // eliding error handling + /// let len = min(n, num_zeroes); + /// this.write-zeroes(len); // eliding error handling + /// num_zeroes -= len; + /// } + /// this.flush(); + /// // Wait for completion of `flush` + /// pollable.block(); + /// // Check for any errors that arose during `flush` + /// let _ = this.check-write(); // eliding error handling + /// ``` + @since(version = 0.2.0) + blocking-write-zeroes-and-flush: func(len: u64) -> result<_, stream-error>; + /// Read from one stream and write to another. + /// + /// The behavior of splice is equivalent to: + /// 1. calling `check-write` on the `output-stream` + /// 2. calling `read` on the `input-stream` with the smaller of the + /// `check-write` permitted length and the `len` provided to `splice` + /// 3. calling `write` on the `output-stream` with that read data. + /// + /// Any error reported by the call to `check-write`, `read`, or + /// `write` ends the splice and reports that error. + /// + /// This function returns the number of bytes transferred; it may be less + /// than `len`. + @since(version = 0.2.0) + splice: func(src: borrow, len: u64) -> result; + /// Read from one stream and write to another, with blocking. + /// + /// This is similar to `splice`, except that it blocks until the + /// `output-stream` is ready for writing, and the `input-stream` + /// is ready for reading, before performing the `splice`. + @since(version = 0.2.0) + blocking-splice: func(src: borrow, len: u64) -> result; + } +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import error; + @since(version = 0.2.0) + import poll; + @since(version = 0.2.0) + import streams; +} diff --git a/crates/cpex-wasm-host/wit/deps/random.wit b/crates/cpex-wasm-host/wit/deps/random.wit new file mode 100644 index 00000000..73edf5b6 --- /dev/null +++ b/crates/cpex-wasm-host/wit/deps/random.wit @@ -0,0 +1,92 @@ +package wasi:random@0.2.6; + +/// The insecure-seed interface for seeding hash-map DoS resistance. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +@since(version = 0.2.0) +interface insecure-seed { + /// Return a 128-bit value that may contain a pseudo-random value. + /// + /// The returned value is not required to be computed from a CSPRNG, and may + /// even be entirely deterministic. Host implementations are encouraged to + /// provide pseudo-random values to any program exposed to + /// attacker-controlled content, to enable DoS protection built into many + /// languages' hash-map implementations. + /// + /// This function is intended to only be called once, by a source language + /// to initialize Denial Of Service (DoS) protection in its hash-map + /// implementation. + /// + /// # Expected future evolution + /// + /// This will likely be changed to a value import, to prevent it from being + /// called multiple times and potentially used for purposes other than DoS + /// protection. + @since(version = 0.2.0) + insecure-seed: func() -> tuple; +} + +/// The insecure interface for insecure pseudo-random numbers. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +@since(version = 0.2.0) +interface insecure { + /// Return `len` insecure pseudo-random bytes. + /// + /// This function is not cryptographically secure. Do not use it for + /// anything related to security. + /// + /// There are no requirements on the values of the returned bytes, however + /// implementations are encouraged to return evenly distributed values with + /// a long period. + @since(version = 0.2.0) + get-insecure-random-bytes: func(len: u64) -> list; + + /// Return an insecure pseudo-random `u64` value. + /// + /// This function returns the same type of pseudo-random data as + /// `get-insecure-random-bytes`, represented as a `u64`. + @since(version = 0.2.0) + get-insecure-random-u64: func() -> u64; +} + +/// WASI Random is a random data API. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +@since(version = 0.2.0) +interface random { + /// Return `len` cryptographically-secure random or pseudo-random bytes. + /// + /// This function must produce data at least as cryptographically secure and + /// fast as an adequately seeded cryptographically-secure pseudo-random + /// number generator (CSPRNG). It must not block, from the perspective of + /// the calling program, under any circumstances, including on the first + /// request and on requests for numbers of bytes. The returned data must + /// always be unpredictable. + /// + /// This function must always return fresh data. Deterministic environments + /// must omit this function, rather than implementing it with deterministic + /// data. + @since(version = 0.2.0) + get-random-bytes: func(len: u64) -> list; + + /// Return a cryptographically-secure random or pseudo-random `u64` value. + /// + /// This function returns the same type of data as `get-random-bytes`, + /// represented as a `u64`. + @since(version = 0.2.0) + get-random-u64: func() -> u64; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import random; + @since(version = 0.2.0) + import insecure; + @since(version = 0.2.0) + import insecure-seed; +} diff --git a/crates/cpex-wasm-host/wit/deps/sockets.wit b/crates/cpex-wasm-host/wit/deps/sockets.wit new file mode 100644 index 00000000..db6d1a23 --- /dev/null +++ b/crates/cpex-wasm-host/wit/deps/sockets.wit @@ -0,0 +1,949 @@ +package wasi:sockets@0.2.6; + +@since(version = 0.2.0) +interface network { + @unstable(feature = network-error-code) + use wasi:io/error@0.2.6.{error}; + + /// An opaque resource that represents access to (a subset of) the network. + /// This enables context-based security for networking. + /// There is no need for this to map 1:1 to a physical network interface. + @since(version = 0.2.0) + resource network; + + /// Error codes. + /// + /// In theory, every API can return any error code. + /// In practice, API's typically only return the errors documented per API + /// combined with a couple of errors that are always possible: + /// - `unknown` + /// - `access-denied` + /// - `not-supported` + /// - `out-of-memory` + /// - `concurrency-conflict` + /// + /// See each individual API for what the POSIX equivalents are. They sometimes differ per API. + @since(version = 0.2.0) + enum error-code { + /// Unknown error + unknown, + /// Access denied. + /// + /// POSIX equivalent: EACCES, EPERM + access-denied, + /// The operation is not supported. + /// + /// POSIX equivalent: EOPNOTSUPP + not-supported, + /// One of the arguments is invalid. + /// + /// POSIX equivalent: EINVAL + invalid-argument, + /// Not enough memory to complete the operation. + /// + /// POSIX equivalent: ENOMEM, ENOBUFS, EAI_MEMORY + out-of-memory, + /// The operation timed out before it could finish completely. + timeout, + /// This operation is incompatible with another asynchronous operation that is already in progress. + /// + /// POSIX equivalent: EALREADY + concurrency-conflict, + /// Trying to finish an asynchronous operation that: + /// - has not been started yet, or: + /// - was already finished by a previous `finish-*` call. + /// + /// Note: this is scheduled to be removed when `future`s are natively supported. + not-in-progress, + /// The operation has been aborted because it could not be completed immediately. + /// + /// Note: this is scheduled to be removed when `future`s are natively supported. + would-block, + /// The operation is not valid in the socket's current state. + invalid-state, + /// A new socket resource could not be created because of a system limit. + new-socket-limit, + /// A bind operation failed because the provided address is not an address that the `network` can bind to. + address-not-bindable, + /// A bind operation failed because the provided address is already in use or because there are no ephemeral ports available. + address-in-use, + /// The remote address is not reachable + remote-unreachable, + /// The TCP connection was forcefully rejected + connection-refused, + /// The TCP connection was reset. + connection-reset, + /// A TCP connection was aborted. + connection-aborted, + /// The size of a datagram sent to a UDP socket exceeded the maximum + /// supported size. + datagram-too-large, + /// Name does not exist or has no suitable associated IP addresses. + name-unresolvable, + /// A temporary failure in name resolution occurred. + temporary-resolver-failure, + /// A permanent failure in name resolution occurred. + permanent-resolver-failure, + } + + @since(version = 0.2.0) + enum ip-address-family { + /// Similar to `AF_INET` in POSIX. + ipv4, + /// Similar to `AF_INET6` in POSIX. + ipv6, + } + + @since(version = 0.2.0) + type ipv4-address = tuple; + + @since(version = 0.2.0) + type ipv6-address = tuple; + + @since(version = 0.2.0) + variant ip-address { + ipv4(ipv4-address), + ipv6(ipv6-address), + } + + @since(version = 0.2.0) + record ipv4-socket-address { + /// sin_port + port: u16, + /// sin_addr + address: ipv4-address, + } + + @since(version = 0.2.0) + record ipv6-socket-address { + /// sin6_port + port: u16, + /// sin6_flowinfo + flow-info: u32, + /// sin6_addr + address: ipv6-address, + /// sin6_scope_id + scope-id: u32, + } + + @since(version = 0.2.0) + variant ip-socket-address { + ipv4(ipv4-socket-address), + ipv6(ipv6-socket-address), + } + + /// Attempts to extract a network-related `error-code` from the stream + /// `error` provided. + /// + /// Stream operations which return `stream-error::last-operation-failed` + /// have a payload with more information about the operation that failed. + /// This payload can be passed through to this function to see if there's + /// network-related information about the error to return. + /// + /// Note that this function is fallible because not all stream-related + /// errors are network-related errors. + @unstable(feature = network-error-code) + network-error-code: func(err: borrow) -> option; +} + +/// This interface provides a value-export of the default network handle.. +@since(version = 0.2.0) +interface instance-network { + @since(version = 0.2.0) + use network.{network}; + + /// Get a handle to the default network. + @since(version = 0.2.0) + instance-network: func() -> network; +} + +@since(version = 0.2.0) +interface ip-name-lookup { + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + @since(version = 0.2.0) + use network.{network, error-code, ip-address}; + + @since(version = 0.2.0) + resource resolve-address-stream { + /// Returns the next address from the resolver. + /// + /// This function should be called multiple times. On each call, it will + /// return the next address in connection order preference. If all + /// addresses have been exhausted, this function returns `none`. + /// + /// This function never returns IPv4-mapped IPv6 addresses. + /// + /// # Typical errors + /// - `name-unresolvable`: Name does not exist or has no suitable associated IP addresses. (EAI_NONAME, EAI_NODATA, EAI_ADDRFAMILY) + /// - `temporary-resolver-failure`: A temporary failure in name resolution occurred. (EAI_AGAIN) + /// - `permanent-resolver-failure`: A permanent failure in name resolution occurred. (EAI_FAIL) + /// - `would-block`: A result is not available yet. (EWOULDBLOCK, EAGAIN) + @since(version = 0.2.0) + resolve-next-address: func() -> result, error-code>; + /// Create a `pollable` which will resolve once the stream is ready for I/O. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + /// Resolve an internet host name to a list of IP addresses. + /// + /// Unicode domain names are automatically converted to ASCII using IDNA encoding. + /// If the input is an IP address string, the address is parsed and returned + /// as-is without making any external requests. + /// + /// See the wasi-socket proposal README.md for a comparison with getaddrinfo. + /// + /// This function never blocks. It either immediately fails or immediately + /// returns successfully with a `resolve-address-stream` that can be used + /// to (asynchronously) fetch the results. + /// + /// # Typical errors + /// - `invalid-argument`: `name` is a syntactically invalid domain name or IP address. + /// + /// # References: + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + resolve-addresses: func(network: borrow, name: string) -> result; +} + +@since(version = 0.2.0) +interface tcp { + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{input-stream, output-stream}; + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + @since(version = 0.2.0) + use wasi:clocks/monotonic-clock@0.2.6.{duration}; + @since(version = 0.2.0) + use network.{network, error-code, ip-socket-address, ip-address-family}; + + @since(version = 0.2.0) + enum shutdown-type { + /// Similar to `SHUT_RD` in POSIX. + receive, + /// Similar to `SHUT_WR` in POSIX. + send, + /// Similar to `SHUT_RDWR` in POSIX. + both, + } + + /// A TCP socket resource. + /// + /// The socket can be in one of the following states: + /// - `unbound` + /// - `bind-in-progress` + /// - `bound` (See note below) + /// - `listen-in-progress` + /// - `listening` + /// - `connect-in-progress` + /// - `connected` + /// - `closed` + /// See + /// for more information. + /// + /// Note: Except where explicitly mentioned, whenever this documentation uses + /// the term "bound" without backticks it actually means: in the `bound` state *or higher*. + /// (i.e. `bound`, `listen-in-progress`, `listening`, `connect-in-progress` or `connected`) + /// + /// In addition to the general error codes documented on the + /// `network::error-code` type, TCP socket methods may always return + /// `error(invalid-state)` when in the `closed` state. + @since(version = 0.2.0) + resource tcp-socket { + /// Bind the socket to a specific network on the provided IP address and port. + /// + /// If the IP address is zero (`0.0.0.0` in IPv4, `::` in IPv6), it is left to the implementation to decide which + /// network interface(s) to bind to. + /// If the TCP/UDP port is zero, the socket will be bound to a random free port. + /// + /// Bind can be attempted multiple times on the same socket, even with + /// different arguments on each iteration. But never concurrently and + /// only as long as the previous bind failed. Once a bind succeeds, the + /// binding can't be changed anymore. + /// + /// # Typical errors + /// - `invalid-argument`: The `local-address` has the wrong address family. (EAFNOSUPPORT, EFAULT on Windows) + /// - `invalid-argument`: `local-address` is not a unicast address. (EINVAL) + /// - `invalid-argument`: `local-address` is an IPv4-mapped IPv6 address. (EINVAL) + /// - `invalid-state`: The socket is already bound. (EINVAL) + /// - `address-in-use`: No ephemeral ports available. (EADDRINUSE, ENOBUFS on Windows) + /// - `address-in-use`: Address is already in use. (EADDRINUSE) + /// - `address-not-bindable`: `local-address` is not an address that the `network` can bind to. (EADDRNOTAVAIL) + /// - `not-in-progress`: A `bind` operation is not in progress. + /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) + /// + /// # Implementors note + /// When binding to a non-zero port, this bind operation shouldn't be affected by the TIME_WAIT + /// state of a recently closed socket on the same local address. In practice this means that the SO_REUSEADDR + /// socket option should be set implicitly on all platforms, except on Windows where this is the default behavior + /// and SO_REUSEADDR performs something different entirely. + /// + /// Unlike in POSIX, in WASI the bind operation is async. This enables + /// interactive WASI hosts to inject permission prompts. Runtimes that + /// don't want to make use of this ability can simply call the native + /// `bind` as part of either `start-bind` or `finish-bind`. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + start-bind: func(network: borrow, local-address: ip-socket-address) -> result<_, error-code>; + @since(version = 0.2.0) + finish-bind: func() -> result<_, error-code>; + /// Connect to a remote endpoint. + /// + /// On success: + /// - the socket is transitioned into the `connected` state. + /// - a pair of streams is returned that can be used to read & write to the connection + /// + /// After a failed connection attempt, the socket will be in the `closed` + /// state and the only valid action left is to `drop` the socket. A single + /// socket can not be used to connect more than once. + /// + /// # Typical errors + /// - `invalid-argument`: The `remote-address` has the wrong address family. (EAFNOSUPPORT) + /// - `invalid-argument`: `remote-address` is not a unicast address. (EINVAL, ENETUNREACH on Linux, EAFNOSUPPORT on MacOS) + /// - `invalid-argument`: `remote-address` is an IPv4-mapped IPv6 address. (EINVAL, EADDRNOTAVAIL on Illumos) + /// - `invalid-argument`: The IP address in `remote-address` is set to INADDR_ANY (`0.0.0.0` / `::`). (EADDRNOTAVAIL on Windows) + /// - `invalid-argument`: The port in `remote-address` is set to 0. (EADDRNOTAVAIL on Windows) + /// - `invalid-argument`: The socket is already attached to a different network. The `network` passed to `connect` must be identical to the one passed to `bind`. + /// - `invalid-state`: The socket is already in the `connected` state. (EISCONN) + /// - `invalid-state`: The socket is already in the `listening` state. (EOPNOTSUPP, EINVAL on Windows) + /// - `timeout`: Connection timed out. (ETIMEDOUT) + /// - `connection-refused`: The connection was forcefully rejected. (ECONNREFUSED) + /// - `connection-reset`: The connection was reset. (ECONNRESET) + /// - `connection-aborted`: The connection was aborted. (ECONNABORTED) + /// - `remote-unreachable`: The remote address is not reachable. (EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) + /// - `address-in-use`: Tried to perform an implicit bind, but there were no ephemeral ports available. (EADDRINUSE, EADDRNOTAVAIL on Linux, EAGAIN on BSD) + /// - `not-in-progress`: A connect operation is not in progress. + /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) + /// + /// # Implementors note + /// The POSIX equivalent of `start-connect` is the regular `connect` syscall. + /// Because all WASI sockets are non-blocking this is expected to return + /// EINPROGRESS, which should be translated to `ok()` in WASI. + /// + /// The POSIX equivalent of `finish-connect` is a `poll` for event `POLLOUT` + /// with a timeout of 0 on the socket descriptor. Followed by a check for + /// the `SO_ERROR` socket option, in case the poll signaled readiness. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + start-connect: func(network: borrow, remote-address: ip-socket-address) -> result<_, error-code>; + @since(version = 0.2.0) + finish-connect: func() -> result, error-code>; + /// Start listening for new connections. + /// + /// Transitions the socket into the `listening` state. + /// + /// Unlike POSIX, the socket must already be explicitly bound. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not bound to any local address. (EDESTADDRREQ) + /// - `invalid-state`: The socket is already in the `connected` state. (EISCONN, EINVAL on BSD) + /// - `invalid-state`: The socket is already in the `listening` state. + /// - `address-in-use`: Tried to perform an implicit bind, but there were no ephemeral ports available. (EADDRINUSE) + /// - `not-in-progress`: A listen operation is not in progress. + /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) + /// + /// # Implementors note + /// Unlike in POSIX, in WASI the listen operation is async. This enables + /// interactive WASI hosts to inject permission prompts. Runtimes that + /// don't want to make use of this ability can simply call the native + /// `listen` as part of either `start-listen` or `finish-listen`. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + start-listen: func() -> result<_, error-code>; + @since(version = 0.2.0) + finish-listen: func() -> result<_, error-code>; + /// Accept a new client socket. + /// + /// The returned socket is bound and in the `connected` state. The following properties are inherited from the listener socket: + /// - `address-family` + /// - `keep-alive-enabled` + /// - `keep-alive-idle-time` + /// - `keep-alive-interval` + /// - `keep-alive-count` + /// - `hop-limit` + /// - `receive-buffer-size` + /// - `send-buffer-size` + /// + /// On success, this function returns the newly accepted client socket along with + /// a pair of streams that can be used to read & write to the connection. + /// + /// # Typical errors + /// - `invalid-state`: Socket is not in the `listening` state. (EINVAL) + /// - `would-block`: No pending connections at the moment. (EWOULDBLOCK, EAGAIN) + /// - `connection-aborted`: An incoming connection was pending, but was terminated by the client before this listener could accept it. (ECONNABORTED) + /// - `new-socket-limit`: The new socket resource could not be created because of a system limit. (EMFILE, ENFILE) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + accept: func() -> result, error-code>; + /// Get the bound local address. + /// + /// POSIX mentions: + /// > If the socket has not been bound to a local name, the value + /// > stored in the object pointed to by `address` is unspecified. + /// + /// WASI is stricter and requires `local-address` to return `invalid-state` when the socket hasn't been bound yet. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not bound to any local address. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + local-address: func() -> result; + /// Get the remote address. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not connected to a remote address. (ENOTCONN) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + remote-address: func() -> result; + /// Whether the socket is in the `listening` state. + /// + /// Equivalent to the SO_ACCEPTCONN socket option. + @since(version = 0.2.0) + is-listening: func() -> bool; + /// Whether this is a IPv4 or IPv6 socket. + /// + /// Equivalent to the SO_DOMAIN socket option. + @since(version = 0.2.0) + address-family: func() -> ip-address-family; + /// Hints the desired listen queue size. Implementations are free to ignore this. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// + /// # Typical errors + /// - `not-supported`: (set) The platform does not support changing the backlog size after the initial listen. + /// - `invalid-argument`: (set) The provided value was 0. + /// - `invalid-state`: (set) The socket is in the `connect-in-progress` or `connected` state. + @since(version = 0.2.0) + set-listen-backlog-size: func(value: u64) -> result<_, error-code>; + /// Enables or disables keepalive. + /// + /// The keepalive behavior can be adjusted using: + /// - `keep-alive-idle-time` + /// - `keep-alive-interval` + /// - `keep-alive-count` + /// These properties can be configured while `keep-alive-enabled` is false, but only come into effect when `keep-alive-enabled` is true. + /// + /// Equivalent to the SO_KEEPALIVE socket option. + @since(version = 0.2.0) + keep-alive-enabled: func() -> result; + @since(version = 0.2.0) + set-keep-alive-enabled: func(value: bool) -> result<_, error-code>; + /// Amount of time the connection has to be idle before TCP starts sending keepalive packets. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the TCP_KEEPIDLE socket option. (TCP_KEEPALIVE on MacOS) + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + keep-alive-idle-time: func() -> result; + @since(version = 0.2.0) + set-keep-alive-idle-time: func(value: duration) -> result<_, error-code>; + /// The time between keepalive packets. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the TCP_KEEPINTVL socket option. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + keep-alive-interval: func() -> result; + @since(version = 0.2.0) + set-keep-alive-interval: func(value: duration) -> result<_, error-code>; + /// The maximum amount of keepalive packets TCP should send before aborting the connection. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the TCP_KEEPCNT socket option. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + keep-alive-count: func() -> result; + @since(version = 0.2.0) + set-keep-alive-count: func(value: u32) -> result<_, error-code>; + /// Equivalent to the IP_TTL & IPV6_UNICAST_HOPS socket options. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The TTL value must be 1 or higher. + @since(version = 0.2.0) + hop-limit: func() -> result; + @since(version = 0.2.0) + set-hop-limit: func(value: u8) -> result<_, error-code>; + /// The kernel buffer space reserved for sends/receives on this socket. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the SO_RCVBUF and SO_SNDBUF socket options. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + receive-buffer-size: func() -> result; + @since(version = 0.2.0) + set-receive-buffer-size: func(value: u64) -> result<_, error-code>; + @since(version = 0.2.0) + send-buffer-size: func() -> result; + @since(version = 0.2.0) + set-send-buffer-size: func(value: u64) -> result<_, error-code>; + /// Create a `pollable` which can be used to poll for, or block on, + /// completion of any of the asynchronous operations of this socket. + /// + /// When `finish-bind`, `finish-listen`, `finish-connect` or `accept` + /// return `error(would-block)`, this pollable can be used to wait for + /// their success or failure, after which the method can be retried. + /// + /// The pollable is not limited to the async operation that happens to be + /// in progress at the time of calling `subscribe` (if any). Theoretically, + /// `subscribe` only has to be called once per socket and can then be + /// (re)used for the remainder of the socket's lifetime. + /// + /// See + /// for more information. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Initiate a graceful shutdown. + /// + /// - `receive`: The socket is not expecting to receive any data from + /// the peer. The `input-stream` associated with this socket will be + /// closed. Any data still in the receive queue at time of calling + /// this method will be discarded. + /// - `send`: The socket has no more data to send to the peer. The `output-stream` + /// associated with this socket will be closed and a FIN packet will be sent. + /// - `both`: Same effect as `receive` & `send` combined. + /// + /// This function is idempotent; shutting down a direction more than once + /// has no effect and returns `ok`. + /// + /// The shutdown function does not close (drop) the socket. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not in the `connected` state. (ENOTCONN) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + shutdown: func(shutdown-type: shutdown-type) -> result<_, error-code>; + } +} + +@since(version = 0.2.0) +interface tcp-create-socket { + @since(version = 0.2.0) + use network.{network, error-code, ip-address-family}; + @since(version = 0.2.0) + use tcp.{tcp-socket}; + + /// Create a new TCP socket. + /// + /// Similar to `socket(AF_INET or AF_INET6, SOCK_STREAM, IPPROTO_TCP)` in POSIX. + /// On IPv6 sockets, IPV6_V6ONLY is enabled by default and can't be configured otherwise. + /// + /// This function does not require a network capability handle. This is considered to be safe because + /// at time of creation, the socket is not bound to any `network` yet. Up to the moment `bind`/`connect` + /// is called, the socket is effectively an in-memory configuration object, unable to communicate with the outside world. + /// + /// All sockets are non-blocking. Use the wasi-poll interface to block on asynchronous operations. + /// + /// # Typical errors + /// - `not-supported`: The specified `address-family` is not supported. (EAFNOSUPPORT) + /// - `new-socket-limit`: The new socket resource could not be created because of a system limit. (EMFILE, ENFILE) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + create-tcp-socket: func(address-family: ip-address-family) -> result; +} + +@since(version = 0.2.0) +interface udp { + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + @since(version = 0.2.0) + use network.{network, error-code, ip-socket-address, ip-address-family}; + + /// A received datagram. + @since(version = 0.2.0) + record incoming-datagram { + /// The payload. + /// + /// Theoretical max size: ~64 KiB. In practice, typically less than 1500 bytes. + data: list, + /// The source address. + /// + /// This field is guaranteed to match the remote address the stream was initialized with, if any. + /// + /// Equivalent to the `src_addr` out parameter of `recvfrom`. + remote-address: ip-socket-address, + } + + /// A datagram to be sent out. + @since(version = 0.2.0) + record outgoing-datagram { + /// The payload. + data: list, + /// The destination address. + /// + /// The requirements on this field depend on how the stream was initialized: + /// - with a remote address: this field must be None or match the stream's remote address exactly. + /// - without a remote address: this field is required. + /// + /// If this value is None, the send operation is equivalent to `send` in POSIX. Otherwise it is equivalent to `sendto`. + remote-address: option, + } + + /// A UDP socket handle. + @since(version = 0.2.0) + resource udp-socket { + /// Bind the socket to a specific network on the provided IP address and port. + /// + /// If the IP address is zero (`0.0.0.0` in IPv4, `::` in IPv6), it is left to the implementation to decide which + /// network interface(s) to bind to. + /// If the port is zero, the socket will be bound to a random free port. + /// + /// # Typical errors + /// - `invalid-argument`: The `local-address` has the wrong address family. (EAFNOSUPPORT, EFAULT on Windows) + /// - `invalid-state`: The socket is already bound. (EINVAL) + /// - `address-in-use`: No ephemeral ports available. (EADDRINUSE, ENOBUFS on Windows) + /// - `address-in-use`: Address is already in use. (EADDRINUSE) + /// - `address-not-bindable`: `local-address` is not an address that the `network` can bind to. (EADDRNOTAVAIL) + /// - `not-in-progress`: A `bind` operation is not in progress. + /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) + /// + /// # Implementors note + /// Unlike in POSIX, in WASI the bind operation is async. This enables + /// interactive WASI hosts to inject permission prompts. Runtimes that + /// don't want to make use of this ability can simply call the native + /// `bind` as part of either `start-bind` or `finish-bind`. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + start-bind: func(network: borrow, local-address: ip-socket-address) -> result<_, error-code>; + @since(version = 0.2.0) + finish-bind: func() -> result<_, error-code>; + /// Set up inbound & outbound communication channels, optionally to a specific peer. + /// + /// This function only changes the local socket configuration and does not generate any network traffic. + /// On success, the `remote-address` of the socket is updated. The `local-address` may be updated as well, + /// based on the best network path to `remote-address`. + /// + /// When a `remote-address` is provided, the returned streams are limited to communicating with that specific peer: + /// - `send` can only be used to send to this destination. + /// - `receive` will only return datagrams sent from the provided `remote-address`. + /// + /// This method may be called multiple times on the same socket to change its association, but + /// only the most recently returned pair of streams will be operational. Implementations may trap if + /// the streams returned by a previous invocation haven't been dropped yet before calling `stream` again. + /// + /// The POSIX equivalent in pseudo-code is: + /// ```text + /// if (was previously connected) { + /// connect(s, AF_UNSPEC) + /// } + /// if (remote_address is Some) { + /// connect(s, remote_address) + /// } + /// ``` + /// + /// Unlike in POSIX, the socket must already be explicitly bound. + /// + /// # Typical errors + /// - `invalid-argument`: The `remote-address` has the wrong address family. (EAFNOSUPPORT) + /// - `invalid-argument`: The IP address in `remote-address` is set to INADDR_ANY (`0.0.0.0` / `::`). (EDESTADDRREQ, EADDRNOTAVAIL) + /// - `invalid-argument`: The port in `remote-address` is set to 0. (EDESTADDRREQ, EADDRNOTAVAIL) + /// - `invalid-state`: The socket is not bound. + /// - `address-in-use`: Tried to perform an implicit bind, but there were no ephemeral ports available. (EADDRINUSE, EADDRNOTAVAIL on Linux, EAGAIN on BSD) + /// - `remote-unreachable`: The remote address is not reachable. (ECONNRESET, ENETRESET, EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) + /// - `connection-refused`: The connection was refused. (ECONNREFUSED) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + %stream: func(remote-address: option) -> result, error-code>; + /// Get the current bound address. + /// + /// POSIX mentions: + /// > If the socket has not been bound to a local name, the value + /// > stored in the object pointed to by `address` is unspecified. + /// + /// WASI is stricter and requires `local-address` to return `invalid-state` when the socket hasn't been bound yet. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not bound to any local address. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + local-address: func() -> result; + /// Get the address the socket is currently streaming to. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not streaming to a specific remote address. (ENOTCONN) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + remote-address: func() -> result; + /// Whether this is a IPv4 or IPv6 socket. + /// + /// Equivalent to the SO_DOMAIN socket option. + @since(version = 0.2.0) + address-family: func() -> ip-address-family; + /// Equivalent to the IP_TTL & IPV6_UNICAST_HOPS socket options. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The TTL value must be 1 or higher. + @since(version = 0.2.0) + unicast-hop-limit: func() -> result; + @since(version = 0.2.0) + set-unicast-hop-limit: func(value: u8) -> result<_, error-code>; + /// The kernel buffer space reserved for sends/receives on this socket. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the SO_RCVBUF and SO_SNDBUF socket options. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + receive-buffer-size: func() -> result; + @since(version = 0.2.0) + set-receive-buffer-size: func(value: u64) -> result<_, error-code>; + @since(version = 0.2.0) + send-buffer-size: func() -> result; + @since(version = 0.2.0) + set-send-buffer-size: func(value: u64) -> result<_, error-code>; + /// Create a `pollable` which will resolve once the socket is ready for I/O. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + @since(version = 0.2.0) + resource incoming-datagram-stream { + /// Receive messages on the socket. + /// + /// This function attempts to receive up to `max-results` datagrams on the socket without blocking. + /// The returned list may contain fewer elements than requested, but never more. + /// + /// This function returns successfully with an empty list when either: + /// - `max-results` is 0, or: + /// - `max-results` is greater than 0, but no results are immediately available. + /// This function never returns `error(would-block)`. + /// + /// # Typical errors + /// - `remote-unreachable`: The remote address is not reachable. (ECONNRESET, ENETRESET on Windows, EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) + /// - `connection-refused`: The connection was refused. (ECONNREFUSED) + /// + /// # References + /// - + /// - + /// - + /// - + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + receive: func(max-results: u64) -> result, error-code>; + /// Create a `pollable` which will resolve once the stream is ready to receive again. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + @since(version = 0.2.0) + resource outgoing-datagram-stream { + /// Check readiness for sending. This function never blocks. + /// + /// Returns the number of datagrams permitted for the next call to `send`, + /// or an error. Calling `send` with more datagrams than this function has + /// permitted will trap. + /// + /// When this function returns ok(0), the `subscribe` pollable will + /// become ready when this function will report at least ok(1), or an + /// error. + /// + /// Never returns `would-block`. + check-send: func() -> result; + /// Send messages on the socket. + /// + /// This function attempts to send all provided `datagrams` on the socket without blocking and + /// returns how many messages were actually sent (or queued for sending). This function never + /// returns `error(would-block)`. If none of the datagrams were able to be sent, `ok(0)` is returned. + /// + /// This function semantically behaves the same as iterating the `datagrams` list and sequentially + /// sending each individual datagram until either the end of the list has been reached or the first error occurred. + /// If at least one datagram has been sent successfully, this function never returns an error. + /// + /// If the input list is empty, the function returns `ok(0)`. + /// + /// Each call to `send` must be permitted by a preceding `check-send`. Implementations must trap if + /// either `check-send` was not called or `datagrams` contains more items than `check-send` permitted. + /// + /// # Typical errors + /// - `invalid-argument`: The `remote-address` has the wrong address family. (EAFNOSUPPORT) + /// - `invalid-argument`: The IP address in `remote-address` is set to INADDR_ANY (`0.0.0.0` / `::`). (EDESTADDRREQ, EADDRNOTAVAIL) + /// - `invalid-argument`: The port in `remote-address` is set to 0. (EDESTADDRREQ, EADDRNOTAVAIL) + /// - `invalid-argument`: The socket is in "connected" mode and `remote-address` is `some` value that does not match the address passed to `stream`. (EISCONN) + /// - `invalid-argument`: The socket is not "connected" and no value for `remote-address` was provided. (EDESTADDRREQ) + /// - `remote-unreachable`: The remote address is not reachable. (ECONNRESET, ENETRESET on Windows, EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) + /// - `connection-refused`: The connection was refused. (ECONNREFUSED) + /// - `datagram-too-large`: The datagram is too large. (EMSGSIZE) + /// + /// # References + /// - + /// - + /// - + /// - + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + send: func(datagrams: list) -> result; + /// Create a `pollable` which will resolve once the stream is ready to send again. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } +} + +@since(version = 0.2.0) +interface udp-create-socket { + @since(version = 0.2.0) + use network.{network, error-code, ip-address-family}; + @since(version = 0.2.0) + use udp.{udp-socket}; + + /// Create a new UDP socket. + /// + /// Similar to `socket(AF_INET or AF_INET6, SOCK_DGRAM, IPPROTO_UDP)` in POSIX. + /// On IPv6 sockets, IPV6_V6ONLY is enabled by default and can't be configured otherwise. + /// + /// This function does not require a network capability handle. This is considered to be safe because + /// at time of creation, the socket is not bound to any `network` yet. Up to the moment `bind` is called, + /// the socket is effectively an in-memory configuration object, unable to communicate with the outside world. + /// + /// All sockets are non-blocking. Use the wasi-poll interface to block on asynchronous operations. + /// + /// # Typical errors + /// - `not-supported`: The specified `address-family` is not supported. (EAFNOSUPPORT) + /// - `new-socket-limit`: The new socket resource could not be created because of a system limit. (EMFILE, ENFILE) + /// + /// # References: + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + create-udp-socket: func(address-family: ip-address-family) -> result; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import network; + @since(version = 0.2.0) + import instance-network; + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import udp; + @since(version = 0.2.0) + import udp-create-socket; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.6; + @since(version = 0.2.0) + import tcp; + @since(version = 0.2.0) + import tcp-create-socket; + @since(version = 0.2.0) + import ip-name-lookup; +} diff --git a/crates/cpex-wasm-host/wit/world.wit b/crates/cpex-wasm-host/wit/world.wit new file mode 100644 index 00000000..fbd15544 --- /dev/null +++ b/crates/cpex-wasm-host/wit/world.wit @@ -0,0 +1,660 @@ +// Location: ./crates/cpex-wasm-plugin/wit/world.wit +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// WIT world definition for the CPEX plugin component. +// Defines the types and exported function that the WASM host uses to invoke plugins. +// +// Supports arbitrary payload types via the hook-payload variant: +// - cmf: structured CMF MessagePayload (field-by-field conversion, no full-payload serialization) +// - custom: any serializable payload as JSON bytes with a type discriminator + +package cpex:plugin; + +interface types { + + // --------------------------------------------------------------------------- + // CMF enums + // --------------------------------------------------------------------------- + + enum role { + system, + developer, + user, + assistant, + tool, + } + + enum channel { + analysis, + commentary, + final, + } + + enum resource-type { + file, + blob, + uri, + database, + api, + memory, + artifact, + } + + // --------------------------------------------------------------------------- + // CMF content parts + // --------------------------------------------------------------------------- + + record image-source { + source-type: string, + data: string, + media-type: option, + } + + record video-source { + source-type: string, + data: string, + media-type: option, + duration-ms: option, + } + + record audio-source { + source-type: string, + data: string, + media-type: option, + duration-ms: option, + } + + record document-source { + source-type: string, + data: string, + media-type: option, + title: option, + } + + record tool-call { + tool-call-id: string, + name: string, + // arguments is a JSON object string (HashMap) + arguments: string, + namespace: option, + } + + record tool-result { + tool-call-id: string, + tool-name: string, + // content is a JSON string (serde_json::Value) + content: string, + is-error: bool, + } + + record cmf-resource { + resource-request-id: string, + uri: string, + name: option, + description: option, + resource-type: resource-type, + content: option, + blob: option>, + mime-type: option, + size-bytes: option, + // annotations is a JSON object string (HashMap) + annotations: string, + version: option, + } + + record resource-reference { + resource-request-id: string, + uri: string, + name: option, + resource-type: resource-type, + range-start: option, + range-end: option, + selector: option, + } + + record prompt-request { + prompt-request-id: string, + name: string, + // arguments is a JSON object string (HashMap) + arguments: string, + server-id: option, + } + + record prompt-result { + prompt-request-id: string, + prompt-name: string, + // messages is a JSON array string (Vec) — cannot be list + // because message → content-part → prompt-result is a recursive cycle, + // which WIT does not support without indirection. + messages: string, + content: option, + is-error: bool, + error-message: option, + } + + variant content-part { + text(string), + thinking(string), + tool-call(tool-call), + tool-result(tool-result), + cmf-resource(cmf-resource), + resource-ref(resource-reference), + prompt-request(prompt-request), + prompt-result(prompt-result), + image(image-source), + video(video-source), + audio(audio-source), + document(document-source), + } + + record message { + schema-version: string, + role: role, + content: list, + channel: option, + } + + record message-payload { + message: message, + } + + // --------------------------------------------------------------------------- + // Custom payload for arbitrary types + // --------------------------------------------------------------------------- + + record custom-payload { + payload-type: string, + payload-data: list, + } + + // --------------------------------------------------------------------------- + // Identity payload (IdentityResolve hook) + // Note: raw_token is #[serde(skip)] in Rust — never crosses the WASM + // boundary. WASM handlers resolve identity from source/headers/claims. + // --------------------------------------------------------------------------- + + enum token-source { + bearer, + user-token, + mtls, + spiffe-jwt-svid, + api-key, + // custom source name carried as a string alongside this enum value + // via identity-payload.source-custom when this variant is selected + custom, + } + + record identity-payload { + // Input fields (host-supplied, read-only for handlers) + source: token-source, + // non-empty when source = custom + source-custom: option, + source-header: option, + // request headers as key-value pairs — escape hatch for custom auth flows + headers: list>, + client-host: option, + client-port: option, + + // Output fields (handlers populate these on the returned payload) + subject: option, + client: option, + caller-workload: option, + // initial delegation chain parsed from act/equivalent claims + delegation: option, + // resolved_at as ISO 8601 string (DateTime) + resolved-at: option, + // raw decoded token claims as a JSON object string (HashMap) + raw-claims: option, + } + + // --------------------------------------------------------------------------- + // Delegation payload (TokenDelegate hook) + // Note: bearer_token and delegated_token.token are #[serde(skip)] in Rust — + // they never cross the WASM boundary. WASM handlers can attenuat scopes and + // populate delegation_update/metadata but cannot mint raw tokens. + // --------------------------------------------------------------------------- + + enum target-type { + tool, + agent, + %resource, + service, + // custom target kind name carried in delegation-payload.target-type-custom + custom, + } + + enum auth-enforced-by { + caller, + target, + both, + } + + enum delegation-mode { + on-behalf-of-user, + as-gateway, + } + + record attenuation-config { + capabilities: list, + resource-template: option, + actions: list, + ttl-seconds: option, + } + + // Minted outbound credential (token bytes are #[serde(skip)] — omitted). + record raw-delegated-token { + outbound-header: string, + audience: string, + scopes: list, + // expires_at as ISO 8601 string (DateTime) + expires-at: string, + } + + record delegation-payload { + // Input fields (host-supplied, read-only for handlers) + target-name: string, + target-type: target-type, + // non-empty when target-type = custom + target-type-custom: option, + target-audience: option, + required-permissions: list, + trust-domain: option, + auth-enforced-by: auth-enforced-by, + route-attenuation: option, + + // Output fields (handlers populate these on the returned payload) + // token bytes omitted — raw credential material never crosses the sandbox + delegated-token: option, + delegation-update: option, + delegation-mode: option, + // minted_at as ISO 8601 string (DateTime) + minted-at: option, + // handler metadata as a JSON object string (HashMap) + metadata: option, + } + + variant hook-payload { + cmf(message-payload), + identity(identity-payload), + delegation(delegation-payload), + custom(custom-payload), + } + + // --------------------------------------------------------------------------- + // Plugin context + // --------------------------------------------------------------------------- + + // State entries are typed key-value pairs (serde_json::Value serialized per + // entry as a JSON string) rather than a single opaque JSON blob. + record context-entry { + key: string, + // value is a JSON string representing serde_json::Value + value: string, + } + + record plugin-context { + local-state: list, + global-state: list, + } + + // --------------------------------------------------------------------------- + // Extensions — base four + // --------------------------------------------------------------------------- + + record request-extension { + environment: option, + request-id: option, + timestamp: option, + trace-id: option, + span-id: option, + } + + enum subject-type { + user, + agent, + service, + system, + } + + record subject-extension { + id: option, + subject-type: option, + roles: list, + permissions: list, + teams: list, + claims: list>, + } + + // --------------------------------------------------------------------------- + // Security extension — full coverage + // --------------------------------------------------------------------------- + + enum client-trust-level { + first-party, + third-party, + internal, + // custom trust level — name carried in client-trust-level-custom on + // client-extension when this variant is not sufficient + } + + record client-extension { + client-id: string, + client-name: option, + trust-level: client-trust-level, + // custom trust level name when trust-level cannot represent it + trust-level-custom: option, + authorized-scopes: list, + authorized-audiences: list, + roles: list, + permissions: list, + teams: list, + // claims values are JSON strings (serde_json::Value per entry) + claims: list>, + } + + record workload-identity { + spiffe-id: option, + trust-domain: option, + // attested-at as ISO 8601 string (DateTime) + attested-at: option, + attestor: option, + selectors: list, + client-id: option, + } + + record object-security-profile { + managed-by: option, + permissions: list, + trust-domain: option, + data-scope: list, + } + + record retention-policy { + max-age-seconds: option, + policy: string, + delete-after: option, + } + + record data-policy { + apply-labels: list, + // None = all actions allowed; Some([]) = no actions allowed + allowed-actions: option>, + denied-actions: list, + retention: option, + } + + record security-extension { + labels: list, + classification: option, + subject: option, + client: option, + caller-workload: option, + this-workload: option, + auth-method: option, + // object security profiles keyed by object name + objects: list>, + // data policies keyed by data element name + data: list>, + } + + record http-extension { + request-headers: list>, + response-headers: list>, + method: option, + path: option, + host: option, + scheme: option, + } + + record meta-extension { + entity-type: option, + entity-name: option, + tags: list, + scope: option, + properties: list>, + } + + // --------------------------------------------------------------------------- + // Overflow extension types — typed + // --------------------------------------------------------------------------- + + record conversation-context { + // history entries are JSON strings (serde_json::Value per entry) + history: list, + summary: option, + topics: list, + } + + record agent-extension { + input: option, + session-id: option, + conversation-id: option, + turn: option, + agent-id: option, + parent-agent-id: option, + conversation: option, + } + + record tool-metadata { + name: string, + title: option, + description: option, + // input-schema and output-schema are JSON strings (serde_json::Value) + input-schema: option, + output-schema: option, + server-id: option, + namespace: option, + // annotation values are JSON strings (serde_json::Value) + annotations: list>, + } + + record resource-metadata { + uri: string, + name: option, + description: option, + mime-type: option, + server-id: option, + // annotation values are JSON strings (serde_json::Value) + annotations: list>, + } + + record prompt-metadata { + name: string, + description: option, + // arguments is a JSON array string (Vec) + arguments: option, + server-id: option, + // annotation values are JSON strings (serde_json::Value) + annotations: list>, + } + + record mcp-extension { + tool: option, + // renamed from `resource` to avoid WIT keyword conflict + resource-info: option, + prompt: option, + } + + // stop-reason: `return-complete` maps to Rust `StopReason::Return` + // (renamed to avoid the WIT `return` keyword) + enum stop-reason { + end, + return-complete, + call, + max-tokens, + stop-sequence, + } + + record token-usage { + input-tokens: u32, + output-tokens: u32, + total-tokens: u32, + } + + record completion-extension { + stop-reason: option, + tokens: option, + model: option, + raw-format: option, + created-at: option, + latency-ms: option, + } + + record provenance-extension { + source: option, + message-id: option, + parent-id: option, + } + + record llm-extension { + model-id: option, + provider: option, + capabilities: list, + } + + record framework-extension { + framework: option, + framework-version: option, + node-id: option, + graph-id: option, + // metadata is a JSON object string (HashMap) + metadata: option, + } + + record authorization-detail { + detail-type: string, + // option preserves None (no constraint) vs Some([]) (empty = deny all) + locations: option>, + actions: option>, + datatypes: option>, + identifier: option, + privileges: option>, + // extra holds flattened RFC 9396 extension fields as a JSON object string + extra: option, + } + + enum delegation-strategy { + token-exchange, + client-credentials, + spiffe-svid, + passthrough, + ucan, + transaction-token, + } + + record delegation-hop { + subject-id: string, + subject-type: option, + audience: option, + scopes-granted: list, + authorization-details: list, + // timestamp as ISO 8601 string (DateTime) + timestamp: string, + ttl-seconds: option, + strategy: option, + // carries the name when Rust DelegationStrategy::Custom(s) is used + strategy-custom: option, + from-cache: bool, + } + + record delegation-extension { + chain: list, + depth: u32, + origin-subject-id: option, + actor-subject-id: option, + delegated: bool, + // age-seconds as string to avoid f64 portability issues + age-seconds: string, + } + + // --------------------------------------------------------------------------- + // Extensions container + // --------------------------------------------------------------------------- + + record extensions { + request: option, + security: option, + http: option, + meta: option, + agent: option, + mcp: option, + completion: option, + provenance: option, + llm: option, + framework: option, + delegation: option, + // custom: escape hatch for plugin-defined arbitrary key-value data + // (maps to cpex-core Extensions.custom: Option>) + custom: option, + } + + // --------------------------------------------------------------------------- + // Violation and result + // --------------------------------------------------------------------------- + + record plugin-violation { + code: string, + reason: string, + description: option, + // details is a JSON object string (HashMap) + details: string, + plugin-name: option, + proto-error-code: option, + } + + // metadata is a JSON string (serde_json::Value) + record hook-result { + continue-processing: bool, + modified-payload: option, + modified-extensions: option, + modified-context: option, + violation: option, + metadata: option, + } +} + +interface host-logging { + enum log-level { + trace, + debug, + info, + warn, + error, + } + + log: func(level: log-level, message: string); +} + +/// Known hook names (not exhaustive - hosts may define custom hooks): +/// +/// Legacy (typed payloads): +/// "tool_pre_invoke", "tool_post_invoke" +/// "prompt_pre_fetch", "prompt_post_fetch" +/// "resource_pre_fetch", "resource_post_fetch" +/// "identity_resolve", "token_delegate" +/// +/// CMF (MessagePayload): +/// "cmf.tool_pre_invoke", "cmf.tool_post_invoke" +/// "cmf.llm_input", "cmf.llm_output" +/// "cmf.prompt_pre_fetch", "cmf.prompt_post_fetch" +/// "cmf.resource_pre_fetch", "cmf.resource_post_fetch" +world plugin { + import wasi:io/poll@0.2.6; + import wasi:io/error@0.2.6; + import wasi:io/streams@0.2.6; + import wasi:clocks/monotonic-clock@0.2.6; + import wasi:http/types@0.2.6; + import wasi:http/outgoing-handler@0.2.6; + import host-logging; + + use types.{hook-payload, extensions, plugin-context, hook-result}; + + export handle-hook: func( + hook-name: string, + payload: hook-payload, + extensions: extensions, + ctx: plugin-context + ) -> hook-result; +} diff --git a/crates/cpex-wasm-plugin/Cargo.toml b/crates/cpex-wasm-plugin/Cargo.toml new file mode 100644 index 00000000..46ed5541 --- /dev/null +++ b/crates/cpex-wasm-plugin/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "cpex-wasm-plugin" +version = "0.1.0" +edition = "2021" +description = "WASM plugin guest SDK for the CPEX framework — register_wasm_plugin! macro, type conversions, and demo plugins" +license = "Apache-2.0" +authors = ["Shriti Priya"] + +[lib] +crate-type = ["cdylib"] + +[features] +default = ["identity-checker"] +identity-checker = [] +header-injector = [] +audit-logger = [] +token-attenuator = [] +noop = [] +fs-test = [] +net-test = [] +env-test = [] +tool-invoke-checker = [] +pii-guard = [] +audit-logger-custom = [] +remote-authz = [] +compute-bench = [] + +[dependencies] +wit-bindgen = "0.57" +wit-bindgen-rt = "0.44" +serde = { version = "1", features = ["derive", "rc"] } +serde_json = "1" +cpex-core = { path = "../cpex-core", default-features = false } +async-trait = "0.1" +chrono = { version = "0.4", features = ["serde"] } +zeroize = "1" + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt"] } + +[package.metadata.component] +package = "cpex:plugin" + +[package.metadata.cargo-machete] +ignored = ["wit-bindgen-rt"] diff --git a/crates/cpex-wasm-plugin/Makefile b/crates/cpex-wasm-plugin/Makefile new file mode 100644 index 00000000..f576012b --- /dev/null +++ b/crates/cpex-wasm-plugin/Makefile @@ -0,0 +1,105 @@ +.PHONY: all build build-debug release stage validate inspect clean check fmt clippy run-demo run-all-demos help + +TARGET := wasm32-wasip2 +TARGET_DIR := target +HOST_WASM_DIR := ../cpex-wasm-host/wasm +PLUGIN_NAME := cpex_wasm_plugin +STAGED := $(HOST_WASM_DIR)/plugin.wasm + +# Default profile for `make build` — override with PROFILE=debug +PROFILE := release +ARTIFACT = $(TARGET_DIR)/$(TARGET)/$(PROFILE)/$(PLUGIN_NAME).wasm + +# Plugin features (one binary per feature) +PLUGINS := identity-checker header-injector audit-logger tool-invoke-checker pii-guard audit-logger-custom remote-authz + +# --------------------------------------------------------------------------- +# Build targets +# --------------------------------------------------------------------------- + +all: build stage ## Build (release) and stage the default plugin (identity-checker) + +build: ## Build the default plugin (release by default, override: make build PROFILE=debug) + cargo build --target $(TARGET) $(if $(filter release,$(PROFILE)),--release,) + @echo "Built: $(ARTIFACT) ($$(du -h $(ARTIFACT) | cut -f1))" + +build-debug: ## Build the default plugin (debug, faster compile) + $(MAKE) build PROFILE=debug + +release: ## Build the default plugin (release, optimized) + $(MAKE) build PROFILE=release + +build-all: ## Build all plugin binaries and stage to host wasm/ directory + @mkdir -p $(HOST_WASM_DIR) + @for plugin in $(PLUGINS); do \ + echo "Building $$plugin..."; \ + cargo build --target $(TARGET) --release --features $$plugin --no-default-features && \ + cp $(TARGET_DIR)/$(TARGET)/release/$(PLUGIN_NAME).wasm $(HOST_WASM_DIR)/$$plugin.wasm && \ + echo " Staged: $(HOST_WASM_DIR)/$$plugin.wasm ($$(du -h $(HOST_WASM_DIR)/$$plugin.wasm | cut -f1))"; \ + done + @echo "All plugins built and staged." + +# --------------------------------------------------------------------------- +# Stage — copy to host wasm/ directory +# --------------------------------------------------------------------------- + +stage: $(ARTIFACT) ## Copy built .wasm to the host's wasm/ directory + @mkdir -p $(HOST_WASM_DIR) + cp $(ARTIFACT) $(STAGED) + @echo "Staged: $(STAGED) ($$(du -h $(STAGED) | cut -f1))" + +# Local copy for validate/inspect +plugin.wasm: $(ARTIFACT) + cp $(ARTIFACT) plugin.wasm + +# --------------------------------------------------------------------------- +# Validation and inspection (requires: cargo install wasm-tools) +# --------------------------------------------------------------------------- + +validate: plugin.wasm ## Validate the component binary + wasm-tools validate plugin.wasm + @echo "plugin.wasm is valid" + +inspect: plugin.wasm ## Print the WIT interface embedded in the binary + wasm-tools component wit plugin.wasm + +# --------------------------------------------------------------------------- +# Development +# --------------------------------------------------------------------------- + +check: ## Type-check without producing a binary (fast feedback) + cargo check --target $(TARGET) + +fmt: ## Format source code + cargo fmt + +clippy: ## Run clippy lints for the wasm target + cargo clippy --target $(TARGET) -- -D warnings + +# --------------------------------------------------------------------------- +# Run demos (from workspace root via the host crate) +# --------------------------------------------------------------------------- + +run-demo: all ## Build, stage, and run the plugin demo + cd ../.. && cargo run -p cpex-wasm-host --example wasm_plugin_demo + +run-all-demos: build-all ## Build all plugins and run both demos + cd ../.. && cargo run -p cpex-wasm-host --example wasm_plugin_demo + cd ../.. && cargo run -p cpex-wasm-host --example wasm_capabilities_demo + +# --------------------------------------------------------------------------- +# Cleanup +# --------------------------------------------------------------------------- + +clean: ## Remove all build artifacts and staged binary + cargo clean + rm -f plugin.wasm + rm -f $(STAGED) + +# --------------------------------------------------------------------------- +# Help +# --------------------------------------------------------------------------- + +help: ## Show available targets + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' diff --git a/crates/cpex-wasm-plugin/README.md b/crates/cpex-wasm-plugin/README.md new file mode 100644 index 00000000..7e1cd3cb --- /dev/null +++ b/crates/cpex-wasm-plugin/README.md @@ -0,0 +1,728 @@ +# cpex-wasm-plugin + +Write CPEX plugins in Rust, compile them to WebAssembly, and run them in sandboxed isolation. You use the exact same `HookHandler` trait as native plugins — the SDK handles all the WASM boundary plumbing for you. + +--- + +## Table of Contents + +1. [What Is This?](#what-is-this) +2. [Prerequisites](#prerequisites) +3. [Create Your First Plugin (Step by Step)](#create-your-first-plugin-step-by-step) +4. [Build and Deploy](#build-and-deploy) +5. [Test Your Plugin](#test-your-plugin) +6. [What Your Plugin Can Do](#what-your-plugin-can-do) +7. [Logging](#logging) +8. [Payload Types](#payload-types) +9. [Custom Payload Types (Your Own Structs)](#custom-payload-types-your-own-structs) +10. [Cross-Invocation State](#cross-invocation-state) +11. [Available Capabilities](#available-capabilities) +12. [Built-in Demo Plugins](#built-in-demo-plugins) +13. [Project Structure](#project-structure) +14. [Constraints (What You Can't Do)](#constraints-what-you-cant-do) +15. [Troubleshooting](#troubleshooting) + +--- + +## What Is This? + +This crate is the **guest SDK** — it's what plugin authors use. If you want to write a WASM plugin, this is your starting point. + +It gives you: +- **`register_wasm_plugin!` macro** — generates all the WIT (WebAssembly Interface Types) glue code. You never deal with WIT directly. +- **`cpex_log!` macro** — structured logging that flows to the host's tracing system. +- **Automatic type conversions** — your handler receives native `cpex-core` types (`Extensions`, `MessagePayload`, etc.), not raw bytes. +- **Native testing** — test your handler logic with `cargo test` (no WASM compilation needed). + +You write a normal Rust struct that implements `HookHandler`. The macro turns it into a WASM component that the host can load and invoke. + +--- + +## Prerequisites + +```bash +# Install the WASM compilation target +rustup target add wasm32-wasip2 +``` + +You'll also need the `cpex-wasm-plugin` crate (this crate) as your starting point. If you're adding a plugin to this repository, you're already set. If you're creating a standalone plugin crate, you'll need `cpex-core` and `wit-bindgen` as dependencies. + +--- + +## Create Your First Plugin (Step by Step) + +This walks through creating a plugin from scratch. By the end you'll have a working `.wasm` binary that runs in the sandbox. + +### Step 1: Create the plugin file + +Create a new file at `src/plugins/my_plugin.rs`: + +```rust +// src/plugins/my_plugin.rs + +use async_trait::async_trait; +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +// Import the logging macro +use crate::cpex_log; + +// --- Your plugin struct --- +// This is re-created on every call. Use `static` for persistent state. +pub struct MyPlugin; + +impl Default for MyPlugin { + fn default() -> Self { + Self + } +} + +// --- Plugin trait (required boilerplate) --- +// This provides metadata about your plugin. The actual logic is in HookHandler below. +static CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for MyPlugin { + fn config(&self) -> &PluginConfig { + CONFIG.get_or_init(|| PluginConfig { + name: "my-plugin".to_string(), + kind: "wasm://my-plugin.wasm".to_string(), + hooks: vec!["cmf.tool_pre_invoke".to_string()], + ..Default::default() + }) + } + async fn initialize(&self) -> Result<(), Box> { Ok(()) } + async fn shutdown(&self) -> Result<(), Box> { Ok(()) } +} + +// --- Your actual logic --- +// This runs every time the hook fires. Receives the payload + extensions, +// returns allow/deny/modify. +impl HookHandler for MyPlugin { + async fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Example: check if the tool call involves PII data + if let Some(ref security) = extensions.security { + if security.has_label("PII") { + // Check if the user has the right role + if let Some(ref subject) = security.subject { + if !subject.roles.contains("admin") { + cpex_log!(warn, "PII access denied — user lacks admin role"); + return PluginResult::deny(PluginViolation::new( + "unauthorized", + "Admin role required for PII access", + )); + } + } + } + } + + cpex_log!(info, "tool call approved"); + PluginResult::allow() + } +} +``` + +### Step 2: Register the plugin module + +Open `src/plugins/mod.rs` and add your module (feature-gated so only one plugin compiles per binary): + +```rust +#[cfg(feature = "my-plugin")] +pub mod my_plugin; +``` + +### Step 3: Register with the WASM macro + +Open `src/lib.rs` and add the registration (this generates all the WIT glue): + +```rust +#[cfg(all(feature = "my-plugin", not(test)))] +register_wasm_plugin!( + plugins::my_plugin::MyPlugin, + [cpex_core::cmf::CmfHook] // list all hook types this plugin handles +); +``` + +### Step 4: Add the feature flag + +Open `Cargo.toml` and add: + +```toml +[features] +my-plugin = [] +``` + +### Step 5: Done! + +Your plugin is now ready to build. Continue to the next section. + +--- + +## Build and Deploy + +### Build to WASM + +```bash +# From the cpex-wasm-plugin directory: +cargo build --target wasm32-wasip2 --release \ + --features my-plugin --no-default-features +``` + +This produces a `.wasm` file at: +``` +target/wasm32-wasip2/release/cpex_wasm_plugin.wasm +``` + +### Deploy to the host + +Copy it to the host's `wasm/` directory with the name matching your YAML config: + +```bash +cp target/wasm32-wasip2/release/cpex_wasm_plugin.wasm \ + ../cpex-wasm-host/wasm/my-plugin.wasm +``` + +### Configure in YAML + +Create or edit the host's YAML config to include your plugin: + +```yaml +plugins: + - name: my-plugin + kind: wasm://my-plugin.wasm # must match the filename you copied + hooks: [cmf.tool_pre_invoke] # which hooks to run on + mode: sequential # when in the pipeline to run + priority: 50 # lower = runs earlier + on_error: fail # what happens if the plugin errors + capabilities: # what data the plugin can see + - read_labels + - read_subject + - read_roles + config: + sandbox_policy: # resource limits for the sandbox + allowed_filesystem: [] # no file access + allowed_network: [] # no network access + allowed_env: [] # no env vars visible + resources: + max_memory_bytes: 10485760 # 10 MB + max_fuel: 1000000000 # instruction budget per call + max_execution_time_ms: 5000 # 5 second timeout per call +``` + +### Build all plugins at once (Makefile) + +```bash +# Build all plugin binaries and stage them to the host +make build-all +``` + +--- + +## Test Your Plugin + +You can test your handler logic **without compiling to WASM**. Tests run as normal native Rust: + +```bash +cargo test --features my-plugin --no-default-features +``` + +### Writing tests + +Add tests directly in your plugin file (or in a separate test module): + +```rust +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::hooks::trait_def::HookHandler; + use cpex_core::cmf::{ContentPart, Message, MessagePayload, Role, ToolCall}; + use cpex_core::cmf::constants::SCHEMA_VERSION; + + fn make_test_payload() -> MessagePayload { + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: "tc_001".into(), + name: "get_data".into(), + arguments: Default::default(), + namespace: None, + }, + }], + channel: None, + }, + } + } + + #[tokio::test] + async fn test_allows_non_pii() { + let plugin = MyPlugin; + let payload = make_test_payload(); + let ext = Extensions::default(); // no security labels = no PII + let mut ctx = PluginContext::default(); + + // Note: you must use fully-qualified syntax for the handler call + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ).await; + + assert!(result.continue_processing); + } + + #[tokio::test] + async fn test_denies_pii_without_admin() { + let plugin = MyPlugin; + let payload = make_test_payload(); + + // Build extensions with PII label but no admin role + let mut security = cpex_core::extensions::security::SecurityExtension::default(); + security.add_label("PII"); + let ext = Extensions { + security: Some(std::sync::Arc::new(security)), + ..Default::default() + }; + let mut ctx = PluginContext::default(); + + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ).await; + + assert!(!result.continue_processing); + assert_eq!(result.violation.as_ref().unwrap().code, "unauthorized"); + } +} +``` + +**Why the fully-qualified syntax?** Because `HookHandler::handle` is generic and the compiler needs to know which hook type you mean. `>::handle(...)` tells it explicitly. + +--- + +## What Your Plugin Can Do + +### Allow a request (most common) + +```rust +PluginResult::allow() +``` + +### Deny a request + +```rust +PluginResult::deny(PluginViolation::new( + "error_code", // machine-readable code + "Human-readable reason" // shown to the caller +)) +``` + +### Read extensions + +Extensions are passed by reference. You only see fields your capabilities grant: + +```rust +// Security labels (requires: read_labels) +if let Some(ref security) = extensions.security { + let has_pii = security.has_label("PII"); + let labels: Vec<&String> = security.labels.iter().collect(); +} + +// Subject identity (requires: read_subject + read_roles) +if let Some(ref security) = extensions.security { + if let Some(ref subject) = security.subject { + let user_id = &subject.id; // Option + let roles = &subject.roles; // HashSet + } +} + +// HTTP headers (requires: read_headers) +if let Some(ref http) = extensions.http { + let auth = http.get_header("Authorization"); + let req_id = http.get_header("X-Request-ID"); +} + +// Request metadata (always available, no capability needed) +if let Some(ref request) = extensions.request { + let env = &request.environment; // "production", "staging", etc. +} +``` + +### Modify extensions + +Use `cow_copy()` to get a mutable copy, modify it, then return it: + +```rust +let mut modified = extensions.cow_copy(); + +// Add a security label (requires: append_labels) +if let Some(ref mut sec) = modified.security { + sec.add_label("PROCESSED"); +} + +PluginResult::modify_extensions(modified) +``` + +### Modify the payload + +```rust +let mut modified_payload = payload.clone(); +// ... change fields on modified_payload ... +PluginResult::modify_payload(modified_payload) +``` + +### Use plugin context (share state between hooks in the same request) + +```rust +// In pre-invoke: store something +ctx.set_local("checked_at", serde_json::json!("2024-01-01T12:00:00Z")); + +// In post-invoke (same request): read it back +if let Some(val) = ctx.get_local("checked_at") { + cpex_log!(info, "was checked at: {}", val); +} +``` + +`local` state is private to your plugin. `global` state is shared across all plugins in the pipeline. + +--- + +## Logging + +Use `cpex_log!` for all logging. Never use `println!` or `eprintln!` in production plugins. + +```rust +use crate::cpex_log; + +cpex_log!(trace, "entering handler"); +cpex_log!(debug, "subject={:?}, roles={:?}", subject_id, roles); +cpex_log!(info, "approved tool '{}' for user '{}'", tool_name, user_id); +cpex_log!(warn, "PII access without clearance"); +cpex_log!(error, "critical: validation failed: {}", reason); +``` + +**How it works:** In production (WASM), logs are sent to the host via the `host-logging` WIT import, which routes them through the host's `tracing` subscriber with your plugin name attached. In tests (native), they fall back to `eprintln!`. + +--- + +## Payload Types + +Your plugin handles one or more payload types. The most common is CMF (`MessagePayload`), but there are others. + +### CMF Payloads — tool calls and results + +The standard payload for tool invocation hooks. Contains the LLM message with tool calls or tool results. + +```rust +use cpex_core::cmf::{CmfHook, MessagePayload}; + +impl HookHandler for MyPlugin { + async fn handle(&self, payload: &MessagePayload, ...) -> PluginResult { + // Get the tool calls from the message + for tc in payload.message.get_tool_calls() { + cpex_log!(info, "tool: {}, args: {:?}", tc.name, tc.arguments); + } + + // Or get tool results (for post-invoke hooks) + for tr in payload.message.get_tool_results() { + cpex_log!(info, "result from: {}, error: {}", tr.tool_name, tr.is_error); + } + + PluginResult::allow() + } +} +``` + +### Identity Payloads — resolving who the caller is + +Used by `identity_resolve` hooks to determine caller identity from headers/tokens. + +```rust +use cpex_core::identity::{IdentityHook, IdentityPayload}; +use cpex_core::extensions::security::{SubjectExtension, SubjectType}; + +impl HookHandler for MyResolver { + async fn handle(&self, payload: &IdentityPayload, ...) -> PluginResult { + let user_id = payload.headers().get("x-user-id"); + + if let Some(uid) = user_id { + let mut resolved = payload.clone(); + resolved.subject = Some(SubjectExtension { + id: Some(uid.clone()), + subject_type: Some(SubjectType::User), + ..Default::default() + }); + return PluginResult::modify_payload(resolved); + } + + PluginResult::allow() + } +} + +// Register for multiple hooks: +register_wasm_plugin!(MyResolver, [CmfHook, IdentityHook]); +``` + +### Delegation Payloads — minting tokens for downstream calls + +Used by `token_delegate` hooks to create scoped tokens for tool backends. + +```rust +use cpex_core::delegation::{DelegationPayload, TokenDelegateHook}; + +impl HookHandler for MyDelegator { + async fn handle(&self, payload: &DelegationPayload, ...) -> PluginResult { + // Mint a token for the target service + let mut resolved = payload.clone(); + // ... set delegated_token, delegation_mode, etc. + PluginResult::modify_payload(resolved) + } +} +``` + +--- + +## Custom Payload Types (Your Own Structs) + +You're not limited to the built-in payload types. You can define any struct as a payload and it will cross the WASM boundary automatically as JSON bytes. + +### How to do it + +```rust +use serde::{Deserialize, Serialize}; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; + +// 1. Define your struct (must be Serialize + Deserialize + Clone) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolInvokePayload { + pub tool_name: String, + pub user: String, + pub arguments: String, +} + +// 2. Implement the traits (these macros handle it) +cpex_core::impl_plugin_payload!(ToolInvokePayload); +cpex_core::impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); +// ^^^^^^^^^^^^^^^^ +// This string MUST match exactly on host and guest sides. +// It's the type discriminator used during serialization. + +// 3. Define a hook type that uses your payload +pub struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +// 4. Implement HookHandler for your hook type +impl HookHandler for MyPlugin { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + if payload.user.is_empty() { + return PluginResult::deny(PluginViolation::new( + "no_identity", + "User identity is required", + )); + } + PluginResult::allow() + } +} + +// 5. Register with your custom hook type +// register_wasm_plugin!(MyPlugin, [ToolPreInvoke]); +``` + +### Requirements + +- The struct **field names and types must match** between host and guest (it's JSON serialized) +- The `impl_wasm_payload!` discriminator string must be **identical** on both sides +- The **host** must register the type with `PayloadSerializerRegistry::register::()` + +### Working examples + +- `src/plugins/tool_invoke_checker.rs` — identity check +- `src/plugins/pii_guard.rs` — PII clearance gate +- `src/plugins/remote_authz.rs` — stateful ACL authorization + +--- + +## Cross-Invocation State + +The plugin struct itself is re-created on every call (`Default::default()`). But WASM linear memory persists between calls — so **module-level `static` variables survive across invocations**. + +Use `OnceLock` for one-time initialization (like caching an ACL or config): + +```rust +use std::collections::HashSet; +use std::sync::OnceLock; + +static ACL: OnceLock> = OnceLock::new(); + +fn get_acl() -> &'static HashSet { + ACL.get_or_init(|| { + cpex_log!(info, "initializing ACL (first call only)"); + let mut acl = HashSet::new(); + acl.insert("alice".to_string()); + acl.insert("bob".to_string()); + acl + }) +} + +impl HookHandler for RemoteAuthzPlugin { + async fn handle(&self, payload: &ToolInvokePayload, ...) -> PluginResult { + let acl = get_acl(); // initialized once, reused forever + + if acl.contains(&payload.user) { + PluginResult::allow() + } else { + PluginResult::deny(...) + } + } +} +``` + +This works because the host's `SandboxManager` keeps the WASM Store alive across invocations (only fuel and epoch are reset per call). See `src/plugins/remote_authz.rs` for the full working example. + +--- + +## Available Capabilities + +Declare these in your YAML config's `capabilities` list. Undeclared slots are invisible — your plugin sees `None` for those fields. + +| Capability | What you can see | +|-----------|------------------| +| `read_labels` | Security labels (`security.labels`) | +| `append_labels` | Modify: add security labels | +| `read_subject` | Subject identity (`security.subject.id`, `.subject_type`) | +| `read_roles` | Subject roles (`security.subject.roles`) | +| `read_teams` | Subject teams | +| `read_claims` | Subject claims | +| `read_permissions` | Subject permissions | +| `read_client` | OAuth client identity | +| `read_workload` | Workload identity | +| `read_headers` | HTTP request/response headers | +| `write_headers` | Modify: HTTP headers | +| `read_agent` | Agent session context | +| `read_delegation` | Delegation chain | +| `append_delegation` | Modify: append to delegation chain | +| `read_inbound_credentials` | Raw inbound tokens | +| `read_delegated_tokens` | Minted delegation tokens | + +--- + +## Built-in Demo Plugins + +### CMF Payload Plugins (used in capabilities demo) + +| Plugin | Feature Flag | What It Does | +|--------|-------------|--------------| +| **identity-checker** | `identity-checker` | PII access control + identity resolution from headers | +| **header-injector** | `header-injector` | Adds "PROCESSED" label + injects HTTP header | +| **audit-logger** | `audit-logger` | Read-only logging of tool name, labels, request ID | +| **token-attenuator** | `token-attenuator` | Mints scoped delegation tokens | +| **noop** | `noop` | Returns `allow()` immediately (for benchmarking) | + +### Custom Payload Plugins (used in plugin demo) + +| Plugin | Feature Flag | What It Does | +|--------|-------------|--------------| +| **tool-invoke-checker** | `tool-invoke-checker` | Identity check — denies empty user | +| **pii-guard** | `pii-guard` | PII clearance gate via context state | +| **audit-logger-custom** | `audit-logger-custom` | Logs invocations (fire-and-forget mode) | +| **remote-authz** | `remote-authz` | ACL authorization with persistent state | +| **compute-bench** | `compute-bench` | Real computation (JSON + hash) for benchmarking | + +### How to build and run them + +```bash +# Build all plugins at once +make build-all + +# Or build a specific one +cargo build --target wasm32-wasip2 --release \ + --features remote-authz --no-default-features + +# Run tests for a specific plugin +cargo test --features remote-authz --no-default-features +``` + +--- + +## Project Structure + +``` +cpex-wasm-plugin/ +├── Cargo.toml # cdylib target, one feature flag per plugin +├── Makefile # Build/stage/validate/run targets +├── src/ +│ ├── lib.rs # SDK core: +│ │ # - wit_bindgen::generate! (WIT bindings) +│ │ # - register_wasm_plugin! macro +│ │ # - cpex_log! macro +│ │ # - __block_on (sync executor) +│ │ # - Plugin registrations (feature-gated) +│ ├── conversions.rs # WIT ↔ native type conversions +│ └── plugins/ +│ ├── mod.rs # Feature-gated module declarations +│ ├── identity_checker.rs +│ ├── header_injector.rs +│ ├── audit_logger.rs +│ ├── token_attenuator.rs +│ ├── noop.rs +│ ├── tool_invoke_checker.rs +│ ├── pii_guard.rs +│ ├── audit_logger_custom.rs +│ ├── remote_authz.rs +│ ├── compute_bench.rs +│ ├── fs_test.rs # (test fixture — attempts filesystem access) +│ ├── net_test.rs # (test fixture — attempts network access) +│ └── env_test.rs # (test fixture — attempts env var access) +└── wit/ + ├── world.wit # WIT interface definition (shared with host) + └── deps/ # WASI P2 interface dependencies +``` + +--- + +## Constraints (What You Can't Do) + +| Constraint | Reason | Workaround | +|-----------|--------|------------| +| **One plugin per `.wasm` binary** | WIT allows only one export per component | Use feature flags — each flag compiles a different plugin | +| **No tokio, no reqwest** | WASM has no async runtime or raw sockets | Use WASI HTTP for network calls (declared in `allowed_network`) | +| **No filesystem I/O** | Sandbox blocks all access by default | Declare paths in `allowed_filesystem` config | +| **No `std::env::var()`** | Sandbox hides all env vars by default | Declare vars in `allowed_env` config | +| **Can't remove security labels** | Monotonic enforcement — labels are add-only | By design: prevents privilege escalation | +| **Can't `.await` yielding futures** | Guest executor polls once then panics | All handlers must complete synchronously (no sleep, no network await in the handler itself) | +| **No raw credential access** | Bearer tokens have `#[serde(skip)]` | By design: plugins needing tokens should run natively | +| **Struct is re-created per call** | `::default()` called each invocation | Use `static OnceLock` for persistent state | + +--- + +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| `cargo build` fails with "target not found" | `rustup target add wasm32-wasip2` | +| Compilation error mentioning `wit_bindgen` | Make sure `wit/` directory exists with `world.wit` | +| Test fails with "type must be known" | Use fully-qualified call: `>::handle(...)` | +| Plugin returns `allow()` but host shows `deny` | Check your YAML capabilities — the host may be rejecting extension modifications you're not authorized for | +| Plugin logs don't appear | Set `RUST_LOG=info` when running the host (logs go through host's tracing) | +| `OnceLock` value resets between calls | It shouldn't — if it does, the host may be reloading the plugin binary. Check config. | +| Plugin panics with "fuel exhausted" | Your logic is too expensive — increase `max_fuel` in YAML config or optimize your code | +| `make build-all` fails | Ensure you're in the `cpex-wasm-plugin` directory and have the WASM target installed | diff --git a/crates/cpex-wasm-plugin/src/conversions.rs b/crates/cpex-wasm-plugin/src/conversions.rs new file mode 100644 index 00000000..0d6d2270 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/conversions.rs @@ -0,0 +1,738 @@ +// Location: ./crates/cpex-wasm-plugin/src/conversions.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// Bidirectional type conversions between WIT-generated types and cpex-core native types. +// WIT types are flat/serialized (e.g., JSON strings for maps); native types use +// proper Rust collections (HashMap, HashSet, Vec). + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use chrono::DateTime; + +use cpex_core::cmf::content as native_content; +use cpex_core::cmf::enums as native_enums; +use cpex_core::cmf::message as native_msg; +use cpex_core::context::PluginContext as NativePluginContext; +use cpex_core::extensions::agent::AgentExtension as NativeAgentExtension; +use cpex_core::extensions::authorization::AuthorizationDetail as NativeAuthDetail; +use cpex_core::extensions::completion::{ + CompletionExtension as NativeCompletionExtension, StopReason as NativeStopReason, + TokenUsage as NativeTokenUsage, +}; +use cpex_core::extensions::container::Extensions as NativeExtensions; +use cpex_core::extensions::delegation::{ + DelegationExtension as NativeDelegationExtension, DelegationHop as NativeDelegationHop, + DelegationStrategy as NativeDelegationStrategy, +}; +use cpex_core::extensions::framework::FrameworkExtension as NativeFrameworkExtension; +use cpex_core::extensions::http::HttpExtension as NativeHttpExtension; +use cpex_core::extensions::llm::LLMExtension as NativeLLMExtension; +use cpex_core::extensions::mcp::{ + MCPExtension as NativeMCPExtension, PromptMetadata as NativePromptMetadata, + ResourceMetadata as NativeResourceMetadata, ToolMetadata as NativeToolMetadata, +}; +use cpex_core::extensions::meta::MetaExtension as NativeMetaExtension; +use cpex_core::extensions::provenance::ProvenanceExtension as NativeProvenanceExtension; +use cpex_core::extensions::request::RequestExtension as NativeRequestExtension; +use cpex_core::extensions::security::{ + ClientExtension as NativeClientExtension, ClientTrustLevel as NativeClientTrustLevel, + DataPolicy as NativeDataPolicy, ObjectSecurityProfile as NativeObjectSecurityProfile, + RetentionPolicy as NativeRetentionPolicy, SecurityExtension as NativeSecurityExtension, + SubjectExtension as NativeSubjectExtension, SubjectType as NativeSubjectType, + WorkloadIdentity as NativeWorkloadIdentity, +}; +use cpex_core::hooks::trait_def::PluginResult as NativePluginResult; + +use crate::cpex::plugin::types::*; + +// --------------------------------------------------------------------------- +// WIT → Native: MessagePayload +// --------------------------------------------------------------------------- + +pub fn wit_payload_to_native(payload: MessagePayload) -> native_msg::MessagePayload { + native_msg::MessagePayload { message: wit_message_to_native(payload.message) } +} + +fn wit_message_to_native(msg: Message) -> native_msg::Message { + native_msg::Message { + schema_version: msg.schema_version, + role: wit_role_to_native(msg.role), + content: msg.content.into_iter().map(wit_content_part_to_native).collect(), + channel: msg.channel.map(wit_channel_to_native), + } +} + +fn wit_role_to_native(role: Role) -> native_enums::Role { + match role { + Role::System => native_enums::Role::System, + Role::Developer => native_enums::Role::Developer, + Role::User => native_enums::Role::User, + Role::Assistant => native_enums::Role::Assistant, + Role::Tool => native_enums::Role::Tool, + } +} + +fn wit_channel_to_native(channel: Channel) -> native_enums::Channel { + match channel { + Channel::Analysis => native_enums::Channel::Analysis, + Channel::Commentary => native_enums::Channel::Commentary, + Channel::Final => native_enums::Channel::Final, + } +} + +fn wit_content_part_to_native(part: ContentPart) -> native_content::ContentPart { + match part { + ContentPart::Text(text) => native_content::ContentPart::Text { text }, + ContentPart::Thinking(text) => native_content::ContentPart::Thinking { text }, + ContentPart::ToolCall(tc) => native_content::ContentPart::ToolCall { + content: native_content::ToolCall { + tool_call_id: tc.tool_call_id, + name: tc.name, + arguments: serde_json::from_str(&tc.arguments).unwrap_or_default(), + namespace: tc.namespace, + }, + }, + ContentPart::ToolResult(tr) => native_content::ContentPart::ToolResult { + content: native_content::ToolResult { + tool_call_id: tr.tool_call_id, + tool_name: tr.tool_name, + content: serde_json::from_str(&tr.content) + .unwrap_or(serde_json::Value::String(tr.content)), + is_error: tr.is_error, + }, + }, + ContentPart::CmfResource(r) => native_content::ContentPart::Resource { + content: native_content::Resource { + resource_request_id: r.resource_request_id, + uri: r.uri, + name: r.name, + description: r.description, + resource_type: wit_resource_type_to_native(r.resource_type), + content: r.content, + blob: r.blob, + mime_type: r.mime_type, + size_bytes: r.size_bytes, + annotations: serde_json::from_str(&r.annotations).unwrap_or_default(), + version: r.version, + }, + }, + ContentPart::ResourceRef(rr) => native_content::ContentPart::ResourceRef { + content: native_content::ResourceReference { + resource_request_id: rr.resource_request_id, + uri: rr.uri, + name: rr.name, + resource_type: wit_resource_type_to_native(rr.resource_type), + range_start: rr.range_start, + range_end: rr.range_end, + selector: rr.selector, + }, + }, + ContentPart::PromptRequest(pr) => native_content::ContentPart::PromptRequest { + content: native_content::PromptRequest { + prompt_request_id: pr.prompt_request_id, + name: pr.name, + arguments: serde_json::from_str(&pr.arguments).unwrap_or_default(), + server_id: pr.server_id, + }, + }, + ContentPart::PromptResult(pr) => native_content::ContentPart::PromptResult { + content: native_content::PromptResult { + prompt_request_id: pr.prompt_request_id, + prompt_name: pr.prompt_name, + messages: serde_json::from_str(&pr.messages).unwrap_or_default(), + content: pr.content, + is_error: pr.is_error, + error_message: pr.error_message, + }, + }, + ContentPart::Image(img) => native_content::ContentPart::Image { + content: native_content::ImageSource { + source_type: img.source_type, + data: img.data, + media_type: img.media_type, + }, + }, + ContentPart::Video(v) => native_content::ContentPart::Video { + content: native_content::VideoSource { + source_type: v.source_type, + data: v.data, + media_type: v.media_type, + duration_ms: v.duration_ms, + }, + }, + ContentPart::Audio(a) => native_content::ContentPart::Audio { + content: native_content::AudioSource { + source_type: a.source_type, + data: a.data, + media_type: a.media_type, + duration_ms: a.duration_ms, + }, + }, + ContentPart::Document(d) => native_content::ContentPart::Document { + content: native_content::DocumentSource { + source_type: d.source_type, + data: d.data, + media_type: d.media_type, + title: d.title, + }, + }, + } +} + +fn wit_resource_type_to_native(rt: ResourceType) -> native_enums::ResourceType { + match rt { + ResourceType::File => native_enums::ResourceType::File, + ResourceType::Blob => native_enums::ResourceType::Blob, + ResourceType::Uri => native_enums::ResourceType::Uri, + ResourceType::Database => native_enums::ResourceType::Database, + ResourceType::Api => native_enums::ResourceType::Api, + ResourceType::Memory => native_enums::ResourceType::Memory, + ResourceType::Artifact => native_enums::ResourceType::Artifact, + } +} + +// --------------------------------------------------------------------------- +// WIT → Native: Extensions (full coverage) +// --------------------------------------------------------------------------- + +pub fn wit_extensions_to_native(ext: Extensions) -> NativeExtensions { + NativeExtensions { + request: ext.request.map(|r| Arc::new(NativeRequestExtension { + environment: r.environment, + request_id: r.request_id, + timestamp: r.timestamp, + trace_id: r.trace_id, + span_id: r.span_id, + })), + security: ext.security.map(|s| Arc::new(wit_security_to_native(s))), + http: ext.http.map(|h| Arc::new(NativeHttpExtension { + request_headers: h.request_headers.into_iter().collect(), + response_headers: h.response_headers.into_iter().collect(), + method: h.method, + path: h.path, + host: h.host, + scheme: h.scheme, + })), + meta: ext.meta.map(|m| Arc::new(NativeMetaExtension { + entity_type: m.entity_type, + entity_name: m.entity_name, + tags: m.tags.into_iter().collect::>(), + scope: m.scope, + properties: m.properties.into_iter().collect::>(), + })), + agent: ext.agent.map(|a| Arc::new(wit_agent_to_native(a))), + mcp: ext.mcp.map(|m| Arc::new(wit_mcp_to_native(m))), + completion: ext.completion.map(|c| Arc::new(wit_completion_to_native(c))), + provenance: ext.provenance.map(|p| Arc::new(NativeProvenanceExtension { + source: p.source, + message_id: p.message_id, + parent_id: p.parent_id, + })), + llm: ext.llm.map(|l| Arc::new(NativeLLMExtension { + model_id: l.model_id, + provider: l.provider, + capabilities: l.capabilities, + })), + framework: ext.framework.map(|f| Arc::new(wit_framework_to_native(f))), + delegation: ext.delegation.map(|d| Arc::new(wit_delegation_to_native(d))), + custom: ext.custom.and_then(|s| serde_json::from_str(&s).ok()).map(Arc::new), + ..Default::default() + } +} + +fn wit_security_to_native(s: SecurityExtension) -> NativeSecurityExtension { + NativeSecurityExtension { + labels: cpex_core::extensions::monotonic::MonotonicSet::from_set( + s.labels.into_iter().collect(), + ), + classification: s.classification, + subject: s.subject.map(|sub| NativeSubjectExtension { + id: sub.id, + subject_type: sub.subject_type.map(wit_subject_type_to_native), + roles: sub.roles.into_iter().collect(), + permissions: sub.permissions.into_iter().collect(), + teams: sub.teams.into_iter().collect(), + claims: sub.claims.into_iter().collect(), + }), + client: s.client.map(|c| { + let trust_level = match c.trust_level_custom { + Some(s) => NativeClientTrustLevel::Custom(s), + None => match c.trust_level { + ClientTrustLevel::FirstParty => NativeClientTrustLevel::FirstParty, + ClientTrustLevel::ThirdParty => NativeClientTrustLevel::ThirdParty, + ClientTrustLevel::Internal => NativeClientTrustLevel::Internal, + }, + }; + NativeClientExtension { + client_id: c.client_id, + client_name: c.client_name, + trust_level, + authorized_scopes: c.authorized_scopes, + authorized_audiences: c.authorized_audiences, + roles: c.roles, + permissions: c.permissions, + teams: c.teams, + claims: c.claims.into_iter() + .map(|(k, v)| (k, serde_json::from_str(&v).unwrap_or(serde_json::Value::String(v)))) + .collect(), + } + }), + caller_workload: s.caller_workload.map(wit_workload_to_native), + this_workload: s.this_workload.map(wit_workload_to_native), + auth_method: s.auth_method, + objects: s.objects.into_iter() + .map(|(k, v)| (k, NativeObjectSecurityProfile { + managed_by: v.managed_by, + permissions: v.permissions, + trust_domain: v.trust_domain, + data_scope: v.data_scope, + })) + .collect(), + data: s.data.into_iter() + .map(|(k, v)| (k, NativeDataPolicy { + apply_labels: v.apply_labels, + allowed_actions: v.allowed_actions, + denied_actions: v.denied_actions, + retention: v.retention.map(|r| NativeRetentionPolicy { + max_age_seconds: r.max_age_seconds, + policy: r.policy, + delete_after: r.delete_after, + }), + })) + .collect(), + } +} + +fn wit_subject_type_to_native(st: SubjectType) -> NativeSubjectType { + match st { + SubjectType::User => NativeSubjectType::User, + SubjectType::Agent => NativeSubjectType::Agent, + SubjectType::Service => NativeSubjectType::Service, + SubjectType::System => NativeSubjectType::System, + } +} + +fn wit_workload_to_native(w: WorkloadIdentity) -> NativeWorkloadIdentity { + NativeWorkloadIdentity { + spiffe_id: w.spiffe_id, + trust_domain: w.trust_domain, + attested_at: w.attested_at.as_deref() + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)), + attestor: w.attestor, + selectors: w.selectors, + client_id: w.client_id, + } +} + +fn wit_agent_to_native(a: AgentExtension) -> NativeAgentExtension { + use cpex_core::extensions::agent::ConversationContext; + NativeAgentExtension { + input: a.input, + session_id: a.session_id, + conversation_id: a.conversation_id, + turn: a.turn, + agent_id: a.agent_id, + parent_agent_id: a.parent_agent_id, + conversation: a.conversation.map(|c| ConversationContext { + history: c.history.iter() + .map(|s| serde_json::from_str(s).unwrap_or(serde_json::Value::String(s.clone()))) + .collect(), + summary: c.summary, + topics: c.topics, + }), + } +} + +fn wit_mcp_to_native(m: McpExtension) -> NativeMCPExtension { + NativeMCPExtension { + tool: m.tool.map(|t| NativeToolMetadata { + name: t.name, + title: t.title, + description: t.description, + input_schema: t.input_schema.and_then(|s| serde_json::from_str(&s).ok()), + output_schema: t.output_schema.and_then(|s| serde_json::from_str(&s).ok()), + server_id: t.server_id, + namespace: t.namespace, + annotations: t.annotations.into_iter() + .map(|(k, v)| (k, serde_json::from_str(&v).unwrap_or(serde_json::Value::String(v)))) + .collect(), + }), + resource: m.resource_info.map(|r| NativeResourceMetadata { + uri: r.uri, + name: r.name, + description: r.description, + mime_type: r.mime_type, + server_id: r.server_id, + annotations: r.annotations.into_iter() + .map(|(k, v)| (k, serde_json::from_str(&v).unwrap_or(serde_json::Value::String(v)))) + .collect(), + }), + prompt: m.prompt.map(|p| NativePromptMetadata { + name: p.name, + description: p.description, + arguments: p.arguments.and_then(|s| serde_json::from_str(&s).ok()), + server_id: p.server_id, + annotations: p.annotations.into_iter() + .map(|(k, v)| (k, serde_json::from_str(&v).unwrap_or(serde_json::Value::String(v)))) + .collect(), + }), + } +} + +fn wit_completion_to_native(c: CompletionExtension) -> NativeCompletionExtension { + NativeCompletionExtension { + stop_reason: c.stop_reason.map(|r| match r { + StopReason::End => NativeStopReason::End, + StopReason::ReturnComplete => NativeStopReason::Return, + StopReason::Call => NativeStopReason::Call, + StopReason::MaxTokens => NativeStopReason::MaxTokens, + StopReason::StopSequence => NativeStopReason::StopSequence, + }), + tokens: c.tokens.map(|t| NativeTokenUsage { + input_tokens: t.input_tokens, + output_tokens: t.output_tokens, + total_tokens: t.total_tokens, + }), + model: c.model, + raw_format: c.raw_format, + created_at: c.created_at, + latency_ms: c.latency_ms, + } +} + +fn wit_framework_to_native(f: FrameworkExtension) -> NativeFrameworkExtension { + NativeFrameworkExtension { + framework: f.framework, + framework_version: f.framework_version, + node_id: f.node_id, + graph_id: f.graph_id, + metadata: f.metadata.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(), + } +} + +fn wit_delegation_to_native(d: DelegationExtension) -> NativeDelegationExtension { + NativeDelegationExtension { + chain: d.chain.into_iter().map(|hop| { + let strategy = match (hop.strategy, hop.strategy_custom) { + (Some(DelegationStrategy::TokenExchange), _) => Some(NativeDelegationStrategy::TokenExchange), + (Some(DelegationStrategy::ClientCredentials), _) => Some(NativeDelegationStrategy::ClientCredentials), + (Some(DelegationStrategy::SpiffeSvid), _) => Some(NativeDelegationStrategy::SpiffeSvid), + (Some(DelegationStrategy::Passthrough), _) => Some(NativeDelegationStrategy::Passthrough), + (Some(DelegationStrategy::Ucan), _) => Some(NativeDelegationStrategy::Ucan), + (Some(DelegationStrategy::TransactionToken), _) => Some(NativeDelegationStrategy::TransactionToken), + (None, Some(s)) => Some(NativeDelegationStrategy::Custom(s)), + (None, None) => None, + }; + NativeDelegationHop { + subject_id: hop.subject_id, + subject_type: hop.subject_type.map(wit_subject_type_to_native), + audience: hop.audience, + scopes_granted: hop.scopes_granted, + authorization_details: hop.authorization_details.into_iter() + .map(|a| NativeAuthDetail { + detail_type: a.detail_type, + locations: a.locations, + actions: a.actions, + datatypes: a.datatypes, + identifier: a.identifier, + privileges: a.privileges, + extra: a.extra + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(), + }) + .collect(), + timestamp: DateTime::parse_from_rfc3339(&hop.timestamp) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .unwrap_or_else(|_| chrono::Utc::now()), + ttl_seconds: hop.ttl_seconds, + strategy, + from_cache: hop.from_cache, + } + }).collect(), + depth: d.depth, + origin_subject_id: d.origin_subject_id, + actor_subject_id: d.actor_subject_id, + delegated: d.delegated, + age_seconds: d.age_seconds.parse().unwrap_or(0.0), + } +} + +// --------------------------------------------------------------------------- +// WIT → Native: PluginContext +// --------------------------------------------------------------------------- + +pub fn wit_context_to_native(ctx: PluginContext) -> NativePluginContext { + NativePluginContext { + local_state: ctx.local_state.into_iter() + .map(|e| (e.key, serde_json::from_str(&e.value).unwrap_or(serde_json::Value::String(e.value)))) + .collect(), + global_state: ctx.global_state.into_iter() + .map(|e| (e.key, serde_json::from_str(&e.value).unwrap_or(serde_json::Value::String(e.value)))) + .collect(), + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: PluginResult → HookResult +// --------------------------------------------------------------------------- + +pub fn native_result_to_hook_result( + result: NativePluginResult, + ctx: &NativePluginContext, +) -> HookResult { + native_result_to_hook_result_generic(result, ctx) +} + +/// Converts a typed `PluginResult

` to a WIT HookResult for any payload +/// type that can cross the WASM boundary. A modified `MessagePayload` goes +/// back structured (cmf variant); every other payload type is serialized +/// into the custom variant with its type discriminator, which the host's +/// PayloadSerializerRegistry uses to reconstruct the concrete type. +pub fn native_result_to_hook_result_generic

( + result: NativePluginResult

, + ctx: &NativePluginContext, +) -> HookResult +where + P: cpex_core::hooks::payload::WasmSerializablePayload + 'static, +{ + let modified_payload = result.modified_payload.and_then(|p| { + let any: &dyn std::any::Any = &p; + if let Some(mp) = any.downcast_ref::() { + Some(HookPayload::Cmf(native_payload_to_wit(mp.clone()))) + } else { + match p.to_wasm_bytes() { + Ok(bytes) => Some(HookPayload::Custom(CustomPayload { + payload_type: P::payload_type_name().to_string(), + payload_data: bytes, + })), + Err(e) => { + eprintln!( + "[WASM] failed to serialize modified payload '{}': {}", + P::payload_type_name(), + e + ); + None + } + } + } + }); + HookResult { + continue_processing: result.continue_processing, + modified_payload, + modified_extensions: result.modified_extensions.as_ref().map(native_owned_extensions_to_wit), + modified_context: Some(native_context_to_wit(ctx)), + violation: result.violation.map(|v| PluginViolation { + code: v.code, + reason: v.reason, + description: v.description, + details: serde_json::to_string(&v.details).unwrap_or_else(|_| "{}".to_string()), + plugin_name: v.plugin_name, + proto_error_code: v.proto_error_code, + }), + metadata: result.metadata.map(|v| serde_json::to_string(&v).unwrap_or_default()), + } +} + +pub(crate) fn native_context_to_wit(ctx: &NativePluginContext) -> PluginContext { + PluginContext { + local_state: ctx.local_state.iter() + .map(|(k, v)| ContextEntry { key: k.clone(), value: serde_json::to_string(v).unwrap_or_default() }) + .collect(), + global_state: ctx.global_state.iter() + .map(|(k, v)| ContextEntry { key: k.clone(), value: serde_json::to_string(v).unwrap_or_default() }) + .collect(), + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: MessagePayload +// --------------------------------------------------------------------------- + +pub fn native_payload_to_wit(payload: native_msg::MessagePayload) -> MessagePayload { + MessagePayload { message: native_message_to_wit(payload.message) } +} + +fn native_message_to_wit(msg: native_msg::Message) -> Message { + Message { + schema_version: msg.schema_version, + role: native_role_to_wit(msg.role), + content: msg.content.into_iter().map(native_content_part_to_wit).collect(), + channel: msg.channel.map(native_channel_to_wit), + } +} + +fn native_role_to_wit(role: native_enums::Role) -> Role { + match role { + native_enums::Role::System => Role::System, + native_enums::Role::Developer => Role::Developer, + native_enums::Role::User => Role::User, + native_enums::Role::Assistant => Role::Assistant, + native_enums::Role::Tool => Role::Tool, + } +} + +fn native_channel_to_wit(channel: native_enums::Channel) -> Channel { + match channel { + native_enums::Channel::Analysis => Channel::Analysis, + native_enums::Channel::Commentary => Channel::Commentary, + native_enums::Channel::Final => Channel::Final, + } +} + +fn native_content_part_to_wit(part: native_content::ContentPart) -> ContentPart { + match part { + native_content::ContentPart::Text { text } => ContentPart::Text(text), + native_content::ContentPart::Thinking { text } => ContentPart::Thinking(text), + native_content::ContentPart::ToolCall { content } => ContentPart::ToolCall(ToolCall { + tool_call_id: content.tool_call_id, + name: content.name, + arguments: serde_json::to_string(&content.arguments).unwrap_or_else(|_| "{}".to_string()), + namespace: content.namespace, + }), + native_content::ContentPart::ToolResult { content } => ContentPart::ToolResult(ToolResult { + tool_call_id: content.tool_call_id, + tool_name: content.tool_name, + content: serde_json::to_string(&content.content).unwrap_or_default(), + is_error: content.is_error, + }), + native_content::ContentPart::Resource { content } => ContentPart::CmfResource(CmfResource { + resource_request_id: content.resource_request_id, + uri: content.uri, + name: content.name, + description: content.description, + resource_type: native_resource_type_to_wit(content.resource_type), + content: content.content, + blob: content.blob, + mime_type: content.mime_type, + size_bytes: content.size_bytes, + annotations: serde_json::to_string(&content.annotations).unwrap_or_else(|_| "{}".to_string()), + version: content.version, + }), + native_content::ContentPart::ResourceRef { content } => ContentPart::ResourceRef(ResourceReference { + resource_request_id: content.resource_request_id, + uri: content.uri, + name: content.name, + resource_type: native_resource_type_to_wit(content.resource_type), + range_start: content.range_start, + range_end: content.range_end, + selector: content.selector, + }), + native_content::ContentPart::PromptRequest { content } => ContentPart::PromptRequest(PromptRequest { + prompt_request_id: content.prompt_request_id, + name: content.name, + arguments: serde_json::to_string(&content.arguments).unwrap_or_else(|_| "{}".to_string()), + server_id: content.server_id, + }), + native_content::ContentPart::PromptResult { content } => ContentPart::PromptResult(PromptResult { + prompt_request_id: content.prompt_request_id, + prompt_name: content.prompt_name, + messages: serde_json::to_string(&content.messages).unwrap_or_else(|_| "[]".to_string()), + content: content.content, + is_error: content.is_error, + error_message: content.error_message, + }), + native_content::ContentPart::Image { content } => ContentPart::Image(ImageSource { + source_type: content.source_type, + data: content.data, + media_type: content.media_type, + }), + native_content::ContentPart::Video { content } => ContentPart::Video(VideoSource { + source_type: content.source_type, + data: content.data, + media_type: content.media_type, + duration_ms: content.duration_ms, + }), + native_content::ContentPart::Audio { content } => ContentPart::Audio(AudioSource { + source_type: content.source_type, + data: content.data, + media_type: content.media_type, + duration_ms: content.duration_ms, + }), + native_content::ContentPart::Document { content } => ContentPart::Document(DocumentSource { + source_type: content.source_type, + data: content.data, + media_type: content.media_type, + title: content.title, + }), + } +} + +fn native_resource_type_to_wit(rt: native_enums::ResourceType) -> ResourceType { + match rt { + native_enums::ResourceType::File => ResourceType::File, + native_enums::ResourceType::Blob => ResourceType::Blob, + native_enums::ResourceType::Uri => ResourceType::Uri, + native_enums::ResourceType::Database => ResourceType::Database, + native_enums::ResourceType::Api => ResourceType::Api, + native_enums::ResourceType::Memory => ResourceType::Memory, + native_enums::ResourceType::Artifact => ResourceType::Artifact, + } +} + +// --------------------------------------------------------------------------- +// Native → WIT: OwnedExtensions (from PluginResult::modified_extensions) +// --------------------------------------------------------------------------- + +fn native_owned_extensions_to_wit( + ext: &cpex_core::extensions::container::OwnedExtensions, +) -> Extensions { + Extensions { + request: ext.request.as_ref().map(|r| RequestExtension { + environment: r.environment.clone(), + request_id: r.request_id.clone(), + timestamp: r.timestamp.clone(), + trace_id: r.trace_id.clone(), + span_id: r.span_id.clone(), + }), + security: ext.security.as_ref().map(|s| SecurityExtension { + labels: s.labels.iter().cloned().collect(), + classification: s.classification.clone(), + subject: s.subject.as_ref().map(|sub| SubjectExtension { + id: sub.id.clone(), + subject_type: sub.subject_type.as_ref().map(|st| match st { + NativeSubjectType::User => SubjectType::User, + NativeSubjectType::Agent => SubjectType::Agent, + NativeSubjectType::Service => SubjectType::Service, + NativeSubjectType::System => SubjectType::System, + }), + roles: sub.roles.iter().cloned().collect(), + permissions: sub.permissions.iter().cloned().collect(), + teams: sub.teams.iter().cloned().collect(), + claims: sub.claims.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + }), + client: None, + caller_workload: None, + this_workload: None, + auth_method: s.auth_method.clone(), + objects: vec![], + data: vec![], + }), + http: ext.http.as_ref().map(|h| HttpExtension { + request_headers: h.read().request_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + response_headers: h.read().response_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + method: h.read().method.clone(), + path: h.read().path.clone(), + host: h.read().host.clone(), + scheme: h.read().scheme.clone(), + }), + meta: ext.meta.as_ref().map(|m| MetaExtension { + entity_type: m.entity_type.clone(), + entity_name: m.entity_name.clone(), + tags: m.tags.iter().cloned().collect(), + scope: m.scope.clone(), + properties: m.properties.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + }), + agent: None, + mcp: None, + completion: None, + provenance: None, + llm: None, + framework: None, + delegation: None, + custom: None, + } +} diff --git a/crates/cpex-wasm-plugin/src/lib.rs b/crates/cpex-wasm-plugin/src/lib.rs new file mode 100644 index 00000000..9dc21d0e --- /dev/null +++ b/crates/cpex-wasm-plugin/src/lib.rs @@ -0,0 +1,740 @@ +// Location: ./crates/cpex-wasm-plugin/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// CPEX WASM Plugin SDK. +// +// Provides: +// - `register_wasm_plugin!(PluginType, [HookType, ...])` macro — generates +// the WIT `Guest` impl with automatic dispatch to `HookHandler` impls. +// - Conversion functions exposed for advanced use cases. +// +// Plugin authors implement `HookHandler` on their type (identical to native +// plugins) and call the macro once — the WIT glue is fully generated. + +pub mod conversions; +mod plugins; + +// Generate Rust bindings from the WIT world definition. +wit_bindgen::generate!({ + path: "wit", + world: "plugin", + generate_all, +}); + +pub use conversions::{ + native_payload_to_wit, native_result_to_hook_result, native_result_to_hook_result_generic, + wit_context_to_native, wit_extensions_to_native, wit_payload_to_native, +}; + +// --------------------------------------------------------------------------- +// Structured host logging — calls the host-logging import +// --------------------------------------------------------------------------- + +/// Log level for host-side structured logging. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +/// Send a structured log message to the host's tracing infrastructure. +/// +/// The host routes this to its `tracing` subscriber with the plugin name +/// attached as a span field. Use this instead of `eprintln!` for production +/// logging. +pub fn host_log(level: LogLevel, message: &str) { + // In test mode, the WIT host import doesn't exist — fall back to eprintln + #[cfg(test)] + { + eprintln!("[{:?}] {}", level, message); + return; + } + + #[cfg(not(test))] + { + use crate::cpex::plugin::host_logging; + + let wit_level = match level { + LogLevel::Trace => host_logging::LogLevel::Trace, + LogLevel::Debug => host_logging::LogLevel::Debug, + LogLevel::Info => host_logging::LogLevel::Info, + LogLevel::Warn => host_logging::LogLevel::Warn, + LogLevel::Error => host_logging::LogLevel::Error, + }; + + host_logging::log(wit_level, message); + } +} + +/// Convenience macro for structured host logging with format arguments. +/// +/// # Example +/// ```no_run +/// cpex_log!(info, "processed {} items in {}ms", count, elapsed); +/// cpex_log!(warn, "payload field missing, using default"); +/// ``` +#[macro_export] +macro_rules! cpex_log { + (trace, $($arg:tt)*) => { + $crate::host_log($crate::LogLevel::Trace, &format!($($arg)*)) + }; + (debug, $($arg:tt)*) => { + $crate::host_log($crate::LogLevel::Debug, &format!($($arg)*)) + }; + (info, $($arg:tt)*) => { + $crate::host_log($crate::LogLevel::Info, &format!($($arg)*)) + }; + (warn, $($arg:tt)*) => { + $crate::host_log($crate::LogLevel::Warn, &format!($($arg)*)) + }; + (error, $($arg:tt)*) => { + $crate::host_log($crate::LogLevel::Error, &format!($($arg)*)) + }; +} + +// --------------------------------------------------------------------------- +// register_wasm_plugin! — the core macro +// +// Generates a complete `Guest` impl that: +// 1. Receives WIT types from the host (hook-name, payload, extensions, ctx) +// 2. Converts WIT → cpex-core native types +// 3. Routes to the matching HookHandler based on the payload's concrete type +// 4. Converts PluginResult → WIT HookResult (with context writeback) +// +// Dispatch is built at compile time from the hook type list: every listed +// hook's `Payload` must implement `WasmSerializablePayload`. A CMF payload +// routes to the hook whose Payload is `MessagePayload`; a custom payload +// routes to the hook whose Payload matches the WIT type discriminator +// (e.g. "cpex.identity" → HookHandler). +// +// Payloads no listed hook handles return allow() — same as a native plugin +// that isn't registered for that hook. A payload that names a listed type +// but fails to decode returns a deny violation: silently allowing on a +// decode failure would skip whatever check this plugin enforces. +// +// Usage: +// register_wasm_plugin!(MyPlugin, [CmfHook, IdentityHook]); +// --------------------------------------------------------------------------- + +#[macro_export] +macro_rules! register_wasm_plugin { + ($plugin_ty:ty, [$($hook_ty:ty),+ $(,)?]) => { + struct _WasmGuestImpl; + + impl Guest for _WasmGuestImpl { + fn handle_hook( + hook_name: String, + payload: HookPayload, + extensions: Extensions, + ctx: PluginContext, + ) -> HookResult { + use cpex_core::hooks::payload::WasmSerializablePayload; + use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef}; + + let native_ext = $crate::wit_extensions_to_native(extensions); + let mut native_ctx = $crate::wit_context_to_native(ctx); + + match payload { + HookPayload::Cmf(mp) => { + let native_payload = $crate::wit_payload_to_native(mp); + let any: &dyn ::std::any::Any = &native_payload; + $( + if let Some(typed) = + any.downcast_ref::<<$hook_ty as HookTypeDef>::Payload>() + { + let plugin = <$plugin_ty>::default(); + + // WASM is single-threaded with no ambient async + // runtime. Drive the future synchronously. + let result = $crate::__block_on( + <$plugin_ty as HookHandler<$hook_ty>>::handle( + &plugin, + typed, + &native_ext, + &mut native_ctx, + ) + ); + return $crate::native_result_to_hook_result_generic( + result, &native_ctx, + ); + } + )+ + eprintln!( + "[WASM] no handler for CMF payload on hook '{}' — allow", + hook_name + ); + $crate::__allow_hook_result(&native_ctx) + } + HookPayload::Identity(_ip) => { + eprintln!( + "[WASM] received Identity variant on hook '{}' — allow", + hook_name + ); + $crate::__allow_hook_result(&native_ctx) + } + HookPayload::Delegation(_dp) => { + eprintln!( + "[WASM] received Delegation variant on hook '{}' — allow", + hook_name + ); + $crate::__allow_hook_result(&native_ctx) + } + HookPayload::Custom(gp) => { + $( + if gp.payload_type + == <<$hook_ty as HookTypeDef>::Payload + as WasmSerializablePayload>::payload_type_name() + { + match <<$hook_ty as HookTypeDef>::Payload + as WasmSerializablePayload>::from_wasm_bytes(&gp.payload_data) + { + Ok(typed) => { + let plugin = <$plugin_ty>::default(); + let result = $crate::__block_on( + <$plugin_ty as HookHandler<$hook_ty>>::handle( + &plugin, + &typed, + &native_ext, + &mut native_ctx, + ) + ); + return $crate::native_result_to_hook_result_generic( + result, &native_ctx, + ); + } + Err(e) => { + return $crate::__decode_error_hook_result( + &gp.payload_type, &e.to_string(), &native_ctx, + ); + } + } + } + )+ + eprintln!( + "[WASM] unhandled custom payload '{}' on hook '{}' — allow", + gp.payload_type, hook_name + ); + $crate::__allow_hook_result(&native_ctx) + } + } + } + } + + export!(_WasmGuestImpl); + }; +} + +// --------------------------------------------------------------------------- +// Macro support functions — HookResult constructors used by the generated +// dispatch. Public so the expanded macro can call them, not part of the +// plugin-author API. +// --------------------------------------------------------------------------- + +/// Allow-and-continue result for payloads this plugin has no handler for. +pub fn __allow_hook_result(ctx: &cpex_core::context::PluginContext) -> HookResult { + HookResult { + continue_processing: true, + modified_payload: None, + modified_extensions: None, + modified_context: Some(conversions::native_context_to_wit(ctx)), + violation: None, + metadata: None, + } +} + +/// Deny result for a payload the plugin declares a handler for but could +/// not decode — failing open here would silently skip the plugin's check. +pub fn __decode_error_hook_result( + payload_type: &str, + error: &str, + ctx: &cpex_core::context::PluginContext, +) -> HookResult { + eprintln!("[WASM] failed to decode payload '{}': {}", payload_type, error); + HookResult { + continue_processing: false, + modified_payload: None, + modified_extensions: None, + modified_context: Some(conversions::native_context_to_wit(ctx)), + violation: Some(crate::cpex::plugin::types::PluginViolation { + code: "wasm_payload_decode_error".to_string(), + reason: format!("failed to decode payload '{}': {}", payload_type, error), + description: None, + details: "{}".to_string(), + plugin_name: None, + proto_error_code: None, + }), + metadata: None, + } +} + +// --------------------------------------------------------------------------- +// __block_on — synchronous async executor for WASM +// +// Futures returned by HookHandler::handle() must be driven to completion +// synchronously. Current handlers await nothing in WASM context, so the +// future completes on the first poll in practice. +// --------------------------------------------------------------------------- + +pub fn __block_on(f: F) -> F::Output { + use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; + + fn noop(_: *const ()) {} + fn noop_clone(_: *const ()) -> RawWaker { + RawWaker::new(std::ptr::null(), &VTABLE) + } + static VTABLE: RawWakerVTable = RawWakerVTable::new(noop_clone, noop, noop, noop); + + let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) }; + let mut cx = Context::from_waker(&waker); + let mut pinned = std::pin::pin!(f); + + loop { + match pinned.as_mut().poll(&mut cx) { + Poll::Ready(val) => return val, + Poll::Pending => continue, + } + } +} + +// --------------------------------------------------------------------------- +// Plugin registration — feature-gated +// +// Each feature compiles a different plugin into the .wasm binary. +// Build with: cargo build --target wasm32-wasip2 --features --no-default-features +// --------------------------------------------------------------------------- + +#[cfg(all(feature = "identity-checker", not(test)))] +register_wasm_plugin!( + plugins::identity_checker::IdentityCheckerPlugin, + [cpex_core::cmf::CmfHook, cpex_core::identity::IdentityHook] +); + +#[cfg(all(feature = "header-injector", not(test)))] +register_wasm_plugin!( + plugins::header_injector::HeaderInjectorPlugin, + [cpex_core::cmf::CmfHook] +); + +#[cfg(all(feature = "audit-logger", not(test)))] +register_wasm_plugin!( + plugins::audit_logger::AuditLoggerPlugin, + [cpex_core::cmf::CmfHook] +); + +#[cfg(all(feature = "token-attenuator", not(test)))] +register_wasm_plugin!( + plugins::token_attenuator::TokenAttenuatorPlugin, + [cpex_core::delegation::TokenDelegateHook] +); + +#[cfg(all(feature = "noop", not(test)))] +register_wasm_plugin!( + plugins::noop::NoopPlugin, + [cpex_core::cmf::CmfHook] +); + +#[cfg(all(feature = "fs-test", not(test)))] +register_wasm_plugin!( + plugins::fs_test::FsTestPlugin, + [cpex_core::cmf::CmfHook] +); + +#[cfg(all(feature = "net-test", not(test)))] +register_wasm_plugin!( + plugins::net_test::NetTestPlugin, + [cpex_core::cmf::CmfHook] +); + +#[cfg(all(feature = "env-test", not(test)))] +register_wasm_plugin!( + plugins::env_test::EnvTestPlugin, + [cpex_core::cmf::CmfHook] +); + +#[cfg(all(feature = "tool-invoke-checker", not(test)))] +register_wasm_plugin!( + plugins::tool_invoke_checker::ToolInvokeCheckerPlugin, + [ + plugins::tool_invoke_checker::ToolPreInvoke, + plugins::tool_invoke_checker::ToolPostInvoke, + ] +); + +#[cfg(all(feature = "pii-guard", not(test)))] +register_wasm_plugin!( + plugins::pii_guard::PiiGuardPlugin, + [plugins::pii_guard::ToolPreInvoke] +); + +#[cfg(all(feature = "audit-logger-custom", not(test)))] +register_wasm_plugin!( + plugins::audit_logger_custom::AuditLoggerCustomPlugin, + [ + plugins::audit_logger_custom::ToolPreInvoke, + plugins::audit_logger_custom::ToolPostInvoke, + ] +); + +#[cfg(all(feature = "remote-authz", not(test)))] +register_wasm_plugin!( + plugins::remote_authz::RemoteAuthzPlugin, + [plugins::remote_authz::ToolPreInvoke] +); + +#[cfg(all(feature = "compute-bench", not(test)))] +register_wasm_plugin!( + plugins::compute_bench::ComputeBenchPlugin, + [cpex_core::cmf::CmfHook] +); + +// --------------------------------------------------------------------------- +// Unit tests — run natively with `cargo test` +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(unused_imports, dead_code)] +mod tests { + use std::sync::Arc; + use cpex_core::cmf::{ContentPart, Message, MessagePayload, Role, ToolCall, ToolResult}; + use cpex_core::cmf::constants::SCHEMA_VERSION; + use cpex_core::context::PluginContext; + use cpex_core::extensions::container::Extensions; + use cpex_core::extensions::http::HttpExtension; + use cpex_core::extensions::security::{SecurityExtension, SubjectExtension, SubjectType}; + use cpex_core::hooks::payload::PluginPayload; + use cpex_core::hooks::trait_def::PluginResult; + + trait ResultAssert { + fn assert_allowed(&self); + fn assert_denied(&self); + fn assert_has_modified_payload(&self); + fn assert_has_modified_extensions(&self); + } + + impl ResultAssert

for PluginResult

{ + fn assert_allowed(&self) { + assert!(self.continue_processing, "expected ALLOW, got DENY"); + } + fn assert_denied(&self) { + assert!(!self.continue_processing, "expected DENY, got ALLOW"); + assert!(self.violation.is_some(), "denied but no violation"); + } + fn assert_has_modified_payload(&self) { + assert!(self.modified_payload.is_some(), "expected modified payload"); + } + fn assert_has_modified_extensions(&self) { + assert!(self.modified_extensions.is_some(), "expected modified extensions"); + } + } + + fn tool_call_payload(name: &str) -> MessagePayload { + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Assistant, + content: vec![ContentPart::ToolCall { + content: ToolCall { + tool_call_id: format!("tc_{}", name), + name: name.into(), + arguments: Default::default(), + namespace: None, + }, + }], + channel: None, + }, + } + } + + fn tool_result_payload(name: &str, content: serde_json::Value, is_error: bool) -> MessagePayload { + MessagePayload { + message: Message { + schema_version: SCHEMA_VERSION.into(), + role: Role::Tool, + content: vec![ContentPart::ToolResult { + content: ToolResult { + tool_call_id: format!("tc_{}", name), + tool_name: name.into(), + content, + is_error, + }, + }], + channel: None, + }, + } + } + + fn ext_with_security(f: impl FnOnce(&mut SecurityExtension)) -> Extensions { + let mut sec = SecurityExtension::default(); + f(&mut sec); + Extensions { security: Some(Arc::new(sec)), ..Default::default() } + } + + #[cfg(feature = "identity-checker")] + mod identity_checker { + use super::*; + use crate::plugins::identity_checker::IdentityCheckerPlugin; + use cpex_core::cmf::CmfHook; + use cpex_core::hooks::trait_def::HookHandler; + + #[tokio::test] + async fn test_denies_pii_access_without_hr_admin_role() { + let ext = ext_with_security(|s| { + s.add_label("PII"); + s.subject = Some(SubjectExtension { + id: Some("bob".into()), + subject_type: Some(SubjectType::User), + roles: ["viewer".to_string()].into(), + ..Default::default() + }); + }); + let payload = tool_call_payload("get_compensation"); + let mut ctx = PluginContext::default(); + + let result: PluginResult<_> = + >::handle( + &IdentityCheckerPlugin, &payload, &ext, &mut ctx, + ).await; + result.assert_denied(); + } + + #[tokio::test] + async fn test_allows_pii_access_with_hr_admin_role() { + let ext = ext_with_security(|s| { + s.add_label("PII"); + s.subject = Some(SubjectExtension { + id: Some("alice".into()), + subject_type: Some(SubjectType::User), + roles: ["hr_admin".to_string()].into(), + ..Default::default() + }); + }); + let payload = tool_call_payload("get_compensation"); + let mut ctx = PluginContext::default(); + + let result: PluginResult<_> = + >::handle( + &IdentityCheckerPlugin, &payload, &ext, &mut ctx, + ).await; + result.assert_allowed(); + } + + #[tokio::test] + async fn test_allows_non_pii_without_role() { + let ext = ext_with_security(|s| s.add_label("PUBLIC")); + let payload = tool_call_payload("get_weather"); + let mut ctx = PluginContext::default(); + + let result: PluginResult<_> = + >::handle( + &IdentityCheckerPlugin, &payload, &ext, &mut ctx, + ).await; + result.assert_allowed(); + } + + use cpex_core::identity::{IdentityHook, IdentityPayload, TokenSource}; + use std::collections::HashMap; + + #[tokio::test] + async fn test_identity_resolves_subject_from_header() { + let mut headers = HashMap::new(); + headers.insert("x-user-id".to_string(), "alice".to_string()); + let payload = IdentityPayload::new("", TokenSource::Bearer).with_headers(headers); + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + + let result: PluginResult<_> = + >::handle( + &IdentityCheckerPlugin, &payload, &ext, &mut ctx, + ).await; + result.assert_allowed(); + result.assert_has_modified_payload(); + let subject = result.modified_payload.as_ref().unwrap() + .subject.as_ref().expect("subject should be resolved"); + assert_eq!(subject.id.as_deref(), Some("alice")); + assert_eq!(subject.subject_type, Some(SubjectType::User)); + } + + #[tokio::test] + async fn test_identity_passes_through_without_header() { + let payload = IdentityPayload::new("", TokenSource::Bearer); + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + + let result: PluginResult<_> = + >::handle( + &IdentityCheckerPlugin, &payload, &ext, &mut ctx, + ).await; + result.assert_allowed(); + assert!(result.modified_payload.is_none()); + } + + #[tokio::test] + async fn test_identity_skips_when_subject_already_resolved() { + let mut headers = HashMap::new(); + headers.insert("x-user-id".to_string(), "bob".to_string()); + let mut payload = IdentityPayload::new("", TokenSource::Bearer).with_headers(headers); + payload.subject = Some(SubjectExtension { + id: Some("existing-user".into()), + subject_type: Some(SubjectType::User), + ..Default::default() + }); + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + + let result: PluginResult<_> = + >::handle( + &IdentityCheckerPlugin, &payload, &ext, &mut ctx, + ).await; + result.assert_allowed(); + assert!(result.modified_payload.is_none()); + } + } + + #[cfg(feature = "header-injector")] + mod header_injector { + use super::*; + use crate::plugins::header_injector::HeaderInjectorPlugin; + use cpex_core::cmf::CmfHook; + use cpex_core::hooks::trait_def::HookHandler; + + #[tokio::test] + async fn test_injects_header_and_label() { + let mut sec = SecurityExtension::default(); + sec.add_label("PII"); + let mut http = HttpExtension::default(); + http.set_header("Authorization", "Bearer token"); + let ext = Extensions { + security: Some(Arc::new(sec)), + http: Some(Arc::new(http)), + ..Default::default() + }; + let payload = tool_call_payload("fetch-data"); + let mut ctx = PluginContext::default(); + + let result: PluginResult<_> = + >::handle( + &HeaderInjectorPlugin, &payload, &ext, &mut ctx, + ).await; + result.assert_allowed(); + result.assert_has_modified_extensions(); + + let modified = result.modified_extensions.as_ref().unwrap(); + assert!(modified.security.as_ref().unwrap().has_label("PROCESSED")); + assert!(modified.security.as_ref().unwrap().has_label("PII")); + let h = modified.http.as_ref().unwrap().read(); + assert_eq!(h.request_headers.get("X-Processed-By").map(|s| s.as_str()), Some("header-injector")); + } + } + + #[cfg(feature = "audit-logger")] + mod audit_logger { + use super::*; + use crate::plugins::audit_logger::AuditLoggerPlugin; + use cpex_core::cmf::CmfHook; + use cpex_core::hooks::trait_def::HookHandler; + + #[tokio::test] + async fn test_always_allows_pre_invoke() { + let mut sec = SecurityExtension::default(); + sec.add_label("PII"); + let mut http = HttpExtension::default(); + http.set_header("X-Request-ID", "req-123"); + let ext = Extensions { + security: Some(Arc::new(sec)), + http: Some(Arc::new(http)), + ..Default::default() + }; + let payload = tool_call_payload("get_data"); + let mut ctx = PluginContext::default(); + + let result: PluginResult<_> = + >::handle( + &AuditLoggerPlugin, &payload, &ext, &mut ctx, + ).await; + result.assert_allowed(); + } + + #[tokio::test] + async fn test_always_allows_post_invoke() { + let ext = Extensions::default(); + let payload = tool_result_payload("get_data", serde_json::json!({"result": "ok"}), false); + let mut ctx = PluginContext::default(); + + let result: PluginResult<_> = + >::handle( + &AuditLoggerPlugin, &payload, &ext, &mut ctx, + ).await; + result.assert_allowed(); + } + } + + #[cfg(feature = "token-attenuator")] + mod token_attenuator { + use super::*; + use crate::plugins::token_attenuator::TokenAttenuatorPlugin; + use cpex_core::delegation::{DelegationPayload, TargetType, TokenDelegateHook}; + use cpex_core::hooks::trait_def::HookHandler; + + #[tokio::test] + async fn test_mints_token_for_tool_target() { + let payload = DelegationPayload::new("", "get_compensation") + .with_target_type(TargetType::Tool) + .with_target_audience("hr-service.internal") + .with_required_permissions(vec!["read_compensation".into()]); + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + + let result: PluginResult<_> = + >::handle( + &TokenAttenuatorPlugin, &payload, &ext, &mut ctx, + ).await; + result.assert_allowed(); + result.assert_has_modified_payload(); + + let modified = result.modified_payload.as_ref().unwrap(); + let token = modified.delegated_token.as_ref().expect("should mint token"); + assert_eq!(token.audience, "hr-service.internal"); + assert_eq!(token.scopes, vec!["read_compensation"]); + assert_eq!(token.outbound_header, "Authorization"); + assert!(modified.minted_at.is_some()); + assert_eq!(modified.metadata.get("minter").and_then(|v| v.as_str()), Some("token-attenuator-wasm")); + } + + #[tokio::test] + async fn test_passes_through_non_tool_targets() { + let payload = DelegationPayload::new("", "agent-downstream") + .with_target_type(TargetType::Agent); + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + + let result: PluginResult<_> = + >::handle( + &TokenAttenuatorPlugin, &payload, &ext, &mut ctx, + ).await; + result.assert_allowed(); + assert!(result.modified_payload.is_none()); + } + + #[tokio::test] + async fn test_uses_target_name_as_audience_when_no_explicit_audience() { + let payload = DelegationPayload::new("", "fetch-records") + .with_target_type(TargetType::Tool); + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + + let result: PluginResult<_> = + >::handle( + &TokenAttenuatorPlugin, &payload, &ext, &mut ctx, + ).await; + result.assert_has_modified_payload(); + let token = result.modified_payload.as_ref().unwrap() + .delegated_token.as_ref().unwrap(); + assert_eq!(token.audience, "fetch-records"); + } + } +} diff --git a/crates/cpex-wasm-plugin/src/plugins/audit_logger.rs b/crates/cpex-wasm-plugin/src/plugins/audit_logger.rs new file mode 100644 index 00000000..bf8b25c5 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/plugins/audit_logger.rs @@ -0,0 +1,106 @@ +// Location: ./crates/cpex-wasm-plugin/src/plugins/audit_logger.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +pub struct AuditLoggerPlugin; + +impl Default for AuditLoggerPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for AuditLoggerPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "audit-logger".to_string(), + kind: "wasm://audit-logger.wasm".to_string(), + hooks: vec![ + "cmf.tool_pre_invoke".to_string(), + "cmf.tool_post_invoke".to_string(), + ], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for AuditLoggerPlugin { + async fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let is_result = payload.message.is_tool_result(); + let phase = if is_result { "POST" } else { "PRE" }; + + let tool_name = if is_result { + payload + .message + .get_tool_results() + .first() + .map(|tr| tr.tool_name.as_str()) + .unwrap_or("unknown") + } else { + payload + .message + .get_tool_calls() + .first() + .map(|tc| tc.name.as_str()) + .unwrap_or("unknown") + }; + + let labels_str = extensions + .security + .as_ref() + .map(|s| { + let labels: Vec<&String> = s.labels.iter().collect(); + format!("{:?}", labels) + }) + .unwrap_or_else(|| "[]".into()); + + let req_id = extensions + .http + .as_ref() + .and_then(|h| h.get_header("X-Request-ID")) + .unwrap_or_default(); + + if is_result { + let is_error = payload + .message + .get_tool_results() + .first() + .map(|tr| tr.is_error) + .unwrap_or(false); + cpex_log!(info, "AUDIT[{}]: tool='{}' labels={} request_id='{}' error={}", + phase, tool_name, labels_str, req_id, is_error); + } else { + cpex_log!(info, "AUDIT[{}]: tool='{}' labels={} request_id='{}'", + phase, tool_name, labels_str, req_id); + } + PluginResult::allow() + } +} diff --git a/crates/cpex-wasm-plugin/src/plugins/audit_logger_custom.rs b/crates/cpex-wasm-plugin/src/plugins/audit_logger_custom.rs new file mode 100644 index 00000000..0ac06583 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/plugins/audit_logger_custom.rs @@ -0,0 +1,141 @@ +// Location: ./crates/cpex-wasm-plugin/src/plugins/audit_logger_custom.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// AuditLoggerCustomPlugin — WASM audit logger for the custom payload demo. +// +// Mirrors the native AuditLogger from plugin_demo.rs: logs all tool invocations +// without blocking. Runs as fire_and_forget mode at priority 100. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +// --------------------------------------------------------------------------- +// Payload and hook types (same definition as all demo plugins) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolInvokePayload { + pub tool_name: String, + pub user: String, + pub arguments: String, +} + +cpex_core::impl_plugin_payload!(ToolInvokePayload); +cpex_core::impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); + +pub struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +pub struct ToolPostInvoke; +impl HookTypeDef for ToolPostInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_post_invoke"; +} + +// --------------------------------------------------------------------------- +// Plugin implementation +// --------------------------------------------------------------------------- + +pub struct AuditLoggerCustomPlugin; + +impl Default for AuditLoggerCustomPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for AuditLoggerCustomPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "audit-logger".to_string(), + kind: "wasm://audit-logger-custom.wasm".to_string(), + hooks: vec!["tool_pre_invoke".to_string(), "tool_post_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for AuditLoggerCustomPlugin { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + cpex_log!( + info, + "[audit-logger] LOG: user='{}' tool='{}' args='{}'", + payload.user, + payload.tool_name, + payload.arguments + ); + PluginResult::allow() + } +} + +impl HookHandler for AuditLoggerCustomPlugin { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + cpex_log!( + info, + "[audit-logger] LOG: post-invoke user='{}' tool='{}'", + payload.user, + payload.tool_name + ); + PluginResult::allow() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::hooks::trait_def::HookHandler; + + #[tokio::test] + async fn test_always_allows() { + let plugin = AuditLoggerCustomPlugin; + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + assert!(result.continue_processing); + } +} diff --git a/crates/cpex-wasm-plugin/src/plugins/compute_bench.rs b/crates/cpex-wasm-plugin/src/plugins/compute_bench.rs new file mode 100644 index 00000000..7a0aaeec --- /dev/null +++ b/crates/cpex-wasm-plugin/src/plugins/compute_bench.rs @@ -0,0 +1,104 @@ +// Location: ./crates/cpex-wasm-plugin/src/plugins/compute_bench.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// ComputeBenchPlugin — WASM plugin that performs real computation. +// +// Used for benchmarking WASM vs native performance on identical workloads: +// JSON parsing, string manipulation, and hash computation. The native +// benchmark does the exact same operations so the comparison isolates +// the runtime difference (not the workload difference). + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +pub struct ComputeBenchPlugin; + +impl Default for ComputeBenchPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for ComputeBenchPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "compute-bench".to_string(), + kind: "wasm://compute-bench.wasm".to_string(), + hooks: vec!["cmf.tool_pre_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for ComputeBenchPlugin { + async fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + // 1. JSON parsing — serialize tool call arguments to a string + let args_json = payload + .message + .get_tool_calls() + .first() + .map(|tc| serde_json::to_string(&tc.arguments).unwrap_or_default()) + .unwrap_or_default(); + + // 2. String manipulation — build a summary from payload + extensions + let mut summary = String::with_capacity(256); + summary.push_str("tool="); + summary.push_str( + payload + .message + .get_tool_calls() + .first() + .map(|tc| tc.name.as_str()) + .unwrap_or("?"), + ); + if let Some(ref sec) = extensions.security { + for label in sec.labels.iter() { + summary.push_str(",label="); + summary.push_str(label); + } + } + if let Some(ref http) = extensions.http { + if let Some(req_id) = http.get_header("X-Request-ID") { + summary.push_str(",req_id="); + summary.push_str(req_id); + } + } + + // 3. Hash computation — simple FNV-like hash over the JSON bytes + let hash: u64 = args_json + .bytes() + .fold(14695981039346656037u64, |acc, b| { + acc.wrapping_mul(1099511628211).wrapping_add(b as u64) + }); + + // 4. Store computed results in context (exercises context write path) + ctx.set_local("hash", serde_json::json!(hash)); + ctx.set_local("summary_len", serde_json::json!(summary.len())); + + PluginResult::allow() + } +} diff --git a/crates/cpex-wasm-plugin/src/plugins/env_test.rs b/crates/cpex-wasm-plugin/src/plugins/env_test.rs new file mode 100644 index 00000000..166d2828 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/plugins/env_test.rs @@ -0,0 +1,75 @@ +// Location: ./crates/cpex-wasm-plugin/src/plugins/env_test.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +pub struct EnvTestPlugin; + +impl Default for EnvTestPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for EnvTestPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "env-test".to_string(), + kind: "wasm://env-test.wasm".to_string(), + hooks: vec!["cmf.tool_pre_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for EnvTestPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + // Try to read HOME (should be hidden unless explicitly allowed) + let home = std::env::var("HOME").unwrap_or_default(); + ctx.set_local("env_HOME", serde_json::json!(home)); + + // Try to read PATH (should be hidden unless explicitly allowed) + let path = std::env::var("PATH").unwrap_or_default(); + ctx.set_local("env_PATH", serde_json::json!(path)); + + // Try to read a test variable that we explicitly allow in the policy + let allowed = std::env::var("CPEX_TEST_ALLOWED").unwrap_or_default(); + ctx.set_local("env_CPEX_TEST_ALLOWED", serde_json::json!(allowed)); + + // Try to read a secret that should never be visible + let secret = std::env::var("SECRET_API_KEY").unwrap_or_default(); + ctx.set_local("env_SECRET_API_KEY", serde_json::json!(secret)); + + cpex_log!(info, "env check: HOME='{}' PATH='{}' ALLOWED='{}' SECRET='{}'", + home, path, allowed, secret); + + PluginResult::allow() + } +} diff --git a/crates/cpex-wasm-plugin/src/plugins/fs_test.rs b/crates/cpex-wasm-plugin/src/plugins/fs_test.rs new file mode 100644 index 00000000..63fa6691 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/plugins/fs_test.rs @@ -0,0 +1,75 @@ +// Location: ./crates/cpex-wasm-plugin/src/plugins/fs_test.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +pub struct FsTestPlugin; + +impl Default for FsTestPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for FsTestPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "fs-test".to_string(), + kind: "wasm://fs-test.wasm".to_string(), + hooks: vec!["cmf.tool_pre_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for FsTestPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + // Try to read /etc/passwd (should fail if not in allowed_filesystem) + let target_path = "/etc/passwd"; + cpex_log!(info, "attempting to read '{}'", target_path); + + match std::fs::read_to_string(target_path) { + Ok(content) => { + // If we could read it, report that in context (test will check this) + ctx.set_local("fs_read_success", serde_json::json!(true)); + ctx.set_local("fs_read_length", serde_json::json!(content.len())); + cpex_log!(warn, "successfully read '{}' ({} bytes) — sandbox escape!", target_path, content.len()); + PluginResult::allow() + } + Err(e) => { + // Expected: access denied + ctx.set_local("fs_read_success", serde_json::json!(false)); + ctx.set_local("fs_read_error", serde_json::json!(e.to_string())); + cpex_log!(info, "read '{}' denied: {} — sandbox working correctly", target_path, e); + PluginResult::allow() + } + } + } +} diff --git a/crates/cpex-wasm-plugin/src/plugins/header_injector.rs b/crates/cpex-wasm-plugin/src/plugins/header_injector.rs new file mode 100644 index 00000000..e58dae16 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/plugins/header_injector.rs @@ -0,0 +1,76 @@ +// Location: ./crates/cpex-wasm-plugin/src/plugins/header_injector.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::guarded::Guarded; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +pub struct HeaderInjectorPlugin; + +impl Default for HeaderInjectorPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for HeaderInjectorPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "header-injector".to_string(), + kind: "wasm://header-injector.wasm".to_string(), + hooks: vec!["cmf.tool_pre_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for HeaderInjectorPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + cpex_log!(debug, "processing hook, http_headers={}", + extensions.http.as_ref().map(|h| h.request_headers.len()).unwrap_or(0)); + + let mut modified = extensions.cow_copy(); + + if let Some(ref mut sec) = modified.security { + sec.add_label("PROCESSED"); + } + + let mut http = extensions + .http + .as_ref() + .map(|h| (**h).clone()) + .unwrap_or_default(); + http.set_header("X-Processed-By", "header-injector"); + modified.http = Some(Guarded::new(http)); + + cpex_log!(info, "injected header 'X-Processed-By' and label 'PROCESSED'"); + + PluginResult::modify_extensions(modified) + } +} diff --git a/crates/cpex-wasm-plugin/src/plugins/identity_checker.rs b/crates/cpex-wasm-plugin/src/plugins/identity_checker.rs new file mode 100644 index 00000000..a14101f7 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/plugins/identity_checker.rs @@ -0,0 +1,137 @@ +// Location: ./crates/cpex-wasm-plugin/src/plugin.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// IdentityCheckerPlugin — the bundled WASM plugin implementation. +// +// Implements HookHandler using the same trait that a native plugin +// would implement. No WIT types here — conversions are handled by the SDK. + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::security::{SubjectExtension, SubjectType}; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::identity::{IdentityHook, IdentityPayload}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +pub struct IdentityCheckerPlugin; + +impl Default for IdentityCheckerPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for IdentityCheckerPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "identity-checker".to_string(), + kind: "wasm://plugin.wasm".to_string(), + hooks: vec!["cmf".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for IdentityCheckerPlugin { + async fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let is_result = payload.message.is_tool_result(); + + if is_result { + let tool_name = payload + .message + .get_tool_results() + .first() + .map(|tr| tr.tool_name.as_str()) + .unwrap_or("unknown"); + + if let Some(ref security) = extensions.security { + if let Some(ref subject) = security.subject { + cpex_log!(info, "POST-INVOKE: result from '{}' authorized for subject={:?}", tool_name, subject.id); + } + } + } else { + let tool_name = payload + .message + .get_tool_calls() + .first() + .map(|tc| tc.name.as_str()) + .unwrap_or("unknown"); + + if let Some(ref security) = extensions.security { + if let Some(ref subject) = security.subject { + cpex_log!(debug, "PRE-INVOKE '{}': subject={:?} roles={:?}", + tool_name, subject.id, subject.roles.iter().collect::>()); + + if security.has_label("PII") && !subject.roles.contains("hr_admin") { + cpex_log!(warn, "PRE-INVOKE '{}': DENIED — missing hr_admin role for PII", tool_name); + return PluginResult::deny(PluginViolation::new( + "insufficient_role", + &format!( + "Tool '{}' requires 'hr_admin' role for PII data", + tool_name + ), + )); + } + } + } + cpex_log!(debug, "PRE-INVOKE '{}': ALLOWED", tool_name); + } + + PluginResult::allow() + } +} + +impl HookHandler for IdentityCheckerPlugin { + /// identity_resolve via the custom payload path. The raw token is + /// `#[serde(skip)]` and never reaches the sandbox, so this resolves + /// the subject from the request headers instead. + async fn handle( + &self, + payload: &IdentityPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + if payload.subject.is_some() { + cpex_log!(debug, "IDENTITY: subject already resolved"); + return PluginResult::allow(); + } + + let Some(user_id) = payload.headers().get("x-user-id") else { + cpex_log!(debug, "IDENTITY: no x-user-id header — passing through"); + return PluginResult::allow(); + }; + + cpex_log!(info, "IDENTITY: resolved subject '{}' from header", user_id); + let mut resolved = payload.clone(); + resolved.subject = Some(SubjectExtension { + id: Some(user_id.clone()), + subject_type: Some(SubjectType::User), + ..Default::default() + }); + PluginResult::modify_payload(resolved) + } +} diff --git a/crates/cpex-wasm-plugin/src/plugins/mod.rs b/crates/cpex-wasm-plugin/src/plugins/mod.rs new file mode 100644 index 00000000..9602985b --- /dev/null +++ b/crates/cpex-wasm-plugin/src/plugins/mod.rs @@ -0,0 +1,43 @@ +// Location: ./crates/cpex-wasm-plugin/src/plugins/mod.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya + +#[cfg(feature = "identity-checker")] +pub mod identity_checker; + +#[cfg(feature = "header-injector")] +pub mod header_injector; + +#[cfg(feature = "audit-logger")] +pub mod audit_logger; + +#[cfg(feature = "token-attenuator")] +pub mod token_attenuator; + +#[cfg(feature = "noop")] +pub mod noop; + +#[cfg(feature = "fs-test")] +pub mod fs_test; + +#[cfg(feature = "net-test")] +pub mod net_test; + +#[cfg(feature = "env-test")] +pub mod env_test; + +#[cfg(feature = "tool-invoke-checker")] +pub mod tool_invoke_checker; + +#[cfg(feature = "compute-bench")] +pub mod compute_bench; + +#[cfg(feature = "pii-guard")] +pub mod pii_guard; + +#[cfg(feature = "audit-logger-custom")] +pub mod audit_logger_custom; + +#[cfg(feature = "remote-authz")] +pub mod remote_authz; diff --git a/crates/cpex-wasm-plugin/src/plugins/net_test.rs b/crates/cpex-wasm-plugin/src/plugins/net_test.rs new file mode 100644 index 00000000..0ea62467 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/plugins/net_test.rs @@ -0,0 +1,81 @@ +// Location: ./crates/cpex-wasm-plugin/src/plugins/net_test.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +pub struct NetTestPlugin; + +impl Default for NetTestPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for NetTestPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "net-test".to_string(), + kind: "wasm://net-test.wasm".to_string(), + hooks: vec!["cmf.tool_pre_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for NetTestPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + cpex_log!(info, "attempting outbound HTTP to httpbin.org"); + + // Attempt an outbound HTTP request using WASI HTTP + // This uses the raw WASI HTTP types generated by wit-bindgen + // In WASM, we can't use reqwest — we use the WASI outgoing-handler directly + // For simplicity, we just report whether the std::net path is available + // (it won't be in WASM — WASI doesn't expose raw sockets via std::net) + // + // The real network test is at the host level: the NetworkPolicy hook + // intercepts outgoing HTTP requests and denies them if not in the allowlist. + // We test this by checking if the environment suggests network is blocked. + + // Try to resolve a DNS name (tests if network stack is available at all) + match std::net::ToSocketAddrs::to_socket_addrs(&("httpbin.org", 80)) { + Ok(_addrs) => { + ctx.set_local("net_access", serde_json::json!("dns_resolved")); + cpex_log!(warn, "DNS resolution succeeded — unexpected in sandboxed WASM"); + } + Err(e) => { + ctx.set_local("net_access", serde_json::json!("denied")); + ctx.set_local("net_error", serde_json::json!(e.to_string())); + cpex_log!(info, "network access denied: {}", e); + } + } + + PluginResult::allow() + } +} diff --git a/crates/cpex-wasm-plugin/src/plugins/noop.rs b/crates/cpex-wasm-plugin/src/plugins/noop.rs new file mode 100644 index 00000000..a7765709 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/plugins/noop.rs @@ -0,0 +1,54 @@ +// Location: ./crates/cpex-wasm-plugin/src/plugins/noop.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +pub struct NoopPlugin; + +impl Default for NoopPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for NoopPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "noop".to_string(), + kind: "wasm://noop.wasm".to_string(), + hooks: vec!["cmf.tool_pre_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for NoopPlugin { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} diff --git a/crates/cpex-wasm-plugin/src/plugins/pii_guard.rs b/crates/cpex-wasm-plugin/src/plugins/pii_guard.rs new file mode 100644 index 00000000..cd50ca9c --- /dev/null +++ b/crates/cpex-wasm-plugin/src/plugins/pii_guard.rs @@ -0,0 +1,150 @@ +// Location: ./crates/cpex-wasm-plugin/src/plugins/pii_guard.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// PiiGuardPlugin — WASM plugin that blocks access to PII tools without clearance. +// +// Mirrors the native PiiGuard from plugin_demo.rs: checks that pii_clearance +// is set in PluginContext global state before allowing PII-tagged tools. +// Runs as priority 20 in the sequential pipeline. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +// --------------------------------------------------------------------------- +// Payload and hook types (same definition as all demo plugins) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolInvokePayload { + pub tool_name: String, + pub user: String, + pub arguments: String, +} + +cpex_core::impl_plugin_payload!(ToolInvokePayload); +cpex_core::impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); + +pub struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +// --------------------------------------------------------------------------- +// Plugin implementation +// --------------------------------------------------------------------------- + +pub struct PiiGuardPlugin; + +impl Default for PiiGuardPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for PiiGuardPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "pii-guard".to_string(), + kind: "wasm://pii-guard.wasm".to_string(), + hooks: vec!["tool_pre_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for PiiGuardPlugin { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + ctx: &mut PluginContext, + ) -> PluginResult { + let has_clearance = ctx + .get_global("pii_clearance") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if !has_clearance { + cpex_log!( + warn, + "[pii-guard] DENIED: user '{}' lacks PII clearance for '{}'", + payload.user, + payload.tool_name + ); + return PluginResult::deny(PluginViolation::new( + "pii_access_denied", + "PII clearance required", + )); + } + + cpex_log!(info, "[pii-guard] OK: user '{}' has PII clearance", payload.user); + PluginResult::allow() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::hooks::trait_def::HookHandler; + + #[tokio::test] + async fn test_no_clearance_denied() { + let plugin = PiiGuardPlugin; + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + assert!(!result.continue_processing); + assert_eq!(result.violation.as_ref().unwrap().code, "pii_access_denied"); + } + + #[tokio::test] + async fn test_with_clearance_allowed() { + let plugin = PiiGuardPlugin; + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + ctx.set_global("pii_clearance", serde_json::Value::Bool(true)); + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + assert!(result.continue_processing); + } +} diff --git a/crates/cpex-wasm-plugin/src/plugins/remote_authz.rs b/crates/cpex-wasm-plugin/src/plugins/remote_authz.rs new file mode 100644 index 00000000..ef6ac142 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/plugins/remote_authz.rs @@ -0,0 +1,215 @@ +// Location: ./crates/cpex-wasm-plugin/src/plugins/remote_authz.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// RemoteAuthzPlugin — WASM plugin that simulates remote authorization with +// cross-invocation state persistence. +// +// Mirrors the native RemoteAuthz from plugin_demo.rs: maintains a cached ACL +// that persists across invocations via WASM linear memory. On the first call, +// the ACL is "fetched" (simulated) and stored in a module-level static. All +// subsequent calls read from the cached set — demonstrating that the WASM +// sandbox's Store (and linear memory) is preserved across invocations. +// +// This is the WASM equivalent of the native plugin's `initialize()` + RwLock +// pattern: the SandboxManager keeps the Store alive, so static variables in +// the WASM module survive between calls. + +use std::collections::HashSet; +use std::sync::OnceLock; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +// --------------------------------------------------------------------------- +// Payload and hook types (same definition as all demo plugins) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolInvokePayload { + pub tool_name: String, + pub user: String, + pub arguments: String, +} + +cpex_core::impl_plugin_payload!(ToolInvokePayload); +cpex_core::impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); + +pub struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +// --------------------------------------------------------------------------- +// Persistent ACL — survives across invocations via WASM linear memory. +// +// The SandboxManager creates the Store once at plugin load time and reuses it +// for every invoke() call (only fuel and epoch are reset). Module-level statics +// in the WASM binary persist across calls, just like an in-memory cache would +// in a long-running native process. +// --------------------------------------------------------------------------- + +static ACL: OnceLock> = OnceLock::new(); + +fn get_or_init_acl() -> &'static HashSet { + ACL.get_or_init(|| { + cpex_log!(info, "[remote-authz] initializing ACL (first invocation — simulating remote fetch)"); + let mut acl = HashSet::new(); + acl.insert("alice".to_string()); + acl.insert("bob".to_string()); + cpex_log!(info, "[remote-authz] ACL cached ({} users)", acl.len()); + acl + }) +} + +// --------------------------------------------------------------------------- +// Plugin implementation +// --------------------------------------------------------------------------- + +pub struct RemoteAuthzPlugin; + +impl Default for RemoteAuthzPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: OnceLock = OnceLock::new(); + +#[async_trait] +impl Plugin for RemoteAuthzPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "remote-authz".to_string(), + kind: "wasm://remote-authz.wasm".to_string(), + hooks: vec!["tool_pre_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for RemoteAuthzPlugin { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let acl = get_or_init_acl(); + + if acl.contains(&payload.user) { + cpex_log!( + info, + "[remote-authz] OK (ACL hit): user '{}' allowed", + payload.user + ); + return PluginResult::allow(); + } + + cpex_log!( + warn, + "[remote-authz] DENIED (ACL miss): user '{}' not in remote ACL", + payload.user + ); + PluginResult::deny(PluginViolation::new( + "remote_authz_denied", + format!("User '{}' not in remote ACL", payload.user), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::hooks::trait_def::HookHandler; + + #[tokio::test] + async fn test_allowed_user_passes() { + let plugin = RemoteAuthzPlugin; + let payload = ToolInvokePayload { + tool_name: "query_external_data".into(), + user: "alice".into(), + arguments: "dataset=sales".into(), + }; + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + assert!(result.continue_processing); + } + + #[tokio::test] + async fn test_denied_user_blocked() { + let plugin = RemoteAuthzPlugin; + let payload = ToolInvokePayload { + tool_name: "query_external_data".into(), + user: "charlie".into(), + arguments: "dataset=sales".into(), + }; + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + assert!(!result.continue_processing); + assert_eq!(result.violation.as_ref().unwrap().code, "remote_authz_denied"); + } + + #[tokio::test] + async fn test_acl_persists_across_calls() { + let plugin = RemoteAuthzPlugin; + let ext = Extensions::default(); + + // First call — initializes ACL + let payload = ToolInvokePayload { + tool_name: "query_external_data".into(), + user: "alice".into(), + arguments: "".into(), + }; + let mut ctx = PluginContext::default(); + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + assert!(result.continue_processing); + + // Second call — reads from cached ACL (no re-init) + let payload = ToolInvokePayload { + tool_name: "query_external_data".into(), + user: "bob".into(), + arguments: "".into(), + }; + let mut ctx = PluginContext::default(); + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + assert!(result.continue_processing); + } +} diff --git a/crates/cpex-wasm-plugin/src/plugins/token_attenuator.rs b/crates/cpex-wasm-plugin/src/plugins/token_attenuator.rs new file mode 100644 index 00000000..769d2e2b --- /dev/null +++ b/crates/cpex-wasm-plugin/src/plugins/token_attenuator.rs @@ -0,0 +1,95 @@ +// Location: ./crates/cpex-wasm-plugin/src/plugins/token_attenuator.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya + +use async_trait::async_trait; +use chrono::Utc; + +use cpex_core::context::PluginContext; +use cpex_core::delegation::{DelegationPayload, TargetType, TokenDelegateHook}; +use cpex_core::error::PluginError; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::raw_credentials::{DelegationMode, RawDelegatedToken}; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +pub struct TokenAttenuatorPlugin; + +impl Default for TokenAttenuatorPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for TokenAttenuatorPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "token-attenuator".to_string(), + kind: "wasm://token-attenuator.wasm".to_string(), + hooks: vec!["token.delegate".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for TokenAttenuatorPlugin { + async fn handle( + &self, + payload: &DelegationPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let target_name = payload.target_name(); + let target_type = payload.target_type(); + + cpex_log!(info, "DELEGATE: minting token for target='{}' type={:?}", target_name, target_type); + + // Only handle Tool targets — pass through for other types + if *target_type != TargetType::Tool { + cpex_log!(debug, "DELEGATE: not a tool target, passing through"); + return PluginResult::allow(); + } + + // Mint a scoped token for the target tool + let mut resolved = payload.clone(); + resolved.delegated_token = Some(RawDelegatedToken { + token: zeroize::Zeroizing::new(String::new()), + outbound_header: "Authorization".to_string(), + audience: payload + .target_audience() + .unwrap_or(target_name) + .to_string(), + scopes: payload + .required_permissions() + .iter() + .map(|s| s.to_string()) + .collect(), + expires_at: Utc::now() + chrono::Duration::minutes(5), + }); + resolved.delegation_mode = Some(DelegationMode::OnBehalfOfUser); + resolved.minted_at = Some(Utc::now()); + resolved.metadata.insert( + "minter".to_string(), + serde_json::json!("token-attenuator-wasm"), + ); + + cpex_log!(info, "DELEGATE: token minted for audience='{}'", + resolved.delegated_token.as_ref().unwrap().audience); + + PluginResult::modify_payload(resolved) + } +} diff --git a/crates/cpex-wasm-plugin/src/plugins/tool_invoke_checker.rs b/crates/cpex-wasm-plugin/src/plugins/tool_invoke_checker.rs new file mode 100644 index 00000000..44893f53 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/plugins/tool_invoke_checker.rs @@ -0,0 +1,178 @@ +// Location: ./crates/cpex-wasm-plugin/src/plugins/tool_invoke_checker.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// ToolInvokeCheckerPlugin — WASM identity resolver. +// +// Mirrors the native IdentityResolver from plugin_demo.rs: checks that a +// user identity is present in the custom ToolInvokePayload. Runs as the +// first plugin in the sequential pipeline (priority 10). + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use cpex_core::context::PluginContext; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::extensions::container::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; + +use crate::cpex_log; + +// --------------------------------------------------------------------------- +// Payload and hook types — shared definition used by all 4 demo plugins. +// Each WASM binary carries its own copy (same struct, same discriminator). +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolInvokePayload { + pub tool_name: String, + pub user: String, + pub arguments: String, +} + +cpex_core::impl_plugin_payload!(ToolInvokePayload); +cpex_core::impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); + +pub struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +pub struct ToolPostInvoke; +impl HookTypeDef for ToolPostInvoke { + type Payload = ToolInvokePayload; + type Result = PluginResult; + const NAME: &'static str = "tool_post_invoke"; +} + +// --------------------------------------------------------------------------- +// Plugin implementation — identity check only +// --------------------------------------------------------------------------- + +pub struct ToolInvokeCheckerPlugin; + +impl Default for ToolInvokeCheckerPlugin { + fn default() -> Self { + Self + } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for ToolInvokeCheckerPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_CONFIG.get_or_init(|| PluginConfig { + name: "identity-resolver".to_string(), + kind: "wasm://tool-invoke-checker.wasm".to_string(), + hooks: vec!["tool_pre_invoke".to_string(), "tool_post_invoke".to_string()], + ..Default::default() + }) + } + + async fn initialize(&self) -> Result<(), Box> { + Ok(()) + } + + async fn shutdown(&self) -> Result<(), Box> { + Ok(()) + } +} + +impl HookHandler for ToolInvokeCheckerPlugin { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + if payload.user.is_empty() { + cpex_log!(warn, "[identity-resolver] DENIED: no user identity"); + return PluginResult::deny(PluginViolation::new( + "no_identity", + "User identity is required", + )); + } + cpex_log!(info, "[identity-resolver] OK: user '{}' identified", payload.user); + PluginResult::allow() + } +} + +impl HookHandler for ToolInvokeCheckerPlugin { + async fn handle( + &self, + payload: &ToolInvokePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + cpex_log!( + info, + "[identity-resolver] post-invoke: user '{}' completed '{}'", + payload.user, + payload.tool_name + ); + PluginResult::allow() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpex_core::hooks::payload::WasmSerializablePayload; + use cpex_core::hooks::trait_def::HookHandler; + + #[test] + fn test_payload_serde_roundtrip() { + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: "employee_id=42".into(), + }; + let bytes = payload.to_wasm_bytes().unwrap(); + let restored = ToolInvokePayload::from_wasm_bytes(&bytes).unwrap(); + assert_eq!(restored.tool_name, "get_compensation"); + assert_eq!(restored.user, "alice"); + assert_eq!(restored.arguments, "employee_id=42"); + } + + #[tokio::test] + async fn test_no_user_denied() { + let plugin = ToolInvokeCheckerPlugin; + let payload = ToolInvokePayload { + tool_name: "list_departments".into(), + user: "".into(), + arguments: "".into(), + }; + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + assert!(!result.continue_processing); + assert_eq!(result.violation.as_ref().unwrap().code, "no_identity"); + } + + #[tokio::test] + async fn test_user_present_allowed() { + let plugin = ToolInvokeCheckerPlugin; + let payload = ToolInvokePayload { + tool_name: "list_departments".into(), + user: "alice".into(), + arguments: "".into(), + }; + let ext = Extensions::default(); + let mut ctx = PluginContext::default(); + let result: PluginResult = + >::handle( + &plugin, &payload, &ext, &mut ctx, + ) + .await; + assert!(result.continue_processing); + } +} diff --git a/crates/cpex-wasm-plugin/wit/deps/cli.wit b/crates/cpex-wasm-plugin/wit/deps/cli.wit new file mode 100644 index 00000000..d7a3ca4d --- /dev/null +++ b/crates/cpex-wasm-plugin/wit/deps/cli.wit @@ -0,0 +1,261 @@ +package wasi:cli@0.2.6; + +@since(version = 0.2.0) +interface environment { + /// Get the POSIX-style environment variables. + /// + /// Each environment variable is provided as a pair of string variable names + /// and string value. + /// + /// Morally, these are a value import, but until value imports are available + /// in the component model, this import function should return the same + /// values each time it is called. + @since(version = 0.2.0) + get-environment: func() -> list>; + + /// Get the POSIX-style arguments to the program. + @since(version = 0.2.0) + get-arguments: func() -> list; + + /// Return a path that programs should use as their initial current working + /// directory, interpreting `.` as shorthand for this. + @since(version = 0.2.0) + initial-cwd: func() -> option; +} + +@since(version = 0.2.0) +interface exit { + /// Exit the current instance and any linked instances. + @since(version = 0.2.0) + exit: func(status: result); + + /// Exit the current instance and any linked instances, reporting the + /// specified status code to the host. + /// + /// The meaning of the code depends on the context, with 0 usually meaning + /// "success", and other values indicating various types of failure. + /// + /// This function does not return; the effect is analogous to a trap, but + /// without the connotation that something bad has happened. + @unstable(feature = cli-exit-with-code) + exit-with-code: func(status-code: u8); +} + +@since(version = 0.2.0) +interface run { + /// Run the program. + @since(version = 0.2.0) + run: func() -> result; +} + +@since(version = 0.2.0) +interface stdin { + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{input-stream}; + + @since(version = 0.2.0) + get-stdin: func() -> input-stream; +} + +@since(version = 0.2.0) +interface stdout { + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{output-stream}; + + @since(version = 0.2.0) + get-stdout: func() -> output-stream; +} + +@since(version = 0.2.0) +interface stderr { + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{output-stream}; + + @since(version = 0.2.0) + get-stderr: func() -> output-stream; +} + +/// Terminal input. +/// +/// In the future, this may include functions for disabling echoing, +/// disabling input buffering so that keyboard events are sent through +/// immediately, querying supported features, and so on. +@since(version = 0.2.0) +interface terminal-input { + /// The input side of a terminal. + @since(version = 0.2.0) + resource terminal-input; +} + +/// Terminal output. +/// +/// In the future, this may include functions for querying the terminal +/// size, being notified of terminal size changes, querying supported +/// features, and so on. +@since(version = 0.2.0) +interface terminal-output { + /// The output side of a terminal. + @since(version = 0.2.0) + resource terminal-output; +} + +/// An interface providing an optional `terminal-input` for stdin as a +/// link-time authority. +@since(version = 0.2.0) +interface terminal-stdin { + @since(version = 0.2.0) + use terminal-input.{terminal-input}; + + /// If stdin is connected to a terminal, return a `terminal-input` handle + /// allowing further interaction with it. + @since(version = 0.2.0) + get-terminal-stdin: func() -> option; +} + +/// An interface providing an optional `terminal-output` for stdout as a +/// link-time authority. +@since(version = 0.2.0) +interface terminal-stdout { + @since(version = 0.2.0) + use terminal-output.{terminal-output}; + + /// If stdout is connected to a terminal, return a `terminal-output` handle + /// allowing further interaction with it. + @since(version = 0.2.0) + get-terminal-stdout: func() -> option; +} + +/// An interface providing an optional `terminal-output` for stderr as a +/// link-time authority. +@since(version = 0.2.0) +interface terminal-stderr { + @since(version = 0.2.0) + use terminal-output.{terminal-output}; + + /// If stderr is connected to a terminal, return a `terminal-output` handle + /// allowing further interaction with it. + @since(version = 0.2.0) + get-terminal-stderr: func() -> option; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import environment; + @since(version = 0.2.0) + import exit; + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import stdin; + @since(version = 0.2.0) + import stdout; + @since(version = 0.2.0) + import stderr; + @since(version = 0.2.0) + import terminal-input; + @since(version = 0.2.0) + import terminal-output; + @since(version = 0.2.0) + import terminal-stdin; + @since(version = 0.2.0) + import terminal-stdout; + @since(version = 0.2.0) + import terminal-stderr; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.6; + @unstable(feature = clocks-timezone) + import wasi:clocks/timezone@0.2.6; + @since(version = 0.2.0) + import wasi:filesystem/types@0.2.6; + @since(version = 0.2.0) + import wasi:filesystem/preopens@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/network@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/instance-network@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/udp@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/udp-create-socket@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/tcp@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/tcp-create-socket@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/ip-name-lookup@0.2.6; + @since(version = 0.2.0) + import wasi:random/random@0.2.6; + @since(version = 0.2.0) + import wasi:random/insecure@0.2.6; + @since(version = 0.2.0) + import wasi:random/insecure-seed@0.2.6; +} +@since(version = 0.2.0) +world command { + @since(version = 0.2.0) + import environment; + @since(version = 0.2.0) + import exit; + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import stdin; + @since(version = 0.2.0) + import stdout; + @since(version = 0.2.0) + import stderr; + @since(version = 0.2.0) + import terminal-input; + @since(version = 0.2.0) + import terminal-output; + @since(version = 0.2.0) + import terminal-stdin; + @since(version = 0.2.0) + import terminal-stdout; + @since(version = 0.2.0) + import terminal-stderr; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.6; + @unstable(feature = clocks-timezone) + import wasi:clocks/timezone@0.2.6; + @since(version = 0.2.0) + import wasi:filesystem/types@0.2.6; + @since(version = 0.2.0) + import wasi:filesystem/preopens@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/network@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/instance-network@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/udp@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/udp-create-socket@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/tcp@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/tcp-create-socket@0.2.6; + @since(version = 0.2.0) + import wasi:sockets/ip-name-lookup@0.2.6; + @since(version = 0.2.0) + import wasi:random/random@0.2.6; + @since(version = 0.2.0) + import wasi:random/insecure@0.2.6; + @since(version = 0.2.0) + import wasi:random/insecure-seed@0.2.6; + + @since(version = 0.2.0) + export run; +} diff --git a/crates/cpex-wasm-plugin/wit/deps/clocks.wit b/crates/cpex-wasm-plugin/wit/deps/clocks.wit new file mode 100644 index 00000000..d638f1a4 --- /dev/null +++ b/crates/cpex-wasm-plugin/wit/deps/clocks.wit @@ -0,0 +1,157 @@ +package wasi:clocks@0.2.6; + +/// WASI Monotonic Clock is a clock API intended to let users measure elapsed +/// time. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +/// +/// A monotonic clock is a clock which has an unspecified initial value, and +/// successive reads of the clock will produce non-decreasing values. +@since(version = 0.2.0) +interface monotonic-clock { + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + + /// An instant in time, in nanoseconds. An instant is relative to an + /// unspecified initial value, and can only be compared to instances from + /// the same monotonic-clock. + @since(version = 0.2.0) + type instant = u64; + + /// A duration of time, in nanoseconds. + @since(version = 0.2.0) + type duration = u64; + + /// Read the current value of the clock. + /// + /// The clock is monotonic, therefore calling this function repeatedly will + /// produce a sequence of non-decreasing values. + @since(version = 0.2.0) + now: func() -> instant; + + /// Query the resolution of the clock. Returns the duration of time + /// corresponding to a clock tick. + @since(version = 0.2.0) + resolution: func() -> duration; + + /// Create a `pollable` which will resolve once the specified instant + /// has occurred. + @since(version = 0.2.0) + subscribe-instant: func(when: instant) -> pollable; + + /// Create a `pollable` that will resolve after the specified duration has + /// elapsed from the time this function is invoked. + @since(version = 0.2.0) + subscribe-duration: func(when: duration) -> pollable; +} + +/// WASI Wall Clock is a clock API intended to let users query the current +/// time. The name "wall" makes an analogy to a "clock on the wall", which +/// is not necessarily monotonic as it may be reset. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +/// +/// A wall clock is a clock which measures the date and time according to +/// some external reference. +/// +/// External references may be reset, so this clock is not necessarily +/// monotonic, making it unsuitable for measuring elapsed time. +/// +/// It is intended for reporting the current date and time for humans. +@since(version = 0.2.0) +interface wall-clock { + /// A time and date in seconds plus nanoseconds. + @since(version = 0.2.0) + record datetime { + seconds: u64, + nanoseconds: u32, + } + + /// Read the current value of the clock. + /// + /// This clock is not monotonic, therefore calling this function repeatedly + /// will not necessarily produce a sequence of non-decreasing values. + /// + /// The returned timestamps represent the number of seconds since + /// 1970-01-01T00:00:00Z, also known as [POSIX's Seconds Since the Epoch], + /// also known as [Unix Time]. + /// + /// The nanoseconds field of the output is always less than 1000000000. + /// + /// [POSIX's Seconds Since the Epoch]: https://pubs.opengroup.org/onlinepubs/9699919799/xrat/V4_xbd_chap04.html#tag_21_04_16 + /// [Unix Time]: https://en.wikipedia.org/wiki/Unix_time + @since(version = 0.2.0) + now: func() -> datetime; + + /// Query the resolution of the clock. + /// + /// The nanoseconds field of the output is always less than 1000000000. + @since(version = 0.2.0) + resolution: func() -> datetime; +} + +@unstable(feature = clocks-timezone) +interface timezone { + @unstable(feature = clocks-timezone) + use wall-clock.{datetime}; + + /// Information useful for displaying the timezone of a specific `datetime`. + /// + /// This information may vary within a single `timezone` to reflect daylight + /// saving time adjustments. + @unstable(feature = clocks-timezone) + record timezone-display { + /// The number of seconds difference between UTC time and the local + /// time of the timezone. + /// + /// The returned value will always be less than 86400 which is the + /// number of seconds in a day (24*60*60). + /// + /// In implementations that do not expose an actual time zone, this + /// should return 0. + utc-offset: s32, + /// The abbreviated name of the timezone to display to a user. The name + /// `UTC` indicates Coordinated Universal Time. Otherwise, this should + /// reference local standards for the name of the time zone. + /// + /// In implementations that do not expose an actual time zone, this + /// should be the string `UTC`. + /// + /// In time zones that do not have an applicable name, a formatted + /// representation of the UTC offset may be returned, such as `-04:00`. + name: string, + /// Whether daylight saving time is active. + /// + /// In implementations that do not expose an actual time zone, this + /// should return false. + in-daylight-saving-time: bool, + } + + /// Return information needed to display the given `datetime`. This includes + /// the UTC offset, the time zone name, and a flag indicating whether + /// daylight saving time is active. + /// + /// If the timezone cannot be determined for the given `datetime`, return a + /// `timezone-display` for `UTC` with a `utc-offset` of 0 and no daylight + /// saving time. + @unstable(feature = clocks-timezone) + display: func(when: datetime) -> timezone-display; + + /// The same as `display`, but only return the UTC offset. + @unstable(feature = clocks-timezone) + utc-offset: func(when: datetime) -> s32; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import monotonic-clock; + @since(version = 0.2.0) + import wall-clock; + @unstable(feature = clocks-timezone) + import timezone; +} diff --git a/crates/cpex-wasm-plugin/wit/deps/filesystem.wit b/crates/cpex-wasm-plugin/wit/deps/filesystem.wit new file mode 100644 index 00000000..9f4a8288 --- /dev/null +++ b/crates/cpex-wasm-plugin/wit/deps/filesystem.wit @@ -0,0 +1,587 @@ +package wasi:filesystem@0.2.6; + +/// WASI filesystem is a filesystem API primarily intended to let users run WASI +/// programs that access their files on their existing filesystems, without +/// significant overhead. +/// +/// It is intended to be roughly portable between Unix-family platforms and +/// Windows, though it does not hide many of the major differences. +/// +/// Paths are passed as interface-type `string`s, meaning they must consist of +/// a sequence of Unicode Scalar Values (USVs). Some filesystems may contain +/// paths which are not accessible by this API. +/// +/// The directory separator in WASI is always the forward-slash (`/`). +/// +/// All paths in WASI are relative paths, and are interpreted relative to a +/// `descriptor` referring to a base directory. If a `path` argument to any WASI +/// function starts with `/`, or if any step of resolving a `path`, including +/// `..` and symbolic link steps, reaches a directory outside of the base +/// directory, or reaches a symlink to an absolute or rooted path in the +/// underlying filesystem, the function fails with `error-code::not-permitted`. +/// +/// For more information about WASI path resolution and sandboxing, see +/// [WASI filesystem path resolution]. +/// +/// [WASI filesystem path resolution]: https://github.com/WebAssembly/wasi-filesystem/blob/main/path-resolution.md +@since(version = 0.2.0) +interface types { + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{input-stream, output-stream, error}; + @since(version = 0.2.0) + use wasi:clocks/wall-clock@0.2.6.{datetime}; + + /// File size or length of a region within a file. + @since(version = 0.2.0) + type filesize = u64; + + /// The type of a filesystem object referenced by a descriptor. + /// + /// Note: This was called `filetype` in earlier versions of WASI. + @since(version = 0.2.0) + enum descriptor-type { + /// The type of the descriptor or file is unknown or is different from + /// any of the other types specified. + unknown, + /// The descriptor refers to a block device inode. + block-device, + /// The descriptor refers to a character device inode. + character-device, + /// The descriptor refers to a directory inode. + directory, + /// The descriptor refers to a named pipe. + fifo, + /// The file refers to a symbolic link inode. + symbolic-link, + /// The descriptor refers to a regular file inode. + regular-file, + /// The descriptor refers to a socket. + socket, + } + + /// Descriptor flags. + /// + /// Note: This was called `fdflags` in earlier versions of WASI. + @since(version = 0.2.0) + flags descriptor-flags { + /// Read mode: Data can be read. + read, + /// Write mode: Data can be written to. + write, + /// Request that writes be performed according to synchronized I/O file + /// integrity completion. The data stored in the file and the file's + /// metadata are synchronized. This is similar to `O_SYNC` in POSIX. + /// + /// The precise semantics of this operation have not yet been defined for + /// WASI. At this time, it should be interpreted as a request, and not a + /// requirement. + file-integrity-sync, + /// Request that writes be performed according to synchronized I/O data + /// integrity completion. Only the data stored in the file is + /// synchronized. This is similar to `O_DSYNC` in POSIX. + /// + /// The precise semantics of this operation have not yet been defined for + /// WASI. At this time, it should be interpreted as a request, and not a + /// requirement. + data-integrity-sync, + /// Requests that reads be performed at the same level of integrity + /// requested for writes. This is similar to `O_RSYNC` in POSIX. + /// + /// The precise semantics of this operation have not yet been defined for + /// WASI. At this time, it should be interpreted as a request, and not a + /// requirement. + requested-write-sync, + /// Mutating directories mode: Directory contents may be mutated. + /// + /// When this flag is unset on a descriptor, operations using the + /// descriptor which would create, rename, delete, modify the data or + /// metadata of filesystem objects, or obtain another handle which + /// would permit any of those, shall fail with `error-code::read-only` if + /// they would otherwise succeed. + /// + /// This may only be set on directories. + mutate-directory, + } + + /// Flags determining the method of how paths are resolved. + @since(version = 0.2.0) + flags path-flags { + /// As long as the resolved path corresponds to a symbolic link, it is + /// expanded. + symlink-follow, + } + + /// Open flags used by `open-at`. + @since(version = 0.2.0) + flags open-flags { + /// Create file if it does not exist, similar to `O_CREAT` in POSIX. + create, + /// Fail if not a directory, similar to `O_DIRECTORY` in POSIX. + directory, + /// Fail if file already exists, similar to `O_EXCL` in POSIX. + exclusive, + /// Truncate file to size 0, similar to `O_TRUNC` in POSIX. + truncate, + } + + /// Number of hard links to an inode. + @since(version = 0.2.0) + type link-count = u64; + + /// File attributes. + /// + /// Note: This was called `filestat` in earlier versions of WASI. + @since(version = 0.2.0) + record descriptor-stat { + /// File type. + %type: descriptor-type, + /// Number of hard links to the file. + link-count: link-count, + /// For regular files, the file size in bytes. For symbolic links, the + /// length in bytes of the pathname contained in the symbolic link. + size: filesize, + /// Last data access timestamp. + /// + /// If the `option` is none, the platform doesn't maintain an access + /// timestamp for this file. + data-access-timestamp: option, + /// Last data modification timestamp. + /// + /// If the `option` is none, the platform doesn't maintain a + /// modification timestamp for this file. + data-modification-timestamp: option, + /// Last file status-change timestamp. + /// + /// If the `option` is none, the platform doesn't maintain a + /// status-change timestamp for this file. + status-change-timestamp: option, + } + + /// When setting a timestamp, this gives the value to set it to. + @since(version = 0.2.0) + variant new-timestamp { + /// Leave the timestamp set to its previous value. + no-change, + /// Set the timestamp to the current time of the system clock associated + /// with the filesystem. + now, + /// Set the timestamp to the given value. + timestamp(datetime), + } + + /// A directory entry. + record directory-entry { + /// The type of the file referred to by this directory entry. + %type: descriptor-type, + /// The name of the object. + name: string, + } + + /// Error codes returned by functions, similar to `errno` in POSIX. + /// Not all of these error codes are returned by the functions provided by this + /// API; some are used in higher-level library layers, and others are provided + /// merely for alignment with POSIX. + enum error-code { + /// Permission denied, similar to `EACCES` in POSIX. + access, + /// Resource unavailable, or operation would block, similar to `EAGAIN` and `EWOULDBLOCK` in POSIX. + would-block, + /// Connection already in progress, similar to `EALREADY` in POSIX. + already, + /// Bad descriptor, similar to `EBADF` in POSIX. + bad-descriptor, + /// Device or resource busy, similar to `EBUSY` in POSIX. + busy, + /// Resource deadlock would occur, similar to `EDEADLK` in POSIX. + deadlock, + /// Storage quota exceeded, similar to `EDQUOT` in POSIX. + quota, + /// File exists, similar to `EEXIST` in POSIX. + exist, + /// File too large, similar to `EFBIG` in POSIX. + file-too-large, + /// Illegal byte sequence, similar to `EILSEQ` in POSIX. + illegal-byte-sequence, + /// Operation in progress, similar to `EINPROGRESS` in POSIX. + in-progress, + /// Interrupted function, similar to `EINTR` in POSIX. + interrupted, + /// Invalid argument, similar to `EINVAL` in POSIX. + invalid, + /// I/O error, similar to `EIO` in POSIX. + io, + /// Is a directory, similar to `EISDIR` in POSIX. + is-directory, + /// Too many levels of symbolic links, similar to `ELOOP` in POSIX. + loop, + /// Too many links, similar to `EMLINK` in POSIX. + too-many-links, + /// Message too large, similar to `EMSGSIZE` in POSIX. + message-size, + /// Filename too long, similar to `ENAMETOOLONG` in POSIX. + name-too-long, + /// No such device, similar to `ENODEV` in POSIX. + no-device, + /// No such file or directory, similar to `ENOENT` in POSIX. + no-entry, + /// No locks available, similar to `ENOLCK` in POSIX. + no-lock, + /// Not enough space, similar to `ENOMEM` in POSIX. + insufficient-memory, + /// No space left on device, similar to `ENOSPC` in POSIX. + insufficient-space, + /// Not a directory or a symbolic link to a directory, similar to `ENOTDIR` in POSIX. + not-directory, + /// Directory not empty, similar to `ENOTEMPTY` in POSIX. + not-empty, + /// State not recoverable, similar to `ENOTRECOVERABLE` in POSIX. + not-recoverable, + /// Not supported, similar to `ENOTSUP` and `ENOSYS` in POSIX. + unsupported, + /// Inappropriate I/O control operation, similar to `ENOTTY` in POSIX. + no-tty, + /// No such device or address, similar to `ENXIO` in POSIX. + no-such-device, + /// Value too large to be stored in data type, similar to `EOVERFLOW` in POSIX. + overflow, + /// Operation not permitted, similar to `EPERM` in POSIX. + not-permitted, + /// Broken pipe, similar to `EPIPE` in POSIX. + pipe, + /// Read-only file system, similar to `EROFS` in POSIX. + read-only, + /// Invalid seek, similar to `ESPIPE` in POSIX. + invalid-seek, + /// Text file busy, similar to `ETXTBSY` in POSIX. + text-file-busy, + /// Cross-device link, similar to `EXDEV` in POSIX. + cross-device, + } + + /// File or memory access pattern advisory information. + @since(version = 0.2.0) + enum advice { + /// The application has no advice to give on its behavior with respect + /// to the specified data. + normal, + /// The application expects to access the specified data sequentially + /// from lower offsets to higher offsets. + sequential, + /// The application expects to access the specified data in a random + /// order. + random, + /// The application expects to access the specified data in the near + /// future. + will-need, + /// The application expects that it will not access the specified data + /// in the near future. + dont-need, + /// The application expects to access the specified data once and then + /// not reuse it thereafter. + no-reuse, + } + + /// A 128-bit hash value, split into parts because wasm doesn't have a + /// 128-bit integer type. + @since(version = 0.2.0) + record metadata-hash-value { + /// 64 bits of a 128-bit hash value. + lower: u64, + /// Another 64 bits of a 128-bit hash value. + upper: u64, + } + + /// A descriptor is a reference to a filesystem object, which may be a file, + /// directory, named pipe, special file, or other object on which filesystem + /// calls may be made. + @since(version = 0.2.0) + resource descriptor { + /// Return a stream for reading from a file, if available. + /// + /// May fail with an error-code describing why the file cannot be read. + /// + /// Multiple read, write, and append streams may be active on the same open + /// file and they do not interfere with each other. + /// + /// Note: This allows using `read-stream`, which is similar to `read` in POSIX. + @since(version = 0.2.0) + read-via-stream: func(offset: filesize) -> result; + /// Return a stream for writing to a file, if available. + /// + /// May fail with an error-code describing why the file cannot be written. + /// + /// Note: This allows using `write-stream`, which is similar to `write` in + /// POSIX. + @since(version = 0.2.0) + write-via-stream: func(offset: filesize) -> result; + /// Return a stream for appending to a file, if available. + /// + /// May fail with an error-code describing why the file cannot be appended. + /// + /// Note: This allows using `write-stream`, which is similar to `write` with + /// `O_APPEND` in POSIX. + @since(version = 0.2.0) + append-via-stream: func() -> result; + /// Provide file advisory information on a descriptor. + /// + /// This is similar to `posix_fadvise` in POSIX. + @since(version = 0.2.0) + advise: func(offset: filesize, length: filesize, advice: advice) -> result<_, error-code>; + /// Synchronize the data of a file to disk. + /// + /// This function succeeds with no effect if the file descriptor is not + /// opened for writing. + /// + /// Note: This is similar to `fdatasync` in POSIX. + @since(version = 0.2.0) + sync-data: func() -> result<_, error-code>; + /// Get flags associated with a descriptor. + /// + /// Note: This returns similar flags to `fcntl(fd, F_GETFL)` in POSIX. + /// + /// Note: This returns the value that was the `fs_flags` value returned + /// from `fdstat_get` in earlier versions of WASI. + @since(version = 0.2.0) + get-flags: func() -> result; + /// Get the dynamic type of a descriptor. + /// + /// Note: This returns the same value as the `type` field of the `fd-stat` + /// returned by `stat`, `stat-at` and similar. + /// + /// Note: This returns similar flags to the `st_mode & S_IFMT` value provided + /// by `fstat` in POSIX. + /// + /// Note: This returns the value that was the `fs_filetype` value returned + /// from `fdstat_get` in earlier versions of WASI. + @since(version = 0.2.0) + get-type: func() -> result; + /// Adjust the size of an open file. If this increases the file's size, the + /// extra bytes are filled with zeros. + /// + /// Note: This was called `fd_filestat_set_size` in earlier versions of WASI. + @since(version = 0.2.0) + set-size: func(size: filesize) -> result<_, error-code>; + /// Adjust the timestamps of an open file or directory. + /// + /// Note: This is similar to `futimens` in POSIX. + /// + /// Note: This was called `fd_filestat_set_times` in earlier versions of WASI. + @since(version = 0.2.0) + set-times: func(data-access-timestamp: new-timestamp, data-modification-timestamp: new-timestamp) -> result<_, error-code>; + /// Read from a descriptor, without using and updating the descriptor's offset. + /// + /// This function returns a list of bytes containing the data that was + /// read, along with a bool which, when true, indicates that the end of the + /// file was reached. The returned list will contain up to `length` bytes; it + /// may return fewer than requested, if the end of the file is reached or + /// if the I/O operation is interrupted. + /// + /// In the future, this may change to return a `stream`. + /// + /// Note: This is similar to `pread` in POSIX. + @since(version = 0.2.0) + read: func(length: filesize, offset: filesize) -> result, bool>, error-code>; + /// Write to a descriptor, without using and updating the descriptor's offset. + /// + /// It is valid to write past the end of a file; the file is extended to the + /// extent of the write, with bytes between the previous end and the start of + /// the write set to zero. + /// + /// In the future, this may change to take a `stream`. + /// + /// Note: This is similar to `pwrite` in POSIX. + @since(version = 0.2.0) + write: func(buffer: list, offset: filesize) -> result; + /// Read directory entries from a directory. + /// + /// On filesystems where directories contain entries referring to themselves + /// and their parents, often named `.` and `..` respectively, these entries + /// are omitted. + /// + /// This always returns a new stream which starts at the beginning of the + /// directory. Multiple streams may be active on the same directory, and they + /// do not interfere with each other. + @since(version = 0.2.0) + read-directory: func() -> result; + /// Synchronize the data and metadata of a file to disk. + /// + /// This function succeeds with no effect if the file descriptor is not + /// opened for writing. + /// + /// Note: This is similar to `fsync` in POSIX. + @since(version = 0.2.0) + sync: func() -> result<_, error-code>; + /// Create a directory. + /// + /// Note: This is similar to `mkdirat` in POSIX. + @since(version = 0.2.0) + create-directory-at: func(path: string) -> result<_, error-code>; + /// Return the attributes of an open file or directory. + /// + /// Note: This is similar to `fstat` in POSIX, except that it does not return + /// device and inode information. For testing whether two descriptors refer to + /// the same underlying filesystem object, use `is-same-object`. To obtain + /// additional data that can be used do determine whether a file has been + /// modified, use `metadata-hash`. + /// + /// Note: This was called `fd_filestat_get` in earlier versions of WASI. + @since(version = 0.2.0) + stat: func() -> result; + /// Return the attributes of a file or directory. + /// + /// Note: This is similar to `fstatat` in POSIX, except that it does not + /// return device and inode information. See the `stat` description for a + /// discussion of alternatives. + /// + /// Note: This was called `path_filestat_get` in earlier versions of WASI. + @since(version = 0.2.0) + stat-at: func(path-flags: path-flags, path: string) -> result; + /// Adjust the timestamps of a file or directory. + /// + /// Note: This is similar to `utimensat` in POSIX. + /// + /// Note: This was called `path_filestat_set_times` in earlier versions of + /// WASI. + @since(version = 0.2.0) + set-times-at: func(path-flags: path-flags, path: string, data-access-timestamp: new-timestamp, data-modification-timestamp: new-timestamp) -> result<_, error-code>; + /// Create a hard link. + /// + /// Fails with `error-code::no-entry` if the old path does not exist, + /// with `error-code::exist` if the new path already exists, and + /// `error-code::not-permitted` if the old path is not a file. + /// + /// Note: This is similar to `linkat` in POSIX. + @since(version = 0.2.0) + link-at: func(old-path-flags: path-flags, old-path: string, new-descriptor: borrow, new-path: string) -> result<_, error-code>; + /// Open a file or directory. + /// + /// If `flags` contains `descriptor-flags::mutate-directory`, and the base + /// descriptor doesn't have `descriptor-flags::mutate-directory` set, + /// `open-at` fails with `error-code::read-only`. + /// + /// If `flags` contains `write` or `mutate-directory`, or `open-flags` + /// contains `truncate` or `create`, and the base descriptor doesn't have + /// `descriptor-flags::mutate-directory` set, `open-at` fails with + /// `error-code::read-only`. + /// + /// Note: This is similar to `openat` in POSIX. + @since(version = 0.2.0) + open-at: func(path-flags: path-flags, path: string, open-flags: open-flags, %flags: descriptor-flags) -> result; + /// Read the contents of a symbolic link. + /// + /// If the contents contain an absolute or rooted path in the underlying + /// filesystem, this function fails with `error-code::not-permitted`. + /// + /// Note: This is similar to `readlinkat` in POSIX. + @since(version = 0.2.0) + readlink-at: func(path: string) -> result; + /// Remove a directory. + /// + /// Return `error-code::not-empty` if the directory is not empty. + /// + /// Note: This is similar to `unlinkat(fd, path, AT_REMOVEDIR)` in POSIX. + @since(version = 0.2.0) + remove-directory-at: func(path: string) -> result<_, error-code>; + /// Rename a filesystem object. + /// + /// Note: This is similar to `renameat` in POSIX. + @since(version = 0.2.0) + rename-at: func(old-path: string, new-descriptor: borrow, new-path: string) -> result<_, error-code>; + /// Create a symbolic link (also known as a "symlink"). + /// + /// If `old-path` starts with `/`, the function fails with + /// `error-code::not-permitted`. + /// + /// Note: This is similar to `symlinkat` in POSIX. + @since(version = 0.2.0) + symlink-at: func(old-path: string, new-path: string) -> result<_, error-code>; + /// Unlink a filesystem object that is not a directory. + /// + /// Return `error-code::is-directory` if the path refers to a directory. + /// Note: This is similar to `unlinkat(fd, path, 0)` in POSIX. + @since(version = 0.2.0) + unlink-file-at: func(path: string) -> result<_, error-code>; + /// Test whether two descriptors refer to the same filesystem object. + /// + /// In POSIX, this corresponds to testing whether the two descriptors have the + /// same device (`st_dev`) and inode (`st_ino` or `d_ino`) numbers. + /// wasi-filesystem does not expose device and inode numbers, so this function + /// may be used instead. + @since(version = 0.2.0) + is-same-object: func(other: borrow) -> bool; + /// Return a hash of the metadata associated with a filesystem object referred + /// to by a descriptor. + /// + /// This returns a hash of the last-modification timestamp and file size, and + /// may also include the inode number, device number, birth timestamp, and + /// other metadata fields that may change when the file is modified or + /// replaced. It may also include a secret value chosen by the + /// implementation and not otherwise exposed. + /// + /// Implementations are encouraged to provide the following properties: + /// + /// - If the file is not modified or replaced, the computed hash value should + /// usually not change. + /// - If the object is modified or replaced, the computed hash value should + /// usually change. + /// - The inputs to the hash should not be easily computable from the + /// computed hash. + /// + /// However, none of these is required. + @since(version = 0.2.0) + metadata-hash: func() -> result; + /// Return a hash of the metadata associated with a filesystem object referred + /// to by a directory descriptor and a relative path. + /// + /// This performs the same hash computation as `metadata-hash`. + @since(version = 0.2.0) + metadata-hash-at: func(path-flags: path-flags, path: string) -> result; + } + + /// A stream of directory entries. + @since(version = 0.2.0) + resource directory-entry-stream { + /// Read a single directory entry from a `directory-entry-stream`. + @since(version = 0.2.0) + read-directory-entry: func() -> result, error-code>; + } + + /// Attempts to extract a filesystem-related `error-code` from the stream + /// `error` provided. + /// + /// Stream operations which return `stream-error::last-operation-failed` + /// have a payload with more information about the operation that failed. + /// This payload can be passed through to this function to see if there's + /// filesystem-related information about the error to return. + /// + /// Note that this function is fallible because not all stream-related + /// errors are filesystem-related errors. + @since(version = 0.2.0) + filesystem-error-code: func(err: borrow) -> option; +} + +@since(version = 0.2.0) +interface preopens { + @since(version = 0.2.0) + use types.{descriptor}; + + /// Return the set of preopened directories, and their paths. + @since(version = 0.2.0) + get-directories: func() -> list>; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.6; + @since(version = 0.2.0) + import types; + @since(version = 0.2.0) + import preopens; +} diff --git a/crates/cpex-wasm-plugin/wit/deps/http.wit b/crates/cpex-wasm-plugin/wit/deps/http.wit new file mode 100644 index 00000000..eb1b25f0 --- /dev/null +++ b/crates/cpex-wasm-plugin/wit/deps/http.wit @@ -0,0 +1,733 @@ +package wasi:http@0.2.6; + +/// This interface defines all of the types and methods for implementing +/// HTTP Requests and Responses, both incoming and outgoing, as well as +/// their headers, trailers, and bodies. +@since(version = 0.2.0) +interface types { + @since(version = 0.2.0) + use wasi:clocks/monotonic-clock@0.2.6.{duration}; + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{input-stream, output-stream}; + @since(version = 0.2.0) + use wasi:io/error@0.2.6.{error as io-error}; + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + + /// This type corresponds to HTTP standard Methods. + @since(version = 0.2.0) + variant method { + get, + head, + post, + put, + delete, + connect, + options, + trace, + patch, + other(string), + } + + /// This type corresponds to HTTP standard Related Schemes. + @since(version = 0.2.0) + variant scheme { + HTTP, + HTTPS, + other(string), + } + + /// Defines the case payload type for `DNS-error` above: + @since(version = 0.2.0) + record DNS-error-payload { + rcode: option, + info-code: option, + } + + /// Defines the case payload type for `TLS-alert-received` above: + @since(version = 0.2.0) + record TLS-alert-received-payload { + alert-id: option, + alert-message: option, + } + + /// Defines the case payload type for `HTTP-response-{header,trailer}-size` above: + @since(version = 0.2.0) + record field-size-payload { + field-name: option, + field-size: option, + } + + /// These cases are inspired by the IANA HTTP Proxy Error Types: + /// + @since(version = 0.2.0) + variant error-code { + DNS-timeout, + DNS-error(DNS-error-payload), + destination-not-found, + destination-unavailable, + destination-IP-prohibited, + destination-IP-unroutable, + connection-refused, + connection-terminated, + connection-timeout, + connection-read-timeout, + connection-write-timeout, + connection-limit-reached, + TLS-protocol-error, + TLS-certificate-error, + TLS-alert-received(TLS-alert-received-payload), + HTTP-request-denied, + HTTP-request-length-required, + HTTP-request-body-size(option), + HTTP-request-method-invalid, + HTTP-request-URI-invalid, + HTTP-request-URI-too-long, + HTTP-request-header-section-size(option), + HTTP-request-header-size(option), + HTTP-request-trailer-section-size(option), + HTTP-request-trailer-size(field-size-payload), + HTTP-response-incomplete, + HTTP-response-header-section-size(option), + HTTP-response-header-size(field-size-payload), + HTTP-response-body-size(option), + HTTP-response-trailer-section-size(option), + HTTP-response-trailer-size(field-size-payload), + HTTP-response-transfer-coding(option), + HTTP-response-content-coding(option), + HTTP-response-timeout, + HTTP-upgrade-failed, + HTTP-protocol-error, + loop-detected, + configuration-error, + /// This is a catch-all error for anything that doesn't fit cleanly into a + /// more specific case. It also includes an optional string for an + /// unstructured description of the error. Users should not depend on the + /// string for diagnosing errors, as it's not required to be consistent + /// between implementations. + internal-error(option), + } + + /// This type enumerates the different kinds of errors that may occur when + /// setting or appending to a `fields` resource. + @since(version = 0.2.0) + variant header-error { + /// This error indicates that a `field-name` or `field-value` was + /// syntactically invalid when used with an operation that sets headers in a + /// `fields`. + invalid-syntax, + /// This error indicates that a forbidden `field-name` was used when trying + /// to set a header in a `fields`. + forbidden, + /// This error indicates that the operation on the `fields` was not + /// permitted because the fields are immutable. + immutable, + } + + /// Field keys are always strings. + /// + /// Field keys should always be treated as case insensitive by the `fields` + /// resource for the purposes of equality checking. + /// + /// # Deprecation + /// + /// This type has been deprecated in favor of the `field-name` type. + @since(version = 0.2.0) + @deprecated(version = 0.2.2) + type field-key = string; + + /// Field names are always strings. + /// + /// Field names should always be treated as case insensitive by the `fields` + /// resource for the purposes of equality checking. + @since(version = 0.2.1) + type field-name = field-key; + + /// Field values should always be ASCII strings. However, in + /// reality, HTTP implementations often have to interpret malformed values, + /// so they are provided as a list of bytes. + @since(version = 0.2.0) + type field-value = list; + + /// This following block defines the `fields` resource which corresponds to + /// HTTP standard Fields. Fields are a common representation used for both + /// Headers and Trailers. + /// + /// A `fields` may be mutable or immutable. A `fields` created using the + /// constructor, `from-list`, or `clone` will be mutable, but a `fields` + /// resource given by other means (including, but not limited to, + /// `incoming-request.headers`, `outgoing-request.headers`) might be + /// immutable. In an immutable fields, the `set`, `append`, and `delete` + /// operations will fail with `header-error.immutable`. + @since(version = 0.2.0) + resource fields { + /// Construct an empty HTTP Fields. + /// + /// The resulting `fields` is mutable. + @since(version = 0.2.0) + constructor(); + /// Construct an HTTP Fields. + /// + /// The resulting `fields` is mutable. + /// + /// The list represents each name-value pair in the Fields. Names + /// which have multiple values are represented by multiple entries in this + /// list with the same name. + /// + /// The tuple is a pair of the field name, represented as a string, and + /// Value, represented as a list of bytes. + /// + /// An error result will be returned if any `field-name` or `field-value` is + /// syntactically invalid, or if a field is forbidden. + @since(version = 0.2.0) + from-list: static func(entries: list>) -> result; + /// Get all of the values corresponding to a name. If the name is not present + /// in this `fields` or is syntactically invalid, an empty list is returned. + /// However, if the name is present but empty, this is represented by a list + /// with one or more empty field-values present. + @since(version = 0.2.0) + get: func(name: field-name) -> list; + /// Returns `true` when the name is present in this `fields`. If the name is + /// syntactically invalid, `false` is returned. + @since(version = 0.2.0) + has: func(name: field-name) -> bool; + /// Set all of the values for a name. Clears any existing values for that + /// name, if they have been set. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.invalid-syntax` if the `field-name` or any of + /// the `field-value`s are syntactically invalid. + @since(version = 0.2.0) + set: func(name: field-name, value: list) -> result<_, header-error>; + /// Delete all values for a name. Does nothing if no values for the name + /// exist. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.invalid-syntax` if the `field-name` is + /// syntactically invalid. + @since(version = 0.2.0) + delete: func(name: field-name) -> result<_, header-error>; + /// Append a value for a name. Does not change or delete any existing + /// values for that name. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.invalid-syntax` if the `field-name` or + /// `field-value` are syntactically invalid. + @since(version = 0.2.0) + append: func(name: field-name, value: field-value) -> result<_, header-error>; + /// Retrieve the full set of names and values in the Fields. Like the + /// constructor, the list represents each name-value pair. + /// + /// The outer list represents each name-value pair in the Fields. Names + /// which have multiple values are represented by multiple entries in this + /// list with the same name. + /// + /// The names and values are always returned in the original casing and in + /// the order in which they will be serialized for transport. + @since(version = 0.2.0) + entries: func() -> list>; + /// Make a deep copy of the Fields. Equivalent in behavior to calling the + /// `fields` constructor on the return value of `entries`. The resulting + /// `fields` is mutable. + @since(version = 0.2.0) + clone: func() -> fields; + } + + /// Headers is an alias for Fields. + @since(version = 0.2.0) + type headers = fields; + + /// Trailers is an alias for Fields. + @since(version = 0.2.0) + type trailers = fields; + + /// Represents an incoming HTTP Request. + @since(version = 0.2.0) + resource incoming-request { + /// Returns the method of the incoming request. + @since(version = 0.2.0) + method: func() -> method; + /// Returns the path with query parameters from the request, as a string. + @since(version = 0.2.0) + path-with-query: func() -> option; + /// Returns the protocol scheme from the request. + @since(version = 0.2.0) + scheme: func() -> option; + /// Returns the authority of the Request's target URI, if present. + @since(version = 0.2.0) + authority: func() -> option; + /// Get the `headers` associated with the request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// The `headers` returned are a child resource: it must be dropped before + /// the parent `incoming-request` is dropped. Dropping this + /// `incoming-request` before all children are dropped will trap. + @since(version = 0.2.0) + headers: func() -> headers; + /// Gives the `incoming-body` associated with this request. Will only + /// return success at most once, and subsequent calls will return error. + @since(version = 0.2.0) + consume: func() -> result; + } + + /// Represents an outgoing HTTP Request. + @since(version = 0.2.0) + resource outgoing-request { + /// Construct a new `outgoing-request` with a default `method` of `GET`, and + /// `none` values for `path-with-query`, `scheme`, and `authority`. + /// + /// * `headers` is the HTTP Headers for the Request. + /// + /// It is possible to construct, or manipulate with the accessor functions + /// below, an `outgoing-request` with an invalid combination of `scheme` + /// and `authority`, or `headers` which are not permitted to be sent. + /// It is the obligation of the `outgoing-handler.handle` implementation + /// to reject invalid constructions of `outgoing-request`. + @since(version = 0.2.0) + constructor(headers: headers); + /// Returns the resource corresponding to the outgoing Body for this + /// Request. + /// + /// Returns success on the first call: the `outgoing-body` resource for + /// this `outgoing-request` can be retrieved at most once. Subsequent + /// calls will return error. + @since(version = 0.2.0) + body: func() -> result; + /// Get the Method for the Request. + @since(version = 0.2.0) + method: func() -> method; + /// Set the Method for the Request. Fails if the string present in a + /// `method.other` argument is not a syntactically valid method. + @since(version = 0.2.0) + set-method: func(method: method) -> result; + /// Get the combination of the HTTP Path and Query for the Request. + /// When `none`, this represents an empty Path and empty Query. + @since(version = 0.2.0) + path-with-query: func() -> option; + /// Set the combination of the HTTP Path and Query for the Request. + /// When `none`, this represents an empty Path and empty Query. Fails is the + /// string given is not a syntactically valid path and query uri component. + @since(version = 0.2.0) + set-path-with-query: func(path-with-query: option) -> result; + /// Get the HTTP Related Scheme for the Request. When `none`, the + /// implementation may choose an appropriate default scheme. + @since(version = 0.2.0) + scheme: func() -> option; + /// Set the HTTP Related Scheme for the Request. When `none`, the + /// implementation may choose an appropriate default scheme. Fails if the + /// string given is not a syntactically valid uri scheme. + @since(version = 0.2.0) + set-scheme: func(scheme: option) -> result; + /// Get the authority of the Request's target URI. A value of `none` may be used + /// with Related Schemes which do not require an authority. The HTTP and + /// HTTPS schemes always require an authority. + @since(version = 0.2.0) + authority: func() -> option; + /// Set the authority of the Request's target URI. A value of `none` may be used + /// with Related Schemes which do not require an authority. The HTTP and + /// HTTPS schemes always require an authority. Fails if the string given is + /// not a syntactically valid URI authority. + @since(version = 0.2.0) + set-authority: func(authority: option) -> result; + /// Get the headers associated with the Request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `outgoing-request` is dropped, or its ownership is transferred to + /// another component by e.g. `outgoing-handler.handle`. + @since(version = 0.2.0) + headers: func() -> headers; + } + + /// Parameters for making an HTTP Request. Each of these parameters is + /// currently an optional timeout applicable to the transport layer of the + /// HTTP protocol. + /// + /// These timeouts are separate from any the user may use to bound a + /// blocking call to `wasi:io/poll.poll`. + @since(version = 0.2.0) + resource request-options { + /// Construct a default `request-options` value. + @since(version = 0.2.0) + constructor(); + /// The timeout for the initial connect to the HTTP Server. + @since(version = 0.2.0) + connect-timeout: func() -> option; + /// Set the timeout for the initial connect to the HTTP Server. An error + /// return value indicates that this timeout is not supported. + @since(version = 0.2.0) + set-connect-timeout: func(duration: option) -> result; + /// The timeout for receiving the first byte of the Response body. + @since(version = 0.2.0) + first-byte-timeout: func() -> option; + /// Set the timeout for receiving the first byte of the Response body. An + /// error return value indicates that this timeout is not supported. + @since(version = 0.2.0) + set-first-byte-timeout: func(duration: option) -> result; + /// The timeout for receiving subsequent chunks of bytes in the Response + /// body stream. + @since(version = 0.2.0) + between-bytes-timeout: func() -> option; + /// Set the timeout for receiving subsequent chunks of bytes in the Response + /// body stream. An error return value indicates that this timeout is not + /// supported. + @since(version = 0.2.0) + set-between-bytes-timeout: func(duration: option) -> result; + } + + /// Represents the ability to send an HTTP Response. + /// + /// This resource is used by the `wasi:http/incoming-handler` interface to + /// allow a Response to be sent corresponding to the Request provided as the + /// other argument to `incoming-handler.handle`. + @since(version = 0.2.0) + resource response-outparam { + /// Send an HTTP 1xx response. + /// + /// Unlike `response-outparam.set`, this does not consume the + /// `response-outparam`, allowing the guest to send an arbitrary number of + /// informational responses before sending the final response using + /// `response-outparam.set`. + /// + /// This will return an `HTTP-protocol-error` if `status` is not in the + /// range [100-199], or an `internal-error` if the implementation does not + /// support informational responses. + @unstable(feature = informational-outbound-responses) + send-informational: func(status: u16, headers: headers) -> result<_, error-code>; + /// Set the value of the `response-outparam` to either send a response, + /// or indicate an error. + /// + /// This method consumes the `response-outparam` to ensure that it is + /// called at most once. If it is never called, the implementation + /// will respond with an error. + /// + /// The user may provide an `error` to `response` to allow the + /// implementation determine how to respond with an HTTP error response. + @since(version = 0.2.0) + set: static func(param: response-outparam, response: result); + } + + /// This type corresponds to the HTTP standard Status Code. + @since(version = 0.2.0) + type status-code = u16; + + /// Represents an incoming HTTP Response. + @since(version = 0.2.0) + resource incoming-response { + /// Returns the status code from the incoming response. + @since(version = 0.2.0) + status: func() -> status-code; + /// Returns the headers from the incoming response. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `incoming-response` is dropped. + @since(version = 0.2.0) + headers: func() -> headers; + /// Returns the incoming body. May be called at most once. Returns error + /// if called additional times. + @since(version = 0.2.0) + consume: func() -> result; + } + + /// Represents an incoming HTTP Request or Response's Body. + /// + /// A body has both its contents - a stream of bytes - and a (possibly + /// empty) set of trailers, indicating that the full contents of the + /// body have been received. This resource represents the contents as + /// an `input-stream` and the delivery of trailers as a `future-trailers`, + /// and ensures that the user of this interface may only be consuming either + /// the body contents or waiting on trailers at any given time. + @since(version = 0.2.0) + resource incoming-body { + /// Returns the contents of the body, as a stream of bytes. + /// + /// Returns success on first call: the stream representing the contents + /// can be retrieved at most once. Subsequent calls will return error. + /// + /// The returned `input-stream` resource is a child: it must be dropped + /// before the parent `incoming-body` is dropped, or consumed by + /// `incoming-body.finish`. + /// + /// This invariant ensures that the implementation can determine whether + /// the user is consuming the contents of the body, waiting on the + /// `future-trailers` to be ready, or neither. This allows for network + /// backpressure is to be applied when the user is consuming the body, + /// and for that backpressure to not inhibit delivery of the trailers if + /// the user does not read the entire body. + @since(version = 0.2.0) + %stream: func() -> result; + /// Takes ownership of `incoming-body`, and returns a `future-trailers`. + /// This function will trap if the `input-stream` child is still alive. + @since(version = 0.2.0) + finish: static func(this: incoming-body) -> future-trailers; + } + + /// Represents a future which may eventually return trailers, or an error. + /// + /// In the case that the incoming HTTP Request or Response did not have any + /// trailers, this future will resolve to the empty set of trailers once the + /// complete Request or Response body has been received. + @since(version = 0.2.0) + resource future-trailers { + /// Returns a pollable which becomes ready when either the trailers have + /// been received, or an error has occurred. When this pollable is ready, + /// the `get` method will return `some`. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Returns the contents of the trailers, or an error which occurred, + /// once the future is ready. + /// + /// The outer `option` represents future readiness. Users can wait on this + /// `option` to become `some` using the `subscribe` method. + /// + /// The outer `result` is used to retrieve the trailers or error at most + /// once. It will be success on the first call in which the outer option + /// is `some`, and error on subsequent calls. + /// + /// The inner `result` represents that either the HTTP Request or Response + /// body, as well as any trailers, were received successfully, or that an + /// error occurred receiving them. The optional `trailers` indicates whether + /// or not trailers were present in the body. + /// + /// When some `trailers` are returned by this method, the `trailers` + /// resource is immutable, and a child. Use of the `set`, `append`, or + /// `delete` methods will return an error, and the resource must be + /// dropped before the parent `future-trailers` is dropped. + @since(version = 0.2.0) + get: func() -> option, error-code>>>; + } + + /// Represents an outgoing HTTP Response. + @since(version = 0.2.0) + resource outgoing-response { + /// Construct an `outgoing-response`, with a default `status-code` of `200`. + /// If a different `status-code` is needed, it must be set via the + /// `set-status-code` method. + /// + /// * `headers` is the HTTP Headers for the Response. + @since(version = 0.2.0) + constructor(headers: headers); + /// Get the HTTP Status Code for the Response. + @since(version = 0.2.0) + status-code: func() -> status-code; + /// Set the HTTP Status Code for the Response. Fails if the status-code + /// given is not a valid http status code. + @since(version = 0.2.0) + set-status-code: func(status-code: status-code) -> result; + /// Get the headers associated with the Request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `outgoing-request` is dropped, or its ownership is transferred to + /// another component by e.g. `outgoing-handler.handle`. + @since(version = 0.2.0) + headers: func() -> headers; + /// Returns the resource corresponding to the outgoing Body for this Response. + /// + /// Returns success on the first call: the `outgoing-body` resource for + /// this `outgoing-response` can be retrieved at most once. Subsequent + /// calls will return error. + @since(version = 0.2.0) + body: func() -> result; + } + + /// Represents an outgoing HTTP Request or Response's Body. + /// + /// A body has both its contents - a stream of bytes - and a (possibly + /// empty) set of trailers, inducating the full contents of the body + /// have been sent. This resource represents the contents as an + /// `output-stream` child resource, and the completion of the body (with + /// optional trailers) with a static function that consumes the + /// `outgoing-body` resource, and ensures that the user of this interface + /// may not write to the body contents after the body has been finished. + /// + /// If the user code drops this resource, as opposed to calling the static + /// method `finish`, the implementation should treat the body as incomplete, + /// and that an error has occurred. The implementation should propagate this + /// error to the HTTP protocol by whatever means it has available, + /// including: corrupting the body on the wire, aborting the associated + /// Request, or sending a late status code for the Response. + @since(version = 0.2.0) + resource outgoing-body { + /// Returns a stream for writing the body contents. + /// + /// The returned `output-stream` is a child resource: it must be dropped + /// before the parent `outgoing-body` resource is dropped (or finished), + /// otherwise the `outgoing-body` drop or `finish` will trap. + /// + /// Returns success on the first call: the `output-stream` resource for + /// this `outgoing-body` may be retrieved at most once. Subsequent calls + /// will return error. + @since(version = 0.2.0) + write: func() -> result; + /// Finalize an outgoing body, optionally providing trailers. This must be + /// called to signal that the response is complete. If the `outgoing-body` + /// is dropped without calling `outgoing-body.finalize`, the implementation + /// should treat the body as corrupted. + /// + /// Fails if the body's `outgoing-request` or `outgoing-response` was + /// constructed with a Content-Length header, and the contents written + /// to the body (via `write`) does not match the value given in the + /// Content-Length. + @since(version = 0.2.0) + finish: static func(this: outgoing-body, trailers: option) -> result<_, error-code>; + } + + /// Represents a future which may eventually return an incoming HTTP + /// Response, or an error. + /// + /// This resource is returned by the `wasi:http/outgoing-handler` interface to + /// provide the HTTP Response corresponding to the sent Request. + @since(version = 0.2.0) + resource future-incoming-response { + /// Returns a pollable which becomes ready when either the Response has + /// been received, or an error has occurred. When this pollable is ready, + /// the `get` method will return `some`. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Returns the incoming HTTP Response, or an error, once one is ready. + /// + /// The outer `option` represents future readiness. Users can wait on this + /// `option` to become `some` using the `subscribe` method. + /// + /// The outer `result` is used to retrieve the response or error at most + /// once. It will be success on the first call in which the outer option + /// is `some`, and error on subsequent calls. + /// + /// The inner `result` represents that either the incoming HTTP Response + /// status and headers have received successfully, or that an error + /// occurred. Errors may also occur while consuming the response body, + /// but those will be reported by the `incoming-body` and its + /// `output-stream` child. + @since(version = 0.2.0) + get: func() -> option>>; + } + + /// Attempts to extract a http-related `error` from the wasi:io `error` + /// provided. + /// + /// Stream operations which return + /// `wasi:io/stream/stream-error::last-operation-failed` have a payload of + /// type `wasi:io/error/error` with more information about the operation + /// that failed. This payload can be passed through to this function to see + /// if there's http-related information about the error to return. + /// + /// Note that this function is fallible because not all io-errors are + /// http-related errors. + @since(version = 0.2.0) + http-error-code: func(err: borrow) -> option; +} + +/// This interface defines a handler of incoming HTTP Requests. It should +/// be exported by components which can respond to HTTP Requests. +@since(version = 0.2.0) +interface incoming-handler { + @since(version = 0.2.0) + use types.{incoming-request, response-outparam}; + + /// This function is invoked with an incoming HTTP Request, and a resource + /// `response-outparam` which provides the capability to reply with an HTTP + /// Response. The response is sent by calling the `response-outparam.set` + /// method, which allows execution to continue after the response has been + /// sent. This enables both streaming to the response body, and performing other + /// work. + /// + /// The implementor of this function must write a response to the + /// `response-outparam` before returning, or else the caller will respond + /// with an error on its behalf. + @since(version = 0.2.0) + handle: func(request: incoming-request, response-out: response-outparam); +} + +/// This interface defines a handler of outgoing HTTP Requests. It should be +/// imported by components which wish to make HTTP Requests. +@since(version = 0.2.0) +interface outgoing-handler { + @since(version = 0.2.0) + use types.{outgoing-request, request-options, future-incoming-response, error-code}; + + /// This function is invoked with an outgoing HTTP Request, and it returns + /// a resource `future-incoming-response` which represents an HTTP Response + /// which may arrive in the future. + /// + /// The `options` argument accepts optional parameters for the HTTP + /// protocol's transport layer. + /// + /// This function may return an error if the `outgoing-request` is invalid + /// or not allowed to be made. Otherwise, protocol errors are reported + /// through the `future-incoming-response`. + @since(version = 0.2.0) + handle: func(request: outgoing-request, options: option) -> result; +} + +/// The `wasi:http/imports` world imports all the APIs for HTTP proxies. +/// It is intended to be `include`d in other worlds. +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.6; + @since(version = 0.2.0) + import wasi:random/random@0.2.6; + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stdout@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stderr@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stdin@0.2.6; + @since(version = 0.2.0) + import types; + @since(version = 0.2.0) + import outgoing-handler; +} +/// The `wasi:http/proxy` world captures a widely-implementable intersection of +/// hosts that includes HTTP forward and reverse proxies. Components targeting +/// this world may concurrently stream in and out any number of incoming and +/// outgoing HTTP requests. +@since(version = 0.2.0) +world proxy { + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.6; + @since(version = 0.2.0) + import wasi:random/random@0.2.6; + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stdout@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stderr@0.2.6; + @since(version = 0.2.0) + import wasi:cli/stdin@0.2.6; + @since(version = 0.2.0) + import types; + @since(version = 0.2.0) + import outgoing-handler; + + @since(version = 0.2.0) + export incoming-handler; +} diff --git a/crates/cpex-wasm-plugin/wit/deps/io.wit b/crates/cpex-wasm-plugin/wit/deps/io.wit new file mode 100644 index 00000000..08ad78e6 --- /dev/null +++ b/crates/cpex-wasm-plugin/wit/deps/io.wit @@ -0,0 +1,331 @@ +package wasi:io@0.2.6; + +@since(version = 0.2.0) +interface error { + /// A resource which represents some error information. + /// + /// The only method provided by this resource is `to-debug-string`, + /// which provides some human-readable information about the error. + /// + /// In the `wasi:io` package, this resource is returned through the + /// `wasi:io/streams/stream-error` type. + /// + /// To provide more specific error information, other interfaces may + /// offer functions to "downcast" this error into more specific types. For example, + /// errors returned from streams derived from filesystem types can be described using + /// the filesystem's own error-code type. This is done using the function + /// `wasi:filesystem/types/filesystem-error-code`, which takes a `borrow` + /// parameter and returns an `option`. + /// + /// The set of functions which can "downcast" an `error` into a more + /// concrete type is open. + @since(version = 0.2.0) + resource error { + /// Returns a string that is suitable to assist humans in debugging + /// this error. + /// + /// WARNING: The returned string should not be consumed mechanically! + /// It may change across platforms, hosts, or other implementation + /// details. Parsing this string is a major platform-compatibility + /// hazard. + @since(version = 0.2.0) + to-debug-string: func() -> string; + } +} + +/// A poll API intended to let users wait for I/O events on multiple handles +/// at once. +@since(version = 0.2.0) +interface poll { + /// `pollable` represents a single I/O event which may be ready, or not. + @since(version = 0.2.0) + resource pollable { + /// Return the readiness of a pollable. This function never blocks. + /// + /// Returns `true` when the pollable is ready, and `false` otherwise. + @since(version = 0.2.0) + ready: func() -> bool; + /// `block` returns immediately if the pollable is ready, and otherwise + /// blocks until ready. + /// + /// This function is equivalent to calling `poll.poll` on a list + /// containing only this pollable. + @since(version = 0.2.0) + block: func(); + } + + /// Poll for completion on a set of pollables. + /// + /// This function takes a list of pollables, which identify I/O sources of + /// interest, and waits until one or more of the events is ready for I/O. + /// + /// The result `list` contains one or more indices of handles in the + /// argument list that is ready for I/O. + /// + /// This function traps if either: + /// - the list is empty, or: + /// - the list contains more elements than can be indexed with a `u32` value. + /// + /// A timeout can be implemented by adding a pollable from the + /// wasi-clocks API to the list. + /// + /// This function does not return a `result`; polling in itself does not + /// do any I/O so it doesn't fail. If any of the I/O sources identified by + /// the pollables has an error, it is indicated by marking the source as + /// being ready for I/O. + @since(version = 0.2.0) + poll: func(in: list>) -> list; +} + +/// WASI I/O is an I/O abstraction API which is currently focused on providing +/// stream types. +/// +/// In the future, the component model is expected to add built-in stream types; +/// when it does, they are expected to subsume this API. +@since(version = 0.2.0) +interface streams { + @since(version = 0.2.0) + use error.{error}; + @since(version = 0.2.0) + use poll.{pollable}; + + /// An error for input-stream and output-stream operations. + @since(version = 0.2.0) + variant stream-error { + /// The last operation (a write or flush) failed before completion. + /// + /// More information is available in the `error` payload. + /// + /// After this, the stream will be closed. All future operations return + /// `stream-error::closed`. + last-operation-failed(error), + /// The stream is closed: no more input will be accepted by the + /// stream. A closed output-stream will return this error on all + /// future operations. + closed, + } + + /// An input bytestream. + /// + /// `input-stream`s are *non-blocking* to the extent practical on underlying + /// platforms. I/O operations always return promptly; if fewer bytes are + /// promptly available than requested, they return the number of bytes promptly + /// available, which could even be zero. To wait for data to be available, + /// use the `subscribe` function to obtain a `pollable` which can be polled + /// for using `wasi:io/poll`. + @since(version = 0.2.0) + resource input-stream { + /// Perform a non-blocking read from the stream. + /// + /// When the source of a `read` is binary data, the bytes from the source + /// are returned verbatim. When the source of a `read` is known to the + /// implementation to be text, bytes containing the UTF-8 encoding of the + /// text are returned. + /// + /// This function returns a list of bytes containing the read data, + /// when successful. The returned list will contain up to `len` bytes; + /// it may return fewer than requested, but not more. The list is + /// empty when no bytes are available for reading at this time. The + /// pollable given by `subscribe` will be ready when more bytes are + /// available. + /// + /// This function fails with a `stream-error` when the operation + /// encounters an error, giving `last-operation-failed`, or when the + /// stream is closed, giving `closed`. + /// + /// When the caller gives a `len` of 0, it represents a request to + /// read 0 bytes. If the stream is still open, this call should + /// succeed and return an empty list, or otherwise fail with `closed`. + /// + /// The `len` parameter is a `u64`, which could represent a list of u8 which + /// is not possible to allocate in wasm32, or not desirable to allocate as + /// as a return value by the callee. The callee may return a list of bytes + /// less than `len` in size while more bytes are available for reading. + @since(version = 0.2.0) + read: func(len: u64) -> result, stream-error>; + /// Read bytes from a stream, after blocking until at least one byte can + /// be read. Except for blocking, behavior is identical to `read`. + @since(version = 0.2.0) + blocking-read: func(len: u64) -> result, stream-error>; + /// Skip bytes from a stream. Returns number of bytes skipped. + /// + /// Behaves identical to `read`, except instead of returning a list + /// of bytes, returns the number of bytes consumed from the stream. + @since(version = 0.2.0) + skip: func(len: u64) -> result; + /// Skip bytes from a stream, after blocking until at least one byte + /// can be skipped. Except for blocking behavior, identical to `skip`. + @since(version = 0.2.0) + blocking-skip: func(len: u64) -> result; + /// Create a `pollable` which will resolve once either the specified stream + /// has bytes available to read or the other end of the stream has been + /// closed. + /// The created `pollable` is a child resource of the `input-stream`. + /// Implementations may trap if the `input-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + /// An output bytestream. + /// + /// `output-stream`s are *non-blocking* to the extent practical on + /// underlying platforms. Except where specified otherwise, I/O operations also + /// always return promptly, after the number of bytes that can be written + /// promptly, which could even be zero. To wait for the stream to be ready to + /// accept data, the `subscribe` function to obtain a `pollable` which can be + /// polled for using `wasi:io/poll`. + /// + /// Dropping an `output-stream` while there's still an active write in + /// progress may result in the data being lost. Before dropping the stream, + /// be sure to fully flush your writes. + @since(version = 0.2.0) + resource output-stream { + /// Check readiness for writing. This function never blocks. + /// + /// Returns the number of bytes permitted for the next call to `write`, + /// or an error. Calling `write` with more bytes than this function has + /// permitted will trap. + /// + /// When this function returns 0 bytes, the `subscribe` pollable will + /// become ready when this function will report at least 1 byte, or an + /// error. + @since(version = 0.2.0) + check-write: func() -> result; + /// Perform a write. This function never blocks. + /// + /// When the destination of a `write` is binary data, the bytes from + /// `contents` are written verbatim. When the destination of a `write` is + /// known to the implementation to be text, the bytes of `contents` are + /// transcoded from UTF-8 into the encoding of the destination and then + /// written. + /// + /// Precondition: check-write gave permit of Ok(n) and contents has a + /// length of less than or equal to n. Otherwise, this function will trap. + /// + /// returns Err(closed) without writing if the stream has closed since + /// the last call to check-write provided a permit. + @since(version = 0.2.0) + write: func(contents: list) -> result<_, stream-error>; + /// Perform a write of up to 4096 bytes, and then flush the stream. Block + /// until all of these operations are complete, or an error occurs. + /// + /// This is a convenience wrapper around the use of `check-write`, + /// `subscribe`, `write`, and `flush`, and is implemented with the + /// following pseudo-code: + /// + /// ```text + /// let pollable = this.subscribe(); + /// while !contents.is_empty() { + /// // Wait for the stream to become writable + /// pollable.block(); + /// let Ok(n) = this.check-write(); // eliding error handling + /// let len = min(n, contents.len()); + /// let (chunk, rest) = contents.split_at(len); + /// this.write(chunk ); // eliding error handling + /// contents = rest; + /// } + /// this.flush(); + /// // Wait for completion of `flush` + /// pollable.block(); + /// // Check for any errors that arose during `flush` + /// let _ = this.check-write(); // eliding error handling + /// ``` + @since(version = 0.2.0) + blocking-write-and-flush: func(contents: list) -> result<_, stream-error>; + /// Request to flush buffered output. This function never blocks. + /// + /// This tells the output-stream that the caller intends any buffered + /// output to be flushed. the output which is expected to be flushed + /// is all that has been passed to `write` prior to this call. + /// + /// Upon calling this function, the `output-stream` will not accept any + /// writes (`check-write` will return `ok(0)`) until the flush has + /// completed. The `subscribe` pollable will become ready when the + /// flush has completed and the stream can accept more writes. + @since(version = 0.2.0) + flush: func() -> result<_, stream-error>; + /// Request to flush buffered output, and block until flush completes + /// and stream is ready for writing again. + @since(version = 0.2.0) + blocking-flush: func() -> result<_, stream-error>; + /// Create a `pollable` which will resolve once the output-stream + /// is ready for more writing, or an error has occurred. When this + /// pollable is ready, `check-write` will return `ok(n)` with n>0, or an + /// error. + /// + /// If the stream is closed, this pollable is always ready immediately. + /// + /// The created `pollable` is a child resource of the `output-stream`. + /// Implementations may trap if the `output-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Write zeroes to a stream. + /// + /// This should be used precisely like `write` with the exact same + /// preconditions (must use check-write first), but instead of + /// passing a list of bytes, you simply pass the number of zero-bytes + /// that should be written. + @since(version = 0.2.0) + write-zeroes: func(len: u64) -> result<_, stream-error>; + /// Perform a write of up to 4096 zeroes, and then flush the stream. + /// Block until all of these operations are complete, or an error + /// occurs. + /// + /// This is a convenience wrapper around the use of `check-write`, + /// `subscribe`, `write-zeroes`, and `flush`, and is implemented with + /// the following pseudo-code: + /// + /// ```text + /// let pollable = this.subscribe(); + /// while num_zeroes != 0 { + /// // Wait for the stream to become writable + /// pollable.block(); + /// let Ok(n) = this.check-write(); // eliding error handling + /// let len = min(n, num_zeroes); + /// this.write-zeroes(len); // eliding error handling + /// num_zeroes -= len; + /// } + /// this.flush(); + /// // Wait for completion of `flush` + /// pollable.block(); + /// // Check for any errors that arose during `flush` + /// let _ = this.check-write(); // eliding error handling + /// ``` + @since(version = 0.2.0) + blocking-write-zeroes-and-flush: func(len: u64) -> result<_, stream-error>; + /// Read from one stream and write to another. + /// + /// The behavior of splice is equivalent to: + /// 1. calling `check-write` on the `output-stream` + /// 2. calling `read` on the `input-stream` with the smaller of the + /// `check-write` permitted length and the `len` provided to `splice` + /// 3. calling `write` on the `output-stream` with that read data. + /// + /// Any error reported by the call to `check-write`, `read`, or + /// `write` ends the splice and reports that error. + /// + /// This function returns the number of bytes transferred; it may be less + /// than `len`. + @since(version = 0.2.0) + splice: func(src: borrow, len: u64) -> result; + /// Read from one stream and write to another, with blocking. + /// + /// This is similar to `splice`, except that it blocks until the + /// `output-stream` is ready for writing, and the `input-stream` + /// is ready for reading, before performing the `splice`. + @since(version = 0.2.0) + blocking-splice: func(src: borrow, len: u64) -> result; + } +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import error; + @since(version = 0.2.0) + import poll; + @since(version = 0.2.0) + import streams; +} diff --git a/crates/cpex-wasm-plugin/wit/deps/random.wit b/crates/cpex-wasm-plugin/wit/deps/random.wit new file mode 100644 index 00000000..73edf5b6 --- /dev/null +++ b/crates/cpex-wasm-plugin/wit/deps/random.wit @@ -0,0 +1,92 @@ +package wasi:random@0.2.6; + +/// The insecure-seed interface for seeding hash-map DoS resistance. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +@since(version = 0.2.0) +interface insecure-seed { + /// Return a 128-bit value that may contain a pseudo-random value. + /// + /// The returned value is not required to be computed from a CSPRNG, and may + /// even be entirely deterministic. Host implementations are encouraged to + /// provide pseudo-random values to any program exposed to + /// attacker-controlled content, to enable DoS protection built into many + /// languages' hash-map implementations. + /// + /// This function is intended to only be called once, by a source language + /// to initialize Denial Of Service (DoS) protection in its hash-map + /// implementation. + /// + /// # Expected future evolution + /// + /// This will likely be changed to a value import, to prevent it from being + /// called multiple times and potentially used for purposes other than DoS + /// protection. + @since(version = 0.2.0) + insecure-seed: func() -> tuple; +} + +/// The insecure interface for insecure pseudo-random numbers. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +@since(version = 0.2.0) +interface insecure { + /// Return `len` insecure pseudo-random bytes. + /// + /// This function is not cryptographically secure. Do not use it for + /// anything related to security. + /// + /// There are no requirements on the values of the returned bytes, however + /// implementations are encouraged to return evenly distributed values with + /// a long period. + @since(version = 0.2.0) + get-insecure-random-bytes: func(len: u64) -> list; + + /// Return an insecure pseudo-random `u64` value. + /// + /// This function returns the same type of pseudo-random data as + /// `get-insecure-random-bytes`, represented as a `u64`. + @since(version = 0.2.0) + get-insecure-random-u64: func() -> u64; +} + +/// WASI Random is a random data API. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +@since(version = 0.2.0) +interface random { + /// Return `len` cryptographically-secure random or pseudo-random bytes. + /// + /// This function must produce data at least as cryptographically secure and + /// fast as an adequately seeded cryptographically-secure pseudo-random + /// number generator (CSPRNG). It must not block, from the perspective of + /// the calling program, under any circumstances, including on the first + /// request and on requests for numbers of bytes. The returned data must + /// always be unpredictable. + /// + /// This function must always return fresh data. Deterministic environments + /// must omit this function, rather than implementing it with deterministic + /// data. + @since(version = 0.2.0) + get-random-bytes: func(len: u64) -> list; + + /// Return a cryptographically-secure random or pseudo-random `u64` value. + /// + /// This function returns the same type of data as `get-random-bytes`, + /// represented as a `u64`. + @since(version = 0.2.0) + get-random-u64: func() -> u64; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import random; + @since(version = 0.2.0) + import insecure; + @since(version = 0.2.0) + import insecure-seed; +} diff --git a/crates/cpex-wasm-plugin/wit/deps/sockets.wit b/crates/cpex-wasm-plugin/wit/deps/sockets.wit new file mode 100644 index 00000000..db6d1a23 --- /dev/null +++ b/crates/cpex-wasm-plugin/wit/deps/sockets.wit @@ -0,0 +1,949 @@ +package wasi:sockets@0.2.6; + +@since(version = 0.2.0) +interface network { + @unstable(feature = network-error-code) + use wasi:io/error@0.2.6.{error}; + + /// An opaque resource that represents access to (a subset of) the network. + /// This enables context-based security for networking. + /// There is no need for this to map 1:1 to a physical network interface. + @since(version = 0.2.0) + resource network; + + /// Error codes. + /// + /// In theory, every API can return any error code. + /// In practice, API's typically only return the errors documented per API + /// combined with a couple of errors that are always possible: + /// - `unknown` + /// - `access-denied` + /// - `not-supported` + /// - `out-of-memory` + /// - `concurrency-conflict` + /// + /// See each individual API for what the POSIX equivalents are. They sometimes differ per API. + @since(version = 0.2.0) + enum error-code { + /// Unknown error + unknown, + /// Access denied. + /// + /// POSIX equivalent: EACCES, EPERM + access-denied, + /// The operation is not supported. + /// + /// POSIX equivalent: EOPNOTSUPP + not-supported, + /// One of the arguments is invalid. + /// + /// POSIX equivalent: EINVAL + invalid-argument, + /// Not enough memory to complete the operation. + /// + /// POSIX equivalent: ENOMEM, ENOBUFS, EAI_MEMORY + out-of-memory, + /// The operation timed out before it could finish completely. + timeout, + /// This operation is incompatible with another asynchronous operation that is already in progress. + /// + /// POSIX equivalent: EALREADY + concurrency-conflict, + /// Trying to finish an asynchronous operation that: + /// - has not been started yet, or: + /// - was already finished by a previous `finish-*` call. + /// + /// Note: this is scheduled to be removed when `future`s are natively supported. + not-in-progress, + /// The operation has been aborted because it could not be completed immediately. + /// + /// Note: this is scheduled to be removed when `future`s are natively supported. + would-block, + /// The operation is not valid in the socket's current state. + invalid-state, + /// A new socket resource could not be created because of a system limit. + new-socket-limit, + /// A bind operation failed because the provided address is not an address that the `network` can bind to. + address-not-bindable, + /// A bind operation failed because the provided address is already in use or because there are no ephemeral ports available. + address-in-use, + /// The remote address is not reachable + remote-unreachable, + /// The TCP connection was forcefully rejected + connection-refused, + /// The TCP connection was reset. + connection-reset, + /// A TCP connection was aborted. + connection-aborted, + /// The size of a datagram sent to a UDP socket exceeded the maximum + /// supported size. + datagram-too-large, + /// Name does not exist or has no suitable associated IP addresses. + name-unresolvable, + /// A temporary failure in name resolution occurred. + temporary-resolver-failure, + /// A permanent failure in name resolution occurred. + permanent-resolver-failure, + } + + @since(version = 0.2.0) + enum ip-address-family { + /// Similar to `AF_INET` in POSIX. + ipv4, + /// Similar to `AF_INET6` in POSIX. + ipv6, + } + + @since(version = 0.2.0) + type ipv4-address = tuple; + + @since(version = 0.2.0) + type ipv6-address = tuple; + + @since(version = 0.2.0) + variant ip-address { + ipv4(ipv4-address), + ipv6(ipv6-address), + } + + @since(version = 0.2.0) + record ipv4-socket-address { + /// sin_port + port: u16, + /// sin_addr + address: ipv4-address, + } + + @since(version = 0.2.0) + record ipv6-socket-address { + /// sin6_port + port: u16, + /// sin6_flowinfo + flow-info: u32, + /// sin6_addr + address: ipv6-address, + /// sin6_scope_id + scope-id: u32, + } + + @since(version = 0.2.0) + variant ip-socket-address { + ipv4(ipv4-socket-address), + ipv6(ipv6-socket-address), + } + + /// Attempts to extract a network-related `error-code` from the stream + /// `error` provided. + /// + /// Stream operations which return `stream-error::last-operation-failed` + /// have a payload with more information about the operation that failed. + /// This payload can be passed through to this function to see if there's + /// network-related information about the error to return. + /// + /// Note that this function is fallible because not all stream-related + /// errors are network-related errors. + @unstable(feature = network-error-code) + network-error-code: func(err: borrow) -> option; +} + +/// This interface provides a value-export of the default network handle.. +@since(version = 0.2.0) +interface instance-network { + @since(version = 0.2.0) + use network.{network}; + + /// Get a handle to the default network. + @since(version = 0.2.0) + instance-network: func() -> network; +} + +@since(version = 0.2.0) +interface ip-name-lookup { + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + @since(version = 0.2.0) + use network.{network, error-code, ip-address}; + + @since(version = 0.2.0) + resource resolve-address-stream { + /// Returns the next address from the resolver. + /// + /// This function should be called multiple times. On each call, it will + /// return the next address in connection order preference. If all + /// addresses have been exhausted, this function returns `none`. + /// + /// This function never returns IPv4-mapped IPv6 addresses. + /// + /// # Typical errors + /// - `name-unresolvable`: Name does not exist or has no suitable associated IP addresses. (EAI_NONAME, EAI_NODATA, EAI_ADDRFAMILY) + /// - `temporary-resolver-failure`: A temporary failure in name resolution occurred. (EAI_AGAIN) + /// - `permanent-resolver-failure`: A permanent failure in name resolution occurred. (EAI_FAIL) + /// - `would-block`: A result is not available yet. (EWOULDBLOCK, EAGAIN) + @since(version = 0.2.0) + resolve-next-address: func() -> result, error-code>; + /// Create a `pollable` which will resolve once the stream is ready for I/O. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + /// Resolve an internet host name to a list of IP addresses. + /// + /// Unicode domain names are automatically converted to ASCII using IDNA encoding. + /// If the input is an IP address string, the address is parsed and returned + /// as-is without making any external requests. + /// + /// See the wasi-socket proposal README.md for a comparison with getaddrinfo. + /// + /// This function never blocks. It either immediately fails or immediately + /// returns successfully with a `resolve-address-stream` that can be used + /// to (asynchronously) fetch the results. + /// + /// # Typical errors + /// - `invalid-argument`: `name` is a syntactically invalid domain name or IP address. + /// + /// # References: + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + resolve-addresses: func(network: borrow, name: string) -> result; +} + +@since(version = 0.2.0) +interface tcp { + @since(version = 0.2.0) + use wasi:io/streams@0.2.6.{input-stream, output-stream}; + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + @since(version = 0.2.0) + use wasi:clocks/monotonic-clock@0.2.6.{duration}; + @since(version = 0.2.0) + use network.{network, error-code, ip-socket-address, ip-address-family}; + + @since(version = 0.2.0) + enum shutdown-type { + /// Similar to `SHUT_RD` in POSIX. + receive, + /// Similar to `SHUT_WR` in POSIX. + send, + /// Similar to `SHUT_RDWR` in POSIX. + both, + } + + /// A TCP socket resource. + /// + /// The socket can be in one of the following states: + /// - `unbound` + /// - `bind-in-progress` + /// - `bound` (See note below) + /// - `listen-in-progress` + /// - `listening` + /// - `connect-in-progress` + /// - `connected` + /// - `closed` + /// See + /// for more information. + /// + /// Note: Except where explicitly mentioned, whenever this documentation uses + /// the term "bound" without backticks it actually means: in the `bound` state *or higher*. + /// (i.e. `bound`, `listen-in-progress`, `listening`, `connect-in-progress` or `connected`) + /// + /// In addition to the general error codes documented on the + /// `network::error-code` type, TCP socket methods may always return + /// `error(invalid-state)` when in the `closed` state. + @since(version = 0.2.0) + resource tcp-socket { + /// Bind the socket to a specific network on the provided IP address and port. + /// + /// If the IP address is zero (`0.0.0.0` in IPv4, `::` in IPv6), it is left to the implementation to decide which + /// network interface(s) to bind to. + /// If the TCP/UDP port is zero, the socket will be bound to a random free port. + /// + /// Bind can be attempted multiple times on the same socket, even with + /// different arguments on each iteration. But never concurrently and + /// only as long as the previous bind failed. Once a bind succeeds, the + /// binding can't be changed anymore. + /// + /// # Typical errors + /// - `invalid-argument`: The `local-address` has the wrong address family. (EAFNOSUPPORT, EFAULT on Windows) + /// - `invalid-argument`: `local-address` is not a unicast address. (EINVAL) + /// - `invalid-argument`: `local-address` is an IPv4-mapped IPv6 address. (EINVAL) + /// - `invalid-state`: The socket is already bound. (EINVAL) + /// - `address-in-use`: No ephemeral ports available. (EADDRINUSE, ENOBUFS on Windows) + /// - `address-in-use`: Address is already in use. (EADDRINUSE) + /// - `address-not-bindable`: `local-address` is not an address that the `network` can bind to. (EADDRNOTAVAIL) + /// - `not-in-progress`: A `bind` operation is not in progress. + /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) + /// + /// # Implementors note + /// When binding to a non-zero port, this bind operation shouldn't be affected by the TIME_WAIT + /// state of a recently closed socket on the same local address. In practice this means that the SO_REUSEADDR + /// socket option should be set implicitly on all platforms, except on Windows where this is the default behavior + /// and SO_REUSEADDR performs something different entirely. + /// + /// Unlike in POSIX, in WASI the bind operation is async. This enables + /// interactive WASI hosts to inject permission prompts. Runtimes that + /// don't want to make use of this ability can simply call the native + /// `bind` as part of either `start-bind` or `finish-bind`. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + start-bind: func(network: borrow, local-address: ip-socket-address) -> result<_, error-code>; + @since(version = 0.2.0) + finish-bind: func() -> result<_, error-code>; + /// Connect to a remote endpoint. + /// + /// On success: + /// - the socket is transitioned into the `connected` state. + /// - a pair of streams is returned that can be used to read & write to the connection + /// + /// After a failed connection attempt, the socket will be in the `closed` + /// state and the only valid action left is to `drop` the socket. A single + /// socket can not be used to connect more than once. + /// + /// # Typical errors + /// - `invalid-argument`: The `remote-address` has the wrong address family. (EAFNOSUPPORT) + /// - `invalid-argument`: `remote-address` is not a unicast address. (EINVAL, ENETUNREACH on Linux, EAFNOSUPPORT on MacOS) + /// - `invalid-argument`: `remote-address` is an IPv4-mapped IPv6 address. (EINVAL, EADDRNOTAVAIL on Illumos) + /// - `invalid-argument`: The IP address in `remote-address` is set to INADDR_ANY (`0.0.0.0` / `::`). (EADDRNOTAVAIL on Windows) + /// - `invalid-argument`: The port in `remote-address` is set to 0. (EADDRNOTAVAIL on Windows) + /// - `invalid-argument`: The socket is already attached to a different network. The `network` passed to `connect` must be identical to the one passed to `bind`. + /// - `invalid-state`: The socket is already in the `connected` state. (EISCONN) + /// - `invalid-state`: The socket is already in the `listening` state. (EOPNOTSUPP, EINVAL on Windows) + /// - `timeout`: Connection timed out. (ETIMEDOUT) + /// - `connection-refused`: The connection was forcefully rejected. (ECONNREFUSED) + /// - `connection-reset`: The connection was reset. (ECONNRESET) + /// - `connection-aborted`: The connection was aborted. (ECONNABORTED) + /// - `remote-unreachable`: The remote address is not reachable. (EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) + /// - `address-in-use`: Tried to perform an implicit bind, but there were no ephemeral ports available. (EADDRINUSE, EADDRNOTAVAIL on Linux, EAGAIN on BSD) + /// - `not-in-progress`: A connect operation is not in progress. + /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) + /// + /// # Implementors note + /// The POSIX equivalent of `start-connect` is the regular `connect` syscall. + /// Because all WASI sockets are non-blocking this is expected to return + /// EINPROGRESS, which should be translated to `ok()` in WASI. + /// + /// The POSIX equivalent of `finish-connect` is a `poll` for event `POLLOUT` + /// with a timeout of 0 on the socket descriptor. Followed by a check for + /// the `SO_ERROR` socket option, in case the poll signaled readiness. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + start-connect: func(network: borrow, remote-address: ip-socket-address) -> result<_, error-code>; + @since(version = 0.2.0) + finish-connect: func() -> result, error-code>; + /// Start listening for new connections. + /// + /// Transitions the socket into the `listening` state. + /// + /// Unlike POSIX, the socket must already be explicitly bound. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not bound to any local address. (EDESTADDRREQ) + /// - `invalid-state`: The socket is already in the `connected` state. (EISCONN, EINVAL on BSD) + /// - `invalid-state`: The socket is already in the `listening` state. + /// - `address-in-use`: Tried to perform an implicit bind, but there were no ephemeral ports available. (EADDRINUSE) + /// - `not-in-progress`: A listen operation is not in progress. + /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) + /// + /// # Implementors note + /// Unlike in POSIX, in WASI the listen operation is async. This enables + /// interactive WASI hosts to inject permission prompts. Runtimes that + /// don't want to make use of this ability can simply call the native + /// `listen` as part of either `start-listen` or `finish-listen`. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + start-listen: func() -> result<_, error-code>; + @since(version = 0.2.0) + finish-listen: func() -> result<_, error-code>; + /// Accept a new client socket. + /// + /// The returned socket is bound and in the `connected` state. The following properties are inherited from the listener socket: + /// - `address-family` + /// - `keep-alive-enabled` + /// - `keep-alive-idle-time` + /// - `keep-alive-interval` + /// - `keep-alive-count` + /// - `hop-limit` + /// - `receive-buffer-size` + /// - `send-buffer-size` + /// + /// On success, this function returns the newly accepted client socket along with + /// a pair of streams that can be used to read & write to the connection. + /// + /// # Typical errors + /// - `invalid-state`: Socket is not in the `listening` state. (EINVAL) + /// - `would-block`: No pending connections at the moment. (EWOULDBLOCK, EAGAIN) + /// - `connection-aborted`: An incoming connection was pending, but was terminated by the client before this listener could accept it. (ECONNABORTED) + /// - `new-socket-limit`: The new socket resource could not be created because of a system limit. (EMFILE, ENFILE) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + accept: func() -> result, error-code>; + /// Get the bound local address. + /// + /// POSIX mentions: + /// > If the socket has not been bound to a local name, the value + /// > stored in the object pointed to by `address` is unspecified. + /// + /// WASI is stricter and requires `local-address` to return `invalid-state` when the socket hasn't been bound yet. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not bound to any local address. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + local-address: func() -> result; + /// Get the remote address. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not connected to a remote address. (ENOTCONN) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + remote-address: func() -> result; + /// Whether the socket is in the `listening` state. + /// + /// Equivalent to the SO_ACCEPTCONN socket option. + @since(version = 0.2.0) + is-listening: func() -> bool; + /// Whether this is a IPv4 or IPv6 socket. + /// + /// Equivalent to the SO_DOMAIN socket option. + @since(version = 0.2.0) + address-family: func() -> ip-address-family; + /// Hints the desired listen queue size. Implementations are free to ignore this. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// + /// # Typical errors + /// - `not-supported`: (set) The platform does not support changing the backlog size after the initial listen. + /// - `invalid-argument`: (set) The provided value was 0. + /// - `invalid-state`: (set) The socket is in the `connect-in-progress` or `connected` state. + @since(version = 0.2.0) + set-listen-backlog-size: func(value: u64) -> result<_, error-code>; + /// Enables or disables keepalive. + /// + /// The keepalive behavior can be adjusted using: + /// - `keep-alive-idle-time` + /// - `keep-alive-interval` + /// - `keep-alive-count` + /// These properties can be configured while `keep-alive-enabled` is false, but only come into effect when `keep-alive-enabled` is true. + /// + /// Equivalent to the SO_KEEPALIVE socket option. + @since(version = 0.2.0) + keep-alive-enabled: func() -> result; + @since(version = 0.2.0) + set-keep-alive-enabled: func(value: bool) -> result<_, error-code>; + /// Amount of time the connection has to be idle before TCP starts sending keepalive packets. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the TCP_KEEPIDLE socket option. (TCP_KEEPALIVE on MacOS) + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + keep-alive-idle-time: func() -> result; + @since(version = 0.2.0) + set-keep-alive-idle-time: func(value: duration) -> result<_, error-code>; + /// The time between keepalive packets. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the TCP_KEEPINTVL socket option. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + keep-alive-interval: func() -> result; + @since(version = 0.2.0) + set-keep-alive-interval: func(value: duration) -> result<_, error-code>; + /// The maximum amount of keepalive packets TCP should send before aborting the connection. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the TCP_KEEPCNT socket option. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + keep-alive-count: func() -> result; + @since(version = 0.2.0) + set-keep-alive-count: func(value: u32) -> result<_, error-code>; + /// Equivalent to the IP_TTL & IPV6_UNICAST_HOPS socket options. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The TTL value must be 1 or higher. + @since(version = 0.2.0) + hop-limit: func() -> result; + @since(version = 0.2.0) + set-hop-limit: func(value: u8) -> result<_, error-code>; + /// The kernel buffer space reserved for sends/receives on this socket. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the SO_RCVBUF and SO_SNDBUF socket options. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + receive-buffer-size: func() -> result; + @since(version = 0.2.0) + set-receive-buffer-size: func(value: u64) -> result<_, error-code>; + @since(version = 0.2.0) + send-buffer-size: func() -> result; + @since(version = 0.2.0) + set-send-buffer-size: func(value: u64) -> result<_, error-code>; + /// Create a `pollable` which can be used to poll for, or block on, + /// completion of any of the asynchronous operations of this socket. + /// + /// When `finish-bind`, `finish-listen`, `finish-connect` or `accept` + /// return `error(would-block)`, this pollable can be used to wait for + /// their success or failure, after which the method can be retried. + /// + /// The pollable is not limited to the async operation that happens to be + /// in progress at the time of calling `subscribe` (if any). Theoretically, + /// `subscribe` only has to be called once per socket and can then be + /// (re)used for the remainder of the socket's lifetime. + /// + /// See + /// for more information. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Initiate a graceful shutdown. + /// + /// - `receive`: The socket is not expecting to receive any data from + /// the peer. The `input-stream` associated with this socket will be + /// closed. Any data still in the receive queue at time of calling + /// this method will be discarded. + /// - `send`: The socket has no more data to send to the peer. The `output-stream` + /// associated with this socket will be closed and a FIN packet will be sent. + /// - `both`: Same effect as `receive` & `send` combined. + /// + /// This function is idempotent; shutting down a direction more than once + /// has no effect and returns `ok`. + /// + /// The shutdown function does not close (drop) the socket. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not in the `connected` state. (ENOTCONN) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + shutdown: func(shutdown-type: shutdown-type) -> result<_, error-code>; + } +} + +@since(version = 0.2.0) +interface tcp-create-socket { + @since(version = 0.2.0) + use network.{network, error-code, ip-address-family}; + @since(version = 0.2.0) + use tcp.{tcp-socket}; + + /// Create a new TCP socket. + /// + /// Similar to `socket(AF_INET or AF_INET6, SOCK_STREAM, IPPROTO_TCP)` in POSIX. + /// On IPv6 sockets, IPV6_V6ONLY is enabled by default and can't be configured otherwise. + /// + /// This function does not require a network capability handle. This is considered to be safe because + /// at time of creation, the socket is not bound to any `network` yet. Up to the moment `bind`/`connect` + /// is called, the socket is effectively an in-memory configuration object, unable to communicate with the outside world. + /// + /// All sockets are non-blocking. Use the wasi-poll interface to block on asynchronous operations. + /// + /// # Typical errors + /// - `not-supported`: The specified `address-family` is not supported. (EAFNOSUPPORT) + /// - `new-socket-limit`: The new socket resource could not be created because of a system limit. (EMFILE, ENFILE) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + create-tcp-socket: func(address-family: ip-address-family) -> result; +} + +@since(version = 0.2.0) +interface udp { + @since(version = 0.2.0) + use wasi:io/poll@0.2.6.{pollable}; + @since(version = 0.2.0) + use network.{network, error-code, ip-socket-address, ip-address-family}; + + /// A received datagram. + @since(version = 0.2.0) + record incoming-datagram { + /// The payload. + /// + /// Theoretical max size: ~64 KiB. In practice, typically less than 1500 bytes. + data: list, + /// The source address. + /// + /// This field is guaranteed to match the remote address the stream was initialized with, if any. + /// + /// Equivalent to the `src_addr` out parameter of `recvfrom`. + remote-address: ip-socket-address, + } + + /// A datagram to be sent out. + @since(version = 0.2.0) + record outgoing-datagram { + /// The payload. + data: list, + /// The destination address. + /// + /// The requirements on this field depend on how the stream was initialized: + /// - with a remote address: this field must be None or match the stream's remote address exactly. + /// - without a remote address: this field is required. + /// + /// If this value is None, the send operation is equivalent to `send` in POSIX. Otherwise it is equivalent to `sendto`. + remote-address: option, + } + + /// A UDP socket handle. + @since(version = 0.2.0) + resource udp-socket { + /// Bind the socket to a specific network on the provided IP address and port. + /// + /// If the IP address is zero (`0.0.0.0` in IPv4, `::` in IPv6), it is left to the implementation to decide which + /// network interface(s) to bind to. + /// If the port is zero, the socket will be bound to a random free port. + /// + /// # Typical errors + /// - `invalid-argument`: The `local-address` has the wrong address family. (EAFNOSUPPORT, EFAULT on Windows) + /// - `invalid-state`: The socket is already bound. (EINVAL) + /// - `address-in-use`: No ephemeral ports available. (EADDRINUSE, ENOBUFS on Windows) + /// - `address-in-use`: Address is already in use. (EADDRINUSE) + /// - `address-not-bindable`: `local-address` is not an address that the `network` can bind to. (EADDRNOTAVAIL) + /// - `not-in-progress`: A `bind` operation is not in progress. + /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) + /// + /// # Implementors note + /// Unlike in POSIX, in WASI the bind operation is async. This enables + /// interactive WASI hosts to inject permission prompts. Runtimes that + /// don't want to make use of this ability can simply call the native + /// `bind` as part of either `start-bind` or `finish-bind`. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + start-bind: func(network: borrow, local-address: ip-socket-address) -> result<_, error-code>; + @since(version = 0.2.0) + finish-bind: func() -> result<_, error-code>; + /// Set up inbound & outbound communication channels, optionally to a specific peer. + /// + /// This function only changes the local socket configuration and does not generate any network traffic. + /// On success, the `remote-address` of the socket is updated. The `local-address` may be updated as well, + /// based on the best network path to `remote-address`. + /// + /// When a `remote-address` is provided, the returned streams are limited to communicating with that specific peer: + /// - `send` can only be used to send to this destination. + /// - `receive` will only return datagrams sent from the provided `remote-address`. + /// + /// This method may be called multiple times on the same socket to change its association, but + /// only the most recently returned pair of streams will be operational. Implementations may trap if + /// the streams returned by a previous invocation haven't been dropped yet before calling `stream` again. + /// + /// The POSIX equivalent in pseudo-code is: + /// ```text + /// if (was previously connected) { + /// connect(s, AF_UNSPEC) + /// } + /// if (remote_address is Some) { + /// connect(s, remote_address) + /// } + /// ``` + /// + /// Unlike in POSIX, the socket must already be explicitly bound. + /// + /// # Typical errors + /// - `invalid-argument`: The `remote-address` has the wrong address family. (EAFNOSUPPORT) + /// - `invalid-argument`: The IP address in `remote-address` is set to INADDR_ANY (`0.0.0.0` / `::`). (EDESTADDRREQ, EADDRNOTAVAIL) + /// - `invalid-argument`: The port in `remote-address` is set to 0. (EDESTADDRREQ, EADDRNOTAVAIL) + /// - `invalid-state`: The socket is not bound. + /// - `address-in-use`: Tried to perform an implicit bind, but there were no ephemeral ports available. (EADDRINUSE, EADDRNOTAVAIL on Linux, EAGAIN on BSD) + /// - `remote-unreachable`: The remote address is not reachable. (ECONNRESET, ENETRESET, EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) + /// - `connection-refused`: The connection was refused. (ECONNREFUSED) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + %stream: func(remote-address: option) -> result, error-code>; + /// Get the current bound address. + /// + /// POSIX mentions: + /// > If the socket has not been bound to a local name, the value + /// > stored in the object pointed to by `address` is unspecified. + /// + /// WASI is stricter and requires `local-address` to return `invalid-state` when the socket hasn't been bound yet. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not bound to any local address. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + local-address: func() -> result; + /// Get the address the socket is currently streaming to. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not streaming to a specific remote address. (ENOTCONN) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + remote-address: func() -> result; + /// Whether this is a IPv4 or IPv6 socket. + /// + /// Equivalent to the SO_DOMAIN socket option. + @since(version = 0.2.0) + address-family: func() -> ip-address-family; + /// Equivalent to the IP_TTL & IPV6_UNICAST_HOPS socket options. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The TTL value must be 1 or higher. + @since(version = 0.2.0) + unicast-hop-limit: func() -> result; + @since(version = 0.2.0) + set-unicast-hop-limit: func(value: u8) -> result<_, error-code>; + /// The kernel buffer space reserved for sends/receives on this socket. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the SO_RCVBUF and SO_SNDBUF socket options. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + receive-buffer-size: func() -> result; + @since(version = 0.2.0) + set-receive-buffer-size: func(value: u64) -> result<_, error-code>; + @since(version = 0.2.0) + send-buffer-size: func() -> result; + @since(version = 0.2.0) + set-send-buffer-size: func(value: u64) -> result<_, error-code>; + /// Create a `pollable` which will resolve once the socket is ready for I/O. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + @since(version = 0.2.0) + resource incoming-datagram-stream { + /// Receive messages on the socket. + /// + /// This function attempts to receive up to `max-results` datagrams on the socket without blocking. + /// The returned list may contain fewer elements than requested, but never more. + /// + /// This function returns successfully with an empty list when either: + /// - `max-results` is 0, or: + /// - `max-results` is greater than 0, but no results are immediately available. + /// This function never returns `error(would-block)`. + /// + /// # Typical errors + /// - `remote-unreachable`: The remote address is not reachable. (ECONNRESET, ENETRESET on Windows, EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) + /// - `connection-refused`: The connection was refused. (ECONNREFUSED) + /// + /// # References + /// - + /// - + /// - + /// - + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + receive: func(max-results: u64) -> result, error-code>; + /// Create a `pollable` which will resolve once the stream is ready to receive again. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + @since(version = 0.2.0) + resource outgoing-datagram-stream { + /// Check readiness for sending. This function never blocks. + /// + /// Returns the number of datagrams permitted for the next call to `send`, + /// or an error. Calling `send` with more datagrams than this function has + /// permitted will trap. + /// + /// When this function returns ok(0), the `subscribe` pollable will + /// become ready when this function will report at least ok(1), or an + /// error. + /// + /// Never returns `would-block`. + check-send: func() -> result; + /// Send messages on the socket. + /// + /// This function attempts to send all provided `datagrams` on the socket without blocking and + /// returns how many messages were actually sent (or queued for sending). This function never + /// returns `error(would-block)`. If none of the datagrams were able to be sent, `ok(0)` is returned. + /// + /// This function semantically behaves the same as iterating the `datagrams` list and sequentially + /// sending each individual datagram until either the end of the list has been reached or the first error occurred. + /// If at least one datagram has been sent successfully, this function never returns an error. + /// + /// If the input list is empty, the function returns `ok(0)`. + /// + /// Each call to `send` must be permitted by a preceding `check-send`. Implementations must trap if + /// either `check-send` was not called or `datagrams` contains more items than `check-send` permitted. + /// + /// # Typical errors + /// - `invalid-argument`: The `remote-address` has the wrong address family. (EAFNOSUPPORT) + /// - `invalid-argument`: The IP address in `remote-address` is set to INADDR_ANY (`0.0.0.0` / `::`). (EDESTADDRREQ, EADDRNOTAVAIL) + /// - `invalid-argument`: The port in `remote-address` is set to 0. (EDESTADDRREQ, EADDRNOTAVAIL) + /// - `invalid-argument`: The socket is in "connected" mode and `remote-address` is `some` value that does not match the address passed to `stream`. (EISCONN) + /// - `invalid-argument`: The socket is not "connected" and no value for `remote-address` was provided. (EDESTADDRREQ) + /// - `remote-unreachable`: The remote address is not reachable. (ECONNRESET, ENETRESET on Windows, EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) + /// - `connection-refused`: The connection was refused. (ECONNREFUSED) + /// - `datagram-too-large`: The datagram is too large. (EMSGSIZE) + /// + /// # References + /// - + /// - + /// - + /// - + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + send: func(datagrams: list) -> result; + /// Create a `pollable` which will resolve once the stream is ready to send again. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } +} + +@since(version = 0.2.0) +interface udp-create-socket { + @since(version = 0.2.0) + use network.{network, error-code, ip-address-family}; + @since(version = 0.2.0) + use udp.{udp-socket}; + + /// Create a new UDP socket. + /// + /// Similar to `socket(AF_INET or AF_INET6, SOCK_DGRAM, IPPROTO_UDP)` in POSIX. + /// On IPv6 sockets, IPV6_V6ONLY is enabled by default and can't be configured otherwise. + /// + /// This function does not require a network capability handle. This is considered to be safe because + /// at time of creation, the socket is not bound to any `network` yet. Up to the moment `bind` is called, + /// the socket is effectively an in-memory configuration object, unable to communicate with the outside world. + /// + /// All sockets are non-blocking. Use the wasi-poll interface to block on asynchronous operations. + /// + /// # Typical errors + /// - `not-supported`: The specified `address-family` is not supported. (EAFNOSUPPORT) + /// - `new-socket-limit`: The new socket resource could not be created because of a system limit. (EMFILE, ENFILE) + /// + /// # References: + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + create-udp-socket: func(address-family: ip-address-family) -> result; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/error@0.2.6; + @since(version = 0.2.0) + import network; + @since(version = 0.2.0) + import instance-network; + @since(version = 0.2.0) + import wasi:io/poll@0.2.6; + @since(version = 0.2.0) + import udp; + @since(version = 0.2.0) + import udp-create-socket; + @since(version = 0.2.0) + import wasi:io/streams@0.2.6; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.6; + @since(version = 0.2.0) + import tcp; + @since(version = 0.2.0) + import tcp-create-socket; + @since(version = 0.2.0) + import ip-name-lookup; +} diff --git a/crates/cpex-wasm-plugin/wit/world.wit b/crates/cpex-wasm-plugin/wit/world.wit new file mode 100644 index 00000000..c8498651 --- /dev/null +++ b/crates/cpex-wasm-plugin/wit/world.wit @@ -0,0 +1,678 @@ +// Location: ./crates/cpex-wasm-plugin/wit/world.wit +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// WIT world definition for the CPEX plugin component. +// Defines the types and exported function that the WASM host uses to invoke plugins. +// +// Supports arbitrary payload types via the hook-payload variant: +// - cmf: structured CMF MessagePayload (field-by-field conversion, no full-payload serialization) +// - custom: any serializable payload as JSON bytes with a type discriminator + +package cpex:plugin; + +interface types { + + // --------------------------------------------------------------------------- + // CMF enums: + // --------------------------------------------------------------------------- + + // Verified + enum role { + system, + developer, + user, + assistant, + tool, + } + + // Verified + enum channel { + analysis, + commentary, + final, + } + + // Verified + enum resource-type { + file, + blob, + uri, + database, + api, + memory, + artifact, + } + + // --------------------------------------------------------------------------- + // CMF content parts + // --------------------------------------------------------------------------- + // Verified + record image-source { + source-type: string, + data: string, + media-type: option, + } + + // Verified + record video-source { + source-type: string, + data: string, + media-type: option, + duration-ms: option, + } + + // Verified + record audio-source { + source-type: string, + data: string, + media-type: option, + duration-ms: option, + } + + // Verified + record document-source { + source-type: string, + data: string, + media-type: option, + title: option, + } + + // Verified + record tool-call { + tool-call-id: string, + name: string, + // arguments is a JSON object string (HashMap) + arguments: string, + namespace: option, + } + + // Verified + record tool-result { + tool-call-id: string, + tool-name: string, + // content is a JSON string (serde_json::Value) + content: string, + is-error: bool, + } + + // Verified + record cmf-resource { + resource-request-id: string, + uri: string, + name: option, + description: option, + resource-type: resource-type, + content: option, + blob: option>, + mime-type: option, + size-bytes: option, + // annotations is a JSON object string (HashMap) + annotations: string, + version: option, + } + + // Verified + record resource-reference { + resource-request-id: string, + uri: string, + name: option, + resource-type: resource-type, + range-start: option, + range-end: option, + selector: option, + } + + // Verified + record prompt-request { + prompt-request-id: string, + name: string, + // arguments is a JSON object string (HashMap) + arguments: string, + server-id: option, + } + + // Verified + record prompt-result { + prompt-request-id: string, + prompt-name: string, + // messages is a JSON array string (Vec) — cannot be list + // because message → content-part → prompt-result is a recursive cycle, + // which WIT does not support without indirection. + messages: string, + content: option, + is-error: bool, + error-message: option, + } + + // Verified + variant content-part { + text(string), + thinking(string), + tool-call(tool-call), + tool-result(tool-result), + cmf-resource(cmf-resource), + resource-ref(resource-reference), + prompt-request(prompt-request), + prompt-result(prompt-result), + image(image-source), + video(video-source), + audio(audio-source), + document(document-source), + } + + // Verified + record message { + schema-version: string, + role: role, + content: list, + channel: option, + } + + // Verified + record message-payload { + message: message, + } + + // --------------------------------------------------------------------------- + // Custom payload for arbitrary types + // --------------------------------------------------------------------------- + + record custom-payload { + payload-type: string, + payload-data: list, + } + + // --------------------------------------------------------------------------- + // Identity payload (IdentityResolve hook) + // Note: raw_token is #[serde(skip)] in Rust — never crosses the WASM + // boundary. WASM handlers resolve identity from source/headers/claims. + // --------------------------------------------------------------------------- + + // Verified + enum token-source { + bearer, + user-token, + mtls, + spiffe-jwt-svid, + api-key, + // custom source name carried as a string alongside this enum value + // via identity-payload.source-custom when this variant is selected + custom, + } + + // Verified + record identity-payload { + // Input fields (host-supplied, read-only for handlers) + source: token-source, + // non-empty when source = custom + source-custom: option, + source-header: option, + // request headers as key-value pairs — escape hatch for custom auth flows + headers: list>, + client-host: option, + client-port: option, + + // Output fields (handlers populate these on the returned payload) + subject: option, + client: option, + caller-workload: option, + // initial delegation chain parsed from act/equivalent claims + delegation: option, + // resolved_at as ISO 8601 string (DateTime) + resolved-at: option, + // raw decoded token claims as a JSON object string (HashMap) + raw-claims: option, + } + + // --------------------------------------------------------------------------- + // Delegation payload (TokenDelegate hook) + // Note: bearer_token and delegated_token.token are #[serde(skip)] in Rust — + // they never cross the WASM boundary. WASM handlers can attenuat scopes and + // populate delegation_update/metadata but cannot mint raw tokens. + // --------------------------------------------------------------------------- + + enum target-type { + tool, + agent, + %resource, + service, + // custom target kind name carried in delegation-payload.target-type-custom + custom, + } + + enum auth-enforced-by { + caller, + target, + both, + } + + enum delegation-mode { + on-behalf-of-user, + as-gateway, + } + + record attenuation-config { + capabilities: list, + resource-template: option, + actions: list, + ttl-seconds: option, + } + + // Minted outbound credential (token bytes are #[serde(skip)] — omitted). + record raw-delegated-token { + outbound-header: string, + audience: string, + scopes: list, + // expires_at as ISO 8601 string (DateTime) + expires-at: string, + } + + record delegation-payload { + // Input fields (host-supplied, read-only for handlers) + target-name: string, + target-type: target-type, + // non-empty when target-type = custom + target-type-custom: option, + target-audience: option, + required-permissions: list, + trust-domain: option, + auth-enforced-by: auth-enforced-by, + route-attenuation: option, + + // Output fields (handlers populate these on the returned payload) + // token bytes omitted — raw credential material never crosses the sandbox + delegated-token: option, + delegation-update: option, + delegation-mode: option, + // minted_at as ISO 8601 string (DateTime) + minted-at: option, + // handler metadata as a JSON object string (HashMap) + metadata: option, + } + + variant hook-payload { + cmf(message-payload), + identity(identity-payload), + delegation(delegation-payload), + custom(custom-payload), + } + + // --------------------------------------------------------------------------- + // Plugin context + // --------------------------------------------------------------------------- + + // State entries are typed key-value pairs (serde_json::Value serialized per + // entry as a JSON string) rather than a single opaque JSON blob. + record context-entry { + key: string, + // value is a JSON string representing serde_json::Value + value: string, + } + + record plugin-context { + local-state: list, + global-state: list, + } + + // --------------------------------------------------------------------------- + // Extensions — base four + // --------------------------------------------------------------------------- + + record request-extension { + environment: option, + request-id: option, + timestamp: option, + trace-id: option, + span-id: option, + } + + enum subject-type { + user, + agent, + service, + system, + } + + record subject-extension { + id: option, + subject-type: option, + roles: list, + permissions: list, + teams: list, + claims: list>, + } + + // --------------------------------------------------------------------------- + // Security extension — full coverage + // --------------------------------------------------------------------------- + + enum client-trust-level { + first-party, + third-party, + internal, + // custom trust level — name carried in client-trust-level-custom on + // client-extension when this variant is not sufficient + } + + record client-extension { + client-id: string, + client-name: option, + trust-level: client-trust-level, + // custom trust level name when trust-level cannot represent it + trust-level-custom: option, + authorized-scopes: list, + authorized-audiences: list, + roles: list, + permissions: list, + teams: list, + // claims values are JSON strings (serde_json::Value per entry) + claims: list>, + } + + record workload-identity { + spiffe-id: option, + trust-domain: option, + // attested-at as ISO 8601 string (DateTime) + attested-at: option, + attestor: option, + selectors: list, + client-id: option, + } + + record object-security-profile { + managed-by: option, + permissions: list, + trust-domain: option, + data-scope: list, + } + + record retention-policy { + max-age-seconds: option, + policy: string, + delete-after: option, + } + + record data-policy { + apply-labels: list, + // None = all actions allowed; Some([]) = no actions allowed + allowed-actions: option>, + denied-actions: list, + retention: option, + } + + record security-extension { + labels: list, + classification: option, + subject: option, + client: option, + caller-workload: option, + this-workload: option, + auth-method: option, + // object security profiles keyed by object name + objects: list>, + // data policies keyed by data element name + data: list>, + } + + record http-extension { + request-headers: list>, + response-headers: list>, + method: option, + path: option, + host: option, + scheme: option, + } + + record meta-extension { + entity-type: option, + entity-name: option, + tags: list, + scope: option, + properties: list>, + } + + // --------------------------------------------------------------------------- + // Overflow extension types — typed + // --------------------------------------------------------------------------- + + record conversation-context { + // history entries are JSON strings (serde_json::Value per entry) + history: list, + summary: option, + topics: list, + } + + record agent-extension { + input: option, + session-id: option, + conversation-id: option, + turn: option, + agent-id: option, + parent-agent-id: option, + conversation: option, + } + + record tool-metadata { + name: string, + title: option, + description: option, + // input-schema and output-schema are JSON strings (serde_json::Value) + input-schema: option, + output-schema: option, + server-id: option, + namespace: option, + // annotation values are JSON strings (serde_json::Value) + annotations: list>, + } + + record resource-metadata { + uri: string, + name: option, + description: option, + mime-type: option, + server-id: option, + // annotation values are JSON strings (serde_json::Value) + annotations: list>, + } + + record prompt-metadata { + name: string, + description: option, + // arguments is a JSON array string (Vec) + arguments: option, + server-id: option, + // annotation values are JSON strings (serde_json::Value) + annotations: list>, + } + + record mcp-extension { + tool: option, + // renamed from `resource` to avoid WIT keyword conflict + resource-info: option, + prompt: option, + } + + // stop-reason: `return-complete` maps to Rust `StopReason::Return` + // (renamed to avoid the WIT `return` keyword) + enum stop-reason { + end, + return-complete, + call, + max-tokens, + stop-sequence, + } + + record token-usage { + input-tokens: u32, + output-tokens: u32, + total-tokens: u32, + } + + record completion-extension { + stop-reason: option, + tokens: option, + model: option, + raw-format: option, + created-at: option, + latency-ms: option, + } + + record provenance-extension { + source: option, + message-id: option, + parent-id: option, + } + + record llm-extension { + model-id: option, + provider: option, + capabilities: list, + } + + record framework-extension { + framework: option, + framework-version: option, + node-id: option, + graph-id: option, + // metadata is a JSON object string (HashMap) + metadata: option, + } + + record authorization-detail { + detail-type: string, + // option preserves None (no constraint) vs Some([]) (empty = deny all) + locations: option>, + actions: option>, + datatypes: option>, + identifier: option, + privileges: option>, + // extra holds flattened RFC 9396 extension fields as a JSON object string + extra: option, + } + + enum delegation-strategy { + token-exchange, + client-credentials, + spiffe-svid, + passthrough, + ucan, + transaction-token, + } + + record delegation-hop { + subject-id: string, + subject-type: option, + audience: option, + scopes-granted: list, + authorization-details: list, + // timestamp as ISO 8601 string (DateTime) + timestamp: string, + ttl-seconds: option, + strategy: option, + // carries the name when Rust DelegationStrategy::Custom(s) is used + strategy-custom: option, + from-cache: bool, + } + + record delegation-extension { + chain: list, + depth: u32, + origin-subject-id: option, + actor-subject-id: option, + delegated: bool, + // age-seconds as string to avoid f64 portability issues + age-seconds: string, + } + + // --------------------------------------------------------------------------- + // Extensions container + // --------------------------------------------------------------------------- + + record extensions { + request: option, + security: option, + http: option, + meta: option, + agent: option, + mcp: option, + completion: option, + provenance: option, + llm: option, + framework: option, + delegation: option, + // custom: escape hatch for plugin-defined arbitrary key-value data + // (maps to cpex-core Extensions.custom: Option>) + custom: option, + } + + // --------------------------------------------------------------------------- + // Violation and result + // --------------------------------------------------------------------------- + + record plugin-violation { + code: string, + reason: string, + description: option, + // details is a JSON object string (HashMap) + details: string, + plugin-name: option, + proto-error-code: option, + } + + // Verified + // metadata is a JSON string (serde_json::Value) + record hook-result { + continue-processing: bool, + modified-payload: option, + modified-extensions: option, + modified-context: option, + violation: option, + metadata: option, + } +} + +interface host-logging { + enum log-level { + trace, + debug, + info, + warn, + error, + } + + log: func(level: log-level, message: string); +} + +/// Known hook names (not exhaustive - hosts may define custom hooks): +/// +/// Legacy (typed payloads): +/// "tool_pre_invoke", "tool_post_invoke" +/// "prompt_pre_fetch", "prompt_post_fetch" +/// "resource_pre_fetch", "resource_post_fetch" +/// "identity_resolve", "token_delegate" +/// +/// CMF (MessagePayload): +/// "cmf.tool_pre_invoke", "cmf.tool_post_invoke" +/// "cmf.llm_input", "cmf.llm_output" +/// "cmf.prompt_pre_fetch", "cmf.prompt_post_fetch" +/// "cmf.resource_pre_fetch", "cmf.resource_post_fetch" +world plugin { + import wasi:io/poll@0.2.6; + import wasi:io/error@0.2.6; + import wasi:io/streams@0.2.6; + import wasi:clocks/monotonic-clock@0.2.6; + import wasi:http/types@0.2.6; + import wasi:http/outgoing-handler@0.2.6; + import host-logging; + + use types.{hook-payload, extensions, plugin-context, hook-result}; + + export handle-hook: func( + hook-name: string, + payload: hook-payload, + extensions: extensions, + ctx: plugin-context + ) -> hook-result; +} diff --git a/docs/specs/cpex-wasm-spec.md b/docs/specs/cpex-wasm-spec.md new file mode 100644 index 00000000..100a88f7 --- /dev/null +++ b/docs/specs/cpex-wasm-spec.md @@ -0,0 +1,621 @@ +# CPEX WASM Plugin — Specification + +**Status**: Draft +**Date**: July 2026 +**Source**: `crates/cpex-wasm-host` + `crates/cpex-wasm-plugin` in `github.com/contextforge-org/contextforge-plugins-framework` + +CPEX WASM extends the core plugin runtime with sandboxed execution via WebAssembly Component Model (WASI P2). It serves two audiences: + +- **Operators** — deploy untrusted or third-party plugins with enforced resource limits, capability-gated data access, and network sandboxing. +- **Plugin authors** — write the same `HookHandler` implementations as native plugins, compiled to `.wasm` components that run in isolation. + +The WASM system integrates transparently with `PluginManager` — WASM and native plugins coexist in the same pipeline, dispatched by the same executor, subject to the same 5-phase execution model. + +--- + +## 1. Architecture + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ PluginManager │ +│ register_factory("wasm://...", WasmPluginFactory) │ +│ load_config(yaml) → factory.create(PluginConfig) │ +│ invoke_named::(hook, payload, ext, ctx_table) │ +└───────────────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Executor (cpex-core) │ +│ group_by_mode → 5-phase dispatch │ +│ filter_extensions(ext, capabilities) → filtered view │ +│ set write tokens (write_headers, append_labels, etc.) │ +│ timeout + on_error policy │ +└───────────────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ WasmBridgeHandler (cpex-wasm-host) │ +│ Payload dispatch: │ +│ MessagePayload → HookPayload::Cmf (structured) │ +│ IdentityPayload → HookPayload::Identity (structured) │ +│ DelegationPayload → HookPayload::Delegation (structured) │ +│ Custom types → HookPayload::Custom (JSON bytes) │ +│ native_extensions_to_wit(filtered) → WIT types │ +│ SandboxManager::invoke() → WASM execution │ +│ wit_hook_result_to_native_filtered() → ErasedResultFields │ +│ validate_extension_modifications() → accept or reject │ +└───────────────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Wasmtime Sandbox │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ SharedEngine (1 per factory) │ │ +│ │ Engine config: component-model + fuel + epoch │ │ +│ │ Linker: WASI P2 + WASI HTTP + host-logging │ │ +│ │ Epoch ticker: 1 thread, 1ms resolution │ │ +│ ├────────────────────────────────────────────────────────────┤ │ +│ │ Store (1 per plugin, isolated) │ │ +│ │ WasmPluginState: WASI ctx, HTTP ctx, NetworkPolicy, │ │ +│ │ ResourceTable, StoreLimits, plugin_name│ │ +│ │ Fuel: reset per invocation │ │ +│ │ Epoch deadline: reset per invocation │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ Guest Component │ │ +│ │ export: handle-hook(hook_name, payload, ext, ctx) │ │ +│ │ import: wasi:io, wasi:clocks, wasi:http, host-logging │ │ +│ └────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Crate Layout + +### cpex-wasm-host (host runtime) + +| Module | Purpose | +|--------|---------| +| `sandbox_manager` | Wasmtime engine, store, plugin loading, invocation, `SharedEngine` | +| `factory` | `WasmPluginFactory`, `WasmBridgeHandler` — integrates with `PluginManager` | +| `conversions` | Bidirectional native ↔ WIT type mapping (~1300 lines) | +| `policy_loader` | `SandboxPolicy` parsing, WASI context builder | +| `payload_registry` | Type-erased serialization for custom payload types | + +### cpex-wasm-plugin (guest SDK) + +| Module | Purpose | +|--------|---------| +| `lib.rs` | WIT bindings, `register_wasm_plugin!` macro, `host_log`/`cpex_log!` | +| `conversions.rs` | WIT → native type conversion for the guest side (~730 lines) | +| `plugins/` | Feature-gated demo plugins (identity-checker, header-injector, audit-logger, token-attenuator, noop) | + +--- + +## 3. WIT Interface Contract + +**Package:** `cpex:plugin` + +### 3.1 Types Interface + +Defines all structured types crossing the boundary: + +- **CMF**: `role`, `channel`, `resource-type`, `content-part` (12 variants), `message`, `message-payload` +- **Identity**: `identity-payload` with source, headers, output fields +- **Delegation**: `delegation-payload` with target, attenuation, output fields +- **Extensions**: All 11 slots (request, security, http, meta, agent, mcp, completion, provenance, llm, framework, delegation) + custom +- **Result**: `hook-result` with continue_processing, modified_payload, modified_extensions, modified_context, violation, metadata + +### 3.2 Host-Logging Interface + +```wit +interface host-logging { + enum log-level { trace, debug, info, warn, error } + log: func(level: log-level, message: string); +} +``` + +### 3.3 World Definition + +```wit +world plugin { + import wasi:io/poll@0.2.6; + import wasi:io/error@0.2.6; + import wasi:io/streams@0.2.6; + import wasi:clocks/monotonic-clock@0.2.6; + import wasi:http/types@0.2.6; + import wasi:http/outgoing-handler@0.2.6; + import host-logging; + + export handle-hook: func( + hook-name: string, + payload: hook-payload, + extensions: extensions, + ctx: plugin-context + ) -> hook-result; +} +``` + +--- + +## 4. Security Model + +### 4.1 Capability-Based Extension Filtering + +The executor filters extensions before they reach the handler. Slots without declared capabilities are `None`. The plugin never receives unauthorized data. + +| Capability | Read Access | Write Access | +|-----------|-------------|--------------| +| `read_labels` | Security labels | — | +| `append_labels` | — | Add labels (monotonic) | +| `read_subject` | Subject id + type | — | +| `read_roles` | Subject roles | — | +| `read_teams` | Subject teams | — | +| `read_claims` | Subject claims | — | +| `read_permissions` | Subject permissions | — | +| `read_client` | OAuth client | — | +| `read_workload` | Workload identity | — | +| `read_headers` | HTTP headers | — | +| `write_headers` | — | Modify HTTP headers | +| `read_agent` | Agent context | — | +| `read_delegation` | Delegation chain | — | +| `append_delegation` | — | Append to chain | +| `read_inbound_credentials` | Raw tokens | — | +| `read_delegated_tokens` | Minted tokens | — | + +### 4.2 Post-Invocation Validation (Defense-in-Depth) + +After the guest returns, the handler validates: + +1. **Immutable tier** — Arc pointer identity on immutable slots (request, agent, mcp, completion, provenance, llm, framework, meta). Tampering → reject all extension modifications. +2. **Monotonic tier** — Security labels must be a superset of the originals. Label removal → reject. +3. **Write authorization** — HTTP/labels/delegation modifications require the corresponding write capability. Unauthorized writes → reject. +4. **Filtered slot preservation** — Slots hidden from the guest are preserved unchanged. + +### 4.3 Sandbox Enforcement + +| Layer | Scope | Default | +|-------|-------|---------| +| Fuel budget | Per-invocation (reset each call) | Unlimited | +| Execution timeout | Per-invocation (epoch-based) | 5,000 ms | +| Memory limit | Store lifetime | Unlimited | +| Network allowlist | Per outbound HTTP request | Deny all | +| Filesystem | Store lifetime (preopened dirs) | Deny all | +| Environment variables | Store lifetime | None exposed | + +### 4.4 Credential Exclusion + +Fields marked `#[serde(skip)]` never cross the boundary: +- `RawInboundToken.token` +- `RawDelegatedToken.token` +- `IdentityPayload.raw_token` +- `DelegationPayload.bearer_token` + +--- + +## 5. Data Flow + +### 5.1 Outbound (Host → Guest) + +``` +PluginPayload + → downcast to MessagePayload? → native_payload_to_wit() → HookPayload::Cmf + → downcast to IdentityPayload? → native_identity_payload_to_wit() → HookPayload::Identity + → downcast to DelegationPayload? → native_delegation_payload_to_wit() → HookPayload::Delegation + → PayloadSerializerRegistry.serialize() → HookPayload::Custom(type_name, bytes) + +Extensions (already filtered by executor) + → native_extensions_to_wit() → WIT Extensions record + (each slot: Arc → field-by-field clone into WIT structs) + (JSON-encoded: custom slot, tool arguments, context entries) + +PluginContext + → native_context_to_wit() → WIT PluginContext + (local_state/global_state → JSON-encoded key-value pairs) +``` + +### 5.2 Inbound (Guest → Host) + +``` +HookResult + → modified_payload: + Cmf → wit_cmf_payload_to_native() + Identity → wit_identity_payload_to_native() + Delegation → wit_delegation_payload_to_native() + Custom → PayloadSerializerRegistry.deserialize() + → modified_extensions: + original.cow_copy() (preserves immutable Arc pointers) + overlay mutable slots ONLY if guest was authorized to see them + → modified_context: + write back local_state + global_state to caller's ctx reference + → violation: + wit_violation_to_native() +``` + +### 5.3 Error Classification + +Wasmtime errors are pattern-matched into `PluginError` variants: + +| Error pattern | Variant | Code | +|--------------|---------|------| +| "epoch deadline" | `Timeout` | — | +| "all fuel consumed" / "fuel" | `Execution` | `fuel_exhausted` | +| "memory" + "grow"/"limit" | `Execution` | `memory_limit` | +| "unreachable" / "wasm trap" / "panic" | `Execution` | `wasm_trap` | +| "request denied" | `Execution` | `network_denied` | +| Other | `Execution` | None | + +--- + +## 6. Plugin SDK + +### 6.1 The `register_wasm_plugin!` Macro + +```rust +register_wasm_plugin!(MyPlugin, [CmfHook, IdentityHook]); +``` + +Generates: +- A `Guest` implementation for the WIT `handle-hook` export +- Payload routing logic (CMF fast-path, Custom variant matching) +- Bidirectional type conversion calls +- A synchronous async executor (`__block_on`) for driving the handler future + +### 6.2 Structured Logging + +```rust +use crate::cpex_log; + +cpex_log!(info, "processing tool '{}' for subject {:?}", tool_name, subject_id); +cpex_log!(warn, "PII access without hr_admin role"); +``` + +Routes to the host's `tracing` subscriber with `plugin=` as a span field. +In `#[cfg(test)]` mode, falls back to `eprintln!` (no host import available natively). + +### 6.3 Available Hook Types + +| Hook Type | Payload | WIT Variant | +|-----------|---------|-------------| +| `CmfHook` | `MessagePayload` | `cmf` (structured) | +| `IdentityHook` | `IdentityPayload` | `identity` (structured) | +| `TokenDelegateHook` | `DelegationPayload` | `delegation` (structured) | +| Custom (via `define_hook!`) | Any `WasmSerializablePayload` | `custom` (JSON) | + +--- + +## 7. Configuration + +```yaml +plugins: + - name: my-plugin + kind: wasm://my-plugin.wasm # wasm:// prefix triggers WasmPluginFactory + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + mode: sequential # sequential|transform|audit|concurrent|fire_and_forget + priority: 50 # lower = earlier (within same mode) + on_error: fail # fail|ignore|disable (circuit breaker) + capabilities: # extension visibility + write grants + - read_labels + - append_labels + - read_headers + - write_headers + config: + sandbox_policy: + allowed_filesystem: [] # deny all by default + allowed_network: + - "api.internal.svc" # hostname allowlist (exact + subdomain match) + allowed_env: [] # env vars exposed to plugin + resources: + max_memory_bytes: 10485760 # 10 MB linear memory cap + max_fuel: 1000000000 # instructions per invocation (~1 billion) + max_execution_time_ms: 5000 # per-invocation timeout (epoch-based) + max_instances: 10 # WASM module instance limit + max_tables: 10 # WASM table limit +``` + +--- + +## 8. Performance Characteristics + +### 8.1 Benchmark Results + +| Scenario | Latency | Overhead vs Native | +|----------|:-------:|:------------------:| +| Native handler (noop) | 84 ns | 1x | +| Type conversion only (no WASM) | 843 ns | 10x | +| WASM noop (minimal payload) | 4.8 µs | 57x | +| WASM with full extensions | 7.9 µs | 94x | + +### 8.2 Throughput + +| Scenario | Calls/sec/core | +|----------|:--------------:| +| Native plugin | ~12,000,000 | +| WASM (minimal) | ~207,000 | +| WASM (full extensions) | ~126,000 | + +### 8.3 Cost Breakdown + +| Stage | Cost | +|-------|------| +| Mutex acquire | ~50 ns | +| Fuel + epoch reset | ~40 ns | +| Type conversion (native → WIT) | ~843 ns | +| Wasmtime component dispatch | ~2.5 µs | +| Guest execution (noop) | ~500 ns | +| Result conversion (WIT → native) | ~500 ns - 2 µs | +| Post-invocation validation | ~100 ns | + +### 8.4 Shared Engine Optimization + +All plugins from the same `WasmPluginFactory` share one `Engine` + one epoch ticker thread. N plugins = 1 thread (not N threads). Each plugin gets an isolated `Store` (separate memory, fuel, limits). + +--- + +## 9. Build & Test + +### 9.1 Building Plugins + +```bash +rustup target add wasm32-wasip2 +cd crates/cpex-wasm-plugin +cargo build --target wasm32-wasip2 --release --features --no-default-features +``` + +One binary per feature (WIT single-export constraint). + +### 9.2 Running Tests + +```bash +# Host-side (35 tests: security, conversions, errors, policy) +cargo test -p cpex-wasm-host + +# Plugin-side (12 tests: all 4 plugins × their hook types) +cargo test --manifest-path crates/cpex-wasm-plugin/Cargo.toml \ + --features identity-checker --no-default-features +cargo test --manifest-path crates/cpex-wasm-plugin/Cargo.toml \ + --features header-injector --no-default-features +cargo test --manifest-path crates/cpex-wasm-plugin/Cargo.toml \ + --features audit-logger --no-default-features +cargo test --manifest-path crates/cpex-wasm-plugin/Cargo.toml \ + --features token-attenuator --no-default-features +``` + +### 9.3 Running Benchmarks + +```bash +cd crates/cpex-wasm-plugin +cargo build --target wasm32-wasip2 --release --features noop --no-default-features +cp target/wasm32-wasip2/release/cpex_wasm_plugin.wasm ../cpex-wasm-host/wasm/noop.wasm +cd ../.. +cargo bench -p cpex-wasm-host +``` + +--- + +## 10. Limitations + +### 10.1 Structural (inherent to WASM model) + +- One plugin per `.wasm` binary (WIT single-export) +- Single-threaded per plugin (Store is not Sync) +- No raw credential access (security boundary) +- Rust-only guest SDK + +### 10.2 Current Implementation + +- No pre-compiled module caching (compile from source each load) +- No plugin instance pooling (serial under concurrent load) +- No streaming support (full payload buffering) +- No schema versioning (WIT changes are breaking) +- No plugin manifest / health check protocol +- Epoch ticker thread never stops (leaks on dynamic unload) +- Error classification relies on wasmtime error message strings +- Silent `.ok()` on some serialization failures in conversions + +### 10.3 By Design + +- `#[serde(skip)]` fields excluded from WIT (credentials) +- Custom extension slot is unrestricted (no capability gating) +- Metadata field dropped at the erasure boundary (consistent with native) +- Guest stdio inherited from host (plugins should use `cpex_log!`) + +--- + +## 11. Design Decisions + +This section records the key architectural decisions and their rationale across both `cpex-wasm-host` and `cpex-wasm-plugin`. + +### 11.1 Transparent Integration via PluginFactory Trait + +**Decision:** WASM plugins register with the same `PluginManager` as native plugins via `WasmPluginFactory` implementing cpex-core's `PluginFactory` trait. + +**Rationale:** Operators should not need to change their pipeline configuration or dispatch logic when switching between native and WASM plugins. A WASM plugin and a native plugin can coexist in the same hook pipeline, dispatched by the same executor, subject to the same 5-phase execution model. This makes WASM an implementation detail — the security boundary is invisible to the orchestration layer. + +**Consequence:** `WasmBridgeHandler` implements `AnyHookHandler` with the same signature as native handlers. The executor calls `.invoke(payload, extensions, ctx)` identically for both. + +### 11.2 SharedEngine — One Epoch Ticker Per Factory + +**Decision:** All plugins loaded from a single `WasmPluginFactory` share one `wasmtime::Engine` and one epoch ticker thread (1ms resolution). Each plugin gets its own `Store` with independent memory, fuel, and state. + +**Rationale:** Epoch interruption requires a dedicated thread that calls `engine.increment_epoch()` in a loop. Without sharing, N plugins would spawn N threads doing identical work. Sharing the engine also enables future optimizations (module caching, ahead-of-time compilation) since compiled modules are engine-scoped. + +**Trade-off:** The epoch ticker thread spawned in `SharedEngine::new()` runs forever — there is no shutdown mechanism. This is an acknowledged leak on dynamic factory unload. + +### 11.3 Per-Invocation Resource Reset + +**Decision:** Both fuel budget and epoch deadline are reset at the start of every `invoke()` call. Memory limits are store-lifetime (not reset). + +**Rationale:** Fuel measures instruction cost and must be fresh each call to prevent a long-lived plugin from silently degrading after accumulating work. The epoch deadline is a wall-clock timeout — it must start from zero each invocation or a plugin that ran fast once could time out later despite being well-behaved. Memory, however, is additive (linear memory never shrinks) so the limit is structural. + +### 11.4 Dual Payload Path — Structured WIT vs JSON Bytes + +**Decision:** Built-in payloads (CMF, Identity, Delegation) use structured WIT types with field-by-field conversion. Custom payload types use `HookPayload::Custom(type_name, json_bytes)`. + +**Rationale:** Structured WIT types give zero-parsing overhead for the common case (most plugins handle CMF messages). For extensibility, the `Custom` variant lets users define arbitrary payload types without modifying the WIT contract. The host's `PayloadSerializerRegistry` maps `TypeId` → `(type_name, serialize_fn, deserialize_fn)` so the dispatch is O(1). + +**Trade-off:** Custom payloads pay a JSON serialization cost on both sides. The structured path for CMF/Identity/Delegation avoids this. + +### 11.5 Capability-Based Extension Filtering (Pre-Invocation) + +**Decision:** The executor strips extension slots the plugin has no declared capability for _before_ converting to WIT. The guest never receives unauthorized data. + +**Rationale:** Defense at the narrowest possible point. Even if the WIT conversion code has bugs, an unauthorized slot is `None` at the source. This is the primary access control mechanism; post-invocation validation is defense-in-depth. + +### 11.6 Post-Invocation 4-Layer Validation (Defense-in-Depth) + +**Decision:** After the guest returns, the host validates extension modifications through 4 independent checks before accepting them. + +**Layer 1 — Immutable tier:** Arc pointer identity on immutable slots (request, agent, mcp, completion, provenance, llm, framework, meta). A malicious guest that reconstructs a "similar" object is detected because the Arc address differs. + +**Layer 2 — Monotonic labels:** Security labels must be a superset of originals. Removal is never valid. + +**Layer 3 — Write authorization:** HTTP/labels/delegation mutations require the corresponding write capability. A plugin with `read_headers` but not `write_headers` cannot modify HTTP headers. + +**Layer 4 — Filtered slot preservation:** Slots hidden from the guest (filtered out pre-invocation) are preserved unchanged in the result. + +**Rationale:** The WASM boundary is a trust boundary. Unlike native plugins (which run in-process and are trusted to some degree), WASM plugins may be third-party. The 4-layer model ensures that even a fully compromised guest cannot escalate privileges beyond its declared capabilities. + +### 11.7 Fail-Closed on Decode Errors + +**Decision:** If a custom payload intended for a declared handler fails to deserialize, the plugin returns a deny violation (`wasm_payload_decode_error`), not an allow. + +**Rationale:** Silently allowing on a decode failure would skip whatever security check this plugin enforces. If an attacker can craft a payload that causes a parse error, they could bypass the plugin entirely. Failing closed ensures the worst case is a false deny (operational disruption), not a false allow (security bypass). + +### 11.8 Credential Exclusion from WIT Types + +**Decision:** Fields marked `#[serde(skip)]` on native types (raw tokens, bearer tokens, token bytes) are excluded from the WIT type definitions entirely — they do not exist in the schema. + +**Rationale:** Credential material should never cross the sandbox boundary. Even with capability gating, a compromised WASM guest could exfiltrate tokens through side channels (timing, memory patterns). By excluding credentials at the schema level, there is no path — intentional or accidental — for tokens to enter the guest's address space. + +### 11.9 Feature-Flag-Per-Plugin Binary + +**Decision:** Each plugin compiles to a separate `.wasm` binary via Cargo feature flags. The SDK crate has 13+ features, one per plugin. + +**Rationale:** The WIT Component Model exports a single world per component. A component can only export one `handle-hook` function. Multiple plugins in one binary would require a dispatch layer inside the guest — adding complexity and defeating the isolation model. One binary per plugin gives clean isolation: each has its own Store, memory space, and fault domain. + +**Consequence:** Build commands require `--features --no-default-features` and produce one `.wasm` artifact per invocation. + +### 11.10 Macro-Driven SDK (`register_wasm_plugin!`) + +**Decision:** Plugin authors never touch WIT types directly. The `register_wasm_plugin!` macro generates the complete `Guest` impl, including payload routing, type conversion, and the synchronous async executor. + +**Rationale:** The WIT-generated types are mechanically derived and verbose. Plugin authors should write standard `HookHandler` implementations (identical to native plugins) and get WASM compatibility for free. This lowers the barrier to entry and ensures all WASM plugins follow the same dispatch pattern. + +**Generated code includes:** +- `Guest::handle_hook()` with match arms for each payload variant +- Compile-time dispatch via `downcast_ref` (CMF) and `payload_type_name()` matching (Custom) +- `__block_on()` — a trivial poll-loop for driving the handler's `async` future synchronously + +### 11.11 Synchronous Async Executor (`__block_on`) + +**Decision:** WASM plugins use a no-op waker poll loop to drive `HookHandler::handle()` futures to completion synchronously. + +**Rationale:** WASM is single-threaded with no ambient async runtime (no tokio, no epoll). However, `HookHandler` is an `async_trait` for API consistency with native plugins. In practice, WASM handlers resolve on the first `poll()` since they cannot await network or timers directly. The trivial executor avoids pulling in an async runtime dependency that cannot function in WASM. + +**Trade-off:** If a handler ever yields `Pending` without external stimulus (impossible today), the loop spin-waits until fuel is exhausted. This is acceptable because WASM handlers are designed to be non-blocking. + +### 11.12 Network Allowlist via WASI HTTP Hooks + +**Decision:** Outbound HTTP requests are intercepted at the `WasiHttpHooks::send_request()` level and checked against a per-plugin hostname allowlist. Requests to non-allowed hosts return `ErrorCode::HttpRequestDenied`. + +**Rationale:** Importing `wasi:http/outgoing-handler` enables HTTP but does not inherently restrict destinations. The `NetworkPolicy` hook provides a clean interception point before any network I/O occurs. Matching is by exact hostname or subdomain (e.g., `api.example.com` matches allowed host `example.com`). + +**Consequence:** Network policy errors surface as `PluginError::Execution { code: "network_denied" }` — distinguishable from other failures for accurate error reporting. + +### 11.13 Filesystem via WASI Preopens (No World-Level Import) + +**Decision:** Filesystem access is controlled by WASI preopened directories in the Store context, not by importing `wasi:filesystem` at the WIT world level. + +**Rationale:** Importing filesystem interfaces at the world level makes them available unconditionally. Preopens are strictly additive and can be configured per-plugin: a plugin with no preopens has zero filesystem visibility regardless of what the WIT world declares. This gives operators fine-grained path-level control. + +### 11.14 JSON-as-String for Recursive/Complex WIT Fields + +**Decision:** Fields that are recursive or structurally complex (e.g., `prompt-result.messages`, `tool-call.arguments`, `context entries`) are serialized as JSON strings in the WIT interface. + +**Rationale:** WIT does not support recursive types, self-referential structures, or arbitrary-depth nesting. Rather than flattening these into deeply nested WIT records (which would be brittle and hard to evolve), they are encoded as JSON strings. The guest deserializes them locally. This is an explicit trade-off: slightly higher runtime cost for schema flexibility. + +### 11.15 Copy-on-Write Extension Mutation Model + +**Decision:** The host creates a CoW copy of extensions before passing to the guest. On return, only mutable slots that the guest was authorized to see are overlaid back. Immutable slots preserve their original Arc pointers. + +**Rationale:** The executor's mutation model requires that a plugin's modifications are validated before being applied. By operating on a copy, the original is never corrupted — even if validation rejects all changes. This also enables the immutable-tier check (Arc pointer identity comparison). + +### 11.16 Cross-Invocation State via WASM Linear Memory + +**Decision:** Module-level `static` variables in the guest persist across invocations because the Store (and thus linear memory) is kept alive between calls. The `OnceLock` pattern provides lazy initialization. + +**Rationale:** Some plugins need initialization state (config parsing, connection caches, lookup tables) that should not be rebuilt on every call. Since each plugin has a dedicated Store that lives for the factory's lifetime, linear memory serves as persistent storage naturally. No explicit state-management API is needed. + +**Constraint:** The guest struct is `Default::default()`-constructed on each call — instance fields do not persist. Only module-level statics survive across invocations. + +### 11.17 WIT Keyword Workarounds + +**Decision:** Several Rust type names conflict with WIT keywords. These are renamed at the WIT level: +- `Resource` (Rust enum) → `%resource` (WIT escaped keyword) +- `Return` (enum variant) → `return-complete` (WIT rename) +- `Resource` (struct) → `resource-info` (WIT rename) + +**Rationale:** WIT reserves `resource` and `return` as keywords. Rather than renaming the Rust types (which would break the native API), the WIT schema uses escaping or alternative names. The conversion layer maps between them. + +### 11.18 Structured Host Logging via WIT Import + +**Decision:** Guest plugins log through a WIT-imported `host-logging` interface that routes to the host's `tracing` subscriber with a `plugin=` span field. A `cpex_log!` macro provides ergonomic formatting. + +**Rationale:** Guest `eprintln!` goes to the host's inherited stdio — unstructured, unattributed, and lost in production. The host-logging import gives structured logs that participate in the operator's observability stack (filtering by plugin name, log level, etc.). In `#[cfg(test)]` mode, the import doesn't exist, so the macro falls back to `eprintln!`. + +### 11.19 Error Classification via String Matching + +**Decision:** Wasmtime errors are classified into `PluginError` variants by pattern-matching on the error message string (e.g., `"epoch deadline"` → `Timeout`, `"all fuel consumed"` → `Execution { code: "fuel_exhausted" }`). + +**Rationale:** Wasmtime does not expose structured error types for all failure modes. The error message is the only reliable signal for distinguishing timeout vs. fuel exhaustion vs. memory limit vs. trap. This enables the executor to apply correct `on_error` policies (circuit breakers, timeout logging, error aggregation) per failure type. + +**Acknowledged limitation:** This is fragile across wasmtime version upgrades if error messages change. A future improvement would be to match on wasmtime's `Trap` enum variants where available. + +### 11.20 Deny-All Default Policy + +**Decision:** When no `sandbox_policy` is configured (or all allowlists are empty), the plugin runs in a fully locked-down sandbox: no filesystem, no network, no environment variables. Resource limits default to unlimited (wasmtime defaults). + +**Rationale:** Secure-by-default. An operator who forgets to configure a policy gets maximum isolation, not maximum access. Plugins that need external access must explicitly declare it — the configuration is the audit trail. + +### 11.21 Type-Erased Payload Registry + +**Decision:** The `PayloadSerializerRegistry` maps `TypeId` → `(type_name, serialize_fn, deserialize_fn)` using closures that capture the concrete type. It is built once at factory creation and shared immutably. + +**Rationale:** The `WasmBridgeHandler` receives `&dyn PluginPayload` (type-erased). It needs to: +1. Determine if the payload type is known +2. Serialize it to a type-discriminated byte format +3. Later, deserialize the guest's returned custom payload back to a concrete type + +The registry solves this with O(1) `TypeId` lookup and O(1) `type_name` lookup, without requiring the handler to know about every possible payload type at compile time. + +### 11.22 Unhandled Payloads Return Allow + +**Decision:** If the guest receives a payload variant it has no handler for (e.g., a CMF hook plugin receiving a Delegation payload), it returns `continue_processing: true` with no modifications — equivalent to a no-op allow. + +**Rationale:** Consistent with native plugin behavior. A plugin is only registered for specific hooks. If the dispatcher sends it a payload type it doesn't handle (which can happen during pipeline evolution), silently allowing is correct — the plugin is not responsible for that payload type. + +**Contrast with 11.7:** Decode errors on a _declared_ handler fail closed. _Undeclared_ payload types pass through. The distinction is: "I handle this type but the data is corrupt" vs. "this type is not my responsibility." + +### 11.23 Extensions Record with 11 Typed Slots + Custom Escape Hatch + +**Decision:** The WIT `extensions` record has 11 explicitly typed optional slots (request, security, http, meta, agent, mcp, completion, provenance, llm, framework, delegation) plus a `custom: option` for arbitrary JSON. + +**Rationale:** Typed slots give the guest structured access to the most common extension data without JSON parsing. The `custom` slot enables forward-compatibility: new extension types can be added without a WIT schema change (at the cost of losing type safety for that slot). + +**Trade-off:** The custom slot is unrestricted by the capability model — any plugin can read/write it. This is by design: custom extensions are application-specific and cannot be pre-categorized into the capability taxonomy. + +### 11.24 WASI P2 (Preview 2) as the Target Platform + +**Decision:** Target `wasm32-wasip2` and use WASI P2 interfaces (version 0.2.6) for all host capabilities. + +**Rationale:** WASI P2 provides the Component Model, typed interfaces, and the `outgoing-handler` HTTP pattern. WASI P1 (preview 1) uses a POSIX-like model that doesn't support typed imports/exports or the Component Model. P2 is required for `wit-bindgen` and the `wasmtime::component` API. The 0.2.6 version is the latest stable specification at time of implementation. + +### 11.25 Mutex-Protected SandboxManager + +**Decision:** Each `WasmBridgeHandler` holds an `Arc>`. Invocations acquire the lock before calling into WASM. + +**Rationale:** A wasmtime `Store` is `!Sync` — it cannot be shared across threads. The Mutex serializes access. Under concurrent load, this means one plugin processes one request at a time (serial execution per plugin). This is an explicit trade-off: simplicity and safety over throughput. + +**Future path:** Instance pooling (multiple Stores per plugin, round-robin dispatch) would remove this bottleneck without changing the security model.