From be51cdbfcd4c46e27fd8d04d2fa2ed7d6390a11f Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Tue, 8 Sep 2026 14:31:42 -0400 Subject: [PATCH 1/3] Intercept and authorize wasi:http calls Access control for wasi:http, split between gates and latches, modeled on componentized/sockets. A gate wraps wasi:http/client or wasi:http/handler and consults a latch before each request, a latch decides whether the request may proceed. A latch defers or denies, a request proceeds unless a latch denies it. Gates: * gate-client: gates wasi:http/client * gate-handler: gates wasi:http/handler * gate: a union of both The componentized:http/latch interface separates deciding from acting on a decision. `authorize` decides without side effects, then the gate reports the final decision to `observe-decision`, where a latch updates any state it keeps. A denied request fails with the latch's reason and is logged as a warning, a latch error or failed observation fails the request with internal-error and is logged as an error. Latches: * latch-defer-all, latch-deny-all: blanket decisions * latch-method, latch-scheme: decide by the request method or scheme, configured with wasi:config/store * latch-method-readonly, latch-scheme-httpsonly: preconfigured, allow only reading methods or only https * latch-deny-random: randomly denies a fraction of requests, reproducible with a seed, for fault injection * latch-n2 to latch-n5: aggregate latches, any latch can deny, every latch observes the final decision * latch-delegate-client, latch-delegate-handler: apply a wrapped latch to only client or handler requests * latch-dry-run: logs what a wrapped latch would deny without enforcing it, to roll out a policy * latch-trace: logs the decisions of a wrapped latch Shared crates: * http-latch: bindings, logging, config loading and display helpers for latches. An invalid config is logged as critical and fails every request with invalid-config, so a typo is noticed rather than ignored * http-latch-n: aggregation for the latch-n components * http-utils: macros to format and parse wasi:http error codes, methods and schemes, expanded for each component's generated types. Tested against wit-bindgen types from the repository's own wit The test harness drives gates with requests created by the host, scripts a host latch, provides wasi:config/store, and composes latch components into the gate, aggregating several with latch-n. Signed-off-by: Scott Andrews Co-Authored-By: Claude --- Cargo.lock | 461 +++++++++++++--- Cargo.toml | 27 +- README.md | 70 ++- components/gate-client/Cargo.toml | 18 + components/gate-client/README.md | 28 + components/gate-client/src/lib.rs | 148 +++++ components/gate-client/tests/gate.rs | 131 +++++ components/gate-handler/Cargo.toml | 18 + components/gate-handler/README.md | 28 + components/gate-handler/src/lib.rs | 148 +++++ components/gate-handler/tests/gate.rs | 131 +++++ components/gate/Cargo.toml | 14 + components/gate/README.md | 32 ++ components/gate/gate.wac | 4 + components/gate/tests/gate.rs | 30 ++ components/latch-defer-all/Cargo.toml | 17 + components/latch-defer-all/README.md | 13 + components/latch-defer-all/src/lib.rs | 16 + components/latch-defer-all/tests/latch.rs | 25 + components/latch-delegate-client/Cargo.toml | 17 + components/latch-delegate-client/README.md | 35 ++ components/latch-delegate-client/src/lib.rs | 28 + .../latch-delegate-client/tests/latch.rs | 33 ++ components/latch-delegate-handler/Cargo.toml | 17 + components/latch-delegate-handler/README.md | 35 ++ components/latch-delegate-handler/src/lib.rs | 28 + .../latch-delegate-handler/tests/latch.rs | 33 ++ components/latch-deny-all/Cargo.toml | 17 + components/latch-deny-all/README.md | 13 + components/latch-deny-all/src/lib.rs | 16 + components/latch-deny-all/tests/latch.rs | 28 + components/latch-deny-random/Cargo.toml | 19 + components/latch-deny-random/README.md | 32 ++ components/latch-deny-random/src/lib.rs | 230 ++++++++ components/latch-deny-random/tests/latch.rs | 119 ++++ components/latch-dry-run/Cargo.toml | 17 + components/latch-dry-run/README.md | 36 ++ components/latch-dry-run/src/lib.rs | 41 ++ components/latch-dry-run/tests/latch.rs | 63 +++ .../latch-method-readonly-config/README.md | 17 + .../latch-method-readonly-config.properties | 5 + components/latch-method-readonly/Cargo.toml | 14 + components/latch-method-readonly/README.md | 16 + .../latch-method-readonly.wac | 6 + .../latch-method-readonly/tests/latch.rs | 30 ++ components/latch-method/Cargo.toml | 17 + components/latch-method/README.md | 29 + components/latch-method/src/lib.rs | 20 + components/latch-method/tests/latch.rs | 81 +++ components/latch-n2/Cargo.toml | 17 + components/latch-n2/README.md | 16 + components/latch-n2/src/lib.rs | 21 + components/latch-n2/tests/latch.rs | 47 ++ components/latch-n3/Cargo.toml | 17 + components/latch-n3/README.md | 17 + components/latch-n3/src/lib.rs | 25 + components/latch-n3/tests/latch.rs | 45 ++ components/latch-n4/Cargo.toml | 17 + components/latch-n4/README.md | 18 + components/latch-n4/src/lib.rs | 31 ++ components/latch-n4/tests/latch.rs | 47 ++ components/latch-n5/Cargo.toml | 17 + components/latch-n5/README.md | 19 + components/latch-n5/src/lib.rs | 33 ++ components/latch-n5/tests/latch.rs | 49 ++ .../latch-scheme-httpsonly-config/README.md | 14 + .../latch-scheme-httpsonly-config.properties | 2 + components/latch-scheme-httpsonly/Cargo.toml | 14 + components/latch-scheme-httpsonly/README.md | 16 + .../latch-scheme-httpsonly.wac | 6 + .../latch-scheme-httpsonly/tests/latch.rs | 23 + components/latch-scheme/Cargo.toml | 17 + components/latch-scheme/README.md | 28 + components/latch-scheme/src/lib.rs | 26 + components/latch-scheme/tests/latch.rs | 49 ++ components/latch-trace/Cargo.toml | 17 + components/latch-trace/README.md | 38 ++ components/latch-trace/src/lib.rs | 27 + components/latch-trace/tests/latch.rs | 80 +++ components/status-codes/wkg.lock | 19 +- components/trace-client/Cargo.toml | 1 + components/trace-handler/Cargo.toml | 1 + components/trace-types/Cargo.toml | 1 + components/trace/Cargo.toml | 1 + .../deps/wasi-config-0.2.0-rc.1/package.wit | 33 ++ .../deps/wasi-logging-0.1.0-draft/package.wit | 36 ++ components/wit/worlds.wit | 39 ++ components/wkg.lock | 18 + crates/http-latch-n/Cargo.toml | 9 + crates/http-latch-n/src/lib.rs | 57 ++ crates/http-latch/Cargo.toml | 10 + crates/http-latch/src/lib.rs | 259 +++++++++ crates/http-utils/Cargo.toml | 9 + crates/http-utils/src/lib.rs | 268 +++++++++ crates/http-utils/tests/bindings/mod.rs | 9 + crates/http-utils/tests/error_code.rs | 82 +++ crates/http-utils/tests/method.rs | 17 + crates/http-utils/tests/parse_error_code.rs | 77 +++ crates/http-utils/tests/scheme.rs | 15 + crates/test-harness/Cargo.toml | 1 + crates/test-harness/src/latch.rs | 273 ++++++++++ crates/test-harness/src/lib.rs | 263 ++++++++- crates/trace/src/types.rs | 19 +- wit/deps/wasi-cli-0.3.0/package.wit | 28 + wit/deps/wasi-clocks-0.3.0/package.wit | 43 ++ wit/deps/wasi-config-0.2.0-rc.1/package.wit | 33 ++ wit/deps/wasi-http-0.3.0/package.wit | 509 ++++++++++++++++++ wit/deps/wasi-random-0.3.0/package.wit | 18 + wit/latch.wit | 82 +++ wit/worlds.wit | 6 + wkg.lock | 19 +- 111 files changed, 5388 insertions(+), 111 deletions(-) create mode 100644 components/gate-client/Cargo.toml create mode 100644 components/gate-client/README.md create mode 100644 components/gate-client/src/lib.rs create mode 100644 components/gate-client/tests/gate.rs create mode 100644 components/gate-handler/Cargo.toml create mode 100644 components/gate-handler/README.md create mode 100644 components/gate-handler/src/lib.rs create mode 100644 components/gate-handler/tests/gate.rs create mode 100644 components/gate/Cargo.toml create mode 100644 components/gate/README.md create mode 100644 components/gate/gate.wac create mode 100644 components/gate/tests/gate.rs create mode 100644 components/latch-defer-all/Cargo.toml create mode 100644 components/latch-defer-all/README.md create mode 100644 components/latch-defer-all/src/lib.rs create mode 100644 components/latch-defer-all/tests/latch.rs create mode 100644 components/latch-delegate-client/Cargo.toml create mode 100644 components/latch-delegate-client/README.md create mode 100644 components/latch-delegate-client/src/lib.rs create mode 100644 components/latch-delegate-client/tests/latch.rs create mode 100644 components/latch-delegate-handler/Cargo.toml create mode 100644 components/latch-delegate-handler/README.md create mode 100644 components/latch-delegate-handler/src/lib.rs create mode 100644 components/latch-delegate-handler/tests/latch.rs create mode 100644 components/latch-deny-all/Cargo.toml create mode 100644 components/latch-deny-all/README.md create mode 100644 components/latch-deny-all/src/lib.rs create mode 100644 components/latch-deny-all/tests/latch.rs create mode 100644 components/latch-deny-random/Cargo.toml create mode 100644 components/latch-deny-random/README.md create mode 100644 components/latch-deny-random/src/lib.rs create mode 100644 components/latch-deny-random/tests/latch.rs create mode 100644 components/latch-dry-run/Cargo.toml create mode 100644 components/latch-dry-run/README.md create mode 100644 components/latch-dry-run/src/lib.rs create mode 100644 components/latch-dry-run/tests/latch.rs create mode 100644 components/latch-method-readonly-config/README.md create mode 100644 components/latch-method-readonly-config/latch-method-readonly-config.properties create mode 100644 components/latch-method-readonly/Cargo.toml create mode 100644 components/latch-method-readonly/README.md create mode 100644 components/latch-method-readonly/latch-method-readonly.wac create mode 100644 components/latch-method-readonly/tests/latch.rs create mode 100644 components/latch-method/Cargo.toml create mode 100644 components/latch-method/README.md create mode 100644 components/latch-method/src/lib.rs create mode 100644 components/latch-method/tests/latch.rs create mode 100644 components/latch-n2/Cargo.toml create mode 100644 components/latch-n2/README.md create mode 100644 components/latch-n2/src/lib.rs create mode 100644 components/latch-n2/tests/latch.rs create mode 100644 components/latch-n3/Cargo.toml create mode 100644 components/latch-n3/README.md create mode 100644 components/latch-n3/src/lib.rs create mode 100644 components/latch-n3/tests/latch.rs create mode 100644 components/latch-n4/Cargo.toml create mode 100644 components/latch-n4/README.md create mode 100644 components/latch-n4/src/lib.rs create mode 100644 components/latch-n4/tests/latch.rs create mode 100644 components/latch-n5/Cargo.toml create mode 100644 components/latch-n5/README.md create mode 100644 components/latch-n5/src/lib.rs create mode 100644 components/latch-n5/tests/latch.rs create mode 100644 components/latch-scheme-httpsonly-config/README.md create mode 100644 components/latch-scheme-httpsonly-config/latch-scheme-httpsonly-config.properties create mode 100644 components/latch-scheme-httpsonly/Cargo.toml create mode 100644 components/latch-scheme-httpsonly/README.md create mode 100644 components/latch-scheme-httpsonly/latch-scheme-httpsonly.wac create mode 100644 components/latch-scheme-httpsonly/tests/latch.rs create mode 100644 components/latch-scheme/Cargo.toml create mode 100644 components/latch-scheme/README.md create mode 100644 components/latch-scheme/src/lib.rs create mode 100644 components/latch-scheme/tests/latch.rs create mode 100644 components/latch-trace/Cargo.toml create mode 100644 components/latch-trace/README.md create mode 100644 components/latch-trace/src/lib.rs create mode 100644 components/latch-trace/tests/latch.rs create mode 100644 components/wit/deps/wasi-config-0.2.0-rc.1/package.wit create mode 100644 components/wit/deps/wasi-logging-0.1.0-draft/package.wit create mode 100644 crates/http-latch-n/Cargo.toml create mode 100644 crates/http-latch-n/src/lib.rs create mode 100644 crates/http-latch/Cargo.toml create mode 100644 crates/http-latch/src/lib.rs create mode 100644 crates/http-utils/Cargo.toml create mode 100644 crates/http-utils/src/lib.rs create mode 100644 crates/http-utils/tests/bindings/mod.rs create mode 100644 crates/http-utils/tests/error_code.rs create mode 100644 crates/http-utils/tests/method.rs create mode 100644 crates/http-utils/tests/parse_error_code.rs create mode 100644 crates/http-utils/tests/scheme.rs create mode 100644 crates/test-harness/src/latch.rs create mode 100644 wit/deps/wasi-cli-0.3.0/package.wit create mode 100644 wit/deps/wasi-clocks-0.3.0/package.wit create mode 100644 wit/deps/wasi-config-0.2.0-rc.1/package.wit create mode 100644 wit/deps/wasi-http-0.3.0/package.wit create mode 100644 wit/deps/wasi-random-0.3.0/package.wit create mode 100644 wit/latch.wit diff --git a/Cargo.lock b/Cargo.lock index 32bb3b1..78245b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "gimli", ] +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "allocator-api2" version = "0.2.21" @@ -43,7 +49,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -52,6 +58,18 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auditable-serde" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d026218ae25ba5c72834245412dd1338f6d270d2c5109ee03a4badec288d4056" +dependencies = [ + "semver", + "serde", + "serde_json", + "topological-sort", +] + [[package]] name = "base64" version = "0.22.1" @@ -60,9 +78,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -426,7 +444,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -495,6 +513,17 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "fnv" version = "1.0.7" @@ -605,6 +634,40 @@ dependencies = [ "serde_json", ] +[[package]] +name = "gate-client" +version = "0.1.0" +dependencies = [ + "http", + "http-utils", + "test-harness", + "tokio", + "wasmtime", + "wit-bindgen", +] + +[[package]] +name = "gate-handler" +version = "0.1.0" +dependencies = [ + "http", + "http-utils", + "test-harness", + "tokio", + "wasmtime", + "wit-bindgen", +] + +[[package]] +name = "gate-tests" +version = "0.1.0" +dependencies = [ + "http", + "test-harness", + "tokio", + "wasmtime", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -725,6 +788,28 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-latch" +version = "0.1.0" +dependencies = [ + "http-utils", + "wit-bindgen", +] + +[[package]] +name = "http-latch-n" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "http-utils" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "httparse" version = "1.10.1" @@ -761,9 +846,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -775,9 +860,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -788,9 +873,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -802,16 +887,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -822,15 +908,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -870,9 +956,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -964,6 +1050,171 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "latch-defer-all" +version = "0.1.0" +dependencies = [ + "http", + "http-latch", + "test-harness", + "tokio", + "wasmtime", +] + +[[package]] +name = "latch-delegate-client" +version = "0.1.0" +dependencies = [ + "http", + "http-latch", + "test-harness", + "tokio", + "wasmtime", +] + +[[package]] +name = "latch-delegate-handler" +version = "0.1.0" +dependencies = [ + "http", + "http-latch", + "test-harness", + "tokio", + "wasmtime", +] + +[[package]] +name = "latch-deny-all" +version = "0.1.0" +dependencies = [ + "http", + "http-latch", + "test-harness", + "tokio", + "wasmtime", +] + +[[package]] +name = "latch-deny-random" +version = "0.1.0" +dependencies = [ + "http", + "http-latch", + "http-utils", + "test-harness", + "tokio", + "wasmtime", + "wit-bindgen", +] + +[[package]] +name = "latch-dry-run" +version = "0.1.0" +dependencies = [ + "http", + "http-latch", + "test-harness", + "tokio", + "wasmtime", +] + +[[package]] +name = "latch-method" +version = "0.1.0" +dependencies = [ + "http", + "http-latch", + "test-harness", + "tokio", + "wasmtime", +] + +[[package]] +name = "latch-method-readonly-tests" +version = "0.1.0" +dependencies = [ + "http", + "test-harness", + "tokio", + "wasmtime", +] + +[[package]] +name = "latch-n2" +version = "0.1.0" +dependencies = [ + "http", + "http-latch-n", + "test-harness", + "tokio", + "wasmtime", +] + +[[package]] +name = "latch-n3" +version = "0.1.0" +dependencies = [ + "http", + "http-latch-n", + "test-harness", + "tokio", + "wasmtime", +] + +[[package]] +name = "latch-n4" +version = "0.1.0" +dependencies = [ + "http", + "http-latch-n", + "test-harness", + "tokio", + "wasmtime", +] + +[[package]] +name = "latch-n5" +version = "0.1.0" +dependencies = [ + "http", + "http-latch-n", + "test-harness", + "tokio", + "wasmtime", +] + +[[package]] +name = "latch-scheme" +version = "0.1.0" +dependencies = [ + "http", + "http-latch", + "test-harness", + "tokio", + "wasmtime", +] + +[[package]] +name = "latch-scheme-httpsonly-tests" +version = "0.1.0" +dependencies = [ + "http", + "test-harness", + "tokio", + "wasmtime", +] + +[[package]] +name = "latch-trace" +version = "0.1.0" +dependencies = [ + "http", + "http-latch", + "test-harness", + "tokio", + "wasmtime", +] + [[package]] name = "leb128" version = "0.2.7" @@ -1005,15 +1256,15 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "mach2" @@ -1029,7 +1280,7 @@ checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -1053,11 +1304,21 @@ dependencies = [ "rustix", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", @@ -1130,9 +1391,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -1144,7 +1405,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" dependencies = [ "proc-macro2", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -1341,7 +1602,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -1383,6 +1644,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simdutf8" version = "0.1.5" @@ -1397,9 +1664,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" dependencies = [ "serde", ] @@ -1414,6 +1681,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spdx" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "646c5a367da914e3babcb9cd8e91c37ccaac42325b8f86f036513ad103f2a716" +dependencies = [ + "smallvec", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1433,9 +1709,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -1444,13 +1720,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -1489,6 +1765,7 @@ dependencies = [ "http", "http-body-util", "tokio", + "wac-graph", "wasmtime", "wasmtime-wasi", "wasmtime-wasi-http", @@ -1531,14 +1808,14 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -1546,9 +1823,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ "bytes", "libc", @@ -1567,7 +1844,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -1623,10 +1900,17 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "topological-sort" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" + [[package]] name = "trace" version = "0.1.0" dependencies = [ + "http-utils", "test-harness", "tokio", "wasmtime", @@ -1637,6 +1921,7 @@ dependencies = [ name = "trace-client" version = "0.1.0" dependencies = [ + "http-utils", "test-harness", "tokio", "wasmtime", @@ -1657,6 +1942,7 @@ dependencies = [ name = "trace-handler" version = "0.1.0" dependencies = [ + "http-utils", "test-harness", "tokio", "wasmtime", @@ -1667,6 +1953,7 @@ dependencies = [ name = "trace-types" version = "0.1.0" dependencies = [ + "http-utils", "test-harness", "tokio", "wasmtime", @@ -1718,9 +2005,9 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-width" @@ -1762,6 +2049,39 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wac-graph" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf6c75ac6a3c0351b04ee506e6c39b5b01b4204364264b034e23f8df7cc9946" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "petgraph", + "semver", + "thiserror 1.0.69", + "wac-types", + "wasm-encoder 0.258.3", + "wasm-metadata 0.258.3", + "wasmparser 0.258.3", +] + +[[package]] +name = "wac-types" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc44aa9e8f525d1f0c9800a4c082c33b4ac0781a84e83465923afdaf4a25755b" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "semver", + "wasm-encoder 0.258.3", + "wasmparser 0.258.3", +] + [[package]] name = "want" version = "0.3.1" @@ -1809,7 +2129,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", "wasm-bindgen-shared", ] @@ -1876,7 +2196,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e10808eb27efde692f1b1706089fe19296728bf872291f5b4e0d50fba88f2f" dependencies = [ "anyhow", + "auditable-serde", + "flate2", "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "url", "wasm-encoder 0.258.3", "wasmparser 0.258.3", ] @@ -2549,7 +2876,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn 3.0.5", + "syn 3.0.6", "wasm-metadata 0.259.0", "wit-bindgen-core", "wit-component 0.259.0", @@ -2566,7 +2893,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2661,9 +2988,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "yoke" @@ -2678,13 +3005,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "synstructure", ] @@ -2699,21 +3026,21 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "synstructure", ] [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -2722,9 +3049,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -2733,15 +3060,21 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 791c9db..ee80aff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,11 +2,32 @@ resolver = "2" members = [ "components/client", + "components/gate", + "components/gate-client", + "components/gate-handler", + "components/latch-defer-all", + "components/latch-delegate-client", + "components/latch-delegate-handler", + "components/latch-deny-all", + "components/latch-deny-random", + "components/latch-dry-run", + "components/latch-method", + "components/latch-method-readonly", + "components/latch-n2", + "components/latch-n3", + "components/latch-n4", + "components/latch-n5", + "components/latch-scheme", + "components/latch-scheme-httpsonly", + "components/latch-trace", "components/trace", "components/trace-client", - "components/trace-handler", "components/trace-componentized-client", + "components/trace-handler", "components/trace-types", + "crates/http-latch", + "crates/http-latch-n", + "crates/http-utils", "crates/test-harness", ] @@ -14,10 +35,14 @@ members = [ bytes = "1" http = "1" http-body-util = "0.1" +http-latch = { path = "./crates/http-latch" } +http-latch-n = { path = "./crates/http-latch-n" } +http-utils = { path = "./crates/http-utils" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" test-harness = { path = "./crates/test-harness" } tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] } +wac-graph = "0.11" wasmtime = { version = "49", features = ["component-model-async"] } wasmtime-wasi = { version = "49", features = ["p3"] } wasmtime-wasi-http = { version = "49", default-features = false, features = ["p3", "component-model-async"] } diff --git a/README.md b/README.md index fed7629..f062d89 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,13 @@ A collection of utility components that remix wasi:http types and interfaces. - [Components](#components) + - [Gates](#gates) + - [Latches](#latches) + - [Blanket decisions](#blanket-decisions) + - [Request properties](#request-properties) + - [Fault injection](#fault-injection) + - [Combining latches](#combining-latches) + - [Tracing](#tracing) - [Choosing a component variant](#choosing-a-component-variant) - [Build](#build) - [Community](#community) @@ -15,13 +22,62 @@ A collection of utility components that remix wasi:http types and interfaces. ## Components -- [`client`](./components/client/) -- [`status-codes`](./components/status-codes/) -- [`trace`](./components/trace/) -- [`trace-client`](./components/trace-client/) -- [`trace-handler`](./components/trace-handler/) -- [`trace-componentized-client`](./components/trace-componentized-client/) -- [`trace-types`](./components/trace-types/) +- [`client`](./components/client/): a higher-level HTTP client that delegates to `wasi:http/client` +- [`status-codes`](./components/status-codes/): constants for HTTP status codes + +### Gates + +Access control for wasi:http is split between gates and latches. A gate wraps `wasi:http/client` or `wasi:http/handler` and consults a latch before each request, a latch decides whether the request may proceed. Latches are small and single purpose, combine them to build a policy. + +- [`gate`](./components/gate/): gates both `wasi:http/client` and `wasi:http/handler` +- [`gate-client`](./components/gate-client/): gates `wasi:http/client`, requests sent +- [`gate-handler`](./components/gate-handler/): gates `wasi:http/handler`, requests handled + +A denied request fails with the latch's reason, and is logged as a warning. A latch error fails the request with `internal-error`, and is logged as an error. + +> [!CAUTION] +> Interfering with HTTP requests can have dramatic, unintended consequences. A denied request surfaces to the caller as a failed request, which can trigger retries, timeouts and fallbacks far from the request that was denied. Install new latches, and new configurations of existing latches, cautiously and monitor the result: roll out with [`latch-dry-run`](./components/latch-dry-run/), watch decisions with [`latch-trace`](./components/latch-trace/), and review the denials the gate logs. + +### Latches + +Decide which requests are allowed. A latch defers or denies, a request proceeds unless a latch denies it. Deciding and acting on a decision are separate steps, a latch is told the final decision for each request with `observe-decision`. + +#### Blanket decisions + +- [`latch-defer-all`](./components/latch-defer-all/): defers every request, allowing all requests +- [`latch-deny-all`](./components/latch-deny-all/): denies every request + +#### Request properties + +- [`latch-method`](./components/latch-method/): decides by the request method, configured with `wasi:config/store` +- [`latch-method-readonly`](./components/latch-method-readonly/): allows only GET, HEAD, QUERY and OPTIONS, `latch-method` with [`latch-method-readonly-config`](./components/latch-method-readonly-config/) +- [`latch-scheme`](./components/latch-scheme/): decides by the request scheme, configured with `wasi:config/store` +- [`latch-scheme-httpsonly`](./components/latch-scheme-httpsonly/): allows only HTTPS, `latch-scheme` with [`latch-scheme-httpsonly-config`](./components/latch-scheme-httpsonly-config/) + +#### Fault injection + +Deny requests on purpose, to prove a component is resilient to failures. + +- [`latch-deny-random`](./components/latch-deny-random/): randomly denies a configurable fraction of requests, reproducible with a seed + +#### Combining latches + +Build a policy from several latches, apply a latch to only part of the requests, or try a policy before enforcing it. + +- [`latch-n2`](./components/latch-n2/), [`latch-n3`](./components/latch-n3/), [`latch-n4`](./components/latch-n4/), [`latch-n5`](./components/latch-n5/): aggregate two to five latches, any latch can deny a request +- [`latch-delegate-client`](./components/latch-delegate-client/) / [`latch-delegate-handler`](./components/latch-delegate-handler/): apply a wrapped latch to only `wasi:http/client` or only `wasi:http/handler` requests +- [`latch-dry-run`](./components/latch-dry-run/): log what a wrapped latch would deny without enforcing it, to roll out a policy +- [`latch-trace`](./components/latch-trace/): log the decisions of a wrapped latch + +### Tracing + +Log wasi:http calls, for debugging or auditing, without affecting them. + +- [`trace`](./components/trace/): traces `wasi:http/types`, `wasi:http/client` and `wasi:http/handler` +- [`trace-client`](./components/trace-client/): traces `wasi:http/types` and `wasi:http/client` +- [`trace-handler`](./components/trace-handler/): traces `wasi:http/types` and `wasi:http/handler` +- [`trace-types`](./components/trace-types/): traces `wasi:http/types` +- [`trace-componentized-client`](./components/trace-componentized-client/): traces `componentized:http/client` ### Choosing a component variant diff --git a/components/gate-client/Cargo.toml b/components/gate-client/Cargo.toml new file mode 100644 index 0000000..846d1b8 --- /dev/null +++ b/components/gate-client/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "gate-client" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +http-utils = { workspace = true } +wit-bindgen = { workspace = true } + +[dev-dependencies] +http = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/gate-client/README.md b/components/gate-client/README.md new file mode 100644 index 0000000..bc2317e --- /dev/null +++ b/components/gate-client/README.md @@ -0,0 +1,28 @@ +# `gate-client` + +HTTP gate access control for requests sent with `wasi:http/client`. + +The latch is asked to authorize each request, then told the final decision with `observe-decision`. A denied request fails with the latch's reason and is logged as a warning: + +``` +Denied REASON=http-request-denied OPERATION=wasi:http/client#send METHOD=post PATH-WITH-QUERY=some +``` + +A latch error, or a failure to observe the decision, fails the request with `internal-error` and is logged as an error: + +``` +Latch error CODE=invalid-config OPERATION=wasi:http/client#send METHOD=get PATH-WITH-QUERY=some +``` + +## Interfaces + +Imports: + +- `componentized:http/latch@0.1.0-dev`: decides whether each request may proceed +- `wasi:logging/logging@0.1.0-draft`: logs denials and latch errors +- `wasi:http/types@0.3.0`: the requests being gated +- `wasi:http/client@0.3.0`: the client the gated client wraps + +Exports: + +- `wasi:http/client@0.3.0`: client gated by the latch diff --git a/components/gate-client/src/lib.rs b/components/gate-client/src/lib.rs new file mode 100644 index 0000000..02b761a --- /dev/null +++ b/components/gate-client/src/lib.rs @@ -0,0 +1,148 @@ +use std::fmt::{self, Display}; + +use crate::{ + componentized::http::latch::{ + self, ClientOperation, + Decision::{self, Deferred, Denied}, + ErrorCode, HttpErrorCode, Operation, SendArgs, + }, + exports::wasi::http::client::{Guest, Request, Response}, + wasi::{ + http::{client, types}, + logging::logging::{Level, log}, + }, +}; + +macro_rules! warn { + ($dst:expr, $($arg:tt)*) => { + log(Level::Warn, "componentized-gate", &format!($dst, $($arg)*)); + }; + ($dst:expr) => { + log(Level::Warn, "componentized-gate", &format!($dst)); + }; +} + +macro_rules! error { + ($dst:expr, $($arg:tt)*) => { + log(Level::Error, "componentized-gate", &format!($dst, $($arg)*)); + }; + ($dst:expr) => { + log(Level::Error, "componentized-gate", &format!($dst)); + }; +} + +/// Authorize the request with the latch, then report the decision back to the latch. Latches act +/// on the final decision in `observe-decision`, never while authorizing. Failing to observe the +/// decision fails the request, the same as a latch error. +fn authorize(operation: &Operation) -> Result { + let decision = latch::authorize(operation)?; + latch::observe_decision(&decision, operation)?; + Ok(decision) +} + +struct GatedHttpClient {} + +impl Guest for GatedHttpClient { + #[doc = "/ This function may be used to either send an outgoing request over the"] + #[doc = "/ network or to forward it to another component."] + #[allow(async_fn_in_trait)] + async fn send(request: Request) -> Result { + let call_summary = || { + format!( + "OPERATION=wasi:http/client#send METHOD={method} PATH-WITH-QUERY={path_with_query}", + method = request.get_method(), + path_with_query = DisplayOption(request.get_path_with_query()), + ) + }; + + match authorize(&Operation::Client(ClientOperation::Send(SendArgs { + request: &request, + }))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={} {}", + DisplayReason(&reason), + call_summary() + ); + Err(reason)? + } + Ok(Deferred) => client::send(request).await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchError(&code), + summary = call_summary() + ); + Err(code)? + } + } + } +} + +impl From for HttpErrorCode { + fn from(value: ErrorCode) -> Self { + match value { + ErrorCode::InvalidConfig(latch) => { + Self::InternalError(Some(format!("latch-error: invalid-config<{latch}>"))) + } + ErrorCode::ObservationFailed(latch) => { + Self::InternalError(Some(format!("latch-error: observation-failed<{latch}>"))) + } + ErrorCode::Other(Some(message)) => { + Self::InternalError(Some(format!("latch-error: {message}"))) + } + ErrorCode::Other(None) => Self::InternalError(Some("latch-error".to_string())), + } + } +} + +impl Display for types::Method { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&http_utils::format_http_method!(types::Method, self)) + } +} + +struct DisplayLatchError<'a>(&'a ErrorCode); +impl fmt::Display for DisplayLatchError<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self(ErrorCode::InvalidConfig(latch)) => { + write!(f, "invalid-config<{latch}>") + } + Self(ErrorCode::ObservationFailed(latch)) => { + write!(f, "observation-failed<{latch}>") + } + Self(ErrorCode::Other(Some(message))) => f.write_str(message), + Self(ErrorCode::Other(None)) => f.write_str("other"), + } + } +} + +/// Displays a denial reason, the name of the error code, e.g. `http-request-denied`. +/// +/// The generated bindings already implement `Display` for [`HttpErrorCode`], with its debug form. +struct DisplayReason<'a>(&'a HttpErrorCode); +impl fmt::Display for DisplayReason<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&http_utils::format_http_error_code!(HttpErrorCode, self.0)) + } +} + +struct DisplayOption(Option); +impl fmt::Display for DisplayOption { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.0 { + Some(value) => write!(f, "some<{}>", value), + None => write!(f, "none"), // Customize what to print if empty + } + } +} + +wit_bindgen::generate!({ + path: "../wit", + world: "gate-client", + merge_structurally_equal_types: true, + generate_all +}); + +export!(GatedHttpClient); diff --git a/components/gate-client/tests/gate.rs b/components/gate-client/tests/gate.rs new file mode 100644 index 0000000..a64de02 --- /dev/null +++ b/components/gate-client/tests/gate.rs @@ -0,0 +1,131 @@ +//! Tests for `gate-client`, which asks a latch before sending each request. + +use test_harness::{ + Authorization, Harness, HostLatch, HttpErrorCode, LogEntry, Observation, host_request, + response_status, +}; + +const URI: &str = "https://example.com/items?page=2"; + +fn authorization() -> Authorization { + Authorization { + operation: "client.send".to_string(), + method: "GET".to_string(), + path_with_query: "/items?page=2".to_string(), + } +} + +fn observation(denied: bool) -> Observation { + Observation { + operation: "client.send".to_string(), + denied, + } +} + +/// Send a GET request through the gate, the status of the response or the error. +async fn send( + gate: &mut test_harness::TestSubject, +) -> wasmtime::Result> { + gate.run(async |accessor, gate| { + let request = host_request(accessor, http::Method::GET, URI)?; + Ok( + match gate.gated_client().call_send(accessor, request).await? { + Ok(response) => Ok(response_status(accessor, &response)?), + Err(err) => Err(err), + }, + ) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn exports_client() -> wasmtime::Result<()> { + let gate = Harness::new("gate-client").build().await?; + gate.exports().gated_client(); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn deferred_request_is_sent() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate-client").build().await?; + let result = send(&mut gate).await?; + assert_eq!(result.ok(), Some(200)); + assert_eq!(gate.recorder().authorizations(), vec![authorization()]); + assert_eq!(gate.recorder().observations(), vec![observation(false)]); + assert_eq!(gate.recorder().requests().len(), 1); + assert_eq!(gate.recorder().logs(), vec![]); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn denied_request_is_not_sent() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate-client") + .host_latch(HostLatch::deny(&["client.send"])) + .build() + .await?; + let result = send(&mut gate).await?; + assert!( + matches!(result, Err(HttpErrorCode::HttpRequestDenied)), + "{result:?}" + ); + assert_eq!(gate.recorder().requests(), vec![]); + // the latch observes the denial it decided + assert_eq!(gate.recorder().observations(), vec![observation(true)]); + assert_eq!( + gate.recorder().logs(), + vec![LogEntry::warn( + "componentized-gate", + "Denied REASON=http-request-denied OPERATION=wasi:http/client#send METHOD=get PATH-WITH-QUERY=some" + )] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn latch_error_fails_the_request() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate-client") + .host_latch(HostLatch::invalid_config("latch-example")) + .build() + .await?; + let result = send(&mut gate).await?; + assert!( + matches!( + &result, + Err(HttpErrorCode::InternalError(Some(message))) + if message == "latch-error: invalid-config" + ), + "{result:?}" + ); + assert_eq!(gate.recorder().requests(), vec![]); + // a decision that was never made is not observed + assert_eq!(gate.recorder().observations(), vec![]); + assert_eq!( + gate.recorder().logs(), + vec![LogEntry::error( + "componentized-gate", + "Latch error CODE=invalid-config OPERATION=wasi:http/client#send METHOD=get PATH-WITH-QUERY=some" + )] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn failed_observation_fails_the_request() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate-client") + .host_latch(HostLatch::defer().fail_observe()) + .build() + .await?; + let result = send(&mut gate).await?; + assert!( + matches!( + &result, + Err(HttpErrorCode::InternalError(Some(message))) + if message == "latch-error: observation-failed" + ), + "{result:?}" + ); + // the latch could not act on the decision, the request is not sent + assert_eq!(gate.recorder().requests(), vec![]); + assert_eq!(gate.recorder().observations(), vec![observation(false)]); + Ok(()) +} diff --git a/components/gate-handler/Cargo.toml b/components/gate-handler/Cargo.toml new file mode 100644 index 0000000..d3bb25f --- /dev/null +++ b/components/gate-handler/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "gate-handler" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +http-utils = { workspace = true } +wit-bindgen = { workspace = true } + +[dev-dependencies] +http = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/gate-handler/README.md b/components/gate-handler/README.md new file mode 100644 index 0000000..68917c1 --- /dev/null +++ b/components/gate-handler/README.md @@ -0,0 +1,28 @@ +# `gate-handler` + +HTTP gate access control for requests handled with `wasi:http/handler`. + +The latch is asked to authorize each request, then told the final decision with `observe-decision`. A denied request fails with the latch's reason and is logged as a warning: + +``` +Denied REASON=http-request-denied OPERATION=wasi:http/client#send METHOD=post PATH-WITH-QUERY=some +``` + +A latch error, or a failure to observe the decision, fails the request with `internal-error` and is logged as an error: + +``` +Latch error CODE=invalid-config OPERATION=wasi:http/client#send METHOD=get PATH-WITH-QUERY=some +``` + +## Interfaces + +Imports: + +- `componentized:http/latch@0.1.0-dev`: decides whether each request may proceed +- `wasi:logging/logging@0.1.0-draft`: logs denials and latch errors +- `wasi:http/types@0.3.0`: the requests being gated +- `wasi:http/handler@0.3.0`: the handler the gated handler wraps + +Exports: + +- `wasi:http/handler@0.3.0`: handler gated by the latch diff --git a/components/gate-handler/src/lib.rs b/components/gate-handler/src/lib.rs new file mode 100644 index 0000000..ae69ba2 --- /dev/null +++ b/components/gate-handler/src/lib.rs @@ -0,0 +1,148 @@ +use std::fmt::{self, Display}; + +use crate::{ + componentized::http::latch::{ + self, + Decision::{self, Deferred, Denied}, + ErrorCode, HandleArgs, HandlerOperation, HttpErrorCode, Operation, + }, + exports::wasi::http::handler::{Guest, Request, Response}, + wasi::{ + http::{handler, types}, + logging::logging::{Level, log}, + }, +}; + +macro_rules! warn { + ($dst:expr, $($arg:tt)*) => { + log(Level::Warn, "componentized-gate", &format!($dst, $($arg)*)); + }; + ($dst:expr) => { + log(Level::Warn, "componentized-gate", &format!($dst)); + }; +} + +macro_rules! error { + ($dst:expr, $($arg:tt)*) => { + log(Level::Error, "componentized-gate", &format!($dst, $($arg)*)); + }; + ($dst:expr) => { + log(Level::Error, "componentized-gate", &format!($dst)); + }; +} + +/// Authorize the request with the latch, then report the decision back to the latch. Latches act +/// on the final decision in `observe-decision`, never while authorizing. Failing to observe the +/// decision fails the request, the same as a latch error. +fn authorize(operation: &Operation) -> Result { + let decision = latch::authorize(operation)?; + latch::observe_decision(&decision, operation)?; + Ok(decision) +} + +struct GatedHttpHandler {} + +impl Guest for GatedHttpHandler { + #[doc = "/ This function may be called with either an incoming request read from the"] + #[doc = "/ network or a request synthesized or forwarded by another component."] + #[allow(async_fn_in_trait)] + async fn handle(request: Request) -> Result { + let call_summary = || { + format!( + "OPERATION=wasi:http/handler#handle METHOD={method} PATH-WITH-QUERY={path_with_query}", + method = request.get_method(), + path_with_query = DisplayOption(request.get_path_with_query()), + ) + }; + + match authorize(&Operation::Handler(HandlerOperation::Handle(HandleArgs { + request: &request, + }))) { + Ok(Denied(reason)) => { + warn!( + "Denied REASON={} {}", + DisplayReason(&reason), + call_summary() + ); + Err(reason)? + } + Ok(Deferred) => handler::handle(request).await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + code = DisplayLatchError(&code), + summary = call_summary() + ); + Err(code)? + } + } + } +} + +impl From for HttpErrorCode { + fn from(value: ErrorCode) -> Self { + match value { + ErrorCode::InvalidConfig(latch) => { + Self::InternalError(Some(format!("latch-error: invalid-config<{latch}>"))) + } + ErrorCode::ObservationFailed(latch) => { + Self::InternalError(Some(format!("latch-error: observation-failed<{latch}>"))) + } + ErrorCode::Other(Some(message)) => { + Self::InternalError(Some(format!("latch-error: {message}"))) + } + ErrorCode::Other(None) => Self::InternalError(Some("latch-error".to_string())), + } + } +} + +impl Display for types::Method { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&http_utils::format_http_method!(types::Method, self)) + } +} + +struct DisplayLatchError<'a>(&'a ErrorCode); +impl fmt::Display for DisplayLatchError<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self(ErrorCode::InvalidConfig(latch)) => { + write!(f, "invalid-config<{latch}>") + } + Self(ErrorCode::ObservationFailed(latch)) => { + write!(f, "observation-failed<{latch}>") + } + Self(ErrorCode::Other(Some(message))) => f.write_str(message), + Self(ErrorCode::Other(None)) => f.write_str("other"), + } + } +} + +/// Displays a denial reason, the name of the error code, e.g. `http-request-denied`. +/// +/// The generated bindings already implement `Display` for [`HttpErrorCode`], with its debug form. +struct DisplayReason<'a>(&'a HttpErrorCode); +impl fmt::Display for DisplayReason<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&http_utils::format_http_error_code!(HttpErrorCode, self.0)) + } +} + +struct DisplayOption(Option); +impl fmt::Display for DisplayOption { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.0 { + Some(value) => write!(f, "some<{}>", value), + None => write!(f, "none"), // Customize what to print if empty + } + } +} + +wit_bindgen::generate!({ + path: "../wit", + world: "gate-handler", + merge_structurally_equal_types: true, + generate_all +}); + +export!(GatedHttpHandler); diff --git a/components/gate-handler/tests/gate.rs b/components/gate-handler/tests/gate.rs new file mode 100644 index 0000000..070999d --- /dev/null +++ b/components/gate-handler/tests/gate.rs @@ -0,0 +1,131 @@ +//! Tests for `gate-handler`, which asks a latch before handling each request. + +use test_harness::{ + Authorization, Harness, HostLatch, HttpErrorCode, LogEntry, Observation, host_request, + response_status, +}; + +const URI: &str = "https://example.com/items?page=2"; + +fn authorization() -> Authorization { + Authorization { + operation: "handler.handle".to_string(), + method: "GET".to_string(), + path_with_query: "/items?page=2".to_string(), + } +} + +fn observation(denied: bool) -> Observation { + Observation { + operation: "handler.handle".to_string(), + denied, + } +} + +/// Handle a GET request through the gate, the status of the response or the error. +async fn send( + gate: &mut test_harness::TestSubject, +) -> wasmtime::Result> { + gate.run(async |accessor, gate| { + let request = host_request(accessor, http::Method::GET, URI)?; + Ok( + match gate.gated_handler().call_handle(accessor, request).await? { + Ok(response) => Ok(response_status(accessor, &response)?), + Err(err) => Err(err), + }, + ) + }) + .await +} + +#[tokio::test(flavor = "multi_thread")] +async fn exports_handler() -> wasmtime::Result<()> { + let gate = Harness::new("gate-handler").build().await?; + gate.exports().gated_handler(); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn deferred_request_is_handled() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate-handler").build().await?; + let result = send(&mut gate).await?; + assert_eq!(result.ok(), Some(200)); + assert_eq!(gate.recorder().authorizations(), vec![authorization()]); + assert_eq!(gate.recorder().observations(), vec![observation(false)]); + assert_eq!(gate.recorder().requests().len(), 1); + assert_eq!(gate.recorder().logs(), vec![]); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn denied_request_is_not_handled() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate-handler") + .host_latch(HostLatch::deny(&["handler.handle"])) + .build() + .await?; + let result = send(&mut gate).await?; + assert!( + matches!(result, Err(HttpErrorCode::HttpRequestDenied)), + "{result:?}" + ); + assert_eq!(gate.recorder().requests(), vec![]); + // the latch observes the denial it decided + assert_eq!(gate.recorder().observations(), vec![observation(true)]); + assert_eq!( + gate.recorder().logs(), + vec![LogEntry::warn( + "componentized-gate", + "Denied REASON=http-request-denied OPERATION=wasi:http/handler#handle METHOD=get PATH-WITH-QUERY=some" + )] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn latch_error_fails_the_request() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate-handler") + .host_latch(HostLatch::invalid_config("latch-example")) + .build() + .await?; + let result = send(&mut gate).await?; + assert!( + matches!( + &result, + Err(HttpErrorCode::InternalError(Some(message))) + if message == "latch-error: invalid-config" + ), + "{result:?}" + ); + assert_eq!(gate.recorder().requests(), vec![]); + // a decision that was never made is not observed + assert_eq!(gate.recorder().observations(), vec![]); + assert_eq!( + gate.recorder().logs(), + vec![LogEntry::error( + "componentized-gate", + "Latch error CODE=invalid-config OPERATION=wasi:http/handler#handle METHOD=get PATH-WITH-QUERY=some" + )] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn failed_observation_fails_the_request() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate-handler") + .host_latch(HostLatch::defer().fail_observe()) + .build() + .await?; + let result = send(&mut gate).await?; + assert!( + matches!( + &result, + Err(HttpErrorCode::InternalError(Some(message))) + if message == "latch-error: observation-failed" + ), + "{result:?}" + ); + // the latch could not act on the decision, the request is not handled + assert_eq!(gate.recorder().requests(), vec![]); + assert_eq!(gate.recorder().observations(), vec![observation(false)]); + Ok(()) +} diff --git a/components/gate/Cargo.toml b/components/gate/Cargo.toml new file mode 100644 index 0000000..deeedc8 --- /dev/null +++ b/components/gate/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "gate-tests" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +publish = false + +# only tests, the component is built from gate.wac + +[dev-dependencies] +http = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/gate/README.md b/components/gate/README.md new file mode 100644 index 0000000..a65e7fd --- /dev/null +++ b/components/gate/README.md @@ -0,0 +1,32 @@ +# `gate` + +HTTP gate access control for both requests sent and handled. + +The latch is asked to authorize each request, then told the final decision with `observe-decision`. A denied request fails with the latch's reason and is logged as a warning: + +``` +Denied REASON=http-request-denied OPERATION=wasi:http/client#send METHOD=post PATH-WITH-QUERY=some +``` + +A latch error, or a failure to observe the decision, fails the request with `internal-error` and is logged as an error: + +``` +Latch error CODE=invalid-config OPERATION=wasi:http/client#send METHOD=get PATH-WITH-QUERY=some +``` + +A union of `gate-client` and `gate-handler`. + +## Interfaces + +Imports: + +- `componentized:http/latch@0.1.0-dev`: decides whether each request may proceed +- `wasi:logging/logging@0.1.0-draft`: logs denials and latch errors +- `wasi:http/types@0.3.0`: the requests being gated +- `wasi:http/client@0.3.0`: the client the gated client wraps +- `wasi:http/handler@0.3.0`: the handler the gated handler wraps + +Exports: + +- `wasi:http/client@0.3.0`: client gated by the latch +- `wasi:http/handler@0.3.0`: handler gated by the latch diff --git a/components/gate/gate.wac b/components/gate/gate.wac new file mode 100644 index 0000000..9cde461 --- /dev/null +++ b/components/gate/gate.wac @@ -0,0 +1,4 @@ +package componentized:http; + +export new local:gate-client{ ... }...; +export new local:gate-handler{ ... }...; diff --git a/components/gate/tests/gate.rs b/components/gate/tests/gate.rs new file mode 100644 index 0000000..73c12cb --- /dev/null +++ b/components/gate/tests/gate.rs @@ -0,0 +1,30 @@ +//! Tests for `gate`, composed from `gate.wac`. Tests for each interface live with `gate-client` +//! and `gate-handler`. + +use test_harness::{Harness, HostLatch, HttpErrorCode}; + +#[tokio::test(flavor = "multi_thread")] +async fn gates_client_and_handler() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate") + .host_latch(HostLatch::deny(&["handler.handle"])) + .build() + .await?; + assert_eq!( + gate.send(http::Method::GET, "https://example.com/") + .await? + .ok(), + Some(200) + ); + let handled = gate + .handle(http::Method::GET, "https://example.com/") + .await?; + assert!( + matches!(handled, Err(HttpErrorCode::HttpRequestDenied)), + "{handled:?}" + ); + assert_eq!( + gate.recorder().operations(), + vec!["client.send", "handler.handle"] + ); + Ok(()) +} diff --git a/components/latch-defer-all/Cargo.toml b/components/latch-defer-all/Cargo.toml new file mode 100644 index 0000000..b05eeda --- /dev/null +++ b/components/latch-defer-all/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "latch-defer-all" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +http-latch = { workspace = true } + +[dev-dependencies] +http = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-defer-all/README.md b/components/latch-defer-all/README.md new file mode 100644 index 0000000..0922a29 --- /dev/null +++ b/components/latch-defer-all/README.md @@ -0,0 +1,13 @@ +# `latch-defer-all` + +HTTP latch that defers every request, allowing all requests. + +## Interfaces + +Imports: + +- `wasi:http/types@0.3.0`: the requests being authorized + +Exports: + +- `componentized:http/latch@0.1.0-dev`: the latch diff --git a/components/latch-defer-all/src/lib.rs b/components/latch-defer-all/src/lib.rs new file mode 100644 index 0000000..5a848d8 --- /dev/null +++ b/components/latch-defer-all/src/lib.rs @@ -0,0 +1,16 @@ +use http_latch::{Decision, ErrorCode, Latch, Operation}; + +struct DeferAllLatch {} + +impl Latch for DeferAllLatch { + fn authorize(_: Operation) -> Result { + Ok(Decision::Deferred) + } + + fn observe_decision(_final_decision: Decision, _operation: Operation) -> Result<(), ErrorCode> { + // no side effects + Ok(()) + } +} + +http_latch::export!(DeferAllLatch with_types_in http_latch::bindings); diff --git a/components/latch-defer-all/tests/latch.rs b/components/latch-defer-all/tests/latch.rs new file mode 100644 index 0000000..270de9e --- /dev/null +++ b/components/latch-defer-all/tests/latch.rs @@ -0,0 +1,25 @@ +use test_harness::Harness; + +#[tokio::test(flavor = "multi_thread")] +async fn defers_every_request() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate") + .latch("latch-defer-all") + .build() + .await?; + assert_eq!( + gate.send(http::Method::POST, "https://example.com/") + .await? + .ok(), + Some(200) + ); + assert_eq!( + gate.handle(http::Method::DELETE, "https://example.com/") + .await? + .ok(), + Some(200) + ); + // latch-defer-all decides every request itself, the host latch is never consulted + assert_eq!(gate.recorder().operations(), Vec::::new()); + assert_eq!(gate.recorder().logs(), vec![]); + Ok(()) +} diff --git a/components/latch-delegate-client/Cargo.toml b/components/latch-delegate-client/Cargo.toml new file mode 100644 index 0000000..1022d08 --- /dev/null +++ b/components/latch-delegate-client/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "latch-delegate-client" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +http-latch = { workspace = true } + +[dev-dependencies] +http = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-delegate-client/README.md b/components/latch-delegate-client/README.md new file mode 100644 index 0000000..b81873d --- /dev/null +++ b/components/latch-delegate-client/README.md @@ -0,0 +1,35 @@ +# `latch-delegate-client` + +HTTP latch that wraps another latch, delegating only `wasi:http/client` requests to it. `wasi:http/handler` requests are deferred without consulting the wrapped latch, it neither authorizes nor observes them. + +Wrapping lets a latch be configured separately for outgoing and incoming requests. For example, to allow only reading methods for outgoing requests, and any method for incoming ones, wrap one `latch-method` with `latch-delegate-client` and another with `latch-delegate-handler`, give each its own config, and aggregate them with `latch-n2`: + +``` +package example:latch; + +let client = new local:latch-method { + store: new local:client-config {}.store, + ... +}; +let handler = new local:latch-method { + store: new local:handler-config {}.store, + ... +}; + +export new local:latch-n2 { + latch0: new local:latch-delegate-client { latch: client.latch }.latch, + latch1: new local:latch-delegate-handler { latch: handler.latch }.latch, + ... +}...; +``` + +## Interfaces + +Imports: + +- `wasi:http/types@0.3.0`: the requests being authorized +- `componentized:http/latch@0.1.0-dev`: the wrapped latch, consulted for `wasi:http/client` requests + +Exports: + +- `componentized:http/latch@0.1.0-dev`: the latch diff --git a/components/latch-delegate-client/src/lib.rs b/components/latch-delegate-client/src/lib.rs new file mode 100644 index 0000000..496b63e --- /dev/null +++ b/components/latch-delegate-client/src/lib.rs @@ -0,0 +1,28 @@ +use http_latch::wrapped as latch; +use http_latch::{Decision, ErrorCode, Latch, Operation}; + +struct DelegateClientLatch {} + +/// Only wasi:http/client requests reach the wrapped latch. +fn is_delegated(operation: &Operation) -> bool { + matches!(operation, Operation::Client(_)) +} + +impl Latch for DelegateClientLatch { + fn authorize(operation: Operation) -> Result { + match is_delegated(&operation) { + true => latch::authorize(&operation), + false => Ok(Decision::Deferred), + } + } + + fn observe_decision(final_decision: Decision, operation: Operation) -> Result<(), ErrorCode> { + // the wrapped latch only observes the requests it was asked to authorize + match is_delegated(&operation) { + true => latch::observe_decision(&final_decision, &operation), + false => Ok(()), + } + } +} + +http_latch::export!(DelegateClientLatch with_types_in http_latch::bindings); diff --git a/components/latch-delegate-client/tests/latch.rs b/components/latch-delegate-client/tests/latch.rs new file mode 100644 index 0000000..91a2db8 --- /dev/null +++ b/components/latch-delegate-client/tests/latch.rs @@ -0,0 +1,33 @@ +use test_harness::{Harness, HostLatch, HttpErrorCode, Observation}; + +#[tokio::test(flavor = "multi_thread")] +async fn delegates_only_client_requests() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate") + .latch("latch-delegate-client") + .host_latch(HostLatch::deny(&["client.send", "handler.handle"])) + .build() + .await?; + let sent = gate.send(http::Method::GET, "https://example.com/").await?; + let handled = gate + .handle(http::Method::GET, "https://example.com/") + .await?; + let (delegated, other) = match "client" { + "client" => (sent, handled), + _ => (handled, sent), + }; + // the wrapped latch denies what it is asked about, the rest is deferred without asking it + assert!( + matches!(delegated, Err(HttpErrorCode::HttpRequestDenied)), + "{delegated:?}" + ); + assert_eq!(other.ok(), Some(200)); + assert_eq!(gate.recorder().operations(), vec!["client.send"]); + assert_eq!( + gate.recorder().observations(), + vec![Observation { + operation: "client.send".to_string(), + denied: true + }] + ); + Ok(()) +} diff --git a/components/latch-delegate-handler/Cargo.toml b/components/latch-delegate-handler/Cargo.toml new file mode 100644 index 0000000..880a19a --- /dev/null +++ b/components/latch-delegate-handler/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "latch-delegate-handler" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +http-latch = { workspace = true } + +[dev-dependencies] +http = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-delegate-handler/README.md b/components/latch-delegate-handler/README.md new file mode 100644 index 0000000..a6e07a7 --- /dev/null +++ b/components/latch-delegate-handler/README.md @@ -0,0 +1,35 @@ +# `latch-delegate-handler` + +HTTP latch that wraps another latch, delegating only `wasi:http/handler` requests to it. `wasi:http/client` requests are deferred without consulting the wrapped latch, it neither authorizes nor observes them. + +Wrapping lets a latch be configured separately for outgoing and incoming requests. For example, to allow only reading methods for outgoing requests, and any method for incoming ones, wrap one `latch-method` with `latch-delegate-client` and another with `latch-delegate-handler`, give each its own config, and aggregate them with `latch-n2`: + +``` +package example:latch; + +let client = new local:latch-method { + store: new local:client-config {}.store, + ... +}; +let handler = new local:latch-method { + store: new local:handler-config {}.store, + ... +}; + +export new local:latch-n2 { + latch0: new local:latch-delegate-client { latch: client.latch }.latch, + latch1: new local:latch-delegate-handler { latch: handler.latch }.latch, + ... +}...; +``` + +## Interfaces + +Imports: + +- `wasi:http/types@0.3.0`: the requests being authorized +- `componentized:http/latch@0.1.0-dev`: the wrapped latch, consulted for `wasi:http/handler` requests + +Exports: + +- `componentized:http/latch@0.1.0-dev`: the latch diff --git a/components/latch-delegate-handler/src/lib.rs b/components/latch-delegate-handler/src/lib.rs new file mode 100644 index 0000000..c03c4ed --- /dev/null +++ b/components/latch-delegate-handler/src/lib.rs @@ -0,0 +1,28 @@ +use http_latch::wrapped as latch; +use http_latch::{Decision, ErrorCode, Latch, Operation}; + +struct DelegateHandlerLatch {} + +/// Only wasi:http/handler requests reach the wrapped latch. +fn is_delegated(operation: &Operation) -> bool { + matches!(operation, Operation::Handler(_)) +} + +impl Latch for DelegateHandlerLatch { + fn authorize(operation: Operation) -> Result { + match is_delegated(&operation) { + true => latch::authorize(&operation), + false => Ok(Decision::Deferred), + } + } + + fn observe_decision(final_decision: Decision, operation: Operation) -> Result<(), ErrorCode> { + // the wrapped latch only observes the requests it was asked to authorize + match is_delegated(&operation) { + true => latch::observe_decision(&final_decision, &operation), + false => Ok(()), + } + } +} + +http_latch::export!(DelegateHandlerLatch with_types_in http_latch::bindings); diff --git a/components/latch-delegate-handler/tests/latch.rs b/components/latch-delegate-handler/tests/latch.rs new file mode 100644 index 0000000..e8becab --- /dev/null +++ b/components/latch-delegate-handler/tests/latch.rs @@ -0,0 +1,33 @@ +use test_harness::{Harness, HostLatch, HttpErrorCode, Observation}; + +#[tokio::test(flavor = "multi_thread")] +async fn delegates_only_handler_requests() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate") + .latch("latch-delegate-handler") + .host_latch(HostLatch::deny(&["client.send", "handler.handle"])) + .build() + .await?; + let sent = gate.send(http::Method::GET, "https://example.com/").await?; + let handled = gate + .handle(http::Method::GET, "https://example.com/") + .await?; + let (delegated, other) = match "handler" { + "client" => (sent, handled), + _ => (handled, sent), + }; + // the wrapped latch denies what it is asked about, the rest is deferred without asking it + assert!( + matches!(delegated, Err(HttpErrorCode::HttpRequestDenied)), + "{delegated:?}" + ); + assert_eq!(other.ok(), Some(200)); + assert_eq!(gate.recorder().operations(), vec!["handler.handle"]); + assert_eq!( + gate.recorder().observations(), + vec![Observation { + operation: "handler.handle".to_string(), + denied: true + }] + ); + Ok(()) +} diff --git a/components/latch-deny-all/Cargo.toml b/components/latch-deny-all/Cargo.toml new file mode 100644 index 0000000..c24e59f --- /dev/null +++ b/components/latch-deny-all/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "latch-deny-all" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +http-latch = { workspace = true } + +[dev-dependencies] +http = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-deny-all/README.md b/components/latch-deny-all/README.md new file mode 100644 index 0000000..9ed2c8f --- /dev/null +++ b/components/latch-deny-all/README.md @@ -0,0 +1,13 @@ +# `latch-deny-all` + +HTTP latch that denies every request with `http-request-denied`. + +## Interfaces + +Imports: + +- `wasi:http/types@0.3.0`: the requests being authorized + +Exports: + +- `componentized:http/latch@0.1.0-dev`: the latch diff --git a/components/latch-deny-all/src/lib.rs b/components/latch-deny-all/src/lib.rs new file mode 100644 index 0000000..5ab6c2b --- /dev/null +++ b/components/latch-deny-all/src/lib.rs @@ -0,0 +1,16 @@ +use http_latch::{Decision, ErrorCode, HttpErrorCode, Latch, Operation}; + +struct DenyAllLatch {} + +impl Latch for DenyAllLatch { + fn authorize(_: Operation) -> Result { + Ok(Decision::Denied(HttpErrorCode::HttpRequestDenied)) + } + + fn observe_decision(_final_decision: Decision, _operation: Operation) -> Result<(), ErrorCode> { + // no side effects + Ok(()) + } +} + +http_latch::export!(DenyAllLatch with_types_in http_latch::bindings); diff --git a/components/latch-deny-all/tests/latch.rs b/components/latch-deny-all/tests/latch.rs new file mode 100644 index 0000000..37d50b9 --- /dev/null +++ b/components/latch-deny-all/tests/latch.rs @@ -0,0 +1,28 @@ +use test_harness::{Harness, HttpErrorCode, LogEntry}; + +#[tokio::test(flavor = "multi_thread")] +async fn denies_every_request() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate").latch("latch-deny-all").build().await?; + let sent = gate.send(http::Method::GET, "https://example.com/").await?; + assert!( + matches!(sent, Err(HttpErrorCode::HttpRequestDenied)), + "{sent:?}" + ); + let handled = gate + .handle(http::Method::GET, "https://example.com/") + .await?; + assert!( + matches!(handled, Err(HttpErrorCode::HttpRequestDenied)), + "{handled:?}" + ); + assert_eq!(gate.recorder().requests(), vec![]); + assert_eq!(gate.recorder().operations(), Vec::::new()); + assert_eq!( + gate.recorder().logs()[0], + LogEntry::warn( + "componentized-gate", + "Denied REASON=http-request-denied OPERATION=wasi:http/client#send METHOD=get PATH-WITH-QUERY=some" + ) + ); + Ok(()) +} diff --git a/components/latch-deny-random/Cargo.toml b/components/latch-deny-random/Cargo.toml new file mode 100644 index 0000000..94f41e4 --- /dev/null +++ b/components/latch-deny-random/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "latch-deny-random" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +http-latch = { workspace = true } +http-utils = { workspace = true } +wit-bindgen = { workspace = true } + +[dev-dependencies] +http = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-deny-random/README.md b/components/latch-deny-random/README.md new file mode 100644 index 0000000..b9ff910 --- /dev/null +++ b/components/latch-deny-random/README.md @@ -0,0 +1,32 @@ +# `latch-deny-random` + +HTTP latch that randomly denies requests, to prove a component is resilient to failures. + +Every request is at risk, both requests sent with `wasi:http/client` and requests handled with `wasi:http/handler`. A denied request fails with `http-request-denied`, or the configured reason. Wrap it with [`latch-delegate-client`](../latch-delegate-client/) or [`latch-delegate-handler`](../latch-delegate-handler/) to put only some of the requests at risk. + +The latch is configured with a wasi:config/store: + +- `probability`: the fraction of requests denied, from `0` to `1`, defaults to `0.1` +- `seed`: an unsigned 64 bit integer, the same seed denies the same requests, defaults to a random seed from `wasi:random` +- `reason`: the wasi:http `error-code` denied requests fail with, as written in the WIT in lower case, e.g. `connection-refused` or `http-response-timeout`, defaults to `http-request-denied`. An error code with a payload has an empty payload. Any other value is reported as `internal-error` with the value as the message + +The seed is logged as a warning when the latch starts, set it in the config to reproduce a failure. + +``` +Randomly denying wasi:http requests PROBABILITY=0.1 SEED=9383211634937261427 +``` + +The decision for a request depends only on the seed and the number of requests observed before it. Requests another latch denies first still count, so aggregating the latch with others does not change which requests it denies. + +## Interfaces + +Imports: + +- `wasi:logging/logging@0.1.0-draft`: logs the seed, and an invalid config +- `wasi:config/store@0.2.0-rc.1`: the latch config +- `wasi:random/insecure@0.3.0`: seeds the decisions when no `seed` is configured +- `wasi:http/types@0.3.0`: the requests being authorized + +Exports: + +- `componentized:http/latch@0.1.0-dev`: the latch diff --git a/components/latch-deny-random/src/lib.rs b/components/latch-deny-random/src/lib.rs new file mode 100644 index 0000000..1877379 --- /dev/null +++ b/components/latch-deny-random/src/lib.rs @@ -0,0 +1,230 @@ +use http_latch::{Decision, ErrorCode, HttpErrorCode, Latch, Local, Operation}; + +const LATCH_NAME: &str = "latch-deny-random"; + +/// The fraction of requests denied when `probability` is not configured. +const DEFAULT_PROBABILITY: f64 = 0.1; + +mod random { + wit_bindgen::generate!({ + path: "../wit", + world: "insecure-random", + generate_all + }); +} + +struct Config { + /// The fraction of requests denied, from 0 to 1. + probability: f64, + /// Seeds the decisions, the same seed denies the same requests. + seed: u64, + reason: HttpErrorCode, +} + +impl Config { + /// Load the config, logging why when the config is invalid. + fn load() -> Result { + let config = http_latch::load_config(LATCH_NAME, |config| { + Config::parse(config, || { + random::wasi::random::insecure::get_insecure_random_u64() + }) + })?; + // the seed reproduces the decisions, e.g. a failure the random seed uncovered + http_latch::warn!( + "Randomly denying wasi:http requests PROBABILITY={} SEED={}", + config.probability, + config.seed + ); + Ok(config) + } + + fn parse( + config: Vec<(String, String)>, + random_seed: impl FnOnce() -> u64, + ) -> Result { + let mut probability = DEFAULT_PROBABILITY; + let mut seed = None; + let mut reason = HttpErrorCode::HttpRequestDenied; + + for (key, value) in config { + let invalid = |err: &str| format!("KEY={key} VALUE={value} ERROR={err}"); + match key.as_str() { + "probability" => { + probability = value + .parse::() + .ok() + .filter(|probability| (0.0..=1.0).contains(probability)) + .ok_or_else(|| invalid("expected a number from 0 to 1"))?; + } + "seed" => { + seed = Some( + value + .parse::() + .map_err(|_| invalid("expected an unsigned 64 bit integer"))?, + ); + } + "reason" => { + reason = get_error_code(value).unwrap_or(HttpErrorCode::HttpRequestDenied); + } + _ => {} + } + } + + Ok(Config { + probability, + seed: seed.unwrap_or_else(random_seed), + reason, + }) + } + + /// The decision for the request observed after `observed` others. + fn decide(&self, observed: u64) -> Decision { + match sample(self.seed, observed) < self.probability { + true => Decision::Denied(self.reason.clone()), + false => Decision::Deferred, + } + } +} + +/// A uniformly distributed number from 0 up to 1, the same for the same seed and index. +fn sample(seed: u64, index: u64) -> f64 { + // splitmix64, each index is a step of the sequence the seed starts + let mut z = seed.wrapping_add(index.wrapping_add(1).wrapping_mul(0x9e37_79b9_7f4a_7c15)); + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^= z >> 31; + // the top 53 bits, the precision of an f64 + (z >> 11) as f64 / (1u64 << 53) as f64 +} + +/// The error code named by the value, e.g. `connection-refused`, or an `internal-error` with the +/// value as its message when it doesn't name one. +fn get_error_code(value: String) -> Option { + match value.as_str() { + "" => None, + name => Some( + http_utils::parse_http_error_code!(http_latch::bindings::wasi::http::types, name) + .unwrap_or(HttpErrorCode::InternalError(Some(value))), + ), + } +} + +/// `None` until the config is loaded. +static CONFIG: Local>> = Local::new(None); + +/// The number of requests observed. +static OBSERVED: Local = Local::new(0); + +struct RandomLatch {} + +impl Latch for RandomLatch { + fn authorize(_operation: Operation) -> Result { + // the decision only depends on how many requests were observed, authorizing does not + // change it, so a request another latch denied first does not shift the decisions + match CONFIG.borrow_mut().get_or_insert_with(Config::load) { + Ok(config) => Ok(config.decide(*OBSERVED.borrow())), + Err(err) => Err(err.clone()), + } + } + + fn observe_decision(_final_decision: Decision, _operation: Operation) -> Result<(), ErrorCode> { + *OBSERVED.borrow_mut() += 1; + Ok(()) + } +} + +http_latch::export!(RandomLatch with_types_in http_latch::bindings); + +#[cfg(test)] +mod tests { + use super::*; + + fn config(entries: &[(&str, &str)]) -> Result { + let entries = entries + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(); + Config::parse(entries, || 42) + } + + fn denied(config: &Config, requests: u64) -> Vec { + (0..requests) + .filter(|observed| matches!(config.decide(*observed), Decision::Denied(_))) + .collect() + } + + #[test] + fn defaults() { + let config = config(&[]).expect("valid config"); + assert_eq!(config.probability, DEFAULT_PROBABILITY); + assert_eq!(config.seed, 42, "seeded randomly"); + assert!(matches!(config.reason, HttpErrorCode::HttpRequestDenied)); + } + + #[test] + fn denies_about_the_configured_fraction() { + for probability in [0.1, 0.5, 0.9] { + let config = config(&[("probability", &probability.to_string()), ("seed", "7")]) + .expect("valid config"); + let fraction = denied(&config, 10_000).len() as f64 / 10_000.0; + assert!( + (fraction - probability).abs() < 0.02, + "probability={probability} fraction={fraction}" + ); + } + } + + #[test] + fn never_or_always_denies() { + let never = config(&[("probability", "0")]).expect("valid config"); + assert!(denied(&never, 1_000).is_empty()); + let always = config(&[("probability", "1")]).expect("valid config"); + assert_eq!(denied(&always, 1_000).len(), 1_000); + } + + #[test] + fn same_seed_same_decisions() { + let first = config(&[("probability", "0.5"), ("seed", "1")]).expect("valid config"); + let again = config(&[("probability", "0.5"), ("seed", "1")]).expect("valid config"); + let other = config(&[("probability", "0.5"), ("seed", "2")]).expect("valid config"); + assert_eq!(denied(&first, 100), denied(&again, 100)); + assert_ne!(denied(&first, 100), denied(&other, 100)); + } + + #[test] + fn denies_with_configured_reason() { + let config = config(&[("probability", "1"), ("reason", "connection-refused")]) + .expect("valid config"); + assert!(matches!( + config.decide(0), + Decision::Denied(HttpErrorCode::ConnectionRefused) + )); + } + + #[test] + fn unknown_reason_is_an_internal_error() { + let config = + config(&[("probability", "1"), ("reason", "chaos-monkey")]).expect("valid config"); + assert!(matches!( + config.decide(0), + Decision::Denied(HttpErrorCode::InternalError(Some(ref message))) if message == "chaos-monkey" + )); + } + + #[test] + fn invalid_config() { + for (key, value) in [ + ("probability", "1.5"), + ("probability", "-0.1"), + ("probability", "often"), + ("seed", "-1"), + ("seed", "random"), + ] { + let err = config(&[(key, value)]).err().expect("invalid config"); + assert!( + err.starts_with(&format!("KEY={key} VALUE={value} ERROR=")), + "{err}" + ); + } + } +} diff --git a/components/latch-deny-random/tests/latch.rs b/components/latch-deny-random/tests/latch.rs new file mode 100644 index 0000000..1d1ad40 --- /dev/null +++ b/components/latch-deny-random/tests/latch.rs @@ -0,0 +1,119 @@ +use test_harness::{Harness, HttpErrorCode, Level, LogEntry, TestSubject}; + +const LATCH: &str = "latch-deny-random"; + +/// A gate with the latch, configured with the entries. +async fn gate(config: &[(&str, &str)]) -> wasmtime::Result { + let mut harness = Harness::new("gate-client").latch(LATCH); + for (key, value) in config { + harness = harness.config(key, value); + } + harness.build().await +} + +/// Send requests, whether each was denied. +async fn sends(gate: &mut TestSubject, count: usize) -> wasmtime::Result> { + let mut denied = vec![]; + for _ in 0..count { + let result = gate.send(http::Method::GET, "https://example.com/").await?; + denied.push(result.is_err()); + } + Ok(denied) +} + +/// The messages the latch logged. +fn latch_logs(gate: &TestSubject) -> Vec { + gate.recorder() + .logs() + .into_iter() + .filter(|entry| entry.context == "componentized-latch") + .collect() +} + +#[tokio::test(flavor = "multi_thread")] +async fn always_denies() -> wasmtime::Result<()> { + let mut gate = gate(&[("probability", "1"), ("seed", "7")]).await?; + assert_eq!(sends(&mut gate, 3).await?, vec![true, true, true]); + assert_eq!(gate.recorder().requests(), vec![]); + assert_eq!( + latch_logs(&gate), + vec![LogEntry::warn( + "componentized-latch", + "Randomly denying wasi:http requests PROBABILITY=1 SEED=7" + )] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn denies_with_the_configured_reason() -> wasmtime::Result<()> { + let mut gate = gate(&[("probability", "1"), ("reason", "connection-refused")]).await?; + let result = gate.send(http::Method::GET, "https://example.com/").await?; + assert!( + matches!(result, Err(HttpErrorCode::ConnectionRefused)), + "{result:?}" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn never_denies() -> wasmtime::Result<()> { + let mut gate = gate(&[("probability", "0")]).await?; + assert_eq!(sends(&mut gate, 3).await?, vec![false, false, false]); + assert_eq!(gate.recorder().requests().len(), 3); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn same_seed_denies_the_same_requests() -> wasmtime::Result<()> { + let config = [("probability", "0.5"), ("seed", "1234")]; + let first = sends(&mut gate(&config).await?, 32).await?; + let again = sends(&mut gate(&config).await?, 32).await?; + assert_eq!(first, again); + // some of each, a coin flip for 32 requests + assert!(first.contains(&true) && first.contains(&false), "{first:?}"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn random_seed_is_logged() -> wasmtime::Result<()> { + let mut logged = vec![]; + for _ in 0..2 { + let mut gate = gate(&[]).await?; + sends(&mut gate, 1).await?; + let logs = latch_logs(&gate); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].level, Level::Warn); + let seed = logs[0] + .message + .strip_prefix("Randomly denying wasi:http requests PROBABILITY=0.1 SEED=") + .expect("seed is logged") + .parse::()?; + logged.push(seed); + } + // each instance draws its own seed + assert_ne!(logged[0], logged[1]); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn invalid_config_fails_requests() -> wasmtime::Result<()> { + let mut gate = gate(&[("probability", "often")]).await?; + let result = gate.send(http::Method::GET, "https://example.com/").await?; + assert!( + matches!( + &result, + Err(HttpErrorCode::InternalError(Some(message))) + if message == "latch-error: invalid-config" + ), + "{result:?}" + ); + assert_eq!( + latch_logs(&gate), + vec![LogEntry::critical( + "componentized-latch", + "Invalid config LATCH=latch-deny-random KEY=probability VALUE=often ERROR=expected a number from 0 to 1" + )] + ); + Ok(()) +} diff --git a/components/latch-dry-run/Cargo.toml b/components/latch-dry-run/Cargo.toml new file mode 100644 index 0000000..c023ecc --- /dev/null +++ b/components/latch-dry-run/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "latch-dry-run" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +http-latch = { workspace = true } + +[dev-dependencies] +http = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-dry-run/README.md b/components/latch-dry-run/README.md new file mode 100644 index 0000000..bd39b0b --- /dev/null +++ b/components/latch-dry-run/README.md @@ -0,0 +1,36 @@ +# `latch-dry-run` + +HTTP latch that wraps another latch, logging the requests it would deny without denying them. + +Use it to roll out a policy: wrap the latch, watch the logs for requests it would deny, and adjust the policy before enforcing it by removing the wrapper. Every request is authorized by the wrapped latch, a denial is logged as a warning and the request is deferred. The log describes the request the same way the gates do. + +``` +Dry run, would deny REASON=http-request-method-invalid OPERATION=wasi:http/client#send METHOD=post PATH-WITH-QUERY=some +``` + +A latch error from the wrapped latch, for example an invalid config, is logged as an error and the request is deferred as well, a dry run never fails a request. + +The wrapped latch observes the final decision with `observe-decision`, which is deferred for requests it would have denied, so a stateful latch acts on what actually happened. A failure to observe the decision is logged as an error and does not fail the request. + +For example, to evaluate `latch-method-readonly`: + +``` +package example:latch; + +export new local:latch-dry-run { + latch: new local:latch-method-readonly { ... }.latch, + ... +}...; +``` + +## Interfaces + +Imports: + +- `wasi:logging/logging@0.1.0-draft`: logs the requests the wrapped latch would deny, and its errors +- `wasi:http/types@0.3.0`: the requests being authorized +- `componentized:http/latch@0.1.0-dev`: the wrapped latch + +Exports: + +- `componentized:http/latch@0.1.0-dev`: the latch diff --git a/components/latch-dry-run/src/lib.rs b/components/latch-dry-run/src/lib.rs new file mode 100644 index 0000000..99e14fd --- /dev/null +++ b/components/latch-dry-run/src/lib.rs @@ -0,0 +1,41 @@ +use http_latch::{ + Decision, DisplayError, DisplayReason, ErrorCode, Latch, Operation, wrapped as latch, +}; + +struct DryRunLatch {} + +impl Latch for DryRunLatch { + fn authorize(operation: Operation) -> Result { + // the wrapped latch decides, its denials and errors are logged but never enforced + match latch::authorize(&operation) { + Ok(Decision::Deferred) => {} + Ok(Decision::Denied(reason)) => { + http_latch::warn!( + "Dry run, would deny REASON={} {operation}", + DisplayReason(&reason) + ); + } + Err(err) => { + http_latch::error!( + "Dry run, latch error CODE={} {operation}", + DisplayError(&err) + ); + } + } + Ok(Decision::Deferred) + } + + fn observe_decision(final_decision: Decision, operation: Operation) -> Result<(), ErrorCode> { + // the wrapped latch observes what actually happens, the request was not denied by this + // latch, so stateful latches act as if their denials were not enforced + if let Err(err) = latch::observe_decision(&final_decision, &operation) { + http_latch::error!( + "Dry run, latch error CODE={} {operation}", + DisplayError(&err) + ); + } + Ok(()) + } +} + +http_latch::export!(DryRunLatch with_types_in http_latch::bindings); diff --git a/components/latch-dry-run/tests/latch.rs b/components/latch-dry-run/tests/latch.rs new file mode 100644 index 0000000..481eaba --- /dev/null +++ b/components/latch-dry-run/tests/latch.rs @@ -0,0 +1,63 @@ +use test_harness::{Harness, HostLatch, LogEntry, Observation}; + +#[tokio::test(flavor = "multi_thread")] +async fn logs_denials_without_enforcing_them() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate") + .latch("latch-dry-run") + .host_latch(HostLatch::deny(&["client.send"])) + .build() + .await?; + // sent, though the wrapped latch denies it + assert_eq!( + gate.send(http::Method::POST, "https://example.com/items") + .await? + .ok(), + Some(200) + ); + assert_eq!(gate.recorder().requests().len(), 1); + // the wrapped latch observes what actually happened + assert_eq!( + gate.recorder().observations(), + vec![Observation { + operation: "client.send".to_string(), + denied: false + }] + ); + assert_eq!( + gate.recorder().logs(), + vec![LogEntry::warn( + "componentized-latch", + "Dry run, would deny REASON=http-request-denied OPERATION=wasi:http/client#send METHOD=post PATH-WITH-QUERY=some" + )] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn logs_errors_without_failing() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate") + .latch("latch-dry-run") + .host_latch(HostLatch::invalid_config("latch-example").fail_observe()) + .build() + .await?; + assert_eq!( + gate.send(http::Method::GET, "https://example.com/") + .await? + .ok(), + Some(200) + ); + assert_eq!( + gate.recorder().logs(), + vec![ + LogEntry::error( + "componentized-latch", + "Dry run, latch error CODE=invalid-config OPERATION=wasi:http/client#send METHOD=get PATH-WITH-QUERY=some" + ), + LogEntry::error( + "componentized-latch", + "Dry run, latch error CODE=observation-failed OPERATION=wasi:http/client#send METHOD=get PATH-WITH-QUERY=some" + ), + ] + ); + Ok(()) +} diff --git a/components/latch-method-readonly-config/README.md b/components/latch-method-readonly-config/README.md new file mode 100644 index 0000000..1a7cb52 --- /dev/null +++ b/components/latch-method-readonly-config/README.md @@ -0,0 +1,17 @@ +# `latch-method-readonly-config` + +Config for `latch-method` that denies every request method except GET, HEAD, QUERY and OPTIONS. + +```properties +get=deferred +head=deferred +query=deferred +options=deferred +*=denied +``` + +## Interfaces + +Exports: + +- `wasi:config/store@0.2.0-rc.1`: the config diff --git a/components/latch-method-readonly-config/latch-method-readonly-config.properties b/components/latch-method-readonly-config/latch-method-readonly-config.properties new file mode 100644 index 0000000..432a5f6 --- /dev/null +++ b/components/latch-method-readonly-config/latch-method-readonly-config.properties @@ -0,0 +1,5 @@ +get=deferred +head=deferred +query=deferred +options=deferred +*=denied diff --git a/components/latch-method-readonly/Cargo.toml b/components/latch-method-readonly/Cargo.toml new file mode 100644 index 0000000..b1855b7 --- /dev/null +++ b/components/latch-method-readonly/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "latch-method-readonly-tests" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +publish = false + +# only tests, the component is built from latch-method-readonly.wac + +[dev-dependencies] +http = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-method-readonly/README.md b/components/latch-method-readonly/README.md new file mode 100644 index 0000000..89bc15f --- /dev/null +++ b/components/latch-method-readonly/README.md @@ -0,0 +1,16 @@ +# `latch-method-readonly` + +HTTP latch that denies every request method except GET, HEAD, QUERY and OPTIONS. + +`latch-method` composed with [`latch-method-readonly-config`](../latch-method-readonly-config/). + +## Interfaces + +Imports: + +- `wasi:logging/logging@0.1.0-draft`: logs an invalid config +- `wasi:http/types@0.3.0`: the requests being authorized + +Exports: + +- `componentized:http/latch@0.1.0-dev`: the latch diff --git a/components/latch-method-readonly/latch-method-readonly.wac b/components/latch-method-readonly/latch-method-readonly.wac new file mode 100644 index 0000000..4c92728 --- /dev/null +++ b/components/latch-method-readonly/latch-method-readonly.wac @@ -0,0 +1,6 @@ +package componentized:http; + +export new local:latch-method{ + store: new local:latch-method-readonly-config{}.store, + ... +}...; diff --git a/components/latch-method-readonly/tests/latch.rs b/components/latch-method-readonly/tests/latch.rs new file mode 100644 index 0000000..390231a --- /dev/null +++ b/components/latch-method-readonly/tests/latch.rs @@ -0,0 +1,30 @@ +//! Tests for `latch-method-readonly`, composed from `latch-method-readonly.wac`. + +use test_harness::{Harness, HttpErrorCode}; + +#[tokio::test(flavor = "multi_thread")] +async fn allows_only_reading_methods() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate") + .latch("latch-method-readonly") + .build() + .await?; + for method in ["GET", "HEAD", "QUERY", "OPTIONS"] { + let method = http::Method::from_bytes(method.as_bytes())?; + assert_eq!( + gate.send(method.clone(), "https://example.com/") + .await? + .ok(), + Some(200), + "{method}" + ); + } + for method in ["POST", "PUT", "PATCH", "DELETE", "BOGUS"] { + let method = http::Method::from_bytes(method.as_bytes())?; + let result = gate.send(method.clone(), "https://example.com/").await?; + assert!( + matches!(result, Err(HttpErrorCode::HttpRequestMethodInvalid)), + "{method} {result:?}" + ); + } + Ok(()) +} diff --git a/components/latch-method/Cargo.toml b/components/latch-method/Cargo.toml new file mode 100644 index 0000000..9b061b4 --- /dev/null +++ b/components/latch-method/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "latch-method" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +http-latch = { workspace = true } + +[dev-dependencies] +http = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-method/README.md b/components/latch-method/README.md new file mode 100644 index 0000000..3892d14 --- /dev/null +++ b/components/latch-method/README.md @@ -0,0 +1,29 @@ +# `latch-method` + +HTTP latch that decides each request by its method. + +The decision for each method is read from `wasi:config/store`. The key is the lowercase method, e.g. `get` or `post`, the value is `deferred` or `denied`. A method without a key uses the `*` key, and is deferred without it. A denied request fails with `http-request-method-invalid`. + +```properties +get=deferred +head=deferred +*=denied +``` + +Every value must be `deferred`, an empty value, or `denied`. Otherwise the cause is logged at the `critical` level and every request fails with an `invalid-config` latch error, so a typo is noticed rather than ignored: + +``` +Invalid config LATCH=latch-method KEY=post VALUE=maybe ERROR=expected one of: 'deferred', 'denied' +``` + +## Interfaces + +Imports: + +- `wasi:config/store@0.2.0-rc.1`: the decision for each method +- `wasi:logging/logging@0.1.0-draft`: logs an invalid config +- `wasi:http/types@0.3.0`: the requests being authorized + +Exports: + +- `componentized:http/latch@0.1.0-dev`: the latch diff --git a/components/latch-method/src/lib.rs b/components/latch-method/src/lib.rs new file mode 100644 index 0000000..12abd4a --- /dev/null +++ b/components/latch-method/src/lib.rs @@ -0,0 +1,20 @@ +use http_latch::{Decision, DecisionConfig, ErrorCode, HttpErrorCode, Latch, Operation}; + +const LATCH_NAME: &str = "latch-method"; + +struct MethodLatch {} + +impl Latch for MethodLatch { + fn authorize(operation: Operation) -> Result { + let config = http_latch::load_config(LATCH_NAME, DecisionConfig::parse)?; + let method = http_latch::request(&operation).get_method().to_string(); + Ok(config.decide(&method, || HttpErrorCode::HttpRequestMethodInvalid)) + } + + fn observe_decision(_final_decision: Decision, _operation: Operation) -> Result<(), ErrorCode> { + // no side effects + Ok(()) + } +} + +http_latch::export!(MethodLatch with_types_in http_latch::bindings); diff --git a/components/latch-method/tests/latch.rs b/components/latch-method/tests/latch.rs new file mode 100644 index 0000000..dec007c --- /dev/null +++ b/components/latch-method/tests/latch.rs @@ -0,0 +1,81 @@ +use test_harness::{Harness, HttpErrorCode, LogEntry}; + +#[tokio::test(flavor = "multi_thread")] +async fn decides_by_method() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate") + .latch("latch-method") + .config("get", "deferred") + .config("head", "") + .config("*", "denied") + .build() + .await?; + assert_eq!( + gate.send(http::Method::GET, "https://example.com/") + .await? + .ok(), + Some(200) + ); + assert_eq!( + gate.handle(http::Method::HEAD, "https://example.com/") + .await? + .ok(), + Some(200) + ); + let denied = gate + .send(http::Method::POST, "https://example.com/") + .await?; + assert!( + matches!(denied, Err(HttpErrorCode::HttpRequestMethodInvalid)), + "{denied:?}" + ); + // the method is matched in lower case + let denied = gate + .send(http::Method::from_bytes(b"PURGE")?, "https://example.com/") + .await?; + assert!( + matches!(denied, Err(HttpErrorCode::HttpRequestMethodInvalid)), + "{denied:?}" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn defers_without_config() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate").latch("latch-method").build().await?; + assert_eq!( + gate.send(http::Method::DELETE, "https://example.com/") + .await? + .ok(), + Some(200) + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn invalid_config_fails_every_request() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate") + .latch("latch-method") + .config("get", "deferred") + .config("post", "maybe") + .build() + .await?; + // even a request for a method with a valid value, the config is invalid as a whole + let result = gate.send(http::Method::GET, "https://example.com/").await?; + assert!( + matches!( + &result, + Err(HttpErrorCode::InternalError(Some(message))) + if message == "latch-error: invalid-config" + ), + "{result:?}" + ); + assert_eq!(gate.recorder().requests(), vec![]); + assert_eq!( + gate.recorder().logs()[0], + LogEntry::critical( + "componentized-latch", + "Invalid config LATCH=latch-method KEY=post VALUE=maybe ERROR=expected one of: 'deferred', 'denied'" + ) + ); + Ok(()) +} diff --git a/components/latch-n2/Cargo.toml b/components/latch-n2/Cargo.toml new file mode 100644 index 0000000..6d96e24 --- /dev/null +++ b/components/latch-n2/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "latch-n2" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +http-latch-n = { workspace = true } + +[dev-dependencies] +http = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-n2/README.md b/components/latch-n2/README.md new file mode 100644 index 0000000..2b0b555 --- /dev/null +++ b/components/latch-n2/README.md @@ -0,0 +1,16 @@ +# `latch-n2` + +HTTP latch that aggregates 2 latches, any latch can deny a request. + +The latches are asked in order, `latch0`, `latch1`, and the first denial is the decision. Latches after a denial are not asked, but every latch observes the final decision with `observe-decision`, even when an earlier latch fails to observe it. The first failure is returned. + +## Interfaces + +Imports: + +- `latch0`: `componentized:http/latch@0.1.0-dev`, a latch to aggregate +- `latch1`: `componentized:http/latch@0.1.0-dev`, a latch to aggregate + +Exports: + +- `componentized:http/latch@0.1.0-dev`: the latch diff --git a/components/latch-n2/src/lib.rs b/components/latch-n2/src/lib.rs new file mode 100644 index 0000000..c4dec26 --- /dev/null +++ b/components/latch-n2/src/lib.rs @@ -0,0 +1,21 @@ +use http_latch_n::{Decision, ErrorCode, Latch, Operation}; +use http_latch_n::{latch0, latch1}; + +struct LatchN2 {} + +impl Latch for LatchN2 { + fn authorize(operation: Operation<'_>) -> Result { + let authorizers = vec![latch0::authorize, latch1::authorize]; + http_latch_n::authorize(operation, authorizers) + } + + fn observe_decision( + final_decision: Decision, + operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + let observers = vec![latch0::observe_decision, latch1::observe_decision]; + http_latch_n::observe_decision(final_decision, operation, observers) + } +} + +http_latch_n::export!(LatchN2 with_types_in http_latch_n::bindings); diff --git a/components/latch-n2/tests/latch.rs b/components/latch-n2/tests/latch.rs new file mode 100644 index 0000000..3a5623f --- /dev/null +++ b/components/latch-n2/tests/latch.rs @@ -0,0 +1,47 @@ +use test_harness::{Harness, HostLatch, HttpErrorCode, Observation}; + +#[tokio::test(flavor = "multi_thread")] +async fn any_latch_denies() -> wasmtime::Result<()> { + // the latches, with the host latch last + let mut gate = Harness::new("gate") + .latch("latch-defer-all") + .latch("latch-defer-all") + .latch("latch-deny-all") + .host_latch(HostLatch::defer()) + .build() + .await?; + let sent = gate.send(http::Method::GET, "https://example.com/").await?; + assert!( + matches!(sent, Err(HttpErrorCode::HttpRequestDenied)), + "{sent:?}" + ); + // the host latch after the denial is not asked, but observes the final decision + assert_eq!(gate.recorder().operations(), Vec::::new()); + assert_eq!( + gate.recorder().observations(), + vec![Observation { + operation: "client.send".to_string(), + denied: true + }] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn defers_when_every_latch_defers() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate") + .latch("latch-defer-all") + .latch("latch-defer-all") + .latch("latch-defer-all") + .host_latch(HostLatch::defer()) + .build() + .await?; + assert_eq!( + gate.send(http::Method::GET, "https://example.com/") + .await? + .ok(), + Some(200) + ); + assert_eq!(gate.recorder().operations(), vec!["client.send"]); + Ok(()) +} diff --git a/components/latch-n3/Cargo.toml b/components/latch-n3/Cargo.toml new file mode 100644 index 0000000..3030620 --- /dev/null +++ b/components/latch-n3/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "latch-n3" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +http-latch-n = { workspace = true } + +[dev-dependencies] +http = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-n3/README.md b/components/latch-n3/README.md new file mode 100644 index 0000000..47f6d9f --- /dev/null +++ b/components/latch-n3/README.md @@ -0,0 +1,17 @@ +# `latch-n3` + +HTTP latch that aggregates 3 latches, any latch can deny a request. + +The latches are asked in order, `latch0`, `latch1`, `latch2`, and the first denial is the decision. Latches after a denial are not asked, but every latch observes the final decision with `observe-decision`, even when an earlier latch fails to observe it. The first failure is returned. + +## Interfaces + +Imports: + +- `latch0`: `componentized:http/latch@0.1.0-dev`, a latch to aggregate +- `latch1`: `componentized:http/latch@0.1.0-dev`, a latch to aggregate +- `latch2`: `componentized:http/latch@0.1.0-dev`, a latch to aggregate + +Exports: + +- `componentized:http/latch@0.1.0-dev`: the latch diff --git a/components/latch-n3/src/lib.rs b/components/latch-n3/src/lib.rs new file mode 100644 index 0000000..fe1fcd0 --- /dev/null +++ b/components/latch-n3/src/lib.rs @@ -0,0 +1,25 @@ +use http_latch_n::{Decision, ErrorCode, Latch, Operation}; +use http_latch_n::{latch0, latch1, latch2}; + +struct LatchN3 {} + +impl Latch for LatchN3 { + fn authorize(operation: Operation<'_>) -> Result { + let authorizers = vec![latch0::authorize, latch1::authorize, latch2::authorize]; + http_latch_n::authorize(operation, authorizers) + } + + fn observe_decision( + final_decision: Decision, + operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + let observers = vec![ + latch0::observe_decision, + latch1::observe_decision, + latch2::observe_decision, + ]; + http_latch_n::observe_decision(final_decision, operation, observers) + } +} + +http_latch_n::export!(LatchN3 with_types_in http_latch_n::bindings); diff --git a/components/latch-n3/tests/latch.rs b/components/latch-n3/tests/latch.rs new file mode 100644 index 0000000..6126ef4 --- /dev/null +++ b/components/latch-n3/tests/latch.rs @@ -0,0 +1,45 @@ +use test_harness::{Harness, HostLatch, HttpErrorCode, Observation}; + +#[tokio::test(flavor = "multi_thread")] +async fn any_latch_denies() -> wasmtime::Result<()> { + // the latches, with the host latch last + let mut gate = Harness::new("gate") + .latch("latch-defer-all") + .latch("latch-deny-all") + .host_latch(HostLatch::defer()) + .build() + .await?; + let sent = gate.send(http::Method::GET, "https://example.com/").await?; + assert!( + matches!(sent, Err(HttpErrorCode::HttpRequestDenied)), + "{sent:?}" + ); + // the host latch after the denial is not asked, but observes the final decision + assert_eq!(gate.recorder().operations(), Vec::::new()); + assert_eq!( + gate.recorder().observations(), + vec![Observation { + operation: "client.send".to_string(), + denied: true + }] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn defers_when_every_latch_defers() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate") + .latch("latch-defer-all") + .latch("latch-defer-all") + .host_latch(HostLatch::defer()) + .build() + .await?; + assert_eq!( + gate.send(http::Method::GET, "https://example.com/") + .await? + .ok(), + Some(200) + ); + assert_eq!(gate.recorder().operations(), vec!["client.send"]); + Ok(()) +} diff --git a/components/latch-n4/Cargo.toml b/components/latch-n4/Cargo.toml new file mode 100644 index 0000000..3fd3688 --- /dev/null +++ b/components/latch-n4/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "latch-n4" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +http-latch-n = { workspace = true } + +[dev-dependencies] +http = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-n4/README.md b/components/latch-n4/README.md new file mode 100644 index 0000000..a258ff7 --- /dev/null +++ b/components/latch-n4/README.md @@ -0,0 +1,18 @@ +# `latch-n4` + +HTTP latch that aggregates 4 latches, any latch can deny a request. + +The latches are asked in order, `latch0`, `latch1`, `latch2`, `latch3`, and the first denial is the decision. Latches after a denial are not asked, but every latch observes the final decision with `observe-decision`, even when an earlier latch fails to observe it. The first failure is returned. + +## Interfaces + +Imports: + +- `latch0`: `componentized:http/latch@0.1.0-dev`, a latch to aggregate +- `latch1`: `componentized:http/latch@0.1.0-dev`, a latch to aggregate +- `latch2`: `componentized:http/latch@0.1.0-dev`, a latch to aggregate +- `latch3`: `componentized:http/latch@0.1.0-dev`, a latch to aggregate + +Exports: + +- `componentized:http/latch@0.1.0-dev`: the latch diff --git a/components/latch-n4/src/lib.rs b/components/latch-n4/src/lib.rs new file mode 100644 index 0000000..d85ec2f --- /dev/null +++ b/components/latch-n4/src/lib.rs @@ -0,0 +1,31 @@ +use http_latch_n::{Decision, ErrorCode, Latch, Operation}; +use http_latch_n::{latch0, latch1, latch2, latch3}; + +struct LatchN4 {} + +impl Latch for LatchN4 { + fn authorize(operation: Operation<'_>) -> Result { + let authorizers = vec![ + latch0::authorize, + latch1::authorize, + latch2::authorize, + latch3::authorize, + ]; + http_latch_n::authorize(operation, authorizers) + } + + fn observe_decision( + final_decision: Decision, + operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + let observers = vec![ + latch0::observe_decision, + latch1::observe_decision, + latch2::observe_decision, + latch3::observe_decision, + ]; + http_latch_n::observe_decision(final_decision, operation, observers) + } +} + +http_latch_n::export!(LatchN4 with_types_in http_latch_n::bindings); diff --git a/components/latch-n4/tests/latch.rs b/components/latch-n4/tests/latch.rs new file mode 100644 index 0000000..3a5623f --- /dev/null +++ b/components/latch-n4/tests/latch.rs @@ -0,0 +1,47 @@ +use test_harness::{Harness, HostLatch, HttpErrorCode, Observation}; + +#[tokio::test(flavor = "multi_thread")] +async fn any_latch_denies() -> wasmtime::Result<()> { + // the latches, with the host latch last + let mut gate = Harness::new("gate") + .latch("latch-defer-all") + .latch("latch-defer-all") + .latch("latch-deny-all") + .host_latch(HostLatch::defer()) + .build() + .await?; + let sent = gate.send(http::Method::GET, "https://example.com/").await?; + assert!( + matches!(sent, Err(HttpErrorCode::HttpRequestDenied)), + "{sent:?}" + ); + // the host latch after the denial is not asked, but observes the final decision + assert_eq!(gate.recorder().operations(), Vec::::new()); + assert_eq!( + gate.recorder().observations(), + vec![Observation { + operation: "client.send".to_string(), + denied: true + }] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn defers_when_every_latch_defers() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate") + .latch("latch-defer-all") + .latch("latch-defer-all") + .latch("latch-defer-all") + .host_latch(HostLatch::defer()) + .build() + .await?; + assert_eq!( + gate.send(http::Method::GET, "https://example.com/") + .await? + .ok(), + Some(200) + ); + assert_eq!(gate.recorder().operations(), vec!["client.send"]); + Ok(()) +} diff --git a/components/latch-n5/Cargo.toml b/components/latch-n5/Cargo.toml new file mode 100644 index 0000000..4a0a47d --- /dev/null +++ b/components/latch-n5/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "latch-n5" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +http-latch-n = { workspace = true } + +[dev-dependencies] +http = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-n5/README.md b/components/latch-n5/README.md new file mode 100644 index 0000000..e77d484 --- /dev/null +++ b/components/latch-n5/README.md @@ -0,0 +1,19 @@ +# `latch-n5` + +HTTP latch that aggregates 5 latches, any latch can deny a request. + +The latches are asked in order, `latch0`, `latch1`, `latch2`, `latch3`, `latch4`, and the first denial is the decision. Latches after a denial are not asked, but every latch observes the final decision with `observe-decision`, even when an earlier latch fails to observe it. The first failure is returned. + +## Interfaces + +Imports: + +- `latch0`: `componentized:http/latch@0.1.0-dev`, a latch to aggregate +- `latch1`: `componentized:http/latch@0.1.0-dev`, a latch to aggregate +- `latch2`: `componentized:http/latch@0.1.0-dev`, a latch to aggregate +- `latch3`: `componentized:http/latch@0.1.0-dev`, a latch to aggregate +- `latch4`: `componentized:http/latch@0.1.0-dev`, a latch to aggregate + +Exports: + +- `componentized:http/latch@0.1.0-dev`: the latch diff --git a/components/latch-n5/src/lib.rs b/components/latch-n5/src/lib.rs new file mode 100644 index 0000000..57a7d85 --- /dev/null +++ b/components/latch-n5/src/lib.rs @@ -0,0 +1,33 @@ +use http_latch_n::{Decision, ErrorCode, Latch, Operation}; +use http_latch_n::{latch0, latch1, latch2, latch3, latch4}; + +struct LatchN5 {} + +impl Latch for LatchN5 { + fn authorize(operation: Operation<'_>) -> Result { + let authorizers = vec![ + latch0::authorize, + latch1::authorize, + latch2::authorize, + latch3::authorize, + latch4::authorize, + ]; + http_latch_n::authorize(operation, authorizers) + } + + fn observe_decision( + final_decision: Decision, + operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + let observers = vec![ + latch0::observe_decision, + latch1::observe_decision, + latch2::observe_decision, + latch3::observe_decision, + latch4::observe_decision, + ]; + http_latch_n::observe_decision(final_decision, operation, observers) + } +} + +http_latch_n::export!(LatchN5 with_types_in http_latch_n::bindings); diff --git a/components/latch-n5/tests/latch.rs b/components/latch-n5/tests/latch.rs new file mode 100644 index 0000000..3b0e86c --- /dev/null +++ b/components/latch-n5/tests/latch.rs @@ -0,0 +1,49 @@ +use test_harness::{Harness, HostLatch, HttpErrorCode, Observation}; + +#[tokio::test(flavor = "multi_thread")] +async fn any_latch_denies() -> wasmtime::Result<()> { + // the latches, with the host latch last + let mut gate = Harness::new("gate") + .latch("latch-defer-all") + .latch("latch-defer-all") + .latch("latch-defer-all") + .latch("latch-deny-all") + .host_latch(HostLatch::defer()) + .build() + .await?; + let sent = gate.send(http::Method::GET, "https://example.com/").await?; + assert!( + matches!(sent, Err(HttpErrorCode::HttpRequestDenied)), + "{sent:?}" + ); + // the host latch after the denial is not asked, but observes the final decision + assert_eq!(gate.recorder().operations(), Vec::::new()); + assert_eq!( + gate.recorder().observations(), + vec![Observation { + operation: "client.send".to_string(), + denied: true + }] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn defers_when_every_latch_defers() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate") + .latch("latch-defer-all") + .latch("latch-defer-all") + .latch("latch-defer-all") + .latch("latch-defer-all") + .host_latch(HostLatch::defer()) + .build() + .await?; + assert_eq!( + gate.send(http::Method::GET, "https://example.com/") + .await? + .ok(), + Some(200) + ); + assert_eq!(gate.recorder().operations(), vec!["client.send"]); + Ok(()) +} diff --git a/components/latch-scheme-httpsonly-config/README.md b/components/latch-scheme-httpsonly-config/README.md new file mode 100644 index 0000000..666f86c --- /dev/null +++ b/components/latch-scheme-httpsonly-config/README.md @@ -0,0 +1,14 @@ +# `latch-scheme-httpsonly-config` + +Config for `latch-scheme` that denies every request scheme except HTTPS. + +```properties +https=deferred +*=denied +``` + +## Interfaces + +Exports: + +- `wasi:config/store@0.2.0-rc.1`: the config diff --git a/components/latch-scheme-httpsonly-config/latch-scheme-httpsonly-config.properties b/components/latch-scheme-httpsonly-config/latch-scheme-httpsonly-config.properties new file mode 100644 index 0000000..ddc5a0b --- /dev/null +++ b/components/latch-scheme-httpsonly-config/latch-scheme-httpsonly-config.properties @@ -0,0 +1,2 @@ +https=deferred +*=denied diff --git a/components/latch-scheme-httpsonly/Cargo.toml b/components/latch-scheme-httpsonly/Cargo.toml new file mode 100644 index 0000000..29d1c1b --- /dev/null +++ b/components/latch-scheme-httpsonly/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "latch-scheme-httpsonly-tests" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +publish = false + +# only tests, the component is built from latch-scheme-httpsonly.wac + +[dev-dependencies] +http = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-scheme-httpsonly/README.md b/components/latch-scheme-httpsonly/README.md new file mode 100644 index 0000000..474022b --- /dev/null +++ b/components/latch-scheme-httpsonly/README.md @@ -0,0 +1,16 @@ +# `latch-scheme-httpsonly` + +HTTP latch that denies every request scheme except HTTPS. + +`latch-scheme` composed with [`latch-scheme-httpsonly-config`](../latch-scheme-httpsonly-config/). + +## Interfaces + +Imports: + +- `wasi:logging/logging@0.1.0-draft`: logs an invalid config +- `wasi:http/types@0.3.0`: the requests being authorized + +Exports: + +- `componentized:http/latch@0.1.0-dev`: the latch diff --git a/components/latch-scheme-httpsonly/latch-scheme-httpsonly.wac b/components/latch-scheme-httpsonly/latch-scheme-httpsonly.wac new file mode 100644 index 0000000..7439477 --- /dev/null +++ b/components/latch-scheme-httpsonly/latch-scheme-httpsonly.wac @@ -0,0 +1,6 @@ +package componentized:http; + +export new local:latch-scheme{ + store: new local:latch-scheme-httpsonly-config{}.store, + ... +}...; diff --git a/components/latch-scheme-httpsonly/tests/latch.rs b/components/latch-scheme-httpsonly/tests/latch.rs new file mode 100644 index 0000000..d44f164 --- /dev/null +++ b/components/latch-scheme-httpsonly/tests/latch.rs @@ -0,0 +1,23 @@ +//! Tests for `latch-scheme-httpsonly`, composed from `latch-scheme-httpsonly.wac`. + +use test_harness::{Harness, HttpErrorCode}; + +#[tokio::test(flavor = "multi_thread")] +async fn allows_only_https() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate") + .latch("latch-scheme-httpsonly") + .build() + .await?; + assert_eq!( + gate.send(http::Method::GET, "https://example.com/") + .await? + .ok(), + Some(200) + ); + let result = gate.send(http::Method::GET, "http://example.com/").await?; + assert!( + matches!(result, Err(HttpErrorCode::HttpRequestDenied)), + "{result:?}" + ); + Ok(()) +} diff --git a/components/latch-scheme/Cargo.toml b/components/latch-scheme/Cargo.toml new file mode 100644 index 0000000..551774e --- /dev/null +++ b/components/latch-scheme/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "latch-scheme" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +http-latch = { workspace = true } + +[dev-dependencies] +http = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-scheme/README.md b/components/latch-scheme/README.md new file mode 100644 index 0000000..52f049e --- /dev/null +++ b/components/latch-scheme/README.md @@ -0,0 +1,28 @@ +# `latch-scheme` + +HTTP latch that decides each request by its scheme. + +The decision for each scheme is read from `wasi:config/store`. The key is the lowercase scheme, e.g. `https`, or `_` for a request without a scheme, the value is `deferred` or `denied`. A scheme without a key uses the `*` key, and is deferred without it. A denied request fails with `http-request-method-invalid`. + +```properties +https=deferred +*=denied +``` + +Every value must be `deferred`, an empty value, or `denied`. Otherwise the cause is logged at the `critical` level and every request fails with an `invalid-config` latch error, so a typo is noticed rather than ignored: + +``` +Invalid config LATCH=latch-scheme KEY=post VALUE=maybe ERROR=expected one of: 'deferred', 'denied' +``` + +## Interfaces + +Imports: + +- `wasi:config/store@0.2.0-rc.1`: the decision for each scheme +- `wasi:logging/logging@0.1.0-draft`: logs an invalid config +- `wasi:http/types@0.3.0`: the requests being authorized + +Exports: + +- `componentized:http/latch@0.1.0-dev`: the latch diff --git a/components/latch-scheme/src/lib.rs b/components/latch-scheme/src/lib.rs new file mode 100644 index 0000000..a21d05b --- /dev/null +++ b/components/latch-scheme/src/lib.rs @@ -0,0 +1,26 @@ +use http_latch::{Decision, DecisionConfig, ErrorCode, HttpErrorCode, Latch, Operation}; + +const LATCH_NAME: &str = "latch-scheme"; + +/// The key for a request without a scheme. +const NO_SCHEME: &str = "_"; + +struct SchemeLatch {} + +impl Latch for SchemeLatch { + fn authorize(operation: Operation) -> Result { + let config = http_latch::load_config(LATCH_NAME, DecisionConfig::parse)?; + let scheme = match http_latch::request(&operation).get_scheme() { + Some(scheme) => scheme.to_string(), + None => NO_SCHEME.to_string(), + }; + Ok(config.decide(&scheme, || HttpErrorCode::HttpRequestDenied)) + } + + fn observe_decision(_final_decision: Decision, _operation: Operation) -> Result<(), ErrorCode> { + // no side effects + Ok(()) + } +} + +http_latch::export!(SchemeLatch with_types_in http_latch::bindings); diff --git a/components/latch-scheme/tests/latch.rs b/components/latch-scheme/tests/latch.rs new file mode 100644 index 0000000..b5be420 --- /dev/null +++ b/components/latch-scheme/tests/latch.rs @@ -0,0 +1,49 @@ +use test_harness::{Harness, HttpErrorCode, LogEntry}; + +#[tokio::test(flavor = "multi_thread")] +async fn decides_by_scheme() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate") + .latch("latch-scheme") + .config("https", "deferred") + .config("*", "denied") + .build() + .await?; + assert_eq!( + gate.send(http::Method::GET, "https://example.com/") + .await? + .ok(), + Some(200) + ); + let denied = gate.send(http::Method::GET, "http://example.com/").await?; + assert!( + matches!(denied, Err(HttpErrorCode::HttpRequestDenied)), + "{denied:?}" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn invalid_config_fails_every_request() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate") + .latch("latch-scheme") + .config("https", "allowed") + .build() + .await?; + let result = gate.send(http::Method::GET, "https://example.com/").await?; + assert!( + matches!( + &result, + Err(HttpErrorCode::InternalError(Some(message))) + if message == "latch-error: invalid-config" + ), + "{result:?}" + ); + assert_eq!( + gate.recorder().logs()[0], + LogEntry::critical( + "componentized-latch", + "Invalid config LATCH=latch-scheme KEY=https VALUE=allowed ERROR=expected one of: 'deferred', 'denied'" + ) + ); + Ok(()) +} diff --git a/components/latch-trace/Cargo.toml b/components/latch-trace/Cargo.toml new file mode 100644 index 0000000..1d1c126 --- /dev/null +++ b/components/latch-trace/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "latch-trace" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +http-latch = { workspace = true } + +[dev-dependencies] +http = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-trace/README.md b/components/latch-trace/README.md new file mode 100644 index 0000000..82dc8a3 --- /dev/null +++ b/components/latch-trace/README.md @@ -0,0 +1,38 @@ +# `latch-trace` + +HTTP latch that wraps another latch, logging each decision it makes without changing it. + +Use it to debug a policy: wrap a latch, or an aggregate of latches, and watch the logs to see how each request is decided. Every request is authorized by the wrapped latch and its decision, or error, is returned unchanged. The log describes the request the same way the gates do. + +``` +Authorization DECISION=denied REASON=http-request-denied OPERATION=wasi:http/client#send METHOD=post PATH-WITH-QUERY=some +``` + +Latch errors from the wrapped latch, for example an invalid config, are logged and returned unchanged, so the gate fails the request as it would without tracing. + +``` +Authorization ERROR=invalid-config OPERATION=wasi:http/client#send METHOD=get PATH-WITH-QUERY=some +``` + +Messages are logged at the `trace` level. For example, to trace `latch-method-readonly`: + +``` +package example:latch; + +export new local:latch-trace { + latch: new local:latch-method-readonly { ... }.latch, + ... +}...; +``` + +## Interfaces + +Imports: + +- `wasi:logging/logging@0.1.0-draft`: logs the decisions of the wrapped latch +- `wasi:http/types@0.3.0`: the requests being authorized +- `componentized:http/latch@0.1.0-dev`: the wrapped latch + +Exports: + +- `componentized:http/latch@0.1.0-dev`: the latch diff --git a/components/latch-trace/src/lib.rs b/components/latch-trace/src/lib.rs new file mode 100644 index 0000000..a0fa1a5 --- /dev/null +++ b/components/latch-trace/src/lib.rs @@ -0,0 +1,27 @@ +use http_latch::{ + Decision, DisplayDecision, DisplayError, ErrorCode, Latch, Operation, wrapped as latch, +}; + +struct TraceLatch {} + +impl Latch for TraceLatch { + fn authorize(operation: Operation) -> Result { + // the wrapped latch decides, its decision is logged and returned unchanged + let result = latch::authorize(&operation); + match &result { + Ok(decision) => { + http_latch::trace!("Authorization {} {operation}", DisplayDecision(decision)); + } + Err(err) => { + http_latch::trace!("Authorization ERROR={} {operation}", DisplayError(err)); + } + } + result + } + + fn observe_decision(final_decision: Decision, operation: Operation) -> Result<(), ErrorCode> { + latch::observe_decision(&final_decision, &operation) + } +} + +http_latch::export!(TraceLatch with_types_in http_latch::bindings); diff --git a/components/latch-trace/tests/latch.rs b/components/latch-trace/tests/latch.rs new file mode 100644 index 0000000..730e03a --- /dev/null +++ b/components/latch-trace/tests/latch.rs @@ -0,0 +1,80 @@ +use test_harness::{Harness, HostLatch, HttpErrorCode, LogEntry, Observation}; + +#[tokio::test(flavor = "multi_thread")] +async fn traces_decisions_without_changing_them() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate") + .latch("latch-trace") + .host_latch(HostLatch::deny(&["client.send"])) + .build() + .await?; + let sent = gate + .send(http::Method::POST, "https://example.com/items") + .await?; + assert!( + matches!(sent, Err(HttpErrorCode::HttpRequestDenied)), + "{sent:?}" + ); + assert_eq!( + gate.handle(http::Method::GET, "https://example.com/") + .await? + .ok(), + Some(200) + ); + // the wrapped host latch decides and observes + assert_eq!( + gate.recorder().operations(), + vec!["client.send", "handler.handle"] + ); + assert_eq!( + gate.recorder().observations(), + vec![ + Observation { + operation: "client.send".to_string(), + denied: true + }, + Observation { + operation: "handler.handle".to_string(), + denied: false + }, + ] + ); + let traces: Vec = gate + .recorder() + .logs() + .into_iter() + .filter(|log| log.context == "componentized-latch") + .collect(); + assert_eq!( + traces, + vec![ + LogEntry::trace( + "componentized-latch", + "Authorization DECISION=denied REASON=http-request-denied OPERATION=wasi:http/client#send METHOD=post PATH-WITH-QUERY=some" + ), + LogEntry::trace( + "componentized-latch", + "Authorization DECISION=deferred OPERATION=wasi:http/handler#handle METHOD=get PATH-WITH-QUERY=some" + ), + ] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn traces_errors_without_changing_them() -> wasmtime::Result<()> { + let mut gate = Harness::new("gate") + .latch("latch-trace") + .host_latch(HostLatch::invalid_config("latch-example")) + .build() + .await?; + let sent = gate.send(http::Method::GET, "https://example.com/").await?; + assert!( + matches!(sent, Err(HttpErrorCode::InternalError(_))), + "{sent:?}" + ); + assert!(gate.recorder().logs().contains(&LogEntry::trace( + "componentized-latch", + "Authorization ERROR=invalid-config OPERATION=wasi:http/client#send METHOD=get PATH-WITH-QUERY=some" + ))); + Ok(()) +} diff --git a/components/status-codes/wkg.lock b/components/status-codes/wkg.lock index 2ad4aa8..fa8b507 100644 --- a/components/status-codes/wkg.lock +++ b/components/status-codes/wkg.lock @@ -1,4 +1,21 @@ # This file is automatically generated. # It is not intended for manual editing. version = 1 -packages = [] + +[[packages]] +name = "wasi:config" +registry = "wasi.dev" + +[[packages.versions]] +requirement = "=0.2.0-rc.1" +version = "0.2.0-rc.1" +digest = "sha256:1b7f1b0fd07bb4cede16c6a6ec8852815dfb924639a78735fc7bdffdc164485d" + +[[packages]] +name = "wasi:http" +registry = "wasi.dev" + +[[packages.versions]] +requirement = "=0.3.0" +version = "0.3.0" +digest = "sha256:92cd8f3730c00226dc15626a2e7b21834dd187fc221f09818720d228585bbbf7" diff --git a/components/trace-client/Cargo.toml b/components/trace-client/Cargo.toml index 1ed8596..5321dce 100644 --- a/components/trace-client/Cargo.toml +++ b/components/trace-client/Cargo.toml @@ -15,6 +15,7 @@ client = [] handler = [] [dependencies] +http-utils = { workspace = true } wit-bindgen = { workspace = true, features = ["async-spawn"] } [dev-dependencies] diff --git a/components/trace-handler/Cargo.toml b/components/trace-handler/Cargo.toml index 9c685ea..4d42738 100644 --- a/components/trace-handler/Cargo.toml +++ b/components/trace-handler/Cargo.toml @@ -15,6 +15,7 @@ client = [] handler = [] [dependencies] +http-utils = { workspace = true } wit-bindgen = { workspace = true, features = ["async-spawn"] } [dev-dependencies] diff --git a/components/trace-types/Cargo.toml b/components/trace-types/Cargo.toml index 4cdbbd8..1adbd03 100644 --- a/components/trace-types/Cargo.toml +++ b/components/trace-types/Cargo.toml @@ -15,6 +15,7 @@ client = [] handler = [] [dependencies] +http-utils = { workspace = true } wit-bindgen = { workspace = true, features = ["async-spawn"] } [dev-dependencies] diff --git a/components/trace/Cargo.toml b/components/trace/Cargo.toml index 02e5734..78b7f9b 100644 --- a/components/trace/Cargo.toml +++ b/components/trace/Cargo.toml @@ -15,6 +15,7 @@ client = [] handler = [] [dependencies] +http-utils = { workspace = true } wit-bindgen = { workspace = true, features = ["async-spawn"] } [dev-dependencies] diff --git a/components/wit/deps/wasi-config-0.2.0-rc.1/package.wit b/components/wit/deps/wasi-config-0.2.0-rc.1/package.wit new file mode 100644 index 0000000..d8950ee --- /dev/null +++ b/components/wit/deps/wasi-config-0.2.0-rc.1/package.wit @@ -0,0 +1,33 @@ +package wasi:config@0.2.0-rc.1; + +interface store { + /// An error type that encapsulates the different errors that can occur fetching configuration values. + variant error { + /// This indicates an error from an "upstream" config source. + /// As this could be almost _anything_ (such as Vault, Kubernetes ConfigMaps, KeyValue buckets, etc), + /// the error message is a string. + upstream(string), + /// This indicates an error from an I/O operation. + /// As this could be almost _anything_ (such as a file read, network connection, etc), + /// the error message is a string. + /// Depending on how this ends up being consumed, + /// we may consider moving this to use the `wasi:io/error` type instead. + /// For simplicity right now in supporting multiple implementations, it is being left as a string. + io(string), + } + + /// Gets a configuration value of type `string` associated with the `key`. + /// + /// The value is returned as an `option`. If the key is not found, + /// `Ok(none)` is returned. If an error occurs, an `Err(error)` is returned. + get: func(key: string) -> result, error>; + + /// Gets a list of configuration key-value pairs of type `string`. + /// + /// If an error occurs, an `Err(error)` is returned. + get-all: func() -> result>, error>; +} + +world imports { + import store; +} diff --git a/components/wit/deps/wasi-logging-0.1.0-draft/package.wit b/components/wit/deps/wasi-logging-0.1.0-draft/package.wit new file mode 100644 index 0000000..164cb5b --- /dev/null +++ b/components/wit/deps/wasi-logging-0.1.0-draft/package.wit @@ -0,0 +1,36 @@ +package wasi:logging@0.1.0-draft; + +/// WASI Logging is a logging API intended to let users emit log messages with +/// simple priority levels and context values. +interface logging { + /// A log level, describing a kind of message. + enum level { + /// Describes messages about the values of variables and the flow of + /// control within a program. + trace, + /// Describes messages likely to be of interest to someone debugging a + /// program. + debug, + /// Describes messages likely to be of interest to someone monitoring a + /// program. + info, + /// Describes messages indicating hazardous situations. + warn, + /// Describes messages indicating serious errors. + error, + /// Describes messages indicating fatal errors. + critical, + } + + /// Emit a log message. + /// + /// A log message has a `level` describing what kind of message is being + /// sent, a context, which is an uninterpreted string meant to help + /// consumers group similar messages, and a string containing the message + /// text. + log: func(level: level, context: string, message: string); +} + +world imports { + import logging; +} diff --git a/components/wit/worlds.wit b/components/wit/worlds.wit index dc14a55..b051414 100644 --- a/components/wit/worlds.wit +++ b/components/wit/worlds.wit @@ -5,6 +5,40 @@ world client { export componentized:http/client@0.1.0-dev; } +world gate-client { + import componentized:http/latch@0.1.0-dev; + import wasi:config/store@0.2.0-rc.1; + import wasi:logging/logging@0.1.0-draft; + import wasi:http/client@0.3.0; + export wasi:http/client@0.3.0; +} + +world gate-handler { + import componentized:http/latch@0.1.0-dev; + import wasi:config/store@0.2.0-rc.1; + import wasi:logging/logging@0.1.0-draft; + import wasi:http/handler@0.3.0; + export wasi:http/handler@0.3.0; +} + +world latch { + import wasi:config/store@0.2.0-rc.1; + import wasi:logging/logging@0.1.0-draft; + import componentized:http/latch@0.1.0-dev; + export componentized:http/latch@0.1.0-dev; +} + +world latch-n { + export componentized:http/latch@0.1.0-dev; + import latch0: componentized:http/latch@0.1.0-dev; + import latch1: componentized:http/latch@0.1.0-dev; + import latch2: componentized:http/latch@0.1.0-dev; + import latch3: componentized:http/latch@0.1.0-dev; + import latch4: componentized:http/latch@0.1.0-dev; + import wasi:http/types@0.3.0; + import wasi:logging/logging@0.1.0-draft; +} + world trace-componentized-client { import componentized:http/client@0.1.0-dev; import wasi:logging/logging@0.1.0-draft; @@ -33,3 +67,8 @@ world trace { include trace-client; include trace-handler; } + +/// Insecure random numbers, e.g. to seed `latch-deny-random`. +world insecure-random { + import wasi:random/insecure@0.3.0; +} diff --git a/components/wkg.lock b/components/wkg.lock index dccfcbf..b03be8e 100644 --- a/components/wkg.lock +++ b/components/wkg.lock @@ -11,6 +11,15 @@ requirement = "=0.3.0" version = "0.3.0" digest = "sha256:59e1f4079e64ada450e19ffc9d08854c904e356ed8cc1fda36ff4fe150264db2" +[[packages]] +name = "wasi:config" +registry = "wasi.dev" + +[[packages.versions]] +requirement = "=0.2.0-rc.1" +version = "0.2.0-rc.1" +digest = "sha256:1b7f1b0fd07bb4cede16c6a6ec8852815dfb924639a78735fc7bdffdc164485d" + [[packages]] name = "wasi:http" registry = "wasi.dev" @@ -33,3 +42,12 @@ registry = "wasi.dev" requirement = "=0.1.0-draft" version = "0.1.0-draft" digest = "sha256:09621a45b12b0a9cddc798517f778aac0e5ae4bd234077b3d70758d6cf625580" + +[[packages]] +name = "wasi:random" +registry = "wasi.dev" + +[[packages.versions]] +requirement = "=0.3.0" +version = "0.3.0" +digest = "sha256:daa3c8897a68fbfc58b95c2e1de1b7af5be8c647bbb2cfeaa54c1e5b1ba9b772" diff --git a/crates/http-latch-n/Cargo.toml b/crates/http-latch-n/Cargo.toml new file mode 100644 index 0000000..9c4a117 --- /dev/null +++ b/crates/http-latch-n/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "http-latch-n" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +publish = false + +[dependencies] +wit-bindgen = { workspace = true } diff --git a/crates/http-latch-n/src/lib.rs b/crates/http-latch-n/src/lib.rs new file mode 100644 index 0000000..ad7081f --- /dev/null +++ b/crates/http-latch-n/src/lib.rs @@ -0,0 +1,57 @@ +//! Bindings and helpers for the `latch-n` components, which aggregate several latches into one. + +pub use crate::bindings::exports::componentized::http::latch::{ + Decision, ErrorCode, Guest as Latch, Operation, +}; +pub use crate::bindings::{latch0, latch1, latch2, latch3, latch4}; + +/// Ask each latch in turn, the first denial is the decision. +pub fn authorize( + operation: Operation, + authorizers: Vec) -> Result>, +) -> Result { + for authorize in authorizers { + match authorize(&operation)? { + Decision::Deferred => {} + Decision::Denied(error_code) => return Ok(Decision::Denied(error_code)), + } + } + Ok(Decision::Deferred) +} + +/// Pass the final decision to every nested latch, including latches that were not asked to +/// authorize the request because an earlier latch denied it. +/// +/// Every observer is called even if an earlier one fails, the result is the first error. +pub fn observe_decision( + final_decision: Decision, + operation: Operation, + observers: Vec) -> Result<(), ErrorCode>>, +) -> Result<(), ErrorCode> { + let mut result = Ok(()); + for observe in observers { + if let Err(err) = observe(&final_decision, &operation) { + if result.is_ok() { + result = Err(err); + } + } + } + result +} + +pub mod bindings { + wit_bindgen::generate!({ + path: "../../components/wit", + world: "latch-n", + pub_export_macro: true, + merge_structurally_equal_types: true, + generate_all + }); +} + +#[macro_export] +macro_rules! export { + ($($t:tt)*) => { + $crate::bindings::export!($($t)*); + }; +} diff --git a/crates/http-latch/Cargo.toml b/crates/http-latch/Cargo.toml new file mode 100644 index 0000000..374af9f --- /dev/null +++ b/crates/http-latch/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "http-latch" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +publish = false + +[dependencies] +http-utils = { workspace = true } +wit-bindgen = { workspace = true } diff --git a/crates/http-latch/src/lib.rs b/crates/http-latch/src/lib.rs new file mode 100644 index 0000000..7ced255 --- /dev/null +++ b/crates/http-latch/src/lib.rs @@ -0,0 +1,259 @@ +//! Bindings and helpers for latch components, which implement the `latch` world. +//! +//! The bindings are generated once here and shared by every latch component. A latch implements +//! [`Latch`] and exports it with [`export!`]: +//! +//! ```ignore +//! struct MyLatch {} +//! +//! impl http_latch::Latch for MyLatch { +//! // ... +//! } +//! +//! http_latch::export!(MyLatch with_types_in http_latch::bindings); +//! ``` +//! +//! Imports a latch does not use, like `wasi:config/store` or the wrapped latch, are dropped from +//! the built component. + +use core::cell::RefCell; +use core::fmt; +use core::ops::Deref; +use std::collections::HashMap; + +pub mod bindings { + wit_bindgen::generate!({ + path: "../../components/wit", + world: "latch", + pub_export_macro: true, + merge_structurally_equal_types: true, + generate_all + }); +} + +#[macro_export] +macro_rules! export { + ($($t:tt)*) => { + $crate::bindings::export!($($t)*); + }; +} + +pub use bindings::exports::componentized::http::latch::{ + ClientOperation, Decision, ErrorCode, Guest as Latch, HandleArgs, HandlerOperation, + HttpErrorCode, Operation, SendArgs, +}; +pub use bindings::wasi::http::types::{Method, Request, Scheme}; + +/// The latch a wrapping latch delegates to, imported as `componentized:http/latch`. +pub mod wrapped { + pub use crate::bindings::componentized::http::latch::{authorize, observe_decision}; +} + +/// Log a message from a latch with `wasi:logging`. +pub fn log(level: bindings::wasi::logging::logging::Level, message: &str) { + bindings::wasi::logging::logging::log(level, "componentized-latch", message); +} + +/// Log a critical message from a latch, formatted like `format!`. +#[macro_export] +macro_rules! critical { + ($($arg:tt)*) => { + $crate::log($crate::bindings::wasi::logging::logging::Level::Critical, &format!($($arg)*)) + }; +} + +/// Log an error from a latch, formatted like `format!`. +#[macro_export] +macro_rules! error { + ($($arg:tt)*) => { + $crate::log($crate::bindings::wasi::logging::logging::Level::Error, &format!($($arg)*)) + }; +} + +/// Log a warning from a latch, formatted like `format!`. +#[macro_export] +macro_rules! warn { + ($($arg:tt)*) => { + $crate::log($crate::bindings::wasi::logging::logging::Level::Warn, &format!($($arg)*)) + }; +} + +/// Log a trace message from a latch, formatted like `format!`. +#[macro_export] +macro_rules! trace { + ($($arg:tt)*) => { + $crate::log($crate::bindings::wasi::logging::logging::Level::Trace, &format!($($arg)*)) + }; +} + +/// Load the latch's config from `wasi:config/store` and parse it. +/// +/// When the config cannot be read or parsed, the cause is logged and an `invalid-config` error +/// naming the latch is returned. Parse errors describe the offending entry, e.g. +/// `KEY= VALUE= ERROR=`. +pub fn load_config( + latch_name: &str, + parse: impl FnOnce(Vec<(String, String)>) -> Result, +) -> Result { + use bindings::wasi::config::store; + + store::get_all() + .map_err(|err| match err { + store::Error::Upstream(message) => format!("ERROR=upstream: {message}"), + store::Error::Io(message) => format!("ERROR=io: {message}"), + }) + .and_then(parse) + .map_err(|message| { + critical!("Invalid config LATCH={latch_name} {message}"); + ErrorCode::InvalidConfig(latch_name.to_string()) + }) +} + +/// A decision configured for each key, e.g. `get=deferred` and `*=denied`. +/// +/// Values are `deferred`, or empty, and `denied`. A key that isn't configured falls back to `*`, +/// and is deferred without it. +pub struct DecisionConfig { + denied: HashMap, +} + +/// The key a decision falls back to. +pub const WILDCARD: &str = "*"; + +impl DecisionConfig { + /// Parse the config entries, for [`load_config`]. + pub fn parse(entries: Vec<(String, String)>) -> Result { + let mut denied = HashMap::new(); + for (key, value) in entries { + let is_denied = match value.as_str() { + "" | "deferred" => false, + "denied" => true, + _ => { + return Err(format!( + "KEY={key} VALUE={value} ERROR=expected one of: 'deferred', 'denied'" + )); + } + }; + denied.insert(key, is_denied); + } + Ok(Self { denied }) + } + + /// The decision for the key, denied with the reason. + pub fn decide(&self, key: &str, reason: impl FnOnce() -> HttpErrorCode) -> Decision { + let denied = self + .denied + .get(key) + .or_else(|| self.denied.get(WILDCARD)) + .copied() + .unwrap_or(false); + match denied { + true => Decision::Denied(reason()), + false => Decision::Deferred, + } + } +} + +/// State kept by a latch between calls, in a `static`. +/// +/// Components are single threaded, and a component is not reentered while it is running, so the +/// state is never shared between threads. +pub struct Local(RefCell); + +// components are single threaded, and a component is not reentered while it is running +unsafe impl Sync for Local {} + +impl Local { + pub const fn new(value: T) -> Local { + Local(RefCell::new(value)) + } +} + +impl Deref for Local { + type Target = RefCell; + + fn deref(&self) -> &RefCell { + &self.0 + } +} + +/// Displays a latch error the way gates log it, e.g. `invalid-config`. +/// +/// The generated bindings already implement `Display` for [`ErrorCode`], with its debug form. +pub struct DisplayError<'a>(pub &'a ErrorCode); + +impl fmt::Display for DisplayError<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0 { + ErrorCode::InvalidConfig(latch) => write!(f, "invalid-config<{latch}>"), + ErrorCode::ObservationFailed(latch) => write!(f, "observation-failed<{latch}>"), + ErrorCode::Other(Some(message)) => f.write_str(message), + ErrorCode::Other(None) => f.write_str("other"), + } + } +} + +/// Displays a denial reason the way gates log it, the name of the error code, e.g. +/// `http-request-denied`. +/// +/// The generated bindings already implement `Display` for [`HttpErrorCode`], with its debug form. +pub struct DisplayReason<'a>(pub &'a HttpErrorCode); + +impl fmt::Display for DisplayReason<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&http_utils::format_http_error_code!(HttpErrorCode, self.0)) + } +} + +/// Displays a decision, e.g. `DECISION=denied REASON=http-request-denied`. +pub struct DisplayDecision<'a>(pub &'a Decision); + +impl fmt::Display for DisplayDecision<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0 { + Decision::Deferred => f.write_str("DECISION=deferred"), + Decision::Denied(reason) => { + write!(f, "DECISION=denied REASON={}", DisplayReason(reason)) + } + } + } +} + +/// A method, displayed the way gates log it, e.g. `get`. +impl fmt::Display for Method { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&http_utils::format_http_method!(Method, self)) + } +} + +/// A scheme, displayed the way gates log it, e.g. `https`. +impl fmt::Display for Scheme { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&http_utils::format_http_scheme!(Scheme, self)) + } +} + +/// The request an operation is for. +pub fn request<'a>(operation: &Operation<'a>) -> &'a Request { + match operation { + Operation::Client(ClientOperation::Send(args)) => args.request, + Operation::Handler(HandlerOperation::Handle(args)) => args.request, + } +} + +/// An operation, displayed the way gates log it, e.g. +/// `OPERATION=wasi:http/client#send METHOD=get PATH-WITH-QUERY=some`. +impl fmt::Display for Operation<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let operation = match self { + Operation::Client(ClientOperation::Send(_)) => "wasi:http/client#send", + Operation::Handler(HandlerOperation::Handle(_)) => "wasi:http/handler#handle", + }; + let request = request(self); + write!(f, "OPERATION={operation} METHOD={}", request.get_method())?; + match request.get_path_with_query() { + Some(path_with_query) => write!(f, " PATH-WITH-QUERY=some<{path_with_query}>"), + None => f.write_str(" PATH-WITH-QUERY=none"), + } + } +} diff --git a/crates/http-utils/Cargo.toml b/crates/http-utils/Cargo.toml new file mode 100644 index 0000000..544374f --- /dev/null +++ b/crates/http-utils/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "http-utils" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +publish = false + +[dev-dependencies] +wit-bindgen = { workspace = true } diff --git a/crates/http-utils/src/lib.rs b/crates/http-utils/src/lib.rs new file mode 100644 index 0000000..fc68479 --- /dev/null +++ b/crates/http-utils/src/lib.rs @@ -0,0 +1,268 @@ +//! Helpers for wasi:http. +//! +//! Each component generates its own bindings for `wasi:http/types`, so the helpers for its types +//! are macros, expanded at the call site for the component's type, e.g. +//! `format_http_method!(types::Method, &method)`. + +#[doc(hidden)] +pub use std::borrow::Cow; + +/// Format a wasi:http `error-code` for logs, as a `Cow<'static, str>`. The name of the variant as +/// written in the WIT, in lower case, e.g. `http-request-denied`. A payload is appended with the +/// values that are present, e.g. `http-response-body-size<1024>` or +/// `dns-error`. +/// +/// Each component generates its own bindings for `wasi:http/types`, so the match is a macro, +/// expanded at the call site for the component's `error-code` type. +/// +/// ```ignore +/// use crate::wasi::http::types::ErrorCode; +/// +/// let name = http_utils::format_http_error_code!(ErrorCode, &error_code); +/// // or with a path to the type +/// let name = http_utils::format_http_error_code!(types::ErrorCode, &error_code); +/// ``` +#[macro_export] +macro_rules! format_http_error_code { + ($($error_code:ident)::+, $value:expr) => { + match $value { + $($error_code)::+::DnsTimeout => $crate::Cow::Borrowed("dns-timeout"), + $($error_code)::+::DnsError(payload) => $crate::with_fields( + "dns-error", + &[ + ("rcode", payload.rcode.clone()), + ("info-code", payload.info_code.map(|code| code.to_string())), + ], + ), + $($error_code)::+::DestinationNotFound => $crate::Cow::Borrowed("destination-not-found"), + $($error_code)::+::DestinationUnavailable => $crate::Cow::Borrowed("destination-unavailable"), + $($error_code)::+::DestinationIpProhibited => { + $crate::Cow::Borrowed("destination-ip-prohibited") + } + $($error_code)::+::DestinationIpUnroutable => { + $crate::Cow::Borrowed("destination-ip-unroutable") + } + $($error_code)::+::ConnectionRefused => $crate::Cow::Borrowed("connection-refused"), + $($error_code)::+::ConnectionTerminated => $crate::Cow::Borrowed("connection-terminated"), + $($error_code)::+::ConnectionTimeout => $crate::Cow::Borrowed("connection-timeout"), + $($error_code)::+::ConnectionReadTimeout => $crate::Cow::Borrowed("connection-read-timeout"), + $($error_code)::+::ConnectionWriteTimeout => { + $crate::Cow::Borrowed("connection-write-timeout") + } + $($error_code)::+::ConnectionLimitReached => { + $crate::Cow::Borrowed("connection-limit-reached") + } + $($error_code)::+::TlsProtocolError => $crate::Cow::Borrowed("tls-protocol-error"), + $($error_code)::+::TlsCertificateError => $crate::Cow::Borrowed("tls-certificate-error"), + $($error_code)::+::TlsAlertReceived(payload) => $crate::with_fields( + "tls-alert-received", + &[ + ("alert-id", payload.alert_id.map(|id| id.to_string())), + ("alert-message", payload.alert_message.clone()), + ], + ), + $($error_code)::+::HttpRequestDenied => $crate::Cow::Borrowed("http-request-denied"), + $($error_code)::+::HttpRequestLengthRequired => { + $crate::Cow::Borrowed("http-request-length-required") + } + $($error_code)::+::HttpRequestBodySize(size) => { + $crate::with_value("http-request-body-size", size.map(|size| size.to_string())) + } + $($error_code)::+::HttpRequestMethodInvalid => { + $crate::Cow::Borrowed("http-request-method-invalid") + } + $($error_code)::+::HttpRequestUriInvalid => $crate::Cow::Borrowed("http-request-uri-invalid"), + $($error_code)::+::HttpRequestUriTooLong => { + $crate::Cow::Borrowed("http-request-uri-too-long") + } + $($error_code)::+::HttpRequestHeaderSectionSize(size) => $crate::with_value( + "http-request-header-section-size", + size.map(|size| size.to_string()), + ), + $($error_code)::+::HttpRequestHeaderSize(None) => { + $crate::Cow::Borrowed("http-request-header-size") + } + $($error_code)::+::HttpRequestHeaderSize(Some(payload)) => { + $crate::with_field_size!("http-request-header-size", payload) + } + $($error_code)::+::HttpRequestTrailerSectionSize(size) => $crate::with_value( + "http-request-trailer-section-size", + size.map(|size| size.to_string()), + ), + $($error_code)::+::HttpRequestTrailerSize(payload) => { + $crate::with_field_size!("http-request-trailer-size", payload) + } + $($error_code)::+::HttpResponseIncomplete => { + $crate::Cow::Borrowed("http-response-incomplete") + } + $($error_code)::+::HttpResponseHeaderSectionSize(size) => $crate::with_value( + "http-response-header-section-size", + size.map(|size| size.to_string()), + ), + $($error_code)::+::HttpResponseHeaderSize(payload) => { + $crate::with_field_size!("http-response-header-size", payload) + } + $($error_code)::+::HttpResponseBodySize(size) => { + $crate::with_value("http-response-body-size", size.map(|size| size.to_string())) + } + $($error_code)::+::HttpResponseTrailerSectionSize(size) => $crate::with_value( + "http-response-trailer-section-size", + size.map(|size| size.to_string()), + ), + $($error_code)::+::HttpResponseTrailerSize(payload) => { + $crate::with_field_size!("http-response-trailer-size", payload) + } + $($error_code)::+::HttpResponseTransferCoding(coding) => { + $crate::with_value("http-response-transfer-coding", coding.clone()) + } + $($error_code)::+::HttpResponseContentCoding(coding) => { + $crate::with_value("http-response-content-coding", coding.clone()) + } + $($error_code)::+::HttpResponseTimeout => $crate::Cow::Borrowed("http-response-timeout"), + $($error_code)::+::HttpUpgradeFailed => $crate::Cow::Borrowed("http-upgrade-failed"), + $($error_code)::+::HttpProtocolError => $crate::Cow::Borrowed("http-protocol-error"), + $($error_code)::+::LoopDetected => $crate::Cow::Borrowed("loop-detected"), + $($error_code)::+::ConfigurationError => $crate::Cow::Borrowed("configuration-error"), + $($error_code)::+::InternalError(message) => { + $crate::with_value("internal-error", message.clone()) + } + } + }; +} + +/// Parse the name of a wasi:http `error-code`, as [`format_http_error_code!`] formats it, e.g. +/// `http-request-denied`, as an `Option`. A variant with a payload has an empty payload, +/// `None` for an option and for each field of a record. `None` when the name isn't an error code. +/// +/// Takes the bindings' `types` module, rather than the `error-code` type, for the records some +/// payloads are. +/// +/// ```ignore +/// let reason = http_utils::parse_http_error_code!(wasi::http::types, "connection-refused"); +/// ``` +#[macro_export] +macro_rules! parse_http_error_code { + ($($types:ident)::+, $value:expr) => { + match $value { + "dns-timeout" => Some($($types)::+::ErrorCode::DnsTimeout), + "dns-error" => Some($($types)::+::ErrorCode::DnsError($($types)::+::DnsErrorPayload { rcode: None, info_code: None })), + "destination-not-found" => Some($($types)::+::ErrorCode::DestinationNotFound), + "destination-unavailable" => Some($($types)::+::ErrorCode::DestinationUnavailable), + "destination-ip-prohibited" => Some($($types)::+::ErrorCode::DestinationIpProhibited), + "destination-ip-unroutable" => Some($($types)::+::ErrorCode::DestinationIpUnroutable), + "connection-refused" => Some($($types)::+::ErrorCode::ConnectionRefused), + "connection-terminated" => Some($($types)::+::ErrorCode::ConnectionTerminated), + "connection-timeout" => Some($($types)::+::ErrorCode::ConnectionTimeout), + "connection-read-timeout" => Some($($types)::+::ErrorCode::ConnectionReadTimeout), + "connection-write-timeout" => Some($($types)::+::ErrorCode::ConnectionWriteTimeout), + "connection-limit-reached" => Some($($types)::+::ErrorCode::ConnectionLimitReached), + "tls-protocol-error" => Some($($types)::+::ErrorCode::TlsProtocolError), + "tls-certificate-error" => Some($($types)::+::ErrorCode::TlsCertificateError), + "tls-alert-received" => Some($($types)::+::ErrorCode::TlsAlertReceived($($types)::+::TlsAlertReceivedPayload { alert_id: None, alert_message: None })), + "http-request-denied" => Some($($types)::+::ErrorCode::HttpRequestDenied), + "http-request-length-required" => Some($($types)::+::ErrorCode::HttpRequestLengthRequired), + "http-request-body-size" => Some($($types)::+::ErrorCode::HttpRequestBodySize(None)), + "http-request-method-invalid" => Some($($types)::+::ErrorCode::HttpRequestMethodInvalid), + "http-request-uri-invalid" => Some($($types)::+::ErrorCode::HttpRequestUriInvalid), + "http-request-uri-too-long" => Some($($types)::+::ErrorCode::HttpRequestUriTooLong), + "http-request-header-section-size" => Some($($types)::+::ErrorCode::HttpRequestHeaderSectionSize(None)), + "http-request-header-size" => Some($($types)::+::ErrorCode::HttpRequestHeaderSize(None)), + "http-request-trailer-section-size" => Some($($types)::+::ErrorCode::HttpRequestTrailerSectionSize(None)), + "http-request-trailer-size" => Some($($types)::+::ErrorCode::HttpRequestTrailerSize($($types)::+::FieldSizePayload { field_name: None, field_size: None })), + "http-response-incomplete" => Some($($types)::+::ErrorCode::HttpResponseIncomplete), + "http-response-header-section-size" => Some($($types)::+::ErrorCode::HttpResponseHeaderSectionSize(None)), + "http-response-header-size" => Some($($types)::+::ErrorCode::HttpResponseHeaderSize($($types)::+::FieldSizePayload { field_name: None, field_size: None })), + "http-response-body-size" => Some($($types)::+::ErrorCode::HttpResponseBodySize(None)), + "http-response-trailer-section-size" => Some($($types)::+::ErrorCode::HttpResponseTrailerSectionSize(None)), + "http-response-trailer-size" => Some($($types)::+::ErrorCode::HttpResponseTrailerSize($($types)::+::FieldSizePayload { field_name: None, field_size: None })), + "http-response-transfer-coding" => Some($($types)::+::ErrorCode::HttpResponseTransferCoding(None)), + "http-response-content-coding" => Some($($types)::+::ErrorCode::HttpResponseContentCoding(None)), + "http-response-timeout" => Some($($types)::+::ErrorCode::HttpResponseTimeout), + "http-upgrade-failed" => Some($($types)::+::ErrorCode::HttpUpgradeFailed), + "http-protocol-error" => Some($($types)::+::ErrorCode::HttpProtocolError), + "loop-detected" => Some($($types)::+::ErrorCode::LoopDetected), + "configuration-error" => Some($($types)::+::ErrorCode::ConfigurationError), + "internal-error" => Some($($types)::+::ErrorCode::InternalError(None)), + _ => None, + } + }; +} + +/// Format a wasi:http `method` for logs, as a `Cow<'static, str>`. The method in lower case, e.g. +/// `get`, including a method without a variant of its own, e.g. `purge`. +/// +/// ```ignore +/// let method = http_utils::format_http_method!(types::Method, &request.get_method()); +/// ``` +#[macro_export] +macro_rules! format_http_method { + ($($method:ident)::+, $value:expr) => { + match $value { + $($method)::+::Get => $crate::Cow::Borrowed("get"), + $($method)::+::Head => $crate::Cow::Borrowed("head"), + $($method)::+::Post => $crate::Cow::Borrowed("post"), + $($method)::+::Put => $crate::Cow::Borrowed("put"), + $($method)::+::Delete => $crate::Cow::Borrowed("delete"), + $($method)::+::Connect => $crate::Cow::Borrowed("connect"), + $($method)::+::Options => $crate::Cow::Borrowed("options"), + $($method)::+::Trace => $crate::Cow::Borrowed("trace"), + $($method)::+::Patch => $crate::Cow::Borrowed("patch"), + $($method)::+::Other(method) => $crate::Cow::Owned(method.to_ascii_lowercase()), + } + }; +} + +/// Format a wasi:http `scheme` for logs, as a `Cow<'static, str>`. The scheme in lower case, e.g. +/// `https`, including a scheme without a variant of its own, e.g. `ftp`. +/// +/// ```ignore +/// let scheme = http_utils::format_http_scheme!(types::Scheme, &scheme); +/// ``` +#[macro_export] +macro_rules! format_http_scheme { + ($($scheme:ident)::+, $value:expr) => { + match $value { + $($scheme)::+::Http => $crate::Cow::Borrowed("http"), + $($scheme)::+::Https => $crate::Cow::Borrowed("https"), + $($scheme)::+::Other(scheme) => $crate::Cow::Owned(scheme.to_ascii_lowercase()), + } + }; +} + +/// The name with a value, or the name alone without one, e.g. `name`. +#[doc(hidden)] +pub fn with_value(name: &'static str, value: Option) -> Cow<'static, str> { + match value { + Some(value) => Cow::Owned(format!("{name}<{value}>")), + None => Cow::Borrowed(name), + } +} + +/// The name with the fields that have a value, or the name alone without any, e.g. +/// `name`. +#[doc(hidden)] +pub fn with_fields(name: &'static str, fields: &[(&str, Option)]) -> Cow<'static, str> { + let fields: Vec = fields + .iter() + .filter_map(|(field, value)| value.as_ref().map(|value| format!("{field}={value}"))) + .collect(); + with_value(name, (!fields.is_empty()).then(|| fields.join(","))) +} + +/// The name with a `field-size-payload`, e.g. `name`. +#[doc(hidden)] +#[macro_export] +macro_rules! with_field_size { + ($name:literal, $payload:expr) => { + $crate::with_fields( + $name, + &[ + ("field-name", $payload.field_name.clone()), + ( + "field-size", + $payload.field_size.map(|size| size.to_string()), + ), + ], + ) + }; +} diff --git a/crates/http-utils/tests/bindings/mod.rs b/crates/http-utils/tests/bindings/mod.rs new file mode 100644 index 0000000..6562ec9 --- /dev/null +++ b/crates/http-utils/tests/bindings/mod.rs @@ -0,0 +1,9 @@ +//! Bindings for the tests, generated by wit-bindgen from the repository's `imports` world, the same +//! generator and `wasi:http/types` the components use. A change to either is a change the tests +//! see. + +wit_bindgen::generate!({ + path: "../../wit", + world: "imports", + generate_all, +}); diff --git a/crates/http-utils/tests/error_code.rs b/crates/http-utils/tests/error_code.rs new file mode 100644 index 0000000..8302817 --- /dev/null +++ b/crates/http-utils/tests/error_code.rs @@ -0,0 +1,82 @@ +//! Expanded for the `error-code` generated by wit-bindgen, as the components generate it. + +mod bindings; + +use bindings::wasi::http::types::{ + DnsErrorPayload, ErrorCode, FieldSizePayload, TlsAlertReceivedPayload, +}; + +fn name(error_code: ErrorCode) -> String { + http_utils::format_http_error_code!(ErrorCode, &error_code).into_owned() +} + +#[test] +fn names_without_a_payload() { + assert_eq!(name(ErrorCode::HttpRequestDenied), "http-request-denied"); + assert_eq!( + name(ErrorCode::DestinationIpProhibited), + "destination-ip-prohibited" + ); + assert_eq!( + name(ErrorCode::HttpRequestUriTooLong), + "http-request-uri-too-long" + ); +} + +#[test] +fn optional_values() { + assert_eq!( + name(ErrorCode::HttpResponseBodySize(None)), + "http-response-body-size" + ); + assert_eq!( + name(ErrorCode::HttpResponseBodySize(Some(1024))), + "http-response-body-size<1024>" + ); + assert_eq!( + name(ErrorCode::HttpResponseContentCoding(Some("br".to_string()))), + "http-response-content-coding
" + ); + assert_eq!(name(ErrorCode::InternalError(None)), "internal-error"); + assert_eq!( + name(ErrorCode::InternalError(Some("boom".to_string()))), + "internal-error" + ); +} + +#[test] +fn records_with_the_fields_that_have_a_value() { + assert_eq!( + name(ErrorCode::DnsError(DnsErrorPayload { + rcode: Some("NXDOMAIN".to_string()), + info_code: Some(3), + })), + "dns-error" + ); + assert_eq!( + name(ErrorCode::TlsAlertReceived(TlsAlertReceivedPayload { + alert_id: Some(40), + alert_message: None, + })), + "tls-alert-received" + ); + assert_eq!( + name(ErrorCode::HttpResponseHeaderSize(FieldSizePayload { + field_name: Some("x-a".to_string()), + field_size: Some(10), + })), + "http-response-header-size" + ); + // a record without any value is the name alone + assert_eq!( + name(ErrorCode::HttpRequestTrailerSize(FieldSizePayload { + field_name: None, + field_size: None, + })), + "http-request-trailer-size" + ); + assert_eq!( + name(ErrorCode::HttpRequestHeaderSize(None)), + "http-request-header-size" + ); +} diff --git a/crates/http-utils/tests/method.rs b/crates/http-utils/tests/method.rs new file mode 100644 index 0000000..86f1f45 --- /dev/null +++ b/crates/http-utils/tests/method.rs @@ -0,0 +1,17 @@ +//! Expanded for the `method` generated by wit-bindgen, as the components generate it. + +mod bindings; + +use bindings::wasi::http::types; + +fn method(method: types::Method) -> String { + // a path to the type, as the gates use it + http_utils::format_http_method!(types::Method, &method).into_owned() +} + +#[test] +fn methods_in_lower_case() { + assert_eq!(method(types::Method::Get), "get"); + assert_eq!(method(types::Method::Patch), "patch"); + assert_eq!(method(types::Method::Other("PURGE".to_string())), "purge"); +} diff --git a/crates/http-utils/tests/parse_error_code.rs b/crates/http-utils/tests/parse_error_code.rs new file mode 100644 index 0000000..d614c91 --- /dev/null +++ b/crates/http-utils/tests/parse_error_code.rs @@ -0,0 +1,77 @@ +//! Expanded for the `error-code` generated by wit-bindgen, as the components generate it. + +mod bindings; + +use bindings::wasi::http::types::{self, ErrorCode}; + +/// Every variant of `error-code`, as written in the WIT, in lower case. +const NAMES: [&str; 39] = [ + "dns-timeout", + "dns-error", + "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", + "http-request-denied", + "http-request-length-required", + "http-request-body-size", + "http-request-method-invalid", + "http-request-uri-invalid", + "http-request-uri-too-long", + "http-request-header-section-size", + "http-request-header-size", + "http-request-trailer-section-size", + "http-request-trailer-size", + "http-response-incomplete", + "http-response-header-section-size", + "http-response-header-size", + "http-response-body-size", + "http-response-trailer-section-size", + "http-response-trailer-size", + "http-response-transfer-coding", + "http-response-content-coding", + "http-response-timeout", + "http-upgrade-failed", + "http-protocol-error", + "loop-detected", + "configuration-error", + "internal-error", +]; + +fn parse(name: &str) -> Option { + http_utils::parse_http_error_code!(types, name) +} + +#[test] +fn parses_every_name_formatted() { + for name in NAMES { + let error_code = parse(name).unwrap_or_else(|| panic!("{name} parses")); + // an empty payload formats as the name alone + assert_eq!( + http_utils::format_http_error_code!(ErrorCode, &error_code), + name + ); + } +} + +#[test] +fn not_an_error_code() { + assert!(parse("").is_none()); + assert!( + parse("HTTP-request-denied").is_none(), + "names are lower case" + ); + assert!( + parse("http-request-denied<1>").is_none(), + "payloads aren't parsed" + ); +} diff --git a/crates/http-utils/tests/scheme.rs b/crates/http-utils/tests/scheme.rs new file mode 100644 index 0000000..9b9a8cf --- /dev/null +++ b/crates/http-utils/tests/scheme.rs @@ -0,0 +1,15 @@ +//! Expanded for the `scheme` generated by wit-bindgen, as the components generate it. + +mod bindings; + +use bindings::wasi::http::types; + +fn scheme(scheme: types::Scheme) -> String { + http_utils::format_http_scheme!(types::Scheme, &scheme).into_owned() +} + +#[test] +fn schemes_in_lower_case() { + assert_eq!(scheme(types::Scheme::Https), "https"); + assert_eq!(scheme(types::Scheme::Other("FTP".to_string())), "ftp"); +} diff --git a/crates/test-harness/Cargo.toml b/crates/test-harness/Cargo.toml index 507f60c..4ee5555 100644 --- a/crates/test-harness/Cargo.toml +++ b/crates/test-harness/Cargo.toml @@ -10,6 +10,7 @@ bytes = { workspace = true } http = { workspace = true } http-body-util = { workspace = true } tokio = { workspace = true } +wac-graph = { workspace = true } wasmtime = { workspace = true } wasmtime-wasi = { workspace = true } wasmtime-wasi-http = { workspace = true } diff --git a/crates/test-harness/src/latch.rs b/crates/test-harness/src/latch.rs new file mode 100644 index 0000000..6feafac --- /dev/null +++ b/crates/test-harness/src/latch.rs @@ -0,0 +1,273 @@ +//! Gates and latches: the host latch, `wasi:config/store`, and composing latch components into +//! the test subject. + +use http_body_util::BodyExt; +use wac_graph::{CompositionGraph, EncodeOptions, types::Package}; +use wasmtime::component::{Accessor, HasSelf, Linker, Resource}; +use wasmtime::{Result, StoreContextMut, bail, format_err}; + +use crate::Ctx; +use crate::gate_bindings::componentized::http::latch::{ + self, ClientOperation, Decision, ErrorCode as LatchErrorCode, HandlerOperation, HttpErrorCode, + Operation, +}; +use crate::gate_bindings::wasi::config::store; + +type Request = wasmtime_wasi_http::p3::Request; +type Response = wasmtime_wasi_http::p3::Response; + +/// An operation the host latch was asked to authorize. +/// +/// Resource handles are not retained, only the operation name and the request it is for. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Authorization { + /// Operation name, e.g. `client.send` + pub operation: String, + /// The request method, e.g. `GET` + pub method: String, + /// The request path with query, e.g. `/items?page=2` + pub path_with_query: String, +} + +/// A decision the host latch was told about with `observe-decision`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Observation { + /// Operation name, e.g. `client.send` + pub operation: String, + /// Whether the final decision denied the request + pub denied: bool, +} + +type Policy = dyn FnMut(&Authorization) -> Result + Send; + +/// Scripted latch implemented by the host. +/// +/// It is the test subject's latch unless latch components are installed, see +/// [`crate::Harness::latch`]. +pub struct HostLatch { + policy: Box, + fail_observe: bool, +} + +impl HostLatch { + /// Defer every request. + pub fn defer() -> Self { + Self::new(|_| Ok(Decision::Deferred)) + } + + /// Deny the named operations with `http-request-denied`, defer the rest. + pub fn deny(operations: &[&str]) -> Self { + let operations: Vec = operations.iter().map(|s| s.to_string()).collect(); + Self::new(move |auth| { + if operations.contains(&auth.operation) { + Ok(Decision::Denied(HttpErrorCode::HttpRequestDenied)) + } else { + Ok(Decision::Deferred) + } + }) + } + + /// Fail every authorization with a latch error. + pub fn error(message: &str) -> Self { + let message = message.to_string(); + Self::new(move |_| Err(LatchErrorCode::Other(Some(message.clone())))) + } + + /// Fail every authorization with an invalid config error from the named latch. + pub fn invalid_config(latch: &str) -> Self { + let latch = latch.to_string(); + Self::new(move |_| Err(LatchErrorCode::InvalidConfig(latch.clone()))) + } + + /// Decide each request with a custom policy. + pub fn new( + policy: impl FnMut(&Authorization) -> Result + Send + 'static, + ) -> Self { + Self { + policy: Box::new(policy), + fail_observe: false, + } + } + + /// Fail every `observe-decision` call. + pub fn fail_observe(mut self) -> Self { + self.fail_observe = true; + self + } +} + +impl Ctx { + fn describe(&mut self, operation: &Operation) -> Authorization { + let (operation, request) = match operation { + Operation::Client(ClientOperation::Send(args)) => ("client.send", &args.request), + Operation::Handler(HandlerOperation::Handle(args)) => ("handler.handle", &args.request), + }; + let (method, path_with_query) = match self.table.get(request) { + Ok(request) => ( + request.method.to_string(), + request + .path_with_query + .as_ref() + .map(|path| path.to_string()) + .unwrap_or_default(), + ), + Err(_) => (String::new(), String::new()), + }; + Authorization { + operation: operation.to_string(), + method, + path_with_query, + } + } +} + +impl latch::Host for Ctx { + fn authorize(&mut self, operation: Operation) -> Result { + let authorization = self.describe(&operation); + self.recorder + .authorizations + .lock() + .unwrap() + .push(authorization.clone()); + (self.latch.policy)(&authorization) + } + + fn observe_decision( + &mut self, + decision: Decision, + operation: Operation, + ) -> Result<(), LatchErrorCode> { + let operation = self.describe(&operation).operation; + self.recorder + .observations + .lock() + .unwrap() + .push(Observation { + operation, + denied: matches!(decision, Decision::Denied(_)), + }); + match self.latch.fail_observe { + true => Err(LatchErrorCode::ObservationFailed("host".to_string())), + false => Ok(()), + } + } +} + +impl store::Host for Ctx { + fn get(&mut self, key: String) -> Result, store::Error> { + Ok(self + .config + .iter() + .rev() + .find(|(k, _)| *k == key) + .map(|(_, v)| v.clone())) + } + + fn get_all(&mut self) -> Result, store::Error> { + Ok(self.config.clone()) + } +} + +/// Define the host latch, `wasi:config/store`, and the host latch under the named imports of a +/// `latch-n` component, e.g. `latch1`. +pub(crate) fn add_to_linker(linker: &mut Linker) -> Result<()> { + latch::add_to_linker::<_, HasSelf>(linker, |ctx| ctx)?; + store::add_to_linker::<_, HasSelf>(linker, |ctx| ctx)?; + for slot in 0..LATCH_N_MAX { + let mut instance = linker.instance(&format!("latch{slot}"))?; + instance.func_wrap( + "authorize", + |mut store: StoreContextMut<'_, Ctx>, (operation,): (Operation,)| { + Ok((latch::Host::authorize(store.data_mut(), operation),)) + }, + )?; + instance.func_wrap( + "observe-decision", + |mut store: StoreContextMut<'_, Ctx>, (decision, operation): (Decision, Operation)| { + Ok((latch::Host::observe_decision( + store.data_mut(), + decision, + operation, + ),)) + }, + )?; + } + Ok(()) +} + +/// The most latches a `latch-n` component aggregates. +pub(crate) const LATCH_N_MAX: usize = 5; + +const LATCH_INTERFACE: &str = "componentized:http/latch@0.1.0-dev"; + +/// Whether the latch component imports a latch, which it wraps. +pub(crate) fn imports_latch(latch: &[u8]) -> Result { + let mut types = wac_graph::types::Types::default(); + let package = Package::from_bytes("test:latch", None, latch.to_vec(), &mut types) + .map_err(|err| format_err!("{err:#}"))?; + Ok(types[package.ty()].imports.contains_key(LATCH_INTERFACE)) +} + +/// Satisfy the leading `latch{i}` imports of a `latch-n` component with the latches, any +/// remaining slot is left for the host. +pub(crate) fn aggregate(latch_n: Vec, latches: Vec>) -> Result> { + let mut graph = CompositionGraph::new(); + let latch_n = Package::from_bytes("test:latch-n", None, latch_n, graph.types_mut()) + .map_err(|err| format_err!("{err:#}"))?; + let latch_n = graph.register_package(latch_n)?; + let latch_n = graph.instantiate(latch_n); + for (slot, latch) in latches.into_iter().enumerate() { + let latch = + Package::from_bytes(&format!("test:latch{slot}"), None, latch, graph.types_mut()) + .map_err(|err| format_err!("{err:#}"))?; + let latch = graph.register_package(latch)?; + let latch = graph.instantiate(latch); + let export = graph.alias_instance_export(latch, LATCH_INTERFACE)?; + graph.set_instantiation_argument(latch_n, &format!("latch{slot}"), export)?; + } + let export = graph.alias_instance_export(latch_n, LATCH_INTERFACE)?; + graph.export(export, LATCH_INTERFACE)?; + Ok(graph.encode(EncodeOptions::default())?) +} + +/// Satisfy the socket's imports with the plug's exports. +pub(crate) fn plug(name: &str, socket: Vec, plug: Vec) -> Result> { + let mut graph = CompositionGraph::new(); + let socket = Package::from_bytes("test:socket", None, socket, graph.types_mut()) + .map_err(|err| format_err!("{err:#}"))?; + let socket = graph.register_package(socket)?; + let plug = Package::from_bytes("test:plug", None, plug, graph.types_mut()) + .map_err(|err| format_err!("{err:#}"))?; + let plug = graph.register_package(plug)?; + if let Err(err) = wac_graph::plug(&mut graph, vec![plug], socket) { + bail!("failed to plug latch into {name}: {err}"); + } + Ok(graph.encode(EncodeOptions::default())?) +} + +/// A request created by the host, for a gate's `wasi:http/client` or `wasi:http/handler`. +pub fn host_request( + accessor: &Accessor, + method: http::Method, + uri: &str, +) -> Result> { + let uri: http::Uri = uri.parse()?; + let parts = uri.into_parts(); + let body = http_body_util::Empty::::new() + .map_err(|never| -> wasmtime_wasi_http::Error { match never {} }); + let (request, _transmitted) = Request::new( + method, + parts.scheme, + parts.authority, + parts.path_and_query, + wasmtime_wasi_http::FieldMap::default(), + None, + body, + ); + accessor.with(|mut store| Ok(store.get().table.push(request)?)) +} + +/// The status of a response the host received from a gate. +pub fn response_status(accessor: &Accessor, response: &Resource) -> Result { + accessor.with(|mut store| Ok(store.get().table.get(response)?.status.as_u16())) +} diff --git a/crates/test-harness/src/lib.rs b/crates/test-harness/src/lib.rs index 283a9e5..6f3ed30 100644 --- a/crates/test-harness/src/lib.rs +++ b/crates/test-harness/src/lib.rs @@ -31,6 +31,41 @@ use crate::bindings::componentized::http::client as upstream_client; use crate::bindings::exports::componentized::http::client as http_client; use crate::bindings::exports::wasi::http::{client, handler, types}; use crate::bindings::wasi::logging::logging; +use crate::gate_bindings::exports::wasi::http as gated; + +mod latch; + +pub use latch::{Authorization, HostLatch, Observation, host_request, response_status}; + +/// Bindings for gates, which export `wasi:http/client` and `wasi:http/handler` with the host's +/// `wasi:http/types`, and import a latch. +pub mod gate_bindings { + wasmtime::component::bindgen!({ + path: "../../components/wit", + inline: " + package componentized:test-harness-gate; + + world gate { + import componentized:http/latch@0.1.0-dev; + import wasi:config/store@0.2.0-rc.1; + import wasi:logging/logging@0.1.0-draft; + export wasi:http/client@0.3.0; + export wasi:http/handler@0.3.0; + } + ", + world: "componentized:test-harness-gate/gate", + exports: { default: async | store }, + with: { + "wasi:http/types": wasmtime_wasi_http::p3::bindings::http::types, + "wasi:clocks": wasmtime_wasi::p3::bindings::clocks, + "wasi:logging": crate::bindings::wasi::logging, + }, + }); +} + +pub use gate_bindings::componentized::http::latch::{ + Decision, ErrorCode as LatchErrorCode, HttpErrorCode, +}; pub mod bindings { wasmtime::component::bindgen!({ @@ -247,6 +282,33 @@ pub struct LogEntry { } impl LogEntry { + /// A warning logged by the test subject. + pub fn warn(context: impl Into, message: impl Into) -> Self { + Self { + level: Level::Warn, + context: context.into(), + message: message.into(), + } + } + + /// An error logged by the test subject. + pub fn error(context: impl Into, message: impl Into) -> Self { + Self { + level: Level::Error, + context: context.into(), + message: message.into(), + } + } + + /// A critical message logged by the test subject. + pub fn critical(context: impl Into, message: impl Into) -> Self { + Self { + level: Level::Critical, + context: context.into(), + message: message.into(), + } + } + /// A trace message logged by the test subject. pub fn trace(context: impl Into, message: impl Into) -> Self { Self { @@ -367,6 +429,8 @@ type Responder = dyn FnMut(&UpstreamRequest) -> UpstreamResponse + Send; pub struct Recorder { logs: Arc>>, requests: Arc>>, + authorizations: Arc>>, + observations: Arc>>, } impl Recorder { @@ -398,6 +462,25 @@ impl Recorder { .collect() } + /// Requests the host latch was asked to authorize, in order. + pub fn authorizations(&self) -> Vec { + self.authorizations.lock().unwrap().clone() + } + + /// Names of the operations the host latch was asked to authorize, in order, e.g. + /// `client.send`. + pub fn operations(&self) -> Vec { + self.authorizations() + .into_iter() + .map(|a| a.operation) + .collect() + } + + /// Decisions the host latch observed, in order. + pub fn observations(&self) -> Vec { + self.observations.lock().unwrap().clone() + } + /// Requests the test subject sent upstream, in order. pub fn requests(&self) -> Vec { self.upstream_requests() @@ -484,6 +567,8 @@ pub struct Ctx { upstream: Upstream, table: ResourceTable, recorder: Recorder, + latch: HostLatch, + config: Vec<(String, String)>, } impl WasiView for Ctx { @@ -706,6 +791,9 @@ fn add_handler_to_linker(linker: &mut Linker) -> Result<()> { pub struct Harness { subject: String, responder: Box, + latches: Vec, + host_latch: Option, + config: Vec<(String, String)>, } impl Harness { @@ -714,7 +802,76 @@ impl Harness { Self { subject: component_name.to_string(), responder: Box::new(|_| UpstreamResponse::default()), + latches: vec![], + host_latch: None, + config: vec![], + } + } + + /// Install a latch component from `target/components/`. + /// + /// Without latch components the host latch is the test subject's latch. A single latch + /// component replaces it, unless the latch imports a latch, then it wraps the host latch. + /// Otherwise several latches, including the host latch when one is set with + /// [`Harness::host_latch`], are aggregated with the `latch-n` component of the same size, + /// in the order installed with the host latch last. + pub fn latch(mut self, name: &str) -> Self { + self.latches.push(name.to_string()); + self + } + + /// Replace the default deferring host latch, it is aggregated with any latch components. + pub fn host_latch(mut self, latch: HostLatch) -> Self { + self.host_latch = Some(latch); + self + } + + /// Add a `wasi:config/store` value, visible to every component in the composition. + /// + /// Values are returned by `get-all` in the order they are added. + pub fn config(mut self, key: &str, value: &str) -> Self { + self.config.push((key.to_string(), value.to_string())); + self + } + + fn compose(&self) -> Result> { + let mut names = vec![self.subject.as_str()]; + names.extend(self.latches.iter().map(String::as_str)); + for name in &names { + ensure_built(name)?; } + + let read = |name: &str| { + let path = component_path(name); + std::fs::read(&path).with_context(|| format!("failed to read {}", path.display())) + }; + + let bytes = read(&self.subject)?; + let latch = match self.latches.as_slice() { + [] => return Ok(bytes), + // a latch that wraps another latch wraps the host latch, its import is left for the host + [latch] if self.host_latch.is_none() || latch::imports_latch(&read(latch)?)? => { + read(latch)? + } + latches => { + // the host latch takes the last slot of latch-n, left unsatisfied it is imported + let slots = latches.len() + usize::from(self.host_latch.is_some()); + if slots > latch::LATCH_N_MAX { + bail!( + "at most {} latches can be aggregated, got {slots}", + latch::LATCH_N_MAX + ); + } + let latch_n = format!("latch-n{slots}"); + ensure_built(&latch_n)?; + let latches = latches + .iter() + .map(|name| read(name)) + .collect::>>()?; + latch::aggregate(read(&latch_n)?, latches)? + } + }; + latch::plug(&self.subject, bytes, latch) } /// Answer the requests sent upstream with `wasi:http` with the responses, instead of the @@ -731,12 +888,13 @@ impl Harness { pub async fn build(self) -> Result { let mut config = Config::new(); config.wasm_component_model_async(true); + // named imports, e.g. `latch0` of a `latch-n` component composed with a latch + config.wasm_component_model_implements(true); let engine = Engine::new(&config)?; - ensure_built(&self.subject)?; - let path = component_path(&self.subject); - let component = Component::from_file(&engine, &path) - .with_context(|| format!("failed to load {}", path.display()))?; + let bytes = self.compose()?; + let component = Component::new(&engine, &bytes) + .with_context(|| format!("failed to load {}", self.subject))?; let mut linker = Linker::new(&engine); wasmtime_wasi::p3::add_to_linker(&mut linker)?; @@ -744,6 +902,7 @@ impl Harness { add_handler_to_linker(&mut linker)?; upstream_client::add_to_linker::<_, UpstreamClient>(&mut linker, |ctx| ctx)?; logging::add_to_linker::<_, HasSelf>(&mut linker, |ctx| ctx)?; + latch::add_to_linker(&mut linker)?; let recorder = Recorder::default(); let mut store = Store::new( @@ -757,6 +916,8 @@ impl Harness { }, table: ResourceTable::new(), recorder: recorder.clone(), + latch: self.host_latch.unwrap_or_else(HostLatch::defer), + config: self.config, }, ); let instance_pre = linker.instantiate_pre(&component)?; @@ -764,24 +925,28 @@ impl Harness { .instantiate_async(&mut store) .await .with_context(|| format!("failed to instantiate {}", self.subject))?; + // an interface is only available when its types match the bindings, e.g. the client + // exported by a gate takes the host's requests, a trace component takes its own let exports = Exports { name: self.subject, - types: match types::GuestIndices::new(&instance_pre) { - Ok(indices) => Some(indices.load(&mut store, &instance)?), - Err(_) => None, - }, - client: match client::GuestIndices::new(&instance_pre) { - Ok(indices) => Some(indices.load(&mut store, &instance)?), - Err(_) => None, - }, - handler: match handler::GuestIndices::new(&instance_pre) { - Ok(indices) => Some(indices.load(&mut store, &instance)?), - Err(_) => None, - }, - http_client: match http_client::GuestIndices::new(&instance_pre) { - Ok(indices) => Some(indices.load(&mut store, &instance)?), - Err(_) => None, - }, + types: types::GuestIndices::new(&instance_pre) + .ok() + .and_then(|indices| indices.load(&mut store, &instance).ok()), + client: client::GuestIndices::new(&instance_pre) + .ok() + .and_then(|indices| indices.load(&mut store, &instance).ok()), + handler: handler::GuestIndices::new(&instance_pre) + .ok() + .and_then(|indices| indices.load(&mut store, &instance).ok()), + http_client: http_client::GuestIndices::new(&instance_pre) + .ok() + .and_then(|indices| indices.load(&mut store, &instance).ok()), + gated_client: gated::client::GuestIndices::new(&instance_pre) + .ok() + .and_then(|indices| indices.load(&mut store, &instance).ok()), + gated_handler: gated::handler::GuestIndices::new(&instance_pre) + .ok() + .and_then(|indices| indices.load(&mut store, &instance).ok()), }; Ok(TestSubject { @@ -801,6 +966,8 @@ pub struct Exports { client: Option, handler: Option, http_client: Option, + gated_client: Option, + gated_handler: Option, } impl Exports { @@ -832,6 +999,22 @@ impl Exports { .unwrap_or_else(|| panic!("{} does not export componentized:http/client", self.name)) } + /// The exported `wasi:http/client` of a gate, which takes requests created by the host, see + /// [`host_request`]. + pub fn gated_client(&self) -> &gated::client::Guest { + self.gated_client + .as_ref() + .unwrap_or_else(|| panic!("{} does not export wasi:http/client", self.name)) + } + + /// The exported `wasi:http/handler` of a gate, which takes requests created by the host, see + /// [`host_request`]. + pub fn gated_handler(&self) -> &gated::handler::Guest { + self.gated_handler + .as_ref() + .unwrap_or_else(|| panic!("{} does not export wasi:http/handler", self.name)) + } + /// Whether the component exports `wasi:http/types`. pub fn exports_types(&self) -> bool { self.types.is_some() @@ -902,6 +1085,46 @@ impl TestSubject { &self.exports } + /// Send a request through the gate's `wasi:http/client`, the status of the response or the + /// error. + pub async fn send( + &mut self, + method: http::Method, + uri: &str, + ) -> Result> { + let uri = uri.to_string(); + self.run(async move |accessor, gate| { + let request = host_request(accessor, method, &uri)?; + Ok( + match gate.gated_client().call_send(accessor, request).await? { + Ok(response) => Ok(response_status(accessor, &response)?), + Err(err) => Err(err), + }, + ) + }) + .await + } + + /// Handle a request with the gate's `wasi:http/handler`, the status of the response or the + /// error. + pub async fn handle( + &mut self, + method: http::Method, + uri: &str, + ) -> Result> { + let uri = uri.to_string(); + self.run(async move |accessor, gate| { + let request = host_request(accessor, method, &uri)?; + Ok( + match gate.gated_handler().call_handle(accessor, request).await? { + Ok(response) => Ok(response_status(accessor, &response)?), + Err(err) => Err(err), + }, + ) + }) + .await + } + /// Run a test body against the test subject's exports. pub async fn run( &mut self, diff --git a/crates/trace/src/types.rs b/crates/trace/src/types.rs index 545d6af..988aa3b 100644 --- a/crates/trace/src/types.rs +++ b/crates/trace/src/types.rs @@ -382,28 +382,13 @@ impl Display for TraceRequest { impl Display for Method { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - types::Method::Get => f.write_str("get"), - types::Method::Head => f.write_str("head"), - types::Method::Post => f.write_str("post"), - types::Method::Put => f.write_str("put"), - types::Method::Delete => f.write_str("delete"), - types::Method::Connect => f.write_str("connect"), - types::Method::Options => f.write_str("options"), - types::Method::Trace => f.write_str("trace"), - types::Method::Patch => f.write_str("patch"), - types::Method::Other(method) => f.write_str(&method.to_ascii_lowercase()), - } + f.write_str(&http_utils::format_http_method!(types::Method, self)) } } impl Display for Scheme { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - types::Scheme::Http => f.write_str("http"), - types::Scheme::Https => f.write_str("https"), - types::Scheme::Other(scheme) => write!(f, "other<{scheme}>"), - } + f.write_str(&http_utils::format_http_scheme!(types::Scheme, self)) } } diff --git a/wit/deps/wasi-cli-0.3.0/package.wit b/wit/deps/wasi-cli-0.3.0/package.wit new file mode 100644 index 0000000..d0b02bb --- /dev/null +++ b/wit/deps/wasi-cli-0.3.0/package.wit @@ -0,0 +1,28 @@ +package wasi:cli@0.3.0; + +interface types { + enum error-code { + io, + illegal-byte-sequence, + pipe, + } +} + +interface stdout { + use types.{error-code}; + + write-via-stream: func(data: stream) -> future>; +} + +interface stderr { + use types.{error-code}; + + write-via-stream: func(data: stream) -> future>; +} + +interface stdin { + use types.{error-code}; + + read-via-stream: func() -> tuple, future>>; +} + diff --git a/wit/deps/wasi-clocks-0.3.0/package.wit b/wit/deps/wasi-clocks-0.3.0/package.wit new file mode 100644 index 0000000..871adf0 --- /dev/null +++ b/wit/deps/wasi-clocks-0.3.0/package.wit @@ -0,0 +1,43 @@ +package wasi:clocks@0.3.0; + +interface types { + type duration = u64; +} + +interface monotonic-clock { + use types.{duration}; + + type mark = u64; + + now: func() -> mark; + + get-resolution: func() -> duration; + + wait-until: async func(when: mark); + + wait-for: async func(how-long: duration); +} + +interface system-clock { + use types.{duration}; + + record instant { + seconds: s64, + nanoseconds: u32, + } + + now: func() -> instant; + + get-resolution: func() -> duration; +} + +interface timezone { + use system-clock.{instant}; + + iana-id: func() -> option; + + utc-offset: func(when: instant) -> option; + + to-debug-string: func() -> string; +} + diff --git a/wit/deps/wasi-config-0.2.0-rc.1/package.wit b/wit/deps/wasi-config-0.2.0-rc.1/package.wit new file mode 100644 index 0000000..d8950ee --- /dev/null +++ b/wit/deps/wasi-config-0.2.0-rc.1/package.wit @@ -0,0 +1,33 @@ +package wasi:config@0.2.0-rc.1; + +interface store { + /// An error type that encapsulates the different errors that can occur fetching configuration values. + variant error { + /// This indicates an error from an "upstream" config source. + /// As this could be almost _anything_ (such as Vault, Kubernetes ConfigMaps, KeyValue buckets, etc), + /// the error message is a string. + upstream(string), + /// This indicates an error from an I/O operation. + /// As this could be almost _anything_ (such as a file read, network connection, etc), + /// the error message is a string. + /// Depending on how this ends up being consumed, + /// we may consider moving this to use the `wasi:io/error` type instead. + /// For simplicity right now in supporting multiple implementations, it is being left as a string. + io(string), + } + + /// Gets a configuration value of type `string` associated with the `key`. + /// + /// The value is returned as an `option`. If the key is not found, + /// `Ok(none)` is returned. If an error occurs, an `Err(error)` is returned. + get: func(key: string) -> result, error>; + + /// Gets a list of configuration key-value pairs of type `string`. + /// + /// If an error occurs, an `Err(error)` is returned. + get-all: func() -> result>, error>; +} + +world imports { + import store; +} diff --git a/wit/deps/wasi-http-0.3.0/package.wit b/wit/deps/wasi-http-0.3.0/package.wit new file mode 100644 index 0000000..08458f7 --- /dev/null +++ b/wit/deps/wasi-http-0.3.0/package.wit @@ -0,0 +1,509 @@ +package wasi:http@0.3.0; + +/// This interface defines all of the types and methods for implementing HTTP +/// Requests and Responses, as well as their headers, trailers, and bodies. +@since(version = 0.3.0) +interface types { + use wasi:clocks/types@0.3.0.{duration}; + + /// This type corresponds to HTTP standard Methods. + @since(version = 0.3.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.3.0) + variant scheme { + HTTP, + HTTPS, + other(string), + } + + /// Defines the case payload type for `DNS-error` above: + @since(version = 0.3.0) + record DNS-error-payload { + rcode: option, + info-code: option, + } + + /// Defines the case payload type for `TLS-alert-received` above: + @since(version = 0.3.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.3.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.3.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.3.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, + /// This error indicates that the operation would exceed an + /// implementation-defined limit on field sizes. This may apply to + /// an individual `field-value`, a single `field-name` plus all its + /// values, or the total aggregate size of all fields. + size-exceeded, + /// This is a catch-all error for anything that doesn't fit cleanly into a + /// more specific case. Implementations can use this to extend the error + /// type without breaking existing code. 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. + other(option), + } + + /// This type enumerates the different kinds of errors that may occur when + /// setting fields of a `request-options` resource. + @since(version = 0.3.0) + variant request-options-error { + /// Indicates the specified field is not supported by this implementation. + not-supported, + /// Indicates that the operation on the `request-options` was not permitted + /// because it is immutable. + immutable, + /// This is a catch-all error for anything that doesn't fit cleanly into a + /// more specific case. Implementations can use this to extend the error + /// type without breaking existing code. 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. + other(option), + } + + /// 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.3.0) + type field-name = string; + + /// 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.3.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, + /// `request.headers`) might be be immutable. In an immutable fields, the + /// `set`, `append`, and `delete` operations will fail with + /// `header-error.immutable`. + /// + /// A `fields` resource should store `field-name`s and `field-value`s in their + /// original casing used to construct or mutate the `fields` resource. The `fields` + /// resource should use that original casing when serializing the fields for + /// transport or when returning them from a method. + /// + /// Implementations may impose limits on individual field values and on total + /// aggregate field section size. Operations that would exceed these limits + /// fail with `header-error.size-exceeded` + @since(version = 0.3.0) + resource fields { + /// Construct an empty HTTP Fields. + /// + /// The resulting `fields` is mutable. + 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. In a valid Fields, all names + /// and values are valid UTF-8 strings. However, values are not always + /// well-formed, so they are represented as a raw list of bytes. + /// + /// An error result will be returned if any header or value was + /// syntactically invalid, if a header was forbidden, or if the + /// entries would exceed an implementation size limit. + 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`, 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. + 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. + 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.size-exceeded` if the name or values would + /// exceed an implementation-defined size limit. + 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. + delete: func(name: field-name) -> result<_, header-error>; + /// Delete all values for a name. Does nothing if no values for the name + /// exist. + /// + /// Returns all values previously corresponding to the name, if any. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + get-and-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.size-exceeded` if the value would exceed + /// an implementation-defined size limit. + 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. + copy-all: func() -> list>; + /// Make a deep copy of the Fields. Equivalent in behavior to calling the + /// `fields` constructor on the return value of `copy-all`. The resulting + /// `fields` is mutable. + clone: func() -> fields; + } + + /// Headers is an alias for Fields. + @since(version = 0.3.0) + type headers = fields; + + /// Trailers is an alias for Fields. + @since(version = 0.3.0) + type trailers = fields; + + /// Represents an HTTP Request. + @since(version = 0.3.0) + resource request { + /// Construct a new `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. + /// + /// `contents` is the optional body content stream with `none` + /// representing a zero-length content stream. + /// Once it is closed, `trailers` future must resolve to a result. + /// If `trailers` resolves to an error, underlying connection + /// will be closed immediately. + /// + /// `options` is optional `request-options` resource to be used + /// if the request is sent over a network connection. + /// + /// It is possible to construct, or manipulate with the accessor functions + /// below, a `request` with an invalid combination of `scheme` + /// and `authority`, or `headers` which are not permitted to be sent. + /// It is the obligation of the `handler.handle` implementation + /// to reject invalid constructions of `request`. + /// + /// The returned future resolves to result of transmission of this request. + new: static func(headers: headers, contents: option>, trailers: future, error-code>>, options: option) -> tuple>>; + /// Get the Method for the Request. + get-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. + 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. + get-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. + 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. + get-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. + 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. + get-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. + set-authority: func(authority: option) -> result; + /// Get the `request-options` to be associated with this request + /// + /// The returned `request-options` resource is immutable: `set-*` operations + /// will fail if invoked. + /// + /// This `request-options` resource is a child: it must be dropped before + /// the parent `request` is dropped, or its ownership is transferred to + /// another component by e.g. `handler.handle`. + get-options: 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`. + get-headers: func() -> headers; + /// Get body of the Request. + /// + /// Stream returned by this method represents the contents of the body. + /// Once the stream is reported as closed, callers should await the returned + /// future to determine whether the body was received successfully. + /// The future will only resolve after the stream is reported as closed. + /// + /// This function takes a `res` future as a parameter, which can be used to + /// communicate an error in handling of the request. + /// + /// Note that function will move the `request`, but references to headers or + /// request options acquired from it previously will remain valid. + consume-body: static func(this: request, res: future>) -> tuple, future, error-code>>>; + } + + /// 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 an + /// asynchronous call. + @since(version = 0.3.0) + resource request-options { + /// Construct a default `request-options` value. + constructor(); + /// The timeout for the initial connect to the HTTP Server. + get-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 or that this + /// handle is immutable. + set-connect-timeout: func(duration: option) -> result<_, request-options-error>; + /// The timeout for receiving the first byte of the Response body. + get-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 or that + /// this handle is immutable. + set-first-byte-timeout: func(duration: option) -> result<_, request-options-error>; + /// The timeout for receiving subsequent chunks of bytes in the Response + /// body stream. + get-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 or that this handle is immutable. + set-between-bytes-timeout: func(duration: option) -> result<_, request-options-error>; + /// Make a deep copy of the `request-options`. + /// The resulting `request-options` is mutable. + clone: func() -> request-options; + } + + /// This type corresponds to the HTTP standard Status Code. + @since(version = 0.3.0) + type status-code = u16; + + /// Represents an HTTP Response. + @since(version = 0.3.0) + resource response { + /// Construct a new `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. + /// + /// `contents` is the optional body content stream with `none` + /// representing a zero-length content stream. + /// Once it is closed, `trailers` future must resolve to a result. + /// If `trailers` resolves to an error, underlying connection + /// will be closed immediately. + /// + /// The returned future resolves to result of transmission of this response. + new: static func(headers: headers, contents: option>, trailers: future, error-code>>) -> tuple>>; + /// Get the HTTP Status Code for the Response. + get-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. + set-status-code: func(status-code: status-code) -> result; + /// Get the headers associated with the Response. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + get-headers: func() -> headers; + /// Get body of the Response. + /// + /// Stream returned by this method represents the contents of the body. + /// Once the stream is reported as closed, callers should await the returned + /// future to determine whether the body was received successfully. + /// The future will only resolve after the stream is reported as closed. + /// + /// This function takes a `res` future as a parameter, which can be used to + /// communicate an error in handling of the response. + /// + /// Note that function will move the `response`, but references to headers + /// acquired from it previously will remain valid. + consume-body: static func(this: response, res: future>) -> tuple, future, error-code>>>; + } +} + +/// This interface defines a handler of HTTP Requests. +/// +/// In a `wasi:http/service` this interface is exported to respond to an +/// incoming HTTP Request with a Response. +/// +/// In `wasi:http/middleware` this interface is both exported and imported as +/// the "downstream" and "upstream" directions of the middleware chain. +@since(version = 0.3.0) +interface handler { + use types.{request, response, error-code}; + + /// This function may be called with either an incoming request read from the + /// network or a request synthesized or forwarded by another component. + handle: async func(request: request) -> result; +} + +/// This interface defines an HTTP client for sending "outgoing" requests. +/// +/// Most components are expected to import this interface to provide the +/// capability to send HTTP requests to arbitrary destinations on a network. +/// +/// The type signature of `client.send` is the same as `handler.handle`. This +/// duplication is currently necessary because some Component Model tooling +/// (including WIT itself) is unable to represent a component importing two +/// instances of the same interface. A `client.send` import may be linked +/// directly to a `handler.handle` export to bypass the network. +@since(version = 0.3.0) +interface client { + use types.{request, response, error-code}; + + /// This function may be used to either send an outgoing request over the + /// network or to forward it to another component. + send: async func(request: request) -> result; +} + +/// The `wasi:http/service` world captures a broad category of HTTP services +/// including web applications, API servers, and proxies. It may be `include`d +/// in more specific worlds such as `wasi:http/middleware`. +@since(version = 0.3.0) +world service { + import wasi:cli/types@0.3.0; + import wasi:cli/stdout@0.3.0; + import wasi:cli/stderr@0.3.0; + import wasi:cli/stdin@0.3.0; + import wasi:clocks/types@0.3.0; + import types; + import client; + import wasi:clocks/monotonic-clock@0.3.0; + import wasi:clocks/system-clock@0.3.0; + @unstable(feature = clocks-timezone) + import wasi:clocks/timezone@0.3.0; + import wasi:random/random@0.3.0; + import wasi:random/insecure@0.3.0; + import wasi:random/insecure-seed@0.3.0; + + export handler; +} +/// The `wasi:http/middleware` world captures HTTP services that forward HTTP +/// Requests to another handler. +/// +/// Components may implement this world to allow them to participate in handler +/// "chains" where a `request` flows through handlers on its way to some terminal +/// `service` and corresponding `response` flows in the opposite direction. +@since(version = 0.3.0) +world middleware { + import wasi:clocks/types@0.3.0; + import types; + import handler; + import wasi:cli/types@0.3.0; + import wasi:cli/stdout@0.3.0; + import wasi:cli/stderr@0.3.0; + import wasi:cli/stdin@0.3.0; + import client; + import wasi:clocks/monotonic-clock@0.3.0; + import wasi:clocks/system-clock@0.3.0; + @unstable(feature = clocks-timezone) + import wasi:clocks/timezone@0.3.0; + import wasi:random/random@0.3.0; + import wasi:random/insecure@0.3.0; + import wasi:random/insecure-seed@0.3.0; + + export handler; +} diff --git a/wit/deps/wasi-random-0.3.0/package.wit b/wit/deps/wasi-random-0.3.0/package.wit new file mode 100644 index 0000000..f6cfb81 --- /dev/null +++ b/wit/deps/wasi-random-0.3.0/package.wit @@ -0,0 +1,18 @@ +package wasi:random@0.3.0; + +interface random { + get-random-bytes: func(max-len: u64) -> list; + + get-random-u64: func() -> u64; +} + +interface insecure { + get-insecure-random-bytes: func(max-len: u64) -> list; + + get-insecure-random-u64: func() -> u64; +} + +interface insecure-seed { + get-insecure-seed: func() -> tuple; +} + diff --git a/wit/latch.wit b/wit/latch.wit new file mode 100644 index 0000000..9bbf835 --- /dev/null +++ b/wit/latch.wit @@ -0,0 +1,82 @@ +/// Access control for wasi:http. +/// +/// A gate consults a latch before each request it sends or handles. The latch either defers, +/// raising no objection, or denies the request. A latch never grants a request, a request +/// proceeds when the latch defers. Several latches can be aggregated into one, the request is +/// denied when any of them denies it. +/// +/// Deciding and acting on a decision are separate steps. `authorize` decides without side +/// effects, then the gate reports the final decision to `observe-decision`, where a latch updates +/// any state it keeps. A latch aggregated with others may not be asked to authorize a request +/// another latch already denied, but it always observes the final decision. +@since(version = 0.1.0-dev) +interface latch { + use wasi:http/types@0.3.0.{error-code as http-error-code, request}; + + @since(version = 0.1.0-dev) + record send-args { + request: borrow, + } + + @since(version = 0.1.0-dev) + record handle-args { + request: borrow, + } + + @since(version = 0.1.0-dev) + variant client-operation { + send(send-args), + } + + @since(version = 0.1.0-dev) + variant handler-operation { + handle(handle-args), + } + + @since(version = 0.1.0-dev) + variant operation { + client(client-operation), + handler(handler-operation), + } + + /// Whether the request is allowed to proceed. + @since(version = 0.1.0-dev) + variant decision { + deferred, + denied(http-error-code), + } + + /// The name of a latch component, identifying which latch reported an error. + @since(version = 0.1.0-dev) + type latch-name = string; + + /// A latch was unable to decide or observe. The gate fails the request. + @since(version = 0.1.0-dev) + variant error-code { + /// The latch's configuration is invalid, the cause is logged by the latch. + invalid-config(latch-name), + /// The latch was unable to act on the final decision. + observation-failed(latch-name), + /// Any other error. + other(option), + } + + /// Decide whether the request may proceed. + /// + /// Must not have side effects. When latches are aggregated, a latch is not asked about a + /// request another latch already denied, so state changed here could disagree with what + /// actually happened. Act on decisions in `observe-decision` instead. + @since(version = 0.1.0-dev) + authorize: func(operation: operation) -> result; + + /// Observe the final decision for a request, after `authorize` returned a decision. + /// + /// Called for both deferred and denied requests, including requests this latch was not asked + /// to authorize because an aggregated latch denied them first. The final decision is what the + /// gate enforces, so this is where a latch updates any state it keeps. Not called when + /// `authorize` returned an error. + /// + /// An error fails the request, since the latch could not act on the decision. + @since(version = 0.1.0-dev) + observe-decision: func(final-decision: decision, operation: operation) -> result<_, error-code>; +} diff --git a/wit/worlds.wit b/wit/worlds.wit index e5320cd..a0c743f 100644 --- a/wit/worlds.wit +++ b/wit/worlds.wit @@ -2,4 +2,10 @@ package componentized:http@0.1.0-dev; world imports { import client; + import latch; +} + +world http-latch { + import wasi:config/store@0.2.0-rc.1; + export latch; } diff --git a/wkg.lock b/wkg.lock index 2ad4aa8..fa8b507 100644 --- a/wkg.lock +++ b/wkg.lock @@ -1,4 +1,21 @@ # This file is automatically generated. # It is not intended for manual editing. version = 1 -packages = [] + +[[packages]] +name = "wasi:config" +registry = "wasi.dev" + +[[packages.versions]] +requirement = "=0.2.0-rc.1" +version = "0.2.0-rc.1" +digest = "sha256:1b7f1b0fd07bb4cede16c6a6ec8852815dfb924639a78735fc7bdffdc164485d" + +[[packages]] +name = "wasi:http" +registry = "wasi.dev" + +[[packages.versions]] +requirement = "=0.3.0" +version = "0.3.0" +digest = "sha256:92cd8f3730c00226dc15626a2e7b21834dd187fc221f09818720d228585bbbf7" From 27b777d3b09dbb6282a596a921ea3fc9449ff617 Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Sun, 4 Oct 2026 10:24:23 -0400 Subject: [PATCH 2/3] wit cleanup Signed-off-by: Scott Andrews --- Makefile | 2 +- components/latch-deny-random/src/lib.rs | 10 +- components/status-codes/wkg.lock | 9 - .../deps/wasi-config-0.2.0-rc.1/package.wit | 33 -- .../deps/wasi-logging-0.1.0-draft/package.wit | 36 -- components/wit/worlds.wit | 6 +- wit/deps/wasi-cli-0.3.0/package.wit | 28 - wit/deps/wasi-clocks-0.3.0/package.wit | 43 -- wit/deps/wasi-config-0.2.0-rc.1/package.wit | 33 -- wit/deps/wasi-http-0.3.0/package.wit | 509 ------------------ wit/deps/wasi-random-0.3.0/package.wit | 18 - wit/latch.wit | 16 + wit/worlds.wit | 5 - wkg.lock | 9 - 14 files changed, 19 insertions(+), 738 deletions(-) delete mode 100644 components/wit/deps/wasi-config-0.2.0-rc.1/package.wit delete mode 100644 components/wit/deps/wasi-logging-0.1.0-draft/package.wit delete mode 100644 wit/deps/wasi-cli-0.3.0/package.wit delete mode 100644 wit/deps/wasi-clocks-0.3.0/package.wit delete mode 100644 wit/deps/wasi-config-0.2.0-rc.1/package.wit delete mode 100644 wit/deps/wasi-http-0.3.0/package.wit delete mode 100644 wit/deps/wasi-random-0.3.0/package.wit diff --git a/Makefile b/Makefile index e6c8d61..a5af19e 100644 --- a/Makefile +++ b/Makefile @@ -146,7 +146,7 @@ wit: $(WIT_DEPS) define FETCH_WIT # a package overridden with a local path, e.g. `{ path = "../wit" }`, has its dependencies fetched first -$(call wit_deps,$1): $1/wkg.toml $(shell find $1/wit -type f -name "*.wit" -not -path "*/deps/*") $(foreach path,$(shell sed -n 's/.*path *= *"\(.*\)".*/\1/p' $1/wkg.toml),$(call relpath,$1/$(path))/deps) | $(call tool,wkg) +$(call wit_deps,$1): $1/wkg.toml $1/wkg.lock $(shell find $1/wit -type f -name "*.wit" -not -path "*/deps/*") $(foreach path,$(shell sed -n 's/.*path *= *"\(.*\)".*/\1/p' $1/wkg.toml),$(call relpath,$1/$(path))/deps) | $(call tool,wkg) $(if $(filter .,$1),,cd $1 && )wkg fetch endef diff --git a/components/latch-deny-random/src/lib.rs b/components/latch-deny-random/src/lib.rs index 1877379..9cd304c 100644 --- a/components/latch-deny-random/src/lib.rs +++ b/components/latch-deny-random/src/lib.rs @@ -5,14 +5,6 @@ const LATCH_NAME: &str = "latch-deny-random"; /// The fraction of requests denied when `probability` is not configured. const DEFAULT_PROBABILITY: f64 = 0.1; -mod random { - wit_bindgen::generate!({ - path: "../wit", - world: "insecure-random", - generate_all - }); -} - struct Config { /// The fraction of requests denied, from 0 to 1. probability: f64, @@ -26,7 +18,7 @@ impl Config { fn load() -> Result { let config = http_latch::load_config(LATCH_NAME, |config| { Config::parse(config, || { - random::wasi::random::insecure::get_insecure_random_u64() + http_latch::bindings::wasi::random::insecure::get_insecure_random_u64() }) })?; // the seed reproduces the decisions, e.g. a failure the random seed uncovered diff --git a/components/status-codes/wkg.lock b/components/status-codes/wkg.lock index fa8b507..1c6187c 100644 --- a/components/status-codes/wkg.lock +++ b/components/status-codes/wkg.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 1 -[[packages]] -name = "wasi:config" -registry = "wasi.dev" - -[[packages.versions]] -requirement = "=0.2.0-rc.1" -version = "0.2.0-rc.1" -digest = "sha256:1b7f1b0fd07bb4cede16c6a6ec8852815dfb924639a78735fc7bdffdc164485d" - [[packages]] name = "wasi:http" registry = "wasi.dev" diff --git a/components/wit/deps/wasi-config-0.2.0-rc.1/package.wit b/components/wit/deps/wasi-config-0.2.0-rc.1/package.wit deleted file mode 100644 index d8950ee..0000000 --- a/components/wit/deps/wasi-config-0.2.0-rc.1/package.wit +++ /dev/null @@ -1,33 +0,0 @@ -package wasi:config@0.2.0-rc.1; - -interface store { - /// An error type that encapsulates the different errors that can occur fetching configuration values. - variant error { - /// This indicates an error from an "upstream" config source. - /// As this could be almost _anything_ (such as Vault, Kubernetes ConfigMaps, KeyValue buckets, etc), - /// the error message is a string. - upstream(string), - /// This indicates an error from an I/O operation. - /// As this could be almost _anything_ (such as a file read, network connection, etc), - /// the error message is a string. - /// Depending on how this ends up being consumed, - /// we may consider moving this to use the `wasi:io/error` type instead. - /// For simplicity right now in supporting multiple implementations, it is being left as a string. - io(string), - } - - /// Gets a configuration value of type `string` associated with the `key`. - /// - /// The value is returned as an `option`. If the key is not found, - /// `Ok(none)` is returned. If an error occurs, an `Err(error)` is returned. - get: func(key: string) -> result, error>; - - /// Gets a list of configuration key-value pairs of type `string`. - /// - /// If an error occurs, an `Err(error)` is returned. - get-all: func() -> result>, error>; -} - -world imports { - import store; -} diff --git a/components/wit/deps/wasi-logging-0.1.0-draft/package.wit b/components/wit/deps/wasi-logging-0.1.0-draft/package.wit deleted file mode 100644 index 164cb5b..0000000 --- a/components/wit/deps/wasi-logging-0.1.0-draft/package.wit +++ /dev/null @@ -1,36 +0,0 @@ -package wasi:logging@0.1.0-draft; - -/// WASI Logging is a logging API intended to let users emit log messages with -/// simple priority levels and context values. -interface logging { - /// A log level, describing a kind of message. - enum level { - /// Describes messages about the values of variables and the flow of - /// control within a program. - trace, - /// Describes messages likely to be of interest to someone debugging a - /// program. - debug, - /// Describes messages likely to be of interest to someone monitoring a - /// program. - info, - /// Describes messages indicating hazardous situations. - warn, - /// Describes messages indicating serious errors. - error, - /// Describes messages indicating fatal errors. - critical, - } - - /// Emit a log message. - /// - /// A log message has a `level` describing what kind of message is being - /// sent, a context, which is an uninterpreted string meant to help - /// consumers group similar messages, and a string containing the message - /// text. - log: func(level: level, context: string, message: string); -} - -world imports { - import logging; -} diff --git a/components/wit/worlds.wit b/components/wit/worlds.wit index b051414..1429942 100644 --- a/components/wit/worlds.wit +++ b/components/wit/worlds.wit @@ -24,6 +24,7 @@ world gate-handler { world latch { import wasi:config/store@0.2.0-rc.1; import wasi:logging/logging@0.1.0-draft; + import wasi:random/insecure@0.3.0; import componentized:http/latch@0.1.0-dev; export componentized:http/latch@0.1.0-dev; } @@ -67,8 +68,3 @@ world trace { include trace-client; include trace-handler; } - -/// Insecure random numbers, e.g. to seed `latch-deny-random`. -world insecure-random { - import wasi:random/insecure@0.3.0; -} diff --git a/wit/deps/wasi-cli-0.3.0/package.wit b/wit/deps/wasi-cli-0.3.0/package.wit deleted file mode 100644 index d0b02bb..0000000 --- a/wit/deps/wasi-cli-0.3.0/package.wit +++ /dev/null @@ -1,28 +0,0 @@ -package wasi:cli@0.3.0; - -interface types { - enum error-code { - io, - illegal-byte-sequence, - pipe, - } -} - -interface stdout { - use types.{error-code}; - - write-via-stream: func(data: stream) -> future>; -} - -interface stderr { - use types.{error-code}; - - write-via-stream: func(data: stream) -> future>; -} - -interface stdin { - use types.{error-code}; - - read-via-stream: func() -> tuple, future>>; -} - diff --git a/wit/deps/wasi-clocks-0.3.0/package.wit b/wit/deps/wasi-clocks-0.3.0/package.wit deleted file mode 100644 index 871adf0..0000000 --- a/wit/deps/wasi-clocks-0.3.0/package.wit +++ /dev/null @@ -1,43 +0,0 @@ -package wasi:clocks@0.3.0; - -interface types { - type duration = u64; -} - -interface monotonic-clock { - use types.{duration}; - - type mark = u64; - - now: func() -> mark; - - get-resolution: func() -> duration; - - wait-until: async func(when: mark); - - wait-for: async func(how-long: duration); -} - -interface system-clock { - use types.{duration}; - - record instant { - seconds: s64, - nanoseconds: u32, - } - - now: func() -> instant; - - get-resolution: func() -> duration; -} - -interface timezone { - use system-clock.{instant}; - - iana-id: func() -> option; - - utc-offset: func(when: instant) -> option; - - to-debug-string: func() -> string; -} - diff --git a/wit/deps/wasi-config-0.2.0-rc.1/package.wit b/wit/deps/wasi-config-0.2.0-rc.1/package.wit deleted file mode 100644 index d8950ee..0000000 --- a/wit/deps/wasi-config-0.2.0-rc.1/package.wit +++ /dev/null @@ -1,33 +0,0 @@ -package wasi:config@0.2.0-rc.1; - -interface store { - /// An error type that encapsulates the different errors that can occur fetching configuration values. - variant error { - /// This indicates an error from an "upstream" config source. - /// As this could be almost _anything_ (such as Vault, Kubernetes ConfigMaps, KeyValue buckets, etc), - /// the error message is a string. - upstream(string), - /// This indicates an error from an I/O operation. - /// As this could be almost _anything_ (such as a file read, network connection, etc), - /// the error message is a string. - /// Depending on how this ends up being consumed, - /// we may consider moving this to use the `wasi:io/error` type instead. - /// For simplicity right now in supporting multiple implementations, it is being left as a string. - io(string), - } - - /// Gets a configuration value of type `string` associated with the `key`. - /// - /// The value is returned as an `option`. If the key is not found, - /// `Ok(none)` is returned. If an error occurs, an `Err(error)` is returned. - get: func(key: string) -> result, error>; - - /// Gets a list of configuration key-value pairs of type `string`. - /// - /// If an error occurs, an `Err(error)` is returned. - get-all: func() -> result>, error>; -} - -world imports { - import store; -} diff --git a/wit/deps/wasi-http-0.3.0/package.wit b/wit/deps/wasi-http-0.3.0/package.wit deleted file mode 100644 index 08458f7..0000000 --- a/wit/deps/wasi-http-0.3.0/package.wit +++ /dev/null @@ -1,509 +0,0 @@ -package wasi:http@0.3.0; - -/// This interface defines all of the types and methods for implementing HTTP -/// Requests and Responses, as well as their headers, trailers, and bodies. -@since(version = 0.3.0) -interface types { - use wasi:clocks/types@0.3.0.{duration}; - - /// This type corresponds to HTTP standard Methods. - @since(version = 0.3.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.3.0) - variant scheme { - HTTP, - HTTPS, - other(string), - } - - /// Defines the case payload type for `DNS-error` above: - @since(version = 0.3.0) - record DNS-error-payload { - rcode: option, - info-code: option, - } - - /// Defines the case payload type for `TLS-alert-received` above: - @since(version = 0.3.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.3.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.3.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.3.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, - /// This error indicates that the operation would exceed an - /// implementation-defined limit on field sizes. This may apply to - /// an individual `field-value`, a single `field-name` plus all its - /// values, or the total aggregate size of all fields. - size-exceeded, - /// This is a catch-all error for anything that doesn't fit cleanly into a - /// more specific case. Implementations can use this to extend the error - /// type without breaking existing code. 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. - other(option), - } - - /// This type enumerates the different kinds of errors that may occur when - /// setting fields of a `request-options` resource. - @since(version = 0.3.0) - variant request-options-error { - /// Indicates the specified field is not supported by this implementation. - not-supported, - /// Indicates that the operation on the `request-options` was not permitted - /// because it is immutable. - immutable, - /// This is a catch-all error for anything that doesn't fit cleanly into a - /// more specific case. Implementations can use this to extend the error - /// type without breaking existing code. 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. - other(option), - } - - /// 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.3.0) - type field-name = string; - - /// 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.3.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, - /// `request.headers`) might be be immutable. In an immutable fields, the - /// `set`, `append`, and `delete` operations will fail with - /// `header-error.immutable`. - /// - /// A `fields` resource should store `field-name`s and `field-value`s in their - /// original casing used to construct or mutate the `fields` resource. The `fields` - /// resource should use that original casing when serializing the fields for - /// transport or when returning them from a method. - /// - /// Implementations may impose limits on individual field values and on total - /// aggregate field section size. Operations that would exceed these limits - /// fail with `header-error.size-exceeded` - @since(version = 0.3.0) - resource fields { - /// Construct an empty HTTP Fields. - /// - /// The resulting `fields` is mutable. - 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. In a valid Fields, all names - /// and values are valid UTF-8 strings. However, values are not always - /// well-formed, so they are represented as a raw list of bytes. - /// - /// An error result will be returned if any header or value was - /// syntactically invalid, if a header was forbidden, or if the - /// entries would exceed an implementation size limit. - 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`, 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. - 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. - 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.size-exceeded` if the name or values would - /// exceed an implementation-defined size limit. - 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. - delete: func(name: field-name) -> result<_, header-error>; - /// Delete all values for a name. Does nothing if no values for the name - /// exist. - /// - /// Returns all values previously corresponding to the name, if any. - /// - /// Fails with `header-error.immutable` if the `fields` are immutable. - get-and-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.size-exceeded` if the value would exceed - /// an implementation-defined size limit. - 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. - copy-all: func() -> list>; - /// Make a deep copy of the Fields. Equivalent in behavior to calling the - /// `fields` constructor on the return value of `copy-all`. The resulting - /// `fields` is mutable. - clone: func() -> fields; - } - - /// Headers is an alias for Fields. - @since(version = 0.3.0) - type headers = fields; - - /// Trailers is an alias for Fields. - @since(version = 0.3.0) - type trailers = fields; - - /// Represents an HTTP Request. - @since(version = 0.3.0) - resource request { - /// Construct a new `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. - /// - /// `contents` is the optional body content stream with `none` - /// representing a zero-length content stream. - /// Once it is closed, `trailers` future must resolve to a result. - /// If `trailers` resolves to an error, underlying connection - /// will be closed immediately. - /// - /// `options` is optional `request-options` resource to be used - /// if the request is sent over a network connection. - /// - /// It is possible to construct, or manipulate with the accessor functions - /// below, a `request` with an invalid combination of `scheme` - /// and `authority`, or `headers` which are not permitted to be sent. - /// It is the obligation of the `handler.handle` implementation - /// to reject invalid constructions of `request`. - /// - /// The returned future resolves to result of transmission of this request. - new: static func(headers: headers, contents: option>, trailers: future, error-code>>, options: option) -> tuple>>; - /// Get the Method for the Request. - get-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. - 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. - get-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. - 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. - get-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. - 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. - get-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. - set-authority: func(authority: option) -> result; - /// Get the `request-options` to be associated with this request - /// - /// The returned `request-options` resource is immutable: `set-*` operations - /// will fail if invoked. - /// - /// This `request-options` resource is a child: it must be dropped before - /// the parent `request` is dropped, or its ownership is transferred to - /// another component by e.g. `handler.handle`. - get-options: 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`. - get-headers: func() -> headers; - /// Get body of the Request. - /// - /// Stream returned by this method represents the contents of the body. - /// Once the stream is reported as closed, callers should await the returned - /// future to determine whether the body was received successfully. - /// The future will only resolve after the stream is reported as closed. - /// - /// This function takes a `res` future as a parameter, which can be used to - /// communicate an error in handling of the request. - /// - /// Note that function will move the `request`, but references to headers or - /// request options acquired from it previously will remain valid. - consume-body: static func(this: request, res: future>) -> tuple, future, error-code>>>; - } - - /// 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 an - /// asynchronous call. - @since(version = 0.3.0) - resource request-options { - /// Construct a default `request-options` value. - constructor(); - /// The timeout for the initial connect to the HTTP Server. - get-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 or that this - /// handle is immutable. - set-connect-timeout: func(duration: option) -> result<_, request-options-error>; - /// The timeout for receiving the first byte of the Response body. - get-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 or that - /// this handle is immutable. - set-first-byte-timeout: func(duration: option) -> result<_, request-options-error>; - /// The timeout for receiving subsequent chunks of bytes in the Response - /// body stream. - get-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 or that this handle is immutable. - set-between-bytes-timeout: func(duration: option) -> result<_, request-options-error>; - /// Make a deep copy of the `request-options`. - /// The resulting `request-options` is mutable. - clone: func() -> request-options; - } - - /// This type corresponds to the HTTP standard Status Code. - @since(version = 0.3.0) - type status-code = u16; - - /// Represents an HTTP Response. - @since(version = 0.3.0) - resource response { - /// Construct a new `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. - /// - /// `contents` is the optional body content stream with `none` - /// representing a zero-length content stream. - /// Once it is closed, `trailers` future must resolve to a result. - /// If `trailers` resolves to an error, underlying connection - /// will be closed immediately. - /// - /// The returned future resolves to result of transmission of this response. - new: static func(headers: headers, contents: option>, trailers: future, error-code>>) -> tuple>>; - /// Get the HTTP Status Code for the Response. - get-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. - set-status-code: func(status-code: status-code) -> result; - /// Get the headers associated with the Response. - /// - /// The returned `headers` resource is immutable: `set`, `append`, and - /// `delete` operations will fail with `header-error.immutable`. - get-headers: func() -> headers; - /// Get body of the Response. - /// - /// Stream returned by this method represents the contents of the body. - /// Once the stream is reported as closed, callers should await the returned - /// future to determine whether the body was received successfully. - /// The future will only resolve after the stream is reported as closed. - /// - /// This function takes a `res` future as a parameter, which can be used to - /// communicate an error in handling of the response. - /// - /// Note that function will move the `response`, but references to headers - /// acquired from it previously will remain valid. - consume-body: static func(this: response, res: future>) -> tuple, future, error-code>>>; - } -} - -/// This interface defines a handler of HTTP Requests. -/// -/// In a `wasi:http/service` this interface is exported to respond to an -/// incoming HTTP Request with a Response. -/// -/// In `wasi:http/middleware` this interface is both exported and imported as -/// the "downstream" and "upstream" directions of the middleware chain. -@since(version = 0.3.0) -interface handler { - use types.{request, response, error-code}; - - /// This function may be called with either an incoming request read from the - /// network or a request synthesized or forwarded by another component. - handle: async func(request: request) -> result; -} - -/// This interface defines an HTTP client for sending "outgoing" requests. -/// -/// Most components are expected to import this interface to provide the -/// capability to send HTTP requests to arbitrary destinations on a network. -/// -/// The type signature of `client.send` is the same as `handler.handle`. This -/// duplication is currently necessary because some Component Model tooling -/// (including WIT itself) is unable to represent a component importing two -/// instances of the same interface. A `client.send` import may be linked -/// directly to a `handler.handle` export to bypass the network. -@since(version = 0.3.0) -interface client { - use types.{request, response, error-code}; - - /// This function may be used to either send an outgoing request over the - /// network or to forward it to another component. - send: async func(request: request) -> result; -} - -/// The `wasi:http/service` world captures a broad category of HTTP services -/// including web applications, API servers, and proxies. It may be `include`d -/// in more specific worlds such as `wasi:http/middleware`. -@since(version = 0.3.0) -world service { - import wasi:cli/types@0.3.0; - import wasi:cli/stdout@0.3.0; - import wasi:cli/stderr@0.3.0; - import wasi:cli/stdin@0.3.0; - import wasi:clocks/types@0.3.0; - import types; - import client; - import wasi:clocks/monotonic-clock@0.3.0; - import wasi:clocks/system-clock@0.3.0; - @unstable(feature = clocks-timezone) - import wasi:clocks/timezone@0.3.0; - import wasi:random/random@0.3.0; - import wasi:random/insecure@0.3.0; - import wasi:random/insecure-seed@0.3.0; - - export handler; -} -/// The `wasi:http/middleware` world captures HTTP services that forward HTTP -/// Requests to another handler. -/// -/// Components may implement this world to allow them to participate in handler -/// "chains" where a `request` flows through handlers on its way to some terminal -/// `service` and corresponding `response` flows in the opposite direction. -@since(version = 0.3.0) -world middleware { - import wasi:clocks/types@0.3.0; - import types; - import handler; - import wasi:cli/types@0.3.0; - import wasi:cli/stdout@0.3.0; - import wasi:cli/stderr@0.3.0; - import wasi:cli/stdin@0.3.0; - import client; - import wasi:clocks/monotonic-clock@0.3.0; - import wasi:clocks/system-clock@0.3.0; - @unstable(feature = clocks-timezone) - import wasi:clocks/timezone@0.3.0; - import wasi:random/random@0.3.0; - import wasi:random/insecure@0.3.0; - import wasi:random/insecure-seed@0.3.0; - - export handler; -} diff --git a/wit/deps/wasi-random-0.3.0/package.wit b/wit/deps/wasi-random-0.3.0/package.wit deleted file mode 100644 index f6cfb81..0000000 --- a/wit/deps/wasi-random-0.3.0/package.wit +++ /dev/null @@ -1,18 +0,0 @@ -package wasi:random@0.3.0; - -interface random { - get-random-bytes: func(max-len: u64) -> list; - - get-random-u64: func() -> u64; -} - -interface insecure { - get-insecure-random-bytes: func(max-len: u64) -> list; - - get-insecure-random-u64: func() -> u64; -} - -interface insecure-seed { - get-insecure-seed: func() -> tuple; -} - diff --git a/wit/latch.wit b/wit/latch.wit index 9bbf835..de9b482 100644 --- a/wit/latch.wit +++ b/wit/latch.wit @@ -13,36 +13,52 @@ interface latch { use wasi:http/types@0.3.0.{error-code as http-error-code, request}; + /// Arguments to wasi:http/client#send. @since(version = 0.1.0-dev) record send-args { + /// The request to send. A latch may read it, e.g. its method, scheme, authority, path or + /// headers, but the request is still sent by the gate, after the latch defers. request: borrow, } + /// Arguments to wasi:http/handler#handle. @since(version = 0.1.0-dev) record handle-args { + /// The request to handle. A latch may read it, e.g. its method, scheme, authority, path or + /// headers, but the request is still handled by the gate, after the latch defers. request: borrow, } + /// Operations of the wasi:http/client interface. @since(version = 0.1.0-dev) variant client-operation { + /// Send a request, wasi:http/client#send. send(send-args), } + /// Operations of the wasi:http/handler interface. @since(version = 0.1.0-dev) variant handler-operation { + /// Handle a request, wasi:http/handler#handle. handle(handle-args), } + /// Operations of the wasi:http package a gate asks a latch to authorize. @since(version = 0.1.0-dev) variant operation { + /// A request sent with wasi:http/client. client(client-operation), + /// A request handled with wasi:http/handler. handler(handler-operation), } /// Whether the request is allowed to proceed. @since(version = 0.1.0-dev) variant decision { + /// The latch raises no objection, the request proceeds unless another latch denies it. deferred, + /// The request must not proceed. The gate fails it with the error code, without sending + /// or handling it. denied(http-error-code), } diff --git a/wit/worlds.wit b/wit/worlds.wit index a0c743f..7ef5e6c 100644 --- a/wit/worlds.wit +++ b/wit/worlds.wit @@ -4,8 +4,3 @@ world imports { import client; import latch; } - -world http-latch { - import wasi:config/store@0.2.0-rc.1; - export latch; -} diff --git a/wkg.lock b/wkg.lock index fa8b507..1c6187c 100644 --- a/wkg.lock +++ b/wkg.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 1 -[[packages]] -name = "wasi:config" -registry = "wasi.dev" - -[[packages.versions]] -requirement = "=0.2.0-rc.1" -version = "0.2.0-rc.1" -digest = "sha256:1b7f1b0fd07bb4cede16c6a6ec8852815dfb924639a78735fc7bdffdc164485d" - [[packages]] name = "wasi:http" registry = "wasi.dev" From 5fd2719408d93cf50561d5ff0d0e18c4c865d2b5 Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Sun, 4 Oct 2026 10:31:24 -0400 Subject: [PATCH 3/3] drop unused import Signed-off-by: Scott Andrews --- components/wit/worlds.wit | 2 -- 1 file changed, 2 deletions(-) diff --git a/components/wit/worlds.wit b/components/wit/worlds.wit index 1429942..a5c9642 100644 --- a/components/wit/worlds.wit +++ b/components/wit/worlds.wit @@ -36,8 +36,6 @@ world latch-n { import latch2: componentized:http/latch@0.1.0-dev; import latch3: componentized:http/latch@0.1.0-dev; import latch4: componentized:http/latch@0.1.0-dev; - import wasi:http/types@0.3.0; - import wasi:logging/logging@0.1.0-draft; } world trace-componentized-client {