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 b2927d83..e4aedbd3 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" @@ -99,9 +114,15 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" 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.1" @@ -515,6 +536,9 @@ name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +dependencies = [ + "allocator-api2", +] [[package]] name = "byteorder" @@ -528,6 +552,74 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[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.52.0", +] + +[[package]] +name = "cap-net-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +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.52.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 = "cc" version = "1.2.62" @@ -542,9 +634,9 @@ dependencies = [ [[package]] name = "cedar-policy" -version = "4.11.2" +version = "4.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fdd98501120bc31fc09f92d3f58c489f4ee4b846904109ac63dd8256de7b84d" +checksum = "674ca6ef6e44a1e29e6c07f6b2ab7e6913938d6049204d48e7849703d02443ce" dependencies = [ "cedar-policy-core", "cedar-policy-formatter", @@ -562,9 +654,9 @@ dependencies = [ [[package]] name = "cedar-policy-core" -version = "4.11.2" +version = "4.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1d41088b497e39b6789e87e05ad6549b7f64a663a4d6ecd73a748dc3a9a98a9" +checksum = "3df781c108240c3b3778c586c6a1b234355f1a2f9d35e08630b63b2590c59dbb" dependencies = [ "chrono", "educe", @@ -590,9 +682,9 @@ dependencies = [ [[package]] name = "cedar-policy-formatter" -version = "4.11.2" +version = "4.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb6c28be47d77dcbc04c2a80181a2c0db9725ed60d24832c6401396cc2ae114b" +checksum = "e293607563253e249d19cf4d36ac05821955170a63cf6d65deb4db60138b192e" dependencies = [ "cedar-policy-core", "itertools 0.14.0", @@ -631,11 +723,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" -version = "0.4.45" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -654,6 +757,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.18", +] + [[package]] name = "colored" version = "3.1.1" @@ -925,6 +1037,33 @@ dependencies = [ "url", ] +[[package]] +name = "cpex-wasm-host" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "cpex-core", + "hyper", + "serde", + "serde_json", + "serde_yaml", + "tokio", + "wasmtime", + "wasmtime-wasi", + "wasmtime-wasi-http", +] + +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -934,6 +1073,185 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "cranelift-assembler-x64" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c4ebb31662e2051dcc49b7342d222405a99e951720756cc4b93315972abd67" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106dfc2ec96ec1c3a8a250602e936712e00a381df032f7a8ad175c8f768c03bb" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5694aa8a2eb2571a15b3feee38d16ccaf2712200e7b5c9ae0479069bdfb46949" +dependencies = [ + "cranelift-entity", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-bitset" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93ab349d30a5fad9699440ee7ccb435374e8a8735dcca26696a4245bcefcc47e" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e95970bdb51d145c828a114a1084cb8b63e65569a51600ad398cb49fa78b062" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.17.0", + "libm", + "log", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "smallvec", + "target-lexicon", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8414b8ecc81f89f8a3f2c5cbc785b9ed690200cd6f9d780e96a92f88879704" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "631c4e5db42e6a0f9e7a68f18f7faba3692862114870c7c598aee7e0e5677e59" + +[[package]] +name = "cranelift-control" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09c6e92c825abfbb739a4beaa5db3988f98a96a68d6ea656f562098efc142976" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55e57cd185782abada9ab2606bfe88d0abc0d42d83a7d432dcf69991e17fe76e" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-frontend" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0f17e48d15e29552e2f264d302c31a661a830196d879bbdaf4d7b2bf2f7011" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "407b80b46934c9dce9a6581f0e80d079b7a69e11372fb03d7dce4c7ba3fee4e3" + +[[package]] +name = "cranelift-native" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a40e056e421d9a6c757983f2184f765ae1c28a51555d3877e98afc97ce5705b" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.132.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cdbadda21e49798825a1ec795dab30bcb03235891f662b5ccf23fa45b39682f" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -969,7 +1287,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", "fiat-crypto", @@ -1053,6 +1371,15 @@ 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" @@ -1104,6 +1431,27 @@ dependencies = [ "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.5" @@ -1217,6 +1565,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" @@ -1226,6 +1586,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.3.2" @@ -1342,6 +1711,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -1375,6 +1750,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.52.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1469,6 +1855,20 @@ dependencies = [ "slab", ] +[[package]] +name = "fxprof-processed-profile" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" +dependencies = [ + "bitflags", + "debugid", + "rustc-hash", + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1516,10 +1916,23 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] +[[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]] name = "group" version = "0.13.0" @@ -1565,6 +1978,12 @@ dependencies = [ "foldhash 0.1.5", ] +[[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.0" @@ -1574,6 +1993,8 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", + "serde", + "serde_core", ] [[package]] @@ -1707,7 +2128,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -1923,6 +2344,22 @@ 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.52.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" @@ -1953,6 +2390,26 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "ittapi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" +dependencies = [ + "anyhow", + "ittapi-sys", + "log", +] + +[[package]] +name = "ittapi-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" +dependencies = [ + "cc", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -1999,7 +2456,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -2013,7 +2470,7 @@ dependencies = [ "ena", "itertools 0.14.0", "lalrpop-util", - "petgraph", + "petgraph 0.7.1", "pico-args", "regex", "regex-syntax", @@ -2043,6 +2500,12 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cc46bac87ef8093eed6f272babb833b6443374399985ac8ed28471ee0918545" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2051,9 +2514,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -2061,6 +2524,15 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -2144,18 +2616,42 @@ 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 = "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.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix", +] + [[package]] name = "miette" version = "7.6.0" @@ -2376,6 +2872,18 @@ 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.0", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2491,13 +2999,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", ] @@ -2573,12 +3091,30 @@ 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 = "portable-atomic" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2745,6 +3281,29 @@ dependencies = [ "cc", ] +[[package]] +name = "pulley-interpreter" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6ccdd6fc70f6c33eb21cb8fe05a71a5f7ee4cbef7a093a8a87dd8a908697cd" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e00101fdb6fcaf9ea98a1994be11861d7e0c910c718c41bae373c44179e3160c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2767,9 +3326,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -2842,6 +3401,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2880,11 +3450,37 @@ 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 = "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.2.4" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bae41a63fd0b8a5372f82b21e810e09a316f5dd7efd96bf08e678fb240fc1918" +checksum = "2fa6f8e4b491d7a8ef3a9550a4d71969bd0064f46e32b8dbbcc7fc60dad94fed" dependencies = [ "arc-swap", "arcstr", @@ -2918,6 +3514,17 @@ 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" @@ -2938,11 +3545,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "regalloc2" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.17.0", + "log", + "rustc-hash", + "smallvec", +] + [[package]] name = "regex" -version = "1.12.4" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -2963,9 +3584,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.11" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" @@ -3002,7 +3623,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -3068,6 +3689,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3102,6 +3729,16 @@ 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.40" @@ -3262,6 +3899,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -3305,9 +3946,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "indexmap 2.14.0", "itoa", @@ -3328,6 +3969,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3403,7 +4053,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.9.0", "opaque-debug", ] @@ -3415,7 +4065,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", ] @@ -3490,6 +4140,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smol_str" @@ -3644,6 +4297,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "term" version = "1.2.1" @@ -3653,6 +4325,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" @@ -3790,9 +4471,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ "bytes", "libc", @@ -3851,6 +4532,45 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 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.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tonic" version = "0.14.6" @@ -4110,9 +4830,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.4" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -4224,6 +4944,23 @@ 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.244.0" @@ -4231,7 +4968,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.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.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a879a421bd17c528b74721b2abf4c62e8f1d1889c2ba8c3c50d02deaf2ce395" +dependencies = [ + "leb128fmt", + "wasmparser 0.251.0", ] [[package]] @@ -4242,8 +4999,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", ] [[package]] @@ -4259,42 +5016,480 @@ dependencies = [ ] [[package]] -name = "web-sys" -version = "0.3.95" +name = "wasmparser" +version = "0.248.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a" dependencies = [ - "js-sys", - "wasm-bindgen", + "bitflags", + "hashbrown 0.17.0", + "indexmap 2.14.0", + "semver", + "serde", ] [[package]] -name = "web-time" -version = "1.1.0" +name = "wasmparser" +version = "0.251.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +checksum = "437970b35b1a85cfde9c74b2398352d8d653f3bd8e3a3db0c063ea8f5b4b36ff" dependencies = [ - "js-sys", - "wasm-bindgen", + "bitflags", + "indexmap 2.14.0", + "semver", ] [[package]] -name = "webpki-roots" -version = "1.0.7" +name = "wasmprinter" +version = "0.248.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "30b264a5410b008d4d199a92bf536eae703cbd614482fc1ec53831cf19e1c183" dependencies = [ - "rustls-pki-types", + "anyhow", + "termcolor", + "wasmparser 0.248.0", ] [[package]] -name = "wildmatch" -version = "2.6.1" +name = "wasmtime" +version = "45.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" - -[[package]] -name = "winapi" +checksum = "c191891081c3bf9f10cb579819b32b0e81695641dc639eef34b57cf10c59ad81" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f337d68a62d868f3c297517b46d20dc7e293f0da36bbee2f6ec3c30eab938bd" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "hashbrown 0.17.0", + "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f45a4fb2a6a269100b845404f2210d874eaee9ecd9efb6fc6c1e80896fab40f" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab1cc8df1940657960571d7bc9bc0eadf869c0eb95cff831a6554409f89b25b0" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser 0.248.0", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f73ccd2e0e6bd91fb0161a17ee5e2d7badbeba6eb7461d0e0e3991492bd3835d" + +[[package]] +name = "wasmtime-internal-core" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "110bf85122cd451d3b9ff67f8911d428ec9b729208abe950a0333c3244660e88" +dependencies = [ + "anyhow", + "hashbrown 0.17.0", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0236a21553c5b30ee953f413a8dc99729548b747219eef7e70f8288ed313ebe" +dependencies = [ + "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.18", + "wasmparser 0.248.0", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ccd50931b61ad593ae91e62d41a96b997b26c9308305cae4523cfef9301d9e8" +dependencies = [ + "cc", + "cfg-if", + "libc", + "rustix", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0a52ca7429272234b44f81fdf80d4793593e5c368b0f960d41fd401b1117bec" +dependencies = [ + "cc", + "object 0.39.1", + "rustix", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa6818a4864719772680694f4e4649a8600bb5efcf71111ebaf7419b266463e8" +dependencies = [ + "cfg-if", + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-unwinder" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fdc6a69c42fbbf13104ccbf0d3c096d0392f00102e2c70b542d9fca402eb162" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "log", + "object 0.39.1", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa0bd1cd696ec4b7e6f46357c0479e7739c3858e54afb69a15765402bbc1f9dd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "wasmtime-internal-winch" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2ca01234ef55acd10c0fa5a2f96c3fd422eba03b221e2e9572944d19a476a7" +dependencies = [ + "cranelift-codegen", + "gimli", + "log", + "object 0.39.1", + "target-lexicon", + "wasmparser 0.248.0", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "winch-codegen", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b3d8cfaad2d33a92ec4ed93d69e9836c04f7eb5b8dfe1dde9df47c41f20b7" +dependencies = [ + "anyhow", + "bitflags", + "heck", + "indexmap 2.14.0", + "wit-parser 0.248.0", +] + +[[package]] +name = "wasmtime-wasi" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79d0c0121dcc5152390d099fb853b9a0d79e7e0a172c12cc668ea8c5d8f883c2" +dependencies = [ + "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.1", + "rustix", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "wasmtime", + "wasmtime-wasi-io", + "wiggle", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-wasi-http" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10e03132bf0eb02270f1a1efb8ad0144c3924e0eb8258543803866e47ab808ce" +dependencies = [ + "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 = "wasmtime-wasi-io" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02ca2fd53dbd62f5f95b470b43ff1e711933e8f30ebc89d9050351a72f6e5c28" +dependencies = [ + "async-trait", + "bytes", + "futures", + "tracing", + "wasmtime", +] + +[[package]] +name = "wast" +version = "35.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" +dependencies = [ + "leb128", +] + +[[package]] +name = "wast" +version = "251.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc7467dda0a96142eb2c980329dfb62480b1e1d3622fdeb1a44e2bca6ceed74" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width 0.2.2", + "wasm-encoder 0.251.0", +] + +[[package]] +name = "wat" +version = "1.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b1086c9e85b95bd6a229a928bc6c6d0662e42af0250c88d067b418831ea4d4" +dependencies = [ + "wast 251.0.0", +] + +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "wiggle" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4cd8015611a3bc95e449ad198dfd133b7488759b0dc49b515052101eb954587" +dependencies = [ + "bitflags", + "thiserror 2.0.18", + "tracing", + "wasmtime", + "wasmtime-environ", + "wiggle-macro", +] + +[[package]] +name = "wiggle-generate" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0f655ec3eba7df68408018796911a6acdda3a41e67d6551a3766563495e333" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasmtime-environ", + "witx", +] + +[[package]] +name = "wiggle-macro" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a22f38e7b0f2a3b58bae4dd655dcedd65d56f257916a5eda5f08c40910ee9d4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "wiggle-generate", +] + +[[package]] +name = "wildmatch" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" + +[[package]] +name = "winapi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" @@ -4324,6 +5519,25 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "winch-codegen" +version = "45.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fdfab04a4d446c558a9ea994ded662d35580a17b15385b862002703aa43245" +dependencies = [ + "cranelift-assembler-x64", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.248.0", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -4539,6 +5753,28 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.52.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -4556,7 +5792,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck", - "wit-parser", + "wit-parser 0.244.0", ] [[package]] @@ -4603,10 +5839,10 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder", + "wasm-encoder 0.244.0", "wasm-metadata", - "wasmparser", - "wit-parser", + "wasmparser 0.244.0", + "wit-parser 0.244.0", ] [[package]] @@ -4624,7 +5860,38 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.244.0", +] + +[[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.0", + "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]] @@ -4645,9 +5912,9 @@ dependencies = [ [[package]] name = "xxhash-rust" -version = "0.8.15" +version = "0.8.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576" [[package]] name = "yoke" @@ -4715,18 +5982,18 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.9.0" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.5.0" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", @@ -4771,3 +6038,31 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[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 55f6fb3c..6098dc22 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", @@ -27,6 +28,9 @@ members = [ "builtins/session/valkey", "examples/go-demo/ffi", ] +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..62a10b94 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 = { path = "../cpex-orchestration", optional = true } [lints] workspace = true diff --git a/crates/cpex-core/examples/plugin_demo.rs b/crates/cpex-core/examples/plugin_demo.rs index 12cfd3f5..6accda86 100644 --- a/crates/cpex-core/examples/plugin_demo.rs +++ b/crates/cpex-core/examples/plugin_demo.rs @@ -75,6 +75,7 @@ impl Plugin for IdentityResolver { } } +// this will go to plugin.rs within cpex-wasm-plugin impl HookHandler for IdentityResolver { async fn handle( &self, diff --git a/crates/cpex-core/src/cmf/message.rs b/crates/cpex-core/src/cmf/message.rs index 6a13a2ec..3846fecd 100644 --- a/crates/cpex-core/src/cmf/message.rs +++ b/crates/cpex-core/src/cmf/message.rs @@ -227,6 +227,7 @@ pub struct MessagePayload { } crate::impl_plugin_payload!(MessagePayload); +crate::impl_wasm_payload!(MessagePayload, "cmf.message"); // --------------------------------------------------------------------------- // CmfHook — Hook Type Definition diff --git a/crates/cpex-core/src/config.rs b/crates/cpex-core/src/config.rs index b4e87f3c..dc5466d1 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}; @@ -595,6 +596,7 @@ 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 f02d725d..671b966b 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::{ @@ -358,6 +359,7 @@ 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 @@ -448,6 +450,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/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 d284bf4c..a8acf3b3 100644 --- a/crates/cpex-core/src/hooks/payload.rs +++ b/crates/cpex-core/src/hooks/payload.rs @@ -131,3 +131,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 42c95221..d5a948f2 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, @@ -285,6 +286,7 @@ 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`) @@ -367,6 +369,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 12378bfd..66bf4550 100644 --- a/crates/cpex-core/src/lib.rs +++ b/crates/cpex-core/src/lib.rs @@ -31,12 +31,20 @@ pub mod config; pub mod context; pub mod delegation; 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..61f3d2c2 --- /dev/null +++ b/crates/cpex-wasm-host/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "cpex-wasm-host" +version = "0.1.0" +edition = "2021" + +[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 = { path = "../cpex-core" } +async-trait = "0.1" +chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/cpex-wasm-host/Makefile b/crates/cpex-wasm-host/Makefile new file mode 100644 index 00000000..c3887ab2 --- /dev/null +++ b/crates/cpex-wasm-host/Makefile @@ -0,0 +1,61 @@ +.PHONY: all build build-plugin build-all-plugins run-demos run-cmf-demo run-identity-demo run-generic-demo run-capabilities-demo clean 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 + +build-all-plugins: ## Build all WASM plugin binaries (identity-checker, header-injector, audit-logger) + $(MAKE) -C $(PLUGIN_DIR) build-all + +build: build-plugin ## Build everything (default plugin + host examples) + cargo build --examples + +# --------------------------------------------------------------------------- +# Run demos +# --------------------------------------------------------------------------- + +run-demos: build ## Run all three WASM plugin demos + @echo "\n========== CMF MessagePayload Demo ==========\n" + cargo run --example wasm_plugin_demo + @echo "\n========== Identity Resolve Demo ==========\n" + cargo run --example wasm_identity_resolve_demo + @echo "\n========== Generic Payload Demo ==========\n" + cargo run --example wasm_generic_payload_demo + @echo "\n========== All demos passed ==========\n" + +run-cmf-demo: build ## Run only the CMF MessagePayload demo + cargo run --example wasm_plugin_demo + +run-identity-demo: build ## Run only the identity resolve demo + cargo run --example wasm_identity_resolve_demo + +run-generic-demo: build ## Run only the generic payload demo + cargo run --example wasm_generic_payload_demo + +run-capabilities-demo: build-all-plugins ## Build all plugins and run the capabilities demo + cargo build --examples + cargo run --example wasm_capabilities_demo + +# --------------------------------------------------------------------------- +# Cleanup +# --------------------------------------------------------------------------- + +clean: ## Remove build artifacts + cargo clean + rm -f $(WASM_DIR)/plugin.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..99d2e139 --- /dev/null +++ b/crates/cpex-wasm-host/README.md @@ -0,0 +1,299 @@ +# cpex-wasm-host + +Loads and executes a WASM plugin inside a sandboxed wasmtime environment. Enforces resource limits (fuel, memory, execution time) and network/filesystem policies. Provides a bridge to cpex-core's `PluginManager` for integration into the hook pipeline. + +## How It Works + +``` +┌──────────────────────────────────────────────────────────┐ +│ PluginManager (cpex-core) │ +│ invoke_named::("cmf.tool_pre_invoke", ...) │ +└──────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ WasmBridgeHandler (factory.rs) │ +│ native MessagePayload → WIT MessagePayload │ +│ native Extensions → WIT Extensions │ +│ native PluginContext → WIT PluginContext │ +└──────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ SandboxManager (sandbox_manager.rs) │ +│ call_handle_hook() inside wasmtime sandbox │ +│ ┌─────────────────────────────────┐ │ +│ │ Sandbox Enforcement │ │ +│ │ • Fuel budget (session-level) │ │ +│ │ • Memory limit │ │ +│ │ • Execution timeout (per-call) │ │ +│ │ • Network allowlist │ │ +│ │ • Filesystem permissions │ │ +│ │ • Environment variable filter │ │ +│ └─────────────────────────────────┘ │ +└──────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ WIT PluginResult → native PluginResult │ +│ returned to PluginManager pipeline │ +└──────────────────────────────────────────────────────────┘ +``` + +## Project Structure + +``` +cpex-wasm-host/ +├── Cargo.toml +├── config/ +│ ├── config.yaml # CMF demo config (pre/post invoke + generic) +│ └── config_identity.yaml # Identity-resolve demo config +├── examples/ +│ ├── wasm_plugin_demo.rs # CMF MessagePayload end-to-end +│ ├── wasm_identity_resolve_demo.rs # IdentityPayload typed dispatch +│ └── wasm_generic_payload_demo.rs # Custom payload pass-through +├── src/ +│ ├── lib.rs # Module exports +│ ├── sandbox_manager.rs # Core: wasmtime engine, plugin loading, invocation +│ ├── policy_loader.rs # Parses sandbox config (filesystem, network, env, resources) +│ ├── payload_registry.rs # Type-erased payload serialization registry +│ ├── conversions.rs # Native cpex-core types ↔ WIT types +│ └── factory.rs # PluginFactory bridge for PluginManager integration +├── wasm/ +│ └── plugin.wasm # Compiled WASM plugin (from cpex-wasm-plugin) +└── wit/ + ├── world.wit # Plugin interface definition + └── deps/ # WASI interface dependencies +``` + +## Components + +### SandboxManager (`sandbox_manager.rs`) + +The core component. Manages a single WASM plugin in an isolated wasmtime environment. + +- `new()` — Creates the wasmtime engine, linker, and epoch ticker thread +- `load_wasmplugin(path, config)` — Instantiates a WASM component with sandbox policies applied +- `invoke(payload, extensions, ctx)` — Calls the plugin's `handle-hook` function +- `is_loaded()` — Checks if a plugin is loaded + +**Sandbox enforcement:** +- Fuel budget is session-level — set once at load, depletes across all invocations +- Execution timeout is per-invocation — reset each call so no single call hangs +- Network requests are gated by an allowlist of hosts +- Filesystem access is limited to preopened directories with explicit permissions +- Only explicitly listed environment variables are visible to the plugin + +### Policy Loader (`policy_loader.rs`) + +Parses sandbox configuration from the plugin's `config.sandbox_policy` YAML key: + +```yaml +plugins: + - name: identity-checker + kind: "wasm://plugin.wasm" + config: + sandbox_policy: + allowed_filesystem: + - dir: /tmp/data + permission: "read" + allowed_network: + - "httpbin.org" + allowed_env: + - "API_KEY" + resources: + max_memory_bytes: 10485760 + max_fuel: 1000000000 + max_execution_time_ms: 5000 +``` + +If `sandbox_policy` is absent, deny-by-default applies (no filesystem, no network, no env vars). + +### Conversions (`conversions.rs`) + +Bidirectional type mappings between native cpex-core types and WIT types: + +| Direction | Purpose | +|---|---| +| Native → WIT | Before calling the WASM sandbox (payload, extensions, context) | +| WIT → Native | After the sandbox returns (plugin result, modified payload) | + +WIT can't represent `HashMap`, `HashSet`, `Arc`, or `serde_json::Value` directly, so these are serialized to JSON strings or flattened to lists/tuples at the boundary. + +### Factory (`factory.rs`) + +Bridges cpex-core's `PluginFactory` trait to the `SandboxManager`. Contains: + +- `WasmPluginFactory` — implements `PluginFactory::create()`, loads the plugin into the sandbox +- `WasmBridgePlugin` — implements `Plugin` trait (lifecycle) +- `WasmBridgeHandler` — implements `AnyHookHandler`, converts types and routes calls through the sandbox + +## Prerequisites + +- Rust toolchain (stable) +- `wasm32-wasip2` target installed: + ```sh + rustup target add wasm32-wasip2 + ``` +- (Optional) `wasm-tools` for validation/inspection: + ```sh + cargo install wasm-tools + ``` + +## Running the End-to-End Demos + +### Prerequisites + +```sh +rustup target add wasm32-wasip2 +``` + +### Available Demos + +| Demo | Native Equivalent | What It Tests | +|------|-------------------|---------------| +| `wasm_plugin_demo` | `plugin_demo` (cpex-core) | Single WASM plugin, CMF payload, pre/post invoke | +| `wasm_capabilities_demo` | `cmf_capabilities_demo` (cpex-core) | 3 plugins, capability isolation, extension modification | +| `wasm_identity_resolve_demo` | — | Identity resolution via custom typed payload | +| `wasm_generic_payload_demo` | — | Custom payload pass-through (unhandled type → allow) | + +### Quick Start (single-plugin demos) + +```sh +# Build the default plugin (identity-checker) and stage it +cd crates/cpex-wasm-plugin && make all && cd ../.. + +# Run the CMF demo (like plugin_demo.rs but via WASM sandbox) +cargo run -p cpex-wasm-host --example wasm_plugin_demo + +# Run the identity resolve demo +cargo run -p cpex-wasm-host --example wasm_identity_resolve_demo + +# Run the generic payload demo +cargo run -p cpex-wasm-host --example wasm_generic_payload_demo +``` + +### Capabilities Demo (3 WASM plugins, like cmf_capabilities_demo.rs) + +This demo runs **three independent WASM plugins** in the same pipeline, each with different capabilities: + +| Plugin | Binary | Capabilities | Behavior | +|--------|--------|--------------|----------| +| identity-checker | `identity-checker.wasm` | `read_labels`, `read_subject`, `read_roles` | Checks PII access | +| header-injector | `header-injector.wasm` | `read_headers`, `write_headers`, `append_labels` | Adds label + injects header | +| audit-logger | `audit-logger.wasm` | `read_headers`, `read_labels` | Read-only audit logging | + +```sh +# Build all three plugin binaries and stage them +cd crates/cpex-wasm-plugin && make build-all && cd ../.. + +# Run the capabilities demo +cargo run -p cpex-wasm-host --example wasm_capabilities_demo +``` + +Or via the host Makefile: +```sh +cd crates/cpex-wasm-host && make run-capabilities-demo +``` + +**Expected output:** + +``` +=== WASM Capabilities Demo === + +=== Phase 1: cmf.tool_pre_invoke === +[identity-checker] sees labels + subject, HTTP NOT visible → ALLOWED +[header-injector] sees HTTP, subject NOT visible → adds label + header +[audit-logger] logs tool, labels (includes "PROCESSED"), request-id + +Pre-invoke result: ALLOWED + Labels after pre-invoke: ["PROCESSED"] + Headers after pre-invoke: {"X-Processed-By": "header-injector", ...} + +=== Phase 2: cmf.tool_post_invoke === +[identity-checker] verifies result → ALLOWED +[audit-logger] logs post-invoke + +Post-invoke result: ALLOWED +=== Demo complete === +``` + +### All demos in one shot + +```sh +cd crates/cpex-wasm-plugin && make build-all && make all && cd ../.. +cargo run -p cpex-wasm-host --example wasm_plugin_demo +cargo run -p cpex-wasm-host --example wasm_identity_resolve_demo +cargo run -p cpex-wasm-host --example wasm_generic_payload_demo +cargo run -p cpex-wasm-host --example wasm_capabilities_demo +``` + +### Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `error: target 'wasm32-wasip2' not found` | Target not installed | `rustup target add wasm32-wasip2` | +| `failed to load wasm from .../plugin.wasm` | Missing binary | `cd crates/cpex-wasm-plugin && make all` | +| `failed to load wasm from .../.wasm` | Capabilities demo binaries missing | `cd crates/cpex-wasm-plugin && make build-all` | +| `failed to instantiate plugin` | Stale `.wasm` (WIT mismatch) | Rebuild plugins: `make build-all` | + +### Build tests + +```sh +cargo test -p cpex-wasm-host +``` + +## Usage + +### Via PluginManager + +```rust +use std::path::PathBuf; +use cpex_wasm_host::factory::WasmPluginFactory; +use cpex_core::manager::PluginManager; +use cpex_core::config::parse_config; + +let mgr = PluginManager::default(); + +mgr.register_factory( + "wasm://plugin.wasm", + Box::new(WasmPluginFactory::new(PathBuf::from("wasm"))), +); + +let config = parse_config(&yaml)?; +mgr.load_config(config)?; +mgr.initialize().await?; + +let (result, bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", payload, ext, None) + .await; +bg.wait_for_background_tasks().await; +``` + +## Config Format + +The `kind` field uses the `wasm://` scheme following cpex-core's convention. +The `sandbox_policy` is nested under the plugin's `config` key: + +```yaml +plugins: + - name: my-plugin + kind: "wasm://plugin.wasm" + hooks: [cmf.tool_pre_invoke] + capabilities: [read_security] + config: + sandbox_policy: + allowed_filesystem: + - dir: /tmp/data + permission: "read" + allowed_network: ["api.example.com"] + allowed_env: ["API_KEY"] + resources: + max_fuel: 500000000 + max_memory_bytes: 5242880 + max_execution_time_ms: 5000 + max_instances: 10 + max_tables: 10 +``` + +If `sandbox_policy` is absent or all lists are empty, deny-by-default applies (no filesystem, no network, no env vars). 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_identity.yaml b/crates/cpex-wasm-host/config/config_identity.yaml new file mode 100644 index 00000000..895a92a4 --- /dev/null +++ b/crates/cpex-wasm-host/config/config_identity.yaml @@ -0,0 +1,28 @@ +# Identity-resolve WASM demo configuration. +# +# One WASM plugin registered on the identity.resolve hook. The payload +# crosses the sandbox via the generic path ("cpex.identity") — the raw +# token is #[serde(skip)] and never enters the sandbox; the plugin +# resolves the subject from request headers. + +plugin_settings: + routing_enabled: false + +plugins: + - name: identity-checker + kind: wasm://plugin.wasm + hooks: [identity.resolve] + mode: sequential + priority: 10 + on_error: fail + config: + 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 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..a4ad1d44 --- /dev/null +++ b/crates/cpex-wasm-host/examples/wasm_capabilities_demo.rs @@ -0,0 +1,203 @@ +// 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() { + 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_generic_payload_demo.rs b/crates/cpex-wasm-host/examples/wasm_generic_payload_demo.rs new file mode 100644 index 00000000..2812477c --- /dev/null +++ b/crates/cpex-wasm-host/examples/wasm_generic_payload_demo.rs @@ -0,0 +1,168 @@ +// Location: ./crates/cpex-wasm-host/examples/wasm_custom_payload_demo.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// Custom payload WASM plugin demo. +// +// Demonstrates a custom payload type (ToolInvokePayload) crossing the WASM +// boundary via HookPayload::Custom. This covers the path beyond CMF: +// +// host: ToolInvokePayload → PayloadSerializerRegistry.serialize() +// → HookPayload::Custom { payload_type: "cpex.tool_invoke", bytes } +// → SandboxManager.invoke() +// WASM guest: receives Custom variant, logs receipt, returns allow() +// host: PayloadSerializerRegistry.deserialize() on any modified payload +// +// The bundled guest has no HookHandler for ToolInvokePayload, so this +// demo exercises the pass-through path: an unhandled custom payload +// returns allow(), same as a native plugin not registered for the hook. +// See wasm_identity_resolve_demo for full typed dispatch on the custom +// path (IdentityPayload -> HookHandler inside the guest). +// +// Prerequisites: build the WASM plugin first: +// cargo build -p cpex-wasm-plugin --target wasm32-wasip2 +// +// Run from the workspace root: +// cargo run -p cpex-wasm-host --example wasm_custom_payload_demo + +use std::path::PathBuf; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use cpex_core::config::parse_config; +use cpex_core::extensions::container::Extensions; +use cpex_core::extensions::security::{SecurityExtension, SubjectExtension, SubjectType}; +use cpex_core::hooks::payload::WasmSerializablePayload; +use cpex_core::hooks::trait_def::PluginResult; +use cpex_core::manager::PluginManager; +use cpex_core::{impl_plugin_payload, impl_wasm_payload}; + +use cpex_wasm_host::factory::WasmPluginFactory; +use cpex_wasm_host::payload_registry::PayloadSerializerRegistry; + +// --------------------------------------------------------------------------- +// Custom payload type — defined on the host, shared with WASM guests +// --------------------------------------------------------------------------- + +/// A structured tool-invocation payload carrying explicit user/tool identity. +/// Distinct from CMF's MessagePayload: models the invocation itself rather +/// than the conversation turn containing the tool call. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolInvokePayload { + /// Name of the tool being invoked. + tool_name: String, + /// Invoking user's identity. + user: String, + /// Serialized tool arguments (JSON). + arguments: String, +} + +// Register with cpex-core's PluginPayload trait system. +impl_plugin_payload!(ToolInvokePayload); + +// Register for WASM transport: type discriminator + JSON serialization. +impl_wasm_payload!(ToolInvokePayload, "cpex.tool_invoke"); + +// --------------------------------------------------------------------------- +// Hook type definition for tool pre-invoke +// --------------------------------------------------------------------------- + +cpex_core::define_hook! { + /// Hook fired before a tool is invoked. Payload carries explicit user/tool identity. + ToolPreInvoke, "tool_pre_invoke" => { + payload: ToolInvokePayload, + result: PluginResult, + } +} + +// --------------------------------------------------------------------------- +// Demo +// --------------------------------------------------------------------------- + +#[tokio::main] +async fn main() { + println!("=== WASM Plugin Demo — Generic Payload (ToolInvokePayload) ===\n"); + + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let config_path = crate_dir.join("config/config.yaml"); + + 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(); + + // Build a registry with both MessagePayload (CMF fast-path) and + // ToolInvokePayload (generic path) registered. + let mut registry = PayloadSerializerRegistry::new(); + registry.register::(); + registry.register::(); + + println!( + "PayloadSerializerRegistry: registered 'cmf.message' and '{}'\n", + ToolInvokePayload::payload_type_name() + ); + + let mgr = PluginManager::default(); + mgr.register_factory( + "wasm://plugin.wasm", + Box::new(WasmPluginFactory::new(crate_dir.join("wasm"), Arc::new(registry))), + ); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // Build the custom payload. + let payload = ToolInvokePayload { + tool_name: "get_compensation".into(), + user: "alice".into(), + arguments: r#"{"employee_id": 42}"#.into(), + }; + + println!("Payload: {:?}", payload); + println!( + "Wire type: '{}' ({} bytes when serialized)\n", + ToolInvokePayload::payload_type_name(), + payload.to_wasm_bytes().unwrap().len() + ); + + // Build extensions with identity context. + let ext = build_extensions(); + + // --- Invoke through the WASM pipeline --- + println!("=== tool_pre_invoke via WASM (Generic path) ==="); + + let (result, bg) = mgr + .invoke_named::("tool_pre_invoke", payload, ext, None) + .await; + + if result.continue_processing { + println!("Result: ALLOWED"); + println!(" (guest has no ToolInvokePayload handler — passed through as allow())"); + } else { + let reason = result.violation.as_ref().map(|v| v.reason.as_str()).unwrap_or("unknown"); + println!("Result: DENIED — {}", reason); + } + + bg.wait_for_background_tasks().await; + + println!("\n=== Demo complete ==="); + println!("\nNote: for typed dispatch of a custom payload inside the guest, see"); + println!("wasm_identity_resolve_demo (IdentityPayload → HookHandler)."); +} + +fn build_extensions() -> Extensions { + let mut security = SecurityExtension::default(); + security.subject = Some(SubjectExtension { + id: Some("alice".into()), + subject_type: Some(SubjectType::User), + roles: ["tool_user".to_string()].into(), + permissions: ["invoke_tools".to_string()].into(), + ..Default::default() + }); + + Extensions { + security: Some(Arc::new(security)), + ..Default::default() + } +} diff --git a/crates/cpex-wasm-host/examples/wasm_identity_resolve_demo.rs b/crates/cpex-wasm-host/examples/wasm_identity_resolve_demo.rs new file mode 100644 index 00000000..5853e50a --- /dev/null +++ b/crates/cpex-wasm-host/examples/wasm_identity_resolve_demo.rs @@ -0,0 +1,110 @@ +// Location: ./crates/cpex-wasm-host/examples/wasm_identity_resolve_demo.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// Identity-resolve WASM plugin demo — full typed dispatch on the custom path. +// +// Exercises the complete non-CMF hook flow: +// +// host: IdentityPayload → PayloadSerializerRegistry.serialize() +// → HookPayload::Custom { payload_type: "cpex.identity", bytes } +// → SandboxManager.invoke("identity.resolve", ...) +// guest: register_wasm_plugin! matches "cpex.identity" against +// IdentityHook::Payload, deserializes, calls +// HookHandler::handle(), returns the modified +// payload as a Custom result +// host: registry.deserialize() reconstructs IdentityPayload; the +// executor writes it back as the pipeline payload +// +// The raw bearer token is #[serde(skip)] — it never enters the sandbox. +// The guest resolves the subject from the x-user-id request header. +// +// Prerequisites: build the WASM plugin first: +// cargo build --target wasm32-wasip2 (in crates/cpex-wasm-plugin) +// cp ../cpex-wasm-plugin/target/wasm32-wasip2/debug/cpex_wasm_plugin.wasm wasm/plugin.wasm +// +// Run from the workspace root: +// cargo run -p cpex-wasm-host --example wasm_identity_resolve_demo + +use std::collections::HashMap; +use std::path::PathBuf; + +use cpex_core::config::parse_config; +use cpex_core::extensions::container::Extensions; +use cpex_core::identity::{IdentityHook, IdentityPayload, TokenSource, HOOK_IDENTITY_RESOLVE}; +use cpex_core::manager::PluginManager; + +use cpex_wasm_host::factory::WasmPluginFactory; + +#[tokio::main] +async fn main() { + println!("=== WASM Plugin Demo — identity.resolve (Generic typed dispatch) ===\n"); + + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let config_path = crate_dir.join("config/config_identity.yaml"); + + 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(); + // with_builtin_payloads registers MessagePayload, IdentityPayload, + // and DelegationPayload in the serializer registry. + mgr.register_factory( + "wasm://plugin.wasm", + Box::new(WasmPluginFactory::with_builtin_payloads(crate_dir.join("wasm"))), + ); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // Build the identity payload as a host would at request entry. + // The raw token stays host-side (#[serde(skip)]); the guest sees + // only the headers and other serializable inputs. + let mut headers = HashMap::new(); + headers.insert("x-user-id".to_string(), "alice".to_string()); + let payload = IdentityPayload::new("secret-bearer-token", TokenSource::Bearer) + .with_headers(headers) + .with_client_host("10.0.0.7"); + + println!("Payload in: subject = {:?}", payload.subject); + println!(" raw_token present host-side, skipped on the wire\n"); + + println!("=== identity.resolve via WASM (Generic path, typed dispatch) ==="); + let (result, bg) = mgr + .invoke_named::(HOOK_IDENTITY_RESOLVE, payload, Extensions::default(), None) + .await; + + if !result.continue_processing { + let reason = result + .violation + .as_ref() + .map(|v| v.reason.as_str()) + .unwrap_or("unknown"); + println!("Result: DENIED — {}", reason); + } else { + match IdentityPayload::from_pipeline_result(&result) { + Some(resolved) => { + let subject_id = resolved.subject.as_ref().and_then(|s| s.id.as_deref()); + println!("Result: ALLOWED"); + println!("Payload out: subject = {:?}", subject_id); + assert_eq!( + subject_id, + Some("alice"), + "guest-modified IdentityPayload did not survive the round trip" + ); + println!("\n✓ Guest resolved the subject from x-user-id and the typed"); + println!(" modification survived the WASM boundary in both directions."); + } + None => { + println!("Result: ALLOWED, but payload came back untyped — writeback broken"); + std::process::exit(1); + } + } + } + + bg.wait_for_background_tasks().await; + + println!("\n=== Demo complete ==="); +} 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..b51fc181 --- /dev/null +++ b/crates/cpex-wasm-host/examples/wasm_plugin_demo.rs @@ -0,0 +1,169 @@ +// Location: ./crates/cpex-wasm-host/examples/wasm_plugin_demo.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya +// +// CMF payload WASM plugin demo. +// +// Shows the end-to-end CMF path: a MessagePayload crosses the WASM boundary, +// the guest IdentityCheckerPlugin (written with HookHandler — identical +// to a native plugin) runs the identity check, and the result flows back. +// +// Prerequisites: build the WASM plugin first: +// cargo build -p cpex-wasm-plugin --target wasm32-wasip2 +// cargo run --example wasm_plugin_demo +// +// Run from the workspace root: +// cargo run -p cpex-wasm-host --example wasm_plugin_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() { + println!("=== WASM Plugin Demo — CMF MessagePayload ===\n"); + + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let config_path = crate_dir.join("config/config.yaml"); + + 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(); + mgr.register_factory( + "wasm://plugin.wasm", + Box::new(WasmPluginFactory::with_builtin_payloads(crate_dir.join("wasm"))), + ); + mgr.load_config(cpex_config).unwrap(); + mgr.initialize().await.unwrap(); + + // Build a pre-invoke payload: 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 pre_ext = build_extensions("PII"); + + // --- Phase 1: pre-invoke --- + println!("=== cmf.tool_pre_invoke ==="); + let (pre_result, pre_bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", pre_payload, pre_ext, None) + .await; + + if pre_result.continue_processing { + println!("Pre-invoke: ALLOWED"); + } else { + let reason = pre_result.violation.as_ref().map(|v| v.reason.as_str()).unwrap_or("unknown"); + println!("Pre-invoke: DENIED — {}", reason); + pre_bg.wait_for_background_tasks().await; + println!("\n=== Demo complete ==="); + return; + } + pre_bg.wait_for_background_tasks().await; + + println!("\n [tool executes: {{\"salary\": 150000, \"currency\": \"USD\"}}]\n"); + + // --- Phase 2: post-invoke with tool result --- + println!("=== cmf.tool_post_invoke ==="); + + 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, + }, + }; + + // Carry forward any modified extensions from pre-invoke; rebuild if none. + let post_ext = pre_result.modified_extensions.unwrap_or_else(|| build_extensions("PII")); + + let (post_result, post_bg) = mgr + .invoke_named::( + "cmf.tool_post_invoke", + post_payload, + post_ext, + Some(pre_result.context_table), + ) + .await; + + if post_result.continue_processing { + println!("Post-invoke: ALLOWED"); + } else { + let reason = post_result.violation.as_ref().map(|v| v.reason.as_str()).unwrap_or("unknown"); + println!("Post-invoke: DENIED — {}", reason); + } + + post_bg.wait_for_background_tasks().await; + println!("\n=== Demo complete ==="); +} + +fn build_extensions(security_label: &str) -> Extensions { + let mut security = SecurityExtension::default(); + security.add_label(security_label); + 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/src/conversions.rs b/crates/cpex-wasm-host/src/conversions.rs new file mode 100644 index 00000000..7688ad52 --- /dev/null +++ b/crates/cpex-wasm-host/src/conversions.rs @@ -0,0 +1,1281 @@ +// 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::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::executor::ErasedResultFields; +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(), + } +} + +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) { + 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) => { + eprintln!("[HOST] 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)), + 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) -> 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(); + + owned.security = ext.security.map(wit_security_to_native); + 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(), + })); + owned.delegation = ext.delegation.map(wit_delegation_to_native); + 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(|c| wit_client_to_native(c)), + caller_workload: s.caller_workload.map(|w| wit_workload_to_native(w)), + this_workload: s.this_workload.map(|w| wit_workload_to_native(w)), + 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..270f7ce7 --- /dev/null +++ b/crates/cpex-wasm-host/src/factory.rs @@ -0,0 +1,241 @@ +// 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::path::PathBuf; +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::Mutex; + +use cpex_core::cmf::message::MessagePayload; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginError; +use cpex_core::extensions::Extensions; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::hooks::payload::PluginPayload; +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_core::registry::AnyHookHandler; + +use crate::conversions::{native_context_to_wit, native_extensions_to_wit, native_payload_to_wit, wit_hook_result_to_native}; +use crate::payload_registry::PayloadSerializerRegistry; +use crate::sandbox_manager::SandboxManager; + +// --------------------------------------------------------------------------- +// WasmPluginFactory +// --------------------------------------------------------------------------- + +/// Factory that creates WASM plugin instances, each with its own SandboxManager. +/// Every plugin gets an isolated wasmtime engine and store — no contention between plugins. +pub struct WasmPluginFactory { + wasm_dir: PathBuf, + registry: Arc, +} + +impl WasmPluginFactory { + /// Create a factory with a pre-built payload registry. + /// Register all payload types the host wants to route through WASM before + /// calling this, then pass the registry here. + pub fn new(wasm_dir: PathBuf, registry: Arc) -> Self { + Self { wasm_dir, registry } + } + + /// 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 new SandboxManager for this plugin (isolated engine + store) + let sandbox = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let mut mgr = SandboxManager::new() + .map_err(|e| format!("failed to create sandbox: {}", e))?; + mgr.load_wasmplugin(&wasm_path, sandbox_policy.as_ref()) + .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 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(), + }); + (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, +} + +#[async_trait] +impl AnyHookHandler for WasmBridgeHandler { + async fn invoke( + &self, + payload: &dyn PluginPayload, + extensions: &Extensions, + ctx: &mut PluginContext, + ) -> Result, Box> { + // Build the WIT payload: CMF fast-path, 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 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, + ), + })); + }; + + 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| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}': WASM invocation failed: {}", + self.plugin_name, e + ), + }) + })? + }; + + // Convert WIT HookResult → type-erased result fields + optional + // context writeback. The erased fields carry the modified payload + // as Box with its concrete type intact (CMF or + // any registry-registered custom payload), so no erase_result + // round-trip through a hardcoded payload type is needed. + let (erased_fields, modified_ctx) = + wit_hook_result_to_native(wit_result, &self.registry, extensions); + + 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" + } +} diff --git a/crates/cpex-wasm-host/src/lib.rs b/crates/cpex-wasm-host/src/lib.rs new file mode 100644 index 00000000..1ce81118 --- /dev/null +++ b/crates/cpex-wasm-host/src/lib.rs @@ -0,0 +1,10 @@ +// Location: ./crates/cpex-wasm-host/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Shriti Priya + +pub mod conversions; +pub mod factory; +pub mod payload_registry; +pub mod policy_loader; +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..9a10b99e --- /dev/null +++ b/crates/cpex-wasm-host/src/payload_registry.rs @@ -0,0 +1,122 @@ +// 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 +// --------------------------------------------------------------------------- + +struct PayloadCodec { + type_name: &'static str, + serialize: Arc Result> + Send + Sync>, + deserialize: Arc Result> + Send + Sync>, +} + +// --------------------------------------------------------------------------- +// 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..17bf70aa --- /dev/null +++ b/crates/cpex-wasm-host/src/policy_loader.rs @@ -0,0 +1,201 @@ +// 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, 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, +} + +impl Default for ResourceLimits { + fn default() -> Self { + Self { + max_memory_bytes: None, + max_fuel: None, + max_execution_time_ms: None, + max_instances: None, + max_tables: None, + } + } +} + +/// 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()); + } +} 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..f9a46ba1 --- /dev/null +++ b/crates/cpex-wasm-host/src/sandbox_manager.rs @@ -0,0 +1,254 @@ +// 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::WasiHttpCtx; +use wasmtime_wasi_http::p2::{WasiHttpCtxView, WasiHttpView}; +use wasmtime_wasi_http::p2::body::HyperOutgoingBody; +use wasmtime_wasi_http::p2::types::{HostFutureIncomingResponse, OutgoingRequestConfig}; +use wasmtime_wasi_http::p2::{HttpResult, WasiHttpHooks, default_send_request}; +use wasmtime_wasi_http::p2::bindings::http::types::ErrorCode; + +use crate::policy_loader::{build_wasi_context, SandboxPolicy, ResourceLimits}; + +// 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, +} + +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, +} + +/// 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, +} + +impl SandboxManager { + /// Create a new SandboxManager with no plugin loaded. + 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)?; + + // Start epoch ticker thread for timeout enforcement + let engine_clone = engine.clone(); + std::thread::spawn(move || loop { + std::thread::sleep(std::time::Duration::from_millis(1)); + engine_clone.increment_epoch(); + }); + + Ok(Self { + engine, + linker, + 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>, + ) -> Result<()> { + eprintln!("[SANDBOX] load_wasmplugin: path={}", wasm_path.display()); + let ctx = build_wasi_context(sandbox_policy)?; + eprintln!("[SANDBOX] WASI context built"); + 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(); + + eprintln!("[SANDBOX] Loading component from file..."); + let component = Component::from_file(&self.engine, wasm_path) + .map_err(|e| anyhow::anyhow!("failed to load wasm from {}: {}", wasm_path.display(), e))?; + eprintln!("[SANDBOX] Component loaded successfully"); + + 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, + }, + ); + + // Apply memory/table limits + store.limiter(|state| &mut state.limits); + + // Apply fuel budget (instruction count limit) + let fuel = resources.max_fuel.unwrap_or(u64::MAX); + store.set_fuel(fuel) + .map_err(|e| anyhow::anyhow!("failed to set fuel: {}", e))?; + + // Apply execution timeout via epoch deadline + // Each epoch tick is ~1ms (from ticker thread), so ms ≈ ticks + let epoch_deadline = resources.max_execution_time_ms.unwrap_or(3_600_000); + store.set_epoch_deadline(epoch_deadline); + store.epoch_deadline_trap(); + + eprintln!("[SANDBOX] Instantiating plugin component..."); + let plugin = Plugin::instantiate_async(&mut store, &component, &self.linker) + .await + .map_err(|e| anyhow::anyhow!("failed to instantiate plugin: {}", e))?; + eprintln!("[SANDBOX] Plugin instantiated OK"); + + self.instance = Some(WasmPluginInstance { + store, + plugin, + epoch_deadline, + }); + + Ok(()) + } + + /// Invoke the loaded plugin's handle-hook function. + /// Fuel is a session-level budget (not reset between invocations). + /// Epoch deadline is per-invocation (reset each call so no single call hangs). + pub async fn invoke( + &mut self, + hook_name: &str, + payload: types::HookPayload, + extensions: types::Extensions, + ctx: types::PluginContext, + ) -> Result { + eprintln!("[SANDBOX] invoke() called, plugin loaded: {}", self.is_loaded()); + let instance = self + .instance + .as_mut() + .with_context(|| "no plugin loaded")?; + + // Reset epoch deadline per invocation (timeout is per-call) + instance.store.set_epoch_deadline(instance.epoch_deadline); + + eprintln!("[SANDBOX] Calling handle_hook on WASM component..."); + let result = instance + .plugin + .call_handle_hook(&mut instance.store, hook_name, &payload, &extensions, &ctx) + .await; + + match &result { + Ok(r) => eprintln!("[SANDBOX] handle_hook OK: continue_processing={}", r.continue_processing), + Err(e) => eprintln!("[SANDBOX] handle_hook FAILED: {}", e), + } + + 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_policy_loader.rs b/crates/cpex-wasm-host/tests/test_policy_loader.rs new file mode 100644 index 00000000..e96aa8d1 --- /dev/null +++ b/crates/cpex-wasm-host/tests/test_policy_loader.rs @@ -0,0 +1,107 @@ +// 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/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..0c202d0b --- /dev/null +++ b/crates/cpex-wasm-host/wit/world.wit @@ -0,0 +1,643 @@ +// 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>, + } + + 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, + } +} + +/// 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; + + 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..6532d35d --- /dev/null +++ b/crates/cpex-wasm-plugin/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "cpex-wasm-plugin" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[features] +default = ["identity-checker"] +identity-checker = [] +header-injector = [] +audit-logger = [] + +[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"] } + +[package.metadata.component] +package = "cpex:plugin" diff --git a/crates/cpex-wasm-plugin/Makefile b/crates/cpex-wasm-plugin/Makefile new file mode 100644 index 00000000..26ec0cab --- /dev/null +++ b/crates/cpex-wasm-plugin/Makefile @@ -0,0 +1,106 @@ +.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 + +# --------------------------------------------------------------------------- +# 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 three plugin binaries (identity-checker, header-injector, audit-logger) + @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 CMF demo + cd ../.. && cargo run -p cpex-wasm-host --example wasm_plugin_demo + +run-all-demos: all ## Build, stage, and run all three demos + cd ../.. && cargo run -p cpex-wasm-host --example wasm_plugin_demo + cd ../.. && cargo run -p cpex-wasm-host --example wasm_identity_resolve_demo + cd ../.. && cargo run -p cpex-wasm-host --example wasm_generic_payload_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..c24cd263 --- /dev/null +++ b/crates/cpex-wasm-plugin/README.md @@ -0,0 +1,361 @@ +# cpex-wasm-plugin + +WASM Plugin SDK and built-in plugins for the CPEX framework. Compiles CPEX plugins into portable `.wasm` components that the host (`cpex-wasm-host`) loads and executes in sandboxed wasmtime environments. + +## SDK Architecture + +This crate serves as both an **SDK** (WIT bindings, conversions, macro) and a **plugin collection** (feature-gated). Adding a new WASM plugin requires only writing a handler implementation — the SDK generates all WIT glue automatically. + +``` +cpex-wasm-plugin/ +├── Cargo.toml # cdylib target, feature flags per plugin +├── Makefile # Build targets (single plugin, all plugins) +├── src/ +│ ├── lib.rs # SDK: wit_bindgen, register_wasm_plugin! macro, helpers +│ ├── conversions.rs # WIT ↔ cpex-core type mappings (all 11 extension types) +│ └── plugins/ +│ ├── mod.rs # Feature-gated module declarations +│ ├── identity_checker.rs # Checks PII access via labels + subject roles +│ ├── header_injector.rs # Modifies extensions: adds label + injects header +│ └── audit_logger.rs # Read-only audit logging +└── wit/ + ├── world.wit # Plugin interface definition (shared with host) + └── deps/ # WASI interface dependencies +``` + +## How It Works + +1. **WIT Interface (`wit/world.wit`)** defines the contract. The plugin exports a single function: + + ```wit + export handle-hook: func( + hook-name: string, + payload: hook-payload, + extensions: extensions, + ctx: plugin-context + ) -> hook-result; + ``` + +2. **`register_wasm_plugin!` macro** generates the complete `Guest` impl. Plugin authors implement `HookHandler` from `cpex-core` — identical code to a native plugin. + +3. **Feature-gated registration** — each plugin is a Cargo feature. Only one plugin compiles into each `.wasm` binary: + + ```toml + [features] + default = ["identity-checker"] + identity-checker = [] + header-injector = [] + audit-logger = [] + ``` + +4. **Type Conversions (`src/conversions.rs`)** handles bidirectional mapping between WIT-generated types and native `cpex-core` types across all 11 extension types. + +## Built-in Plugins + +| Plugin | Feature Flag | Capabilities | Behavior | +|--------|-------------|--------------|----------| +| `identity-checker` | `identity-checker` | `read_labels`, `read_subject`, `read_roles` | Checks PII access — denies if subject lacks required role | +| `header-injector` | `header-injector` | `read_headers`, `write_headers`, `append_labels` | Adds "PROCESSED" label + "X-Processed-By" header | +| `audit-logger` | `audit-logger` | `read_headers`, `read_labels` | Read-only audit logging of tool invocations | + +## Prerequisites + +- Rust toolchain with the `wasm32-wasip2` target: + ```sh + rustup target add wasm32-wasip2 + ``` +- `wasm-tools` CLI (optional, for validation and inspection): + ```sh + cargo install wasm-tools + ``` + +## Building + +### Single plugin (default: identity-checker) + +```sh +make all +``` + +### All plugins + +```sh +make build-all +``` + +This builds each feature into a separate `.wasm` and stages them in `../cpex-wasm-host/wasm/`: + +``` +wasm/identity-checker.wasm +wasm/header-injector.wasm +wasm/audit-logger.wasm +``` + +### Manual build (specific plugin) + +```sh +cargo build --target wasm32-wasip2 --release --features header-injector --no-default-features +cp target/wasm32-wasip2/release/cpex_wasm_plugin.wasm ../cpex-wasm-host/wasm/header-injector.wasm +``` + +## Running the Demos + +From the workspace root: + +```sh +# Build all plugins + run the capabilities demo (3 plugins, capability isolation) +cd crates/cpex-wasm-plugin && make build-all && cd ../.. +cargo run -p cpex-wasm-host --example wasm_capabilities_demo + +# Or use the host Makefile: +cd crates/cpex-wasm-host && make run-capabilities-demo +``` + +Expected output shows three plugins running in the same pipeline with different views of the extensions: +- `identity-checker` sees labels + subject, HTTP NOT visible +- `header-injector` sees HTTP, subject NOT visible, adds label + injects header +- `audit-logger` logs tool name, labels (including "PROCESSED"), request-id + +## Writing a New Plugin + +Adding a new plugin requires **3 steps** — no WIT knowledge needed: + +### Step 1: Create `src/plugins/my_plugin.rs` + +```rust +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}; + +pub struct MyPlugin; + +impl Default for MyPlugin { + fn default() -> Self { Self } +} + +static PLUGIN_CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[async_trait] +impl Plugin for MyPlugin { + fn config(&self) -> &PluginConfig { + PLUGIN_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(()) } +} + +impl HookHandler for MyPlugin { + async fn handle( + &self, + payload: &MessagePayload, + extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Your logic here — identical to a native plugin. + PluginResult::allow() + } +} +``` + +### Step 2: Add the feature flag + +In `Cargo.toml`: +```toml +[features] +my-plugin = [] +``` + +In `src/plugins/mod.rs`: +```rust +#[cfg(feature = "my-plugin")] +pub mod my_plugin; +``` + +In `src/lib.rs` (at the bottom, with the other registrations): +```rust +#[cfg(feature = "my-plugin")] +register_wasm_plugin!( + plugins::my_plugin::MyPlugin, + [cpex_core::cmf::CmfHook] +); +``` + +### Step 3: Build and stage + +```sh +cargo build --target wasm32-wasip2 --release --features my-plugin --no-default-features +cp target/wasm32-wasip2/release/cpex_wasm_plugin.wasm ../cpex-wasm-host/wasm/my-plugin.wasm +``` + +That's it — your WASM component is ready for the host to load. + +### Denying a request + +Return `PluginResult::deny(...)` with a `PluginViolation` to block processing: + +```rust +if security.has_label("PII") && !subject.roles.contains("hr_admin") { + return PluginResult::deny(PluginViolation::new( + "insufficient_role", + "Tool requires 'hr_admin' role for PII data", + )); +} +``` + +### Reading extensions + +All 11 extension types are available — security, HTTP, request, meta, agent, MCP, completion, provenance, LLM, framework, delegation: + +```rust +// Security labels and subject identity +if let Some(ref security) = extensions.security { + let labels: Vec<_> = security.labels.iter().collect(); + if let Some(ref subject) = security.subject { + // subject.id, subject.roles, subject.permissions, ... + } + if let Some(ref client) = security.client { + // client.client_id, client.trust_level, client.authorized_scopes, ... + } +} + +// Request tracing +if let Some(ref request) = extensions.request { + // request.request_id, request.trace_id, request.environment, ... +} + +// MCP tool metadata +if let Some(ref mcp) = extensions.mcp { + if let Some(ref tool) = mcp.tool { + // tool.name, tool.description, tool.input_schema, ... + } +} +``` + +### Modifying extensions (labels, headers) + +Use `cow_copy()` to get a mutable workspace, modify it, and return via `PluginResult::modify_extensions`: + +```rust +use cpex_core::extensions::guarded::Guarded; + +let mut modified = extensions.cow_copy(); + +// Add a security label (requires append_labels capability on host) +if let Some(ref mut sec) = modified.security { + sec.add_label("PROCESSED"); +} + +// Modify HTTP headers (requires write_headers capability on host) +let mut http = extensions.http.as_ref().map(|h| (**h).clone()).unwrap_or_default(); +http.set_header("X-Processed-By", "my-plugin"); +modified.http = Some(Guarded::new(http)); + +PluginResult::modify_extensions(modified) +``` + +The host executor validates modifications against the plugin's declared capabilities — if the plugin lacks `write_headers`, HTTP changes are rejected. + +### Modifying payload or context + +```rust +ctx.local_state.insert("checked_at".to_string(), serde_json::json!("pre_invoke")); + +let mut modified = payload.clone(); +modified.message.content.push(ContentPart::Text { + text: "[audited]".into(), +}); +PluginResult::allow_with_payload(modified) +``` + +### Handling non-CMF hooks (identity_resolve, token_delegate, custom payloads) + +Hooks whose payload is not `MessagePayload` cross the boundary via the WIT +`generic` variant: the host serializes the payload with its type +discriminator, and `register_wasm_plugin!` routes it to the matching +`HookHandler` by that discriminator. Implement the handler exactly like a +native plugin and add the hook type to the registration list: + +```rust +use cpex_core::identity::{IdentityHook, IdentityPayload}; + +impl HookHandler for MyPlugin { + async fn handle( + &self, + payload: &IdentityPayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // Resolve identity from payload.headers() — the raw token is + // #[serde(skip)] and never enters the sandbox. + let mut resolved = payload.clone(); + // ... populate resolved.subject / resolved.client ... + PluginResult::modify_payload(resolved) + } +} + +register_wasm_plugin!(MyPlugin, [CmfHook, IdentityHook]); +``` + +Three requirements for a payload type to cross the boundary: + +1. It implements `WasmSerializablePayload` — built-ins already do + (`MessagePayload` = `"cmf.message"`, `IdentityPayload` = `"cpex.identity"`, + `DelegationPayload` = `"cpex.delegation"`); custom types use + `impl_wasm_payload!(MyPayload, "my.payload")`. +2. The host registers it in the `PayloadSerializerRegistry` + (`WasmPluginFactory::with_builtin_payloads` covers the built-ins). +3. Fields carrying secrets are `#[serde(skip)]` if they must stay host-side — + skipped fields never serialize into the sandbox and never come back. + +A generic payload no listed hook handles returns `allow()` (pass-through); a +payload that matches a listed hook but fails to decode returns a deny +violation rather than silently skipping the plugin's check. + +--- + +## Constraints + +### One plugin per binary + +`register_wasm_plugin!` calls `export!(_WasmGuestImpl)` which is a WIT component export — there can only be one per `.wasm` binary. That's why each plugin is gated behind a Cargo feature — building with `--features header-injector --no-default-features` produces a binary containing only the header-injector plugin. + +### WASM-compatible dependencies only + +Any crate you `use` inside `plugin.rs` must compile to `wasm32-wasip2`. The compiler will tell you immediately if something doesn't compile for WASM. + +| Works in WASM | Does not work in WASM | +|---|---| +| `cpex-core` (no default features) | `cpex-core` with `runtime` feature (pulls Tokio) | +| `serde`, `serde_json` | Tokio, `std::thread::spawn` | +| `chrono` | `std::fs`, `std::net` | +| `async-trait` | Any crate that does file I/O or spawns OS threads | + +--- + +## Makefile Targets + +| Target | Description | +|--------|-------------| +| `make all` | Build default plugin (identity-checker) and stage as `plugin.wasm` | +| `make build-all` | Build all three plugins and stage as separate `.wasm` files | +| `make build-debug` | Debug build (faster compile) | +| `make check` | Type-check only (fastest feedback) | +| `make clean` | Remove all artifacts | +| `make run-demo` | Build + stage + run the CMF demo | +| `make run-all-demos` | Build + stage + run all existing demos | +| `make help` | Show all available targets | + +--- + +## Dependency Notes + +This crate depends on `cpex-core` with `default-features = false`. This excludes the `runtime` feature (Tokio, task spawning, orchestration), which is not available in WASM. All types, traits, and extension types are available; only the executor/manager/registry modules are excluded. diff --git a/crates/cpex-wasm-plugin/src/conversions.rs b/crates/cpex-wasm-plugin/src/conversions.rs new file mode 100644 index 00000000..22420182 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/conversions.rs @@ -0,0 +1,730 @@ +// 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(), + })), + 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(), + }), + 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..2206f32c --- /dev/null +++ b/crates/cpex-wasm-plugin/src/lib.rs @@ -0,0 +1,260 @@ +// 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, +}; + +// --------------------------------------------------------------------------- +// 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}; + + eprintln!("[WASM] handle_hook: {}", hook_name); + + 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(feature = "identity-checker")] +register_wasm_plugin!( + plugins::identity_checker::IdentityCheckerPlugin, + [cpex_core::cmf::CmfHook, cpex_core::identity::IdentityHook] +); + +#[cfg(feature = "header-injector")] +register_wasm_plugin!( + plugins::header_injector::HeaderInjectorPlugin, + [cpex_core::cmf::CmfHook] +); + +#[cfg(feature = "audit-logger")] +register_wasm_plugin!( + plugins::audit_logger::AuditLoggerPlugin, + [cpex_core::cmf::CmfHook] +); 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..be6af208 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/plugins/audit_logger.rs @@ -0,0 +1,95 @@ +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 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") + }; + + eprint!("[WASM:audit-logger] AUDIT[{}]: tool='{}' ", phase, tool_name); + + if let Some(ref security) = extensions.security { + let labels: Vec<&String> = security.labels.iter().collect(); + eprint!("labels={:?} ", labels); + } + + if let Some(ref http) = extensions.http { + if let Some(req_id) = http.get_header("X-Request-ID") { + eprint!("request_id='{}' ", req_id); + } + } + + if is_result { + let is_error = payload + .message + .get_tool_results() + .first() + .map(|tr| tr.is_error) + .unwrap_or(false); + eprint!("error={} ", is_error); + } + + eprintln!(); + 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..8c320413 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/plugins/header_injector.rs @@ -0,0 +1,81 @@ +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}; + +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 { + if let Some(ref http) = extensions.http { + eprintln!( + "[WASM:header-injector] HTTP visible: {} headers", + http.request_headers.len() + ); + } + + if let Some(ref security) = extensions.security { + if security.subject.is_some() { + eprintln!("[WASM:header-injector] WARNING: Subject visible (unexpected!)"); + } else { + eprintln!("[WASM:header-injector] Subject: not visible (correct — no read_subject)"); + } + } + + let mut modified = extensions.cow_copy(); + + if let Some(ref mut sec) = modified.security { + sec.add_label("PROCESSED"); + eprintln!("[WASM:header-injector] Added 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)); + eprintln!("[WASM:header-injector] Injected header 'X-Processed-By'"); + + 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..3c2d1ee4 --- /dev/null +++ b/crates/cpex-wasm-plugin/src/plugins/identity_checker.rs @@ -0,0 +1,143 @@ +// 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}; + +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"); + eprintln!("[WASM] POST-INVOKE: verifying result from '{}'", tool_name); + + if let Some(ref security) = extensions.security { + if let Some(ref subject) = security.subject { + eprintln!("[WASM] Result authorized for subject: {:?}", subject.id); + } + } + eprintln!("[WASM] POST-INVOKE ALLOWED"); + } else { + let tool_name = payload + .message + .get_tool_calls() + .first() + .map(|tc| tc.name.as_str()) + .unwrap_or("unknown"); + eprintln!("[WASM] PRE-INVOKE: checking identity for '{}'", tool_name); + + if let Some(ref security) = extensions.security { + let labels: Vec<&String> = security.labels.iter().collect(); + eprintln!("[WASM] Security labels: {:?}", labels); + + if let Some(ref subject) = security.subject { + eprintln!( + "[WASM] Subject: {:?}, Roles: {:?}", + subject.id, + subject.roles.iter().collect::>() + ); + + if security.has_label("PII") && !subject.roles.contains("hr_admin") { + return PluginResult::deny(PluginViolation::new( + "insufficient_role", + &format!( + "Tool '{}' requires 'hr_admin' role for PII data", + tool_name + ), + )); + } + } + } + eprintln!("[WASM] PRE-INVOKE ALLOWED"); + } + + 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() { + eprintln!("[WASM] IDENTITY: subject already resolved"); + return PluginResult::allow(); + } + + let Some(user_id) = payload.headers().get("x-user-id") else { + eprintln!("[WASM] IDENTITY: no x-user-id header — passing through"); + return PluginResult::allow(); + }; + + eprintln!("[WASM] 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..93abfa4b --- /dev/null +++ b/crates/cpex-wasm-plugin/src/plugins/mod.rs @@ -0,0 +1,8 @@ +#[cfg(feature = "identity-checker")] +pub mod identity_checker; + +#[cfg(feature = "header-injector")] +pub mod header_injector; + +#[cfg(feature = "audit-logger")] +pub mod audit_logger; 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..fe93d2e3 --- /dev/null +++ b/crates/cpex-wasm-plugin/wit/world.wit @@ -0,0 +1,661 @@ +// 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>, + } + + 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, + } +} + +/// 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; + + 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; +}