From 067d0fe1bfd32b57acf64d5e09a3f545e61e6bc7 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 20 Oct 2026 07:28:09 -0500 Subject: [PATCH 1/4] Adapt Trusted Server to EdgeZero RC431 --- .cargo/config.toml | 6 +- .dockerignore | 35 + .github/workflows/format.yml | 4 +- .github/workflows/test.yml | 10 +- .tool-versions | 7 +- AGENTS.md | 30 +- Cargo.lock | 961 +++++++++++++++--- Cargo.toml | 25 +- Dockerfile | 56 + crates/trusted-server-adapter-axum/Cargo.toml | 8 + crates/trusted-server-adapter-axum/src/app.rs | 198 ++-- crates/trusted-server-adapter-axum/src/lib.rs | 13 +- .../trusted-server-adapter-axum/src/main.rs | 44 +- .../src/middleware.rs | 77 +- .../src/outbound.rs | 292 ++++++ .../src/platform.rs | 918 +++-------------- .../src/startup.rs | 423 ++++++++ .../tests/fixtures/native/README.md | 83 ++ .../fixtures/native/nextjs/app/actions.js | 10 + .../native/nextjs/app/api/data/route.js | 3 + .../fixtures/native/nextjs/app/layout.jsx | 7 + .../tests/fixtures/native/nextjs/app/page.jsx | 13 + .../fixtures/native/nextjs/next.config.mjs | 6 + .../tests/fixtures/native/origin.rs | 148 +++ .../tests/fixtures/native/proxy.rs | 137 +++ .../tests/native_acceptance.rs | 735 ++++++++++++++ .../tests/native_http.rs | 612 +++++++++++ .../tests/routes.rs | 46 +- .../tests/startup.rs | 319 ++++++ .../src/app.rs | 22 +- .../src/middleware.rs | 65 +- .../src/platform.rs | 35 +- .../trusted-server-adapter-fastly/Cargo.toml | 1 + .../trusted-server-adapter-fastly/src/app.rs | 64 +- .../src/compat.rs | 75 +- .../src/delivery_tests.rs | 245 +++++ .../trusted-server-adapter-fastly/src/main.rs | 315 +++--- .../src/middleware.rs | 45 +- .../src/platform.rs | 12 +- crates/trusted-server-adapter-spin/Cargo.toml | 1 + crates/trusted-server-adapter-spin/README.md | 82 +- crates/trusted-server-adapter-spin/spin.toml | 5 +- crates/trusted-server-adapter-spin/src/app.rs | 490 +++------ crates/trusted-server-adapter-spin/src/lib.rs | 16 +- .../src/middleware.rs | 133 ++- .../src/platform.rs | 262 +++-- .../src/startup.rs | 421 ++++++++ .../src/startup/loading/tests.rs | 541 ++++++++++ .../tests/routes.rs | 7 +- .../src/auction/endpoints.rs | 10 +- .../src/auction/provider.rs | 10 +- crates/trusted-server-core/src/auth.rs | 73 +- crates/trusted-server-core/src/cache_purge.rs | 3 +- .../trusted-server-core/src/config_payload.rs | 154 ++- .../trusted-server-core/src/ec/batch_sync.rs | 11 +- crates/trusted-server-core/src/http_util.rs | 15 +- .../src/integrations/google_tag_manager.rs | 69 +- .../src/integrations/mod.rs | 112 +- .../src/integrations/sourcepoint.rs | 8 +- .../trusted-server-core/src/platform/http.rs | 38 + crates/trusted-server-core/src/proxy.rs | 90 +- crates/trusted-server-core/src/publisher.rs | 150 ++- .../src/request_signing/endpoints.rs | 6 +- .../src/secret_resolution.rs | 149 +-- crates/trusted-server-core/src/settings.rs | 16 +- .../Cargo.toml | 1 + .../tests/environments/axum.rs | 140 +-- .../tests/parity.rs | 49 +- docs/guide/native-rc-demo.md | 230 +++++ ...10-20-edgezero-431-compatibility-design.md | 228 +++++ edgezero.toml | 4 +- rust-toolchain.toml | 4 +- scripts/smoke-axum.sh | 127 ++- scripts/smoke-spin.sh | 39 +- scripts/test-native-container.mjs | 380 +++++++ scripts/test-native-providers.mjs | 638 ++++++++++++ scripts/tests/test-native-providers.test.mjs | 121 +++ 77 files changed, 8667 insertions(+), 2271 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 crates/trusted-server-adapter-axum/src/outbound.rs create mode 100644 crates/trusted-server-adapter-axum/src/startup.rs create mode 100644 crates/trusted-server-adapter-axum/tests/fixtures/native/README.md create mode 100644 crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/actions.js create mode 100644 crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/api/data/route.js create mode 100644 crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/layout.jsx create mode 100644 crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/page.jsx create mode 100644 crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/next.config.mjs create mode 100644 crates/trusted-server-adapter-axum/tests/fixtures/native/origin.rs create mode 100644 crates/trusted-server-adapter-axum/tests/fixtures/native/proxy.rs create mode 100644 crates/trusted-server-adapter-axum/tests/native_acceptance.rs create mode 100644 crates/trusted-server-adapter-axum/tests/native_http.rs create mode 100644 crates/trusted-server-adapter-axum/tests/startup.rs create mode 100644 crates/trusted-server-adapter-fastly/src/delivery_tests.rs create mode 100644 crates/trusted-server-adapter-spin/src/startup.rs create mode 100644 crates/trusted-server-adapter-spin/src/startup/loading/tests.rs create mode 100644 docs/guide/native-rc-demo.md create mode 100644 docs/superpowers/specs/2026-10-20-edgezero-431-compatibility-design.md create mode 100644 scripts/test-native-container.mjs create mode 100644 scripts/test-native-providers.mjs create mode 100644 scripts/tests/test-native-providers.test.mjs diff --git a/.cargo/config.toml b/.cargo/config.toml index aea0df2d59..e32fc54737 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -2,7 +2,7 @@ # trusted-server-adapter-fastly → wasm32-wasip1 (Fastly Compute) # trusted-server-adapter-axum → native (dev server) # trusted-server-adapter-cloudflare → wasm32-unknown-unknown (Cloudflare Workers) -# trusted-server-adapter-spin → wasm32-wasip1 (Fermyon Spin) +# trusted-server-adapter-spin → wasm32-wasip2 (Fermyon Spin) # trusted-server-cli → native (operator CLI) # # All adapters are workspace members so `-p` resolves each. @@ -54,9 +54,9 @@ clippy-cloudflare-wasm = "clippy -p trusted-server-adapter-cloudflare --target w test-cloudflare = "test -p trusted-server-adapter-cloudflare" # --- Spin adapter (native host tests + wasm32-wasip1 target) --- -check-spin = "check -p trusted-server-adapter-spin --target wasm32-wasip1 --features spin" +check-spin = "check -p trusted-server-adapter-spin --target wasm32-wasip2 --features spin" clippy-spin-native = "clippy -p trusted-server-adapter-spin --all-targets -- -D warnings" -clippy-spin-wasm = "clippy -p trusted-server-adapter-spin --target wasm32-wasip1 --features spin --lib -- -D warnings" +clippy-spin-wasm = "clippy -p trusted-server-adapter-spin --target wasm32-wasip2 --features spin --lib -- -D warnings" test-spin = "test -p trusted-server-adapter-spin" # --- ts operator CLI (native host; install uses the current host platform) --- diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..407d2ec25b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,35 @@ +# Allow only build inputs, never the entire operator checkout. +** +!Cargo.toml +!Cargo.lock +!.tool-versions +!rust-toolchain.toml +!edgezero.toml +!.cargo/ +!.cargo/config.toml +!crates/ +!crates/** +# Exclude operator TOMLs even if placed below a crate. +**/*.toml +!**/Cargo.toml +!rust-toolchain.toml +!edgezero.toml +!.cargo/config.toml +!trusted-server.example.toml +**/.git +**/target +**/node_modules +**/dist +**/.env +**/.env.* +**/*.pem +**/*.key +**/*.p12 +**/*.pfx +**/keys +**/secrets +**/.aws +**/.npmrc +**/.cargo/credentials* +**/.ts-tmp +**/.tsjs-npm-ci.lock diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index f458572eb7..bb6e5ffcb1 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -60,7 +60,7 @@ jobs: - name: Set up rust toolchain uses: ./.github/actions/setup-rust with: - target: wasm32-wasip1,wasm32-unknown-unknown + target: wasm32-wasip1,wasm32-wasip2,wasm32-unknown-unknown components: "clippy, rustfmt" cache-key: cargo-clippy @@ -85,7 +85,7 @@ jobs: - name: Run cargo clippy (Spin — native) run: cargo clippy-spin-native - - name: Run cargo clippy (Spin — wasm32-wasip1) + - name: Run cargo clippy (Spin — wasm32-wasip2) run: cargo clippy-spin-wasm - name: Run host-target CLI clippy diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b27173dd66..8eedc8fa60 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -135,21 +135,21 @@ jobs: run: cargo test-cloudflare test-spin: - name: cargo check/build/test (spin native + wasm32-wasip1) + name: cargo check/build/test (spin native + wasm32-wasip2) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Rust toolchain (native + wasm32-wasip1) + - name: Set up Rust toolchain (native + wasm32-wasip2) uses: ./.github/actions/setup-rust with: - target: wasm32-wasip1 + target: wasm32-wasip2 cache-key: cargo-spin - name: Check Spin adapter (native host) run: cargo check -p trusted-server-adapter-spin - - name: Check Spin adapter (wasm32-wasip1) + - name: Check Spin adapter (wasm32-wasip2) run: cargo check-spin - name: Build Spin adapter release WASM @@ -162,7 +162,7 @@ jobs: TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: integration-test-proxy-secret TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret-padded-32 TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" - run: cargo build --package trusted-server-adapter-spin --target wasm32-wasip1 --features spin --release + run: cargo build --package trusted-server-adapter-spin --target wasm32-wasip2 --features spin --release - name: Run Spin adapter tests (native host) run: cargo test-spin diff --git a/.tool-versions b/.tool-versions index 1b29972c26..c2a1f4e061 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,7 +1,8 @@ fastly 15.1.0 -rust 1.95.0 +rust 1.98.1 nodejs 24.12.0 -viceroy 0.17.0 -wasmtime 44.0.1 +viceroy 0.21.0 +wasmtime 48.0.2 +spin-framework 4.1.0 awscli 2.36.45 terraform 1.16.2 diff --git a/AGENTS.md b/AGENTS.md index f5795a6a36..b8295859a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,16 +31,17 @@ Supporting files: `edgezero.toml`, `fastly.toml`, ## Toolchain -| Tool | Version / Target | -| ----------- | ---------------------------------------- | -| Rust | 1.95.0 (pinned in `rust-toolchain.toml`) | -| WASM target | `wasm32-wasip1` | -| Node | 24.12.0 (from `.tool-versions`) | -| Fastly CLI | 15.1.0 (from `.tool-versions`) | -| Viceroy | 0.17.0 (from `.tool-versions`) | -| Wasmtime | 44.0.1 (from `.tool-versions`) | -| AWS CLI | 2.36.45 (from `.tool-versions`) | -| Terraform | 1.16.2 (from `.tool-versions`) | +| Tool | Version / Target | +| ----------- | -------------------------------------------- | +| Rust | 1.98.1 (pinned in `rust-toolchain.toml`) | +| WASM target | Fastly `wasm32-wasip1`; Spin `wasm32-wasip2` | +| Node | 24.12.0 (from `.tool-versions`) | +| Fastly CLI | 15.1.0 (from `.tool-versions`) | +| Viceroy | 0.21.0 (from `.tool-versions`) | +| Wasmtime | 48.0.2 (from `.tool-versions`) | +| Spin | 4.1.0 (from `.tool-versions`) | +| AWS CLI | 2.36.45 (from `.tool-versions`) | +| Terraform | 1.16.2 (from `.tool-versions`) | --- @@ -62,7 +63,8 @@ fastly compute serve EDGEZERO_MANIFEST="/edgezero.toml" \ ts deploy --adapter fastly --service-id --application-release "" -# Run Axum dev server (native — no Viceroy) +# Run native TS with prepared config/secrets, not legacy TRUSTED_SERVER_* inputs. +# See docs/guide/native-rc-demo.md for local files, secret variables and providers. cargo run -p trusted-server-adapter-axum # Test Axum adapter only @@ -87,7 +89,7 @@ cargo check-spin cargo test-spin # Production-style Spin WASM artifact used by crates/trusted-server-adapter-spin/spin.toml -cargo build --package trusted-server-adapter-spin --target wasm32-wasip1 --features spin --release +cargo build --package trusted-server-adapter-spin --target wasm32-wasip2 --features spin --release # Optional local Spin runtime smoke, if the Spin CLI is installed spin up --from crates/trusted-server-adapter-spin @@ -158,7 +160,7 @@ cd crates/trusted-server-js/lib && node build-all.mjs ### Install prerequisites ```bash -cargo install viceroy --version 0.17.0 --locked --force +cargo install viceroy --version 0.21.0 --locked --force ``` --- @@ -345,7 +347,7 @@ IntegrationRegistration::builder(ID) | `fastly.toml` | Fastly service configuration and build settings | | `trusted-server.example.toml` | Source-controlled Trusted Server app-config template | | `trusted-server.toml` | Operator-owned app config; gitignored; `ts config push` publishes it as an EdgeZero blob envelope | -| `rust-toolchain.toml` | Pins Rust version to 1.95.0 | +| `rust-toolchain.toml` | Pins Rust version to 1.98.1 | | `.env.dev` | Local development environment variables | --- diff --git a/Cargo.lock b/Cargo.lock index 00dff9fc05..a0ae54d44c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,22 +14,10 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -218,9 +206,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -300,6 +288,49 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-config" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8d7b388a9fc3a6db15a5ec778c38b354eff1364882c94d08e0252f7a47dcaa4" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema 0.2.1", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http", + "sha1", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + [[package]] name = "aws-lc-rs" version = "1.17.1" @@ -323,6 +354,364 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "aws-runtime" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8a9911551b4ea6ca13805ef52ed96f7d2bbb43cc3b4a14cb0776a71f33cfaa" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http", + "http-body", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-secretsmanager" +version = "1.120.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832dc9d5dbd19c0ac6b24e21ef9f3e553695d0f3928de7134567407b3b7a447d" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema 0.2.1", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.114.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12be2f9c8eef7f5fc919c96d538e629698469a02b4cb75408b26b1bd984ebe79" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema 0.2.1", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.116.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7645db8724ea3b82fdccfb67e1b0f637c9d8ab0e7ef29d84884d8d8d73f805d" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema 0.2.1", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.119.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e490aa904849b38e770922faac779dd245f5cf8be81f19065acf18d276e4ad" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema 0.2.1", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2312577f088c9fbf4206dfdb884cf1de9407b43e1a923cbed5237775116fc24b" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac 0.13.0", + "http", + "percent-encoding", + "sha2 0.11.1", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de681a9df7ff79823b759ee17061076ec049cd61a8fa28ece4293d89c9f6964e" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.64.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8960956e0ee0ca3f3161e62913a61bc65a7ef638ca3ef1d4c7159f8443e398" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeca69f65362f7375891008ea525fd440ad6db1836458ac188c87b126804114e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2", + "http", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower 0.5.3", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.63.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3385d469edbe8b60cc72002784652b5efca39178192aa9cc4b44c9875c6bdc18" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema 0.2.1", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.62.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d1d71f6562be974caa85442ecd90194c40fdb5df045f182a6c2e872ce95056" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema 0.2.1", + "aws-smithy-types", + "aws-smithy-xml", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cef0c41e8ca40f74f68ed8ee4d1ddbde12bb847b95ef9b3c025681ddfae58a" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema 1.0.0", + "aws-smithy-types", + "bytes", + "fastrand", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06788004dea79018413f381891da6dcca23196acd79c4d51b767fbdacc76eae4" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8f395d93304280b64b7632fea798d177e74897fe7f063416ce627cd6fa24829" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http", +] + +[[package]] +name = "aws-smithy-schema" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7a3b01a2a1b453176457b260b0ed55d33de61d35d21b74022c51ec62a42895" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http", +] + +[[package]] +name = "aws-smithy-types" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55752025c39c0e094962fd73f1c57014b191f678612e517f219468e575af9822" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http", + "http-body", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.62.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b932c8d6dc127fc980eecd78f8694ae9b9551b69a93a7def2a199c1c0033daf" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema 0.2.1", + "aws-smithy-types", + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema 0.2.1", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.8.9" @@ -381,12 +770,28 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -435,6 +840,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bollard" version = "0.19.4" @@ -525,9 +939,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.3" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -573,13 +987,23 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "cast" version = "0.3.0" @@ -757,7 +1181,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", "zeroize", ] @@ -820,6 +1244,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -889,6 +1319,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -1068,6 +1504,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.35.0" @@ -1114,6 +1559,15 @@ dependencies = [ "linktime-proc-macro", ] +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1268,7 +1722,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "zeroize", ] @@ -1366,14 +1820,26 @@ dependencies = [ [[package]] name = "digest" -version = "0.10.7" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.10.4", - "const-oid", - "crypto-common", - "subtle", + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -1498,11 +1964,13 @@ dependencies = [ [[package]] name = "edgezero-adapter" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.9#f7176742d46aa4d1a04ce4588b606afda388fd30" +source = "git+https://github.com/IABTechLab/edgezero.git?rev=61bfe3b931fd97f5300287e4a91e33f32aa0fbee#61bfe3b931fd97f5300287e4a91e33f32aa0fbee" dependencies = [ + "base16ct 1.0.0", + "edgezero-core", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.1", "toml", "walkdir", ] @@ -1510,21 +1978,31 @@ dependencies = [ [[package]] name = "edgezero-adapter-axum" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.9#f7176742d46aa4d1a04ce4588b606afda388fd30" +source = "git+https://github.com/IABTechLab/edgezero.git?rev=61bfe3b931fd97f5300287e4a91e33f32aa0fbee#61bfe3b931fd97f5300287e4a91e33f32aa0fbee" dependencies = [ "anyhow", + "async-stream", "async-trait", "axum", "bytes", "ctor", "edgezero-adapter", "edgezero-core", + "edgezero-store-aws", "futures", "futures-util", + "getrandom 0.4.2", "http", + "http-body", + "hyper", + "hyper-util", + "ipnet", + "libc", "log", + "mime", "redb", "reqwest 0.13.4", + "serde", "serde_json", "simple_logger", "thiserror 2.0.18", @@ -1538,9 +2016,10 @@ dependencies = [ [[package]] name = "edgezero-adapter-cloudflare" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.9#f7176742d46aa4d1a04ce4588b606afda388fd30" +source = "git+https://github.com/IABTechLab/edgezero.git?rev=61bfe3b931fd97f5300287e4a91e33f32aa0fbee#61bfe3b931fd97f5300287e4a91e33f32aa0fbee" dependencies = [ "anyhow", + "async-stream", "async-trait", "brotli", "bytes", @@ -1555,17 +2034,20 @@ dependencies = [ "tempfile", "toml_edit 0.25.12+spec-1.1.0", "walkdir", + "wasm-streams 0.6.0", + "web-sys", "worker", ] [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.9#f7176742d46aa4d1a04ce4588b606afda388fd30" +source = "git+https://github.com/IABTechLab/edgezero.git?rev=61bfe3b931fd97f5300287e4a91e33f32aa0fbee#61bfe3b931fd97f5300287e4a91e33f32aa0fbee" dependencies = [ "anyhow", "async-stream", "async-trait", + "base16ct 1.0.0", "brotli", "bytes", "chrono", @@ -1573,6 +2055,8 @@ dependencies = [ "edgezero-adapter", "edgezero-core", "fastly", + "fastly-shared", + "fastly-sys", "fern", "flate2", "futures", @@ -1581,7 +2065,7 @@ dependencies = [ "log-fastly", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.1", "thiserror 2.0.18", "toml_edit 0.25.12+spec-1.1.0", "walkdir", @@ -1590,16 +2074,15 @@ dependencies = [ [[package]] name = "edgezero-adapter-spin" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.9#f7176742d46aa4d1a04ce4588b606afda388fd30" +source = "git+https://github.com/IABTechLab/edgezero.git?rev=61bfe3b931fd97f5300287e4a91e33f32aa0fbee#61bfe3b931fd97f5300287e4a91e33f32aa0fbee" dependencies = [ "anyhow", + "async-stream", "async-trait", - "brotli", "bytes", "ctor", "edgezero-adapter", "edgezero-core", - "flate2", "futures", "futures-util", "log", @@ -1617,7 +2100,7 @@ dependencies = [ [[package]] name = "edgezero-cli" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.9#f7176742d46aa4d1a04ce4588b606afda388fd30" +source = "git+https://github.com/IABTechLab/edgezero.git?rev=61bfe3b931fd97f5300287e4a91e33f32aa0fbee#61bfe3b931fd97f5300287e4a91e33f32aa0fbee" dependencies = [ "chrono", "clap", @@ -1632,7 +2115,7 @@ dependencies = [ "log", "serde", "serde_json", - "similar", + "similar 3.2.0", "simple_logger", "thiserror 2.0.18", "toml", @@ -1642,30 +2125,43 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.9#f7176742d46aa4d1a04ce4588b606afda388fd30" +source = "git+https://github.com/IABTechLab/edgezero.git?rev=61bfe3b931fd97f5300287e4a91e33f32aa0fbee#61bfe3b931fd97f5300287e4a91e33f32aa0fbee" dependencies = [ + "adler2", + "alloc-no-stdlib", + "alloc-stdlib", "anyhow", "async-compression", "async-stream", "async-trait", + "base16ct 1.0.0", + "brotli", + "brotli-decompressor", "bytes", + "compression-codecs", + "compression-core", + "crc32fast", "edgezero-macros", + "flate2", "futures", "futures-util", "http", "http-body", "log", "matchit 0.9.2", + "miniz_oxide", "ryu", "serde", "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha2 0.10.9", + "sha2 0.11.1", + "simd-adler32", "thiserror 2.0.18", "toml", "tower-service", "tracing", + "url", "validator", "web-time", ] @@ -1673,7 +2169,7 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.9#f7176742d46aa4d1a04ce4588b606afda388fd30" +source = "git+https://github.com/IABTechLab/edgezero.git?rev=61bfe3b931fd97f5300287e4a91e33f32aa0fbee#61bfe3b931fd97f5300287e4a91e33f32aa0fbee" dependencies = [ "log", "proc-macro2", @@ -1685,6 +2181,25 @@ dependencies = [ "validator", ] +[[package]] +name = "edgezero-store-aws" +version = "0.1.0" +source = "git+https://github.com/IABTechLab/edgezero.git?rev=61bfe3b931fd97f5300287e4a91e33f32aa0fbee#61bfe3b931fd97f5300287e4a91e33f32aa0fbee" +dependencies = [ + "async-trait", + "aws-config", + "aws-sdk-secretsmanager", + "bytes", + "edgezero-core", + "futures-util", + "percent-encoding", + "reqwest 0.13.4", + "serde", + "thiserror 2.0.18", + "tokio", + "url", +] + [[package]] name = "ego-tree" version = "0.10.0" @@ -1703,7 +2218,7 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", + "base16ct 0.2.0", "crypto-bigint", "digest 0.10.7", "ff", @@ -1797,8 +2312,7 @@ dependencies = [ [[package]] name = "esi" version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "026264c3022569f7bc5052f9452362537e923b34ecc9248b803437c2b89cdd81" +source = "git+https://github.com/stackpop/esi?rev=75bfbb396d4cb7792610f1287cbec61675f13f38#75bfbb396d4cb7792610f1287cbec61675f13f38" dependencies = [ "atoi", "base64", @@ -1850,9 +2364,9 @@ dependencies = [ [[package]] name = "fastly" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "531e4c3df48350d9f4fc95b4deaf87fd29820336b7926bb84bf460457c2a126b" +checksum = "3c5c07c0c67d0b5826d1cb2311e563a17860b50e2ae89e1c703d6400d904d1e2" dependencies = [ "anyhow", "bytes", @@ -1863,7 +2377,6 @@ dependencies = [ "fastly-sys", "http", "itertools 0.13.0", - "lazy_static", "mime", "serde", "serde_json", @@ -1878,9 +2391,9 @@ dependencies = [ [[package]] name = "fastly-macros" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2aef5f9690b04c8890f9a54ddb591b12b9779ec25ee0e572d207106e52e3d8" +checksum = "698025eab5612adab20df48963a5126650893014e8505af02a0af20939ee6a05" dependencies = [ "proc-macro2", "quote", @@ -1889,9 +2402,9 @@ dependencies = [ [[package]] name = "fastly-shared" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080ad138403159fd366d3e0b14bb49cb0c01dc18c25095bbbd1c85e3338f5413" +checksum = "191c2cf082f5d17f4ca0685cedaa0642ad4ade949fac8d1dbdd62ff756b13500" dependencies = [ "bitflags 1.3.2", "http", @@ -1899,9 +2412,9 @@ dependencies = [ [[package]] name = "fastly-sys" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de75ef193f6c29c43d667458bede648970715aedd5db2d42c2eba3ffa3ad738b" +checksum = "239c77706cf9110b423a89b6b03b8011729b144390d2a7f641d95ef66b2768b8" dependencies = [ "bitflags 1.3.2", "fastly-shared", @@ -1988,6 +2501,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -2171,14 +2690,16 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasip2", + "wasip3 0.4.0+wasi-0.3.0-rc-2026-01-06", ] [[package]] @@ -2977,7 +3498,7 @@ checksum = "0da1c46491b49458a446cc76f0085860f8164c2290742e0aa8c653ce67240a97" dependencies = [ "bstr", "fastrand", - "getrandom 0.4.3", + "getrandom 0.4.2", "unicode-normalization", ] @@ -3127,8 +3648,14 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "ahash", + "foldhash 0.1.5", ] [[package]] @@ -3139,7 +3666,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -3150,16 +3677,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", -] - -[[package]] -name = "hashlink" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" -dependencies = [ - "hashbrown 0.14.5", + "foldhash 0.2.0", ] [[package]] @@ -3208,6 +3726,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "home" version = "0.5.12" @@ -3279,11 +3806,20 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" -version = "1.10.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", @@ -3326,6 +3862,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -3849,9 +4386,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" dependencies = [ "cc", "pkg-config", @@ -3905,9 +4442,9 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "log-fastly" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51dae5def13a2d557fdb63862d642f8d4641ec3773c036bb14092697b6764013" +checksum = "433713b6191ad3e1d5248db9536f3e2d99a12d7ff998afd272a98d96e6dbc2ed" dependencies = [ "fastly", "log", @@ -3924,7 +4461,7 @@ dependencies = [ "cfg-if", "cssparser 0.36.0", "encoding_rs", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "memchr", "mime", @@ -4288,6 +4825,12 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "p256" version = "0.13.2" @@ -4761,7 +5304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck", - "itertools 0.10.5", + "itertools 0.13.0", "log", "multimap", "once_cell", @@ -4781,7 +5324,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.118", @@ -4794,7 +5337,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.118", @@ -4939,7 +5482,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20 0.10.1", - "getrandom 0.4.3", + "getrandom 0.4.2", "rand_core 0.10.1", ] @@ -5003,9 +5546,9 @@ dependencies = [ [[package]] name = "redb" -version = "4.1.0" +version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e925444704b5f17d32bf42f5b6e2df050bceebc3dcd6e71cc73dafe8092e839" +checksum = "39178e0a867f9e3eaa6292a4ab1dd3613f2519c5b564341e1e7b29b270736cc4" dependencies = [ "libc", ] @@ -5073,6 +5616,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.11" @@ -5151,12 +5700,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower 0.5.3", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.5.0", "web-sys", ] @@ -5205,7 +5756,7 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", + "const-oid 0.9.6", "digest 0.10.7", "num-bigint-dig", "num-integer", @@ -5231,14 +5782,13 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.32.1" +version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ "bitflags 2.13.0", "fallible-iterator", "fallible-streaming-iterator", - "hashlink 0.9.1", "libsqlite3-sys", "smallvec", ] @@ -5457,7 +6007,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", + "base16ct 0.2.0", "der", "generic-array", "subtle", @@ -5740,6 +6290,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "shell-words" version = "1.1.1" @@ -5800,6 +6361,15 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "similar" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f66ca1f7aca2474dc10c942eb22feffc897735f54cd1db90138c2fddb490987" +dependencies = [ + "bstr", +] + [[package]] name = "simple_logger" version = "5.2.0" @@ -5848,9 +6418,9 @@ checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] name = "spin-macro" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11e483b94d5bcfac493caf0427fa875063e3e8604d0466a4ab491ec200a42857" +checksum = "ac8a641da857d4e7cada31a853c40180eeb10485a0f2f0282d0a7e5a2a4105d4" dependencies = [ "proc-macro2", "quote", @@ -5859,9 +6429,9 @@ dependencies = [ [[package]] name = "spin-sdk" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fd2abac3eb2ee249c2241ab87f7b1287f36172c8cc1ea815c19c85e41ede44d" +checksum = "2b7a3d9a6805ccdb4d404e91ccf7198ac5e486b6199dc9387aeebaa368272112" dependencies = [ "anyhow", "bytes", @@ -5871,7 +6441,7 @@ dependencies = [ "http-body-util", "spin-macro", "thiserror 2.0.18", - "wasip3", + "wasip3 0.7.1+wasi-0.3.0", ] [[package]] @@ -6046,7 +6616,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -6503,8 +7073,10 @@ dependencies = [ "futures", "log", "reqwest 0.12.28", + "serde_json", "simple_logger", "temp-env", + "tempfile", "tokio", "tower 0.4.13", "trusted-server-core", @@ -6574,6 +7146,7 @@ dependencies = [ "futures", "http-body-util", "log", + "serde_json", "spin-sdk", "tokio", "trusted-server-core", @@ -6615,7 +7188,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", - "similar", + "similar 2.7.0", "temp-env", "tempfile", "time", @@ -6656,7 +7229,7 @@ dependencies = [ "getrandom 0.2.17", "glob", "hex", - "hmac", + "hmac 0.12.1", "http", "httpdate", "iab_gpp", @@ -6703,6 +7276,7 @@ dependencies = [ "reqwest 0.12.28", "scraper", "serde_json", + "tempfile", "testcontainers", "tokio", "toml", @@ -6836,7 +7410,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -6928,19 +7502,18 @@ version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] [[package]] name = "validator" -version = "0.20.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +checksum = "c3d68c6633c483df6780cc5277a417c7c2d1bceee2649d06c8ab6b0fd2dd3c81" dependencies = [ "idna", - "once_cell", "regex", "serde", "serde_derive", @@ -6975,6 +7548,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -6996,9 +7575,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -7020,9 +7599,18 @@ dependencies = [ [[package]] name = "wasip3" -version = "0.6.0+wasi-0.3.0-rc-2026-03-15" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasip3" +version = "0.7.1+wasi-0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed83456dd6a0b8581998c0365e4651fa2997e5093b49243b7f35391afaa7a3d9" +checksum = "75e9ed91ee5a995b6fe16c89d4511a8c12eb76967be20b57557618e02e781bcc" dependencies = [ "bytes", "http", @@ -7086,6 +7674,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser 0.244.0", +] + [[package]] name = "wasm-encoder" version = "0.247.0" @@ -7093,7 +7691,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30b6733b8b91d010a6ac5b0fb237dc46a19650bc4c67db66857e2e787d437204" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.247.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", ] [[package]] @@ -7104,8 +7714,21 @@ checksum = "665fe59e56cc9b419ca6fcca56673e3421d1a5011e3b65caf6b726fd9e041d10" dependencies = [ "anyhow", "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", + "wasm-encoder 0.247.0", + "wasmparser 0.247.0", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] @@ -7121,6 +7744,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + [[package]] name = "wasmparser" version = "0.247.0" @@ -7475,6 +8110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ "bitflags 2.13.0", + "wit-bindgen-rust-macro 0.51.0", ] [[package]] @@ -7485,7 +8121,18 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" dependencies = [ "bitflags 2.13.0", "futures", - "wit-bindgen-rust-macro", + "wit-bindgen-rust-macro 0.57.1", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.244.0", ] [[package]] @@ -7496,7 +8143,23 @@ checksum = "02dee27a2dc20d1008016c742ec9fc6ea498492994ba3750be7454cbc97ff04c" dependencies = [ "anyhow", "heck", - "wit-parser", + "wit-parser 0.247.0", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.118", + "wasm-metadata 0.244.0", + "wit-bindgen-core 0.51.0", + "wit-component 0.244.0", ] [[package]] @@ -7510,9 +8173,24 @@ dependencies = [ "indexmap 2.14.0", "prettyplease", "syn 2.0.118", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", + "wasm-metadata 0.247.0", + "wit-bindgen-core 0.57.1", + "wit-component 0.247.0", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.118", + "wit-bindgen-core 0.51.0", + "wit-bindgen-rust 0.51.0", ] [[package]] @@ -7526,8 +8204,27 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.118", - "wit-bindgen-core", - "wit-bindgen-rust", + "wit-bindgen-core 0.57.1", + "wit-bindgen-rust 0.57.1", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.244.0", + "wasm-metadata 0.244.0", + "wasmparser 0.244.0", + "wit-parser 0.244.0", ] [[package]] @@ -7543,10 +8240,28 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", + "wasm-encoder 0.247.0", + "wasm-metadata 0.247.0", + "wasmparser 0.247.0", + "wit-parser 0.247.0", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.244.0", ] [[package]] @@ -7565,7 +8280,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.247.0", ] [[package]] @@ -7593,7 +8308,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.6.0", "web-sys", "worker-macros", "worker-sys", @@ -7661,6 +8376,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "yaml-rust2" version = "0.11.0" @@ -7669,7 +8390,7 @@ checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", - "hashlink 0.11.1", + "hashlink", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2d56af4bb2..a83bd6e5a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,16 +56,16 @@ cssparser = "0.36" derive_more = { version = "2.0", features = ["display", "error"] } directories = "5" ed25519-dalek = { version = "2.2", features = ["rand_core"] } -edgezero-adapter-axum = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.9", default-features = false } -edgezero-adapter-cloudflare = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.9", default-features = false } -edgezero-adapter-fastly = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.9", default-features = false } -edgezero-adapter-spin = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.9", default-features = false } -edgezero-cli = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.9" } -edgezero-core = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.9", default-features = false } +edgezero-adapter-axum = { git = "https://github.com/IABTechLab/edgezero.git", rev = "61bfe3b931fd97f5300287e4a91e33f32aa0fbee", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/IABTechLab/edgezero.git", rev = "61bfe3b931fd97f5300287e4a91e33f32aa0fbee", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/IABTechLab/edgezero.git", rev = "61bfe3b931fd97f5300287e4a91e33f32aa0fbee", default-features = false } +edgezero-adapter-spin = { git = "https://github.com/IABTechLab/edgezero.git", rev = "61bfe3b931fd97f5300287e4a91e33f32aa0fbee", default-features = false } +edgezero-cli = { git = "https://github.com/IABTechLab/edgezero.git", rev = "61bfe3b931fd97f5300287e4a91e33f32aa0fbee" } +edgezero-core = { git = "https://github.com/IABTechLab/edgezero.git", rev = "61bfe3b931fd97f5300287e4a91e33f32aa0fbee", default-features = false } env_logger = "0.11" error-stack = "0.6" esi = "0.7.2" -fastly = "0.12" +fastly = "0.13" fern = "0.7.1" flate2 = "1.1" futures = "0.3" @@ -87,7 +87,7 @@ jose-jwk = "0.1.2" js-sys = "0.3" libc = "0.2" log = "0.4.29" -log-fastly = "0.12" +log-fastly = "0.13" lol_html = "2.7.2" matchit = "0.9" mime = "0.3" @@ -107,7 +107,7 @@ serde_yaml_ng = "0.10" sha2 = "0.10.9" similar = "2.7" simple_logger = "5" -spin-sdk = { version = "~6.0", default-features = false, features = ["http", "key-value", "variables"] } +spin-sdk = { version = "=7.0.0", default-features = false, features = ["http", "key-value", "variables"] } subtle = "2.6" temp-env = "0.3.6" tempfile = "3.24" @@ -125,13 +125,18 @@ trusted-server-openrtb = { path = "crates/trusted-server-openrtb" } url = "2.5.8" urlencoding = "2.1" uuid = { version = "1.18", features = ["v4"] } -validator = { version = "0.20", features = ["derive"] } +validator = { version = "0.21", features = ["derive"] } web-time = "1" webpki-roots = "1" which = "8" worker = { version = "0.8", default-features = false, features = ["http"] } x509-parser = "0.16" +# ESI 0.7.2 requires Fastly 0.12. This dependency-only patch aligns its SDK +# identity with EdgeZero. Remove when upstream releases the fix. Refs #1122. +[patch.crates-io] +esi = { git = "https://github.com/stackpop/esi", rev = "75bfbb396d4cb7792610f1287cbec61675f13f38" } + [workspace.lints.clippy] # Correctness - Force explicit error handling in production code unwrap_used = "deny" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..b9341cf2af --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +# syntax=docker/dockerfile:1 +# Adapted from EdgeZero 61bfe3b's hardened native recipe. Build at workspace root. +FROM node:24.12.0-bookworm-slim@sha256:7326fb2dbdce998edd72140946851be64ef4a643e8715e138ca467e8e9d92c99 AS node +FROM rust:1.98.1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e AS builder +COPY --from=node /usr/local/ /usr/local/ +WORKDIR /src +COPY . . +# AWS providers are default-off. Direct Cargo builds must forward them explicitly. +ARG NATIVE_FEATURES="" +# Optional existing build metadata input; .git never enters the build context. +ARG TRUSTED_SERVER__GIT_VERSION="" +RUN <<'SH' +set -eu +test "$(rustc --version | awk '{print $2}')" = 1.98.1 +test "$(node --version)" = v24.12.0 +test "$(awk '$1 == "rust" {print $2}' .tool-versions)" = 1.98.1 +grep -Eq '^channel = "1.98.1"$' rust-toolchain.toml +case "$NATIVE_FEATURES" in + ""|aws-appconfig-agent|aws-secrets-manager|aws-appconfig-agent,aws-secrets-manager|aws-secrets-manager,aws-appconfig-agent) ;; + *) echo 'Only the two declared native AWS provider features are supported' >&2; exit 1 ;; +esac +apt-get update +apt-get install -y --no-install-recommends cmake +rm -rf /var/lib/apt/lists/* +npm --prefix crates/trusted-server-js/lib ci --no-audit --no-fund +# No host target, node_modules or prebuilt dist enters this context. build.rs +# builds and validates new bundles in Cargo's private OUT_DIR before embedding. +host=$(rustc -vV | awk '$1 == "host:" {print $2}') +set -- +if test -n "$NATIVE_FEATURES"; then set -- --features "$NATIVE_FEATURES"; fi +cargo build -p trusted-server-adapter-axum --bin trusted-server-axum --release --locked --target "$host" "$@" +mkdir /out +cp "target/$host/release/trusted-server-axum" /out/app +strip --strip-debug /out/app +SH + +# Retain an auditable hash list of the exact context, not host working-tree state. +RUN find . -type d \( -name target -o -name node_modules \) -prune -o -type f ! -name .tsjs-npm-ci.lock -print0 \ + | sort -z | xargs -0 sha256sum > /out/build-inputs.sha256 + +FROM debian:bookworm-slim@sha256:7c7b2c966bc9ee8cedfeef67e0e279108992c77681fa595db4a9d65c06ccc587 AS runtime +RUN apt-get update \ + && apt-get upgrade -y --no-install-recommends \ + && apt-get install -y --no-install-recommends ca-certificates libgcc-s1 \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=builder /out/app /usr/local/bin/app +COPY --from=builder /out/build-inputs.sha256 /usr/local/share/ts-build-inputs.sha256 +ENV EDGEZERO__ADAPTER__HOST=0.0.0.0 \ + EDGEZERO__ADAPTER__PORT=8787 \ + EDGEZERO__ADAPTER__CONFIG_DIR=/config \ + EDGEZERO__ADAPTER__DATA_DIR=/data +USER 10001:10001 +EXPOSE 8787 +STOPSIGNAL SIGTERM +ENTRYPOINT ["/usr/local/bin/app"] diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml index 94ad02170c..7094358623 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -18,6 +18,11 @@ path = "src/lib.rs" name = "trusted-server-axum" path = "src/main.rs" +[features] +default = [] +aws-appconfig-agent = ["edgezero-adapter-axum/aws-appconfig-agent"] +aws-secrets-manager = ["edgezero-adapter-axum/aws-secrets-manager"] + [dependencies] async-trait = { workspace = true } edgezero-adapter-axum = { workspace = true, features = ["axum"] } @@ -31,6 +36,9 @@ tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "ti trusted-server-core = { workspace = true } [dev-dependencies] +serde_json = { workspace = true } +tempfile = { workspace = true } +reqwest = { workspace = true, features = ["blocking"] } trusted-server-core = { workspace = true, features = ["test-utils"] } axum = { workspace = true } base64 = { workspace = true } diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index caba714d68..a4371692b8 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -1,12 +1,14 @@ use core::future::Future; use std::sync::Arc; -use edgezero_core::app::Hooks; +use async_trait::async_trait; +use edgezero_core::app::{Hooks, StoreMetadata, StoresMetadata}; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{ HandlerFuture, HeaderValue, Method, Request, Response, StatusCode, header, }; +use edgezero_core::middleware::{Middleware, Next}; use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; @@ -14,6 +16,7 @@ use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan, }; use trusted_server_core::cache_policy::EdgeCacheHeader; +use trusted_server_core::config_payload::{DEFAULT_CONFIG_STORE_ID, DEFAULT_SECRET_STORE_ID}; use trusted_server_core::ec::EcContext; use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, @@ -33,14 +36,11 @@ use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; -use trusted_server_core::settings_data::{ - default_config_key, default_config_store_name, get_settings_from_config_store, -}; use trusted_server_core::platform::RuntimeServices; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, SanitizeRequestMiddleware}; -use crate::platform::{AxumPlatformConfigStore, AxumPlatformSecretStore, build_runtime_services}; +use crate::platform::{apply_native_ingress, build_runtime_services}; // --------------------------------------------------------------------------- // AppState @@ -48,38 +48,19 @@ use crate::platform::{AxumPlatformConfigStore, AxumPlatformSecretStore, build_ru /// Application state built once at startup and shared across all requests. pub struct AppState { - settings: Arc, + pub(crate) settings: Arc, orchestrator: Arc, registry: Arc, services: Option, } -/// Build the application state, loading settings and constructing all per-application components. -/// -/// # Errors -/// -/// Returns an error when settings, the auction orchestrator, or the integration -/// registry fail to initialise. -fn build_state() -> Result, Report> { - let store_name = default_config_store_name(); - let config_key = default_config_key(); - let settings = get_settings_from_config_store( - &AxumPlatformConfigStore, - &AxumPlatformSecretStore, - &store_name, - &config_key, - &trusted_server_core::settings_data::default_secret_store_name(), - )?; - build_state_with_settings(settings) -} - /// Build the application state from explicit settings. /// /// # Errors /// /// Returns an error when the auction orchestrator or the integration /// registry fail to initialise. -fn build_state_with_settings( +pub(crate) fn build_state_with_settings( settings: Settings, ) -> Result, Report> { build_state_with_services(settings, None) @@ -103,10 +84,20 @@ fn build_state_with_services( } impl AppState { - fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { - self.services - .clone() - .unwrap_or_else(|| build_runtime_services(ctx)) + pub(crate) fn from_request(ctx: &RequestContext) -> Result, EdgeError> { + ctx.extensions().get::>().cloned().ok_or_else(|| { + EdgeError::service_unavailable("Trusted Server application state is not initialized") + }) + } + + fn services_for_request(&self, ctx: &RequestContext) -> Result { + if let Some(services) = &self.services { + return Ok(services.clone()); + } + build_runtime_services(ctx).map_err(|error| { + log::error!("native runtime services unavailable: {error:?}"); + EdgeError::service_unavailable("initialized native runtime services are unavailable") + }) } } @@ -150,17 +141,31 @@ fn legacy_admin_alias_denied() -> Response { // Shared handler executor // --------------------------------------------------------------------------- -async fn execute_handler( - state: Arc, - ctx: RequestContext, - handler: F, -) -> Result +async fn execute_handler(ctx: RequestContext, handler: F) -> Result where F: FnOnce(Arc, RuntimeServices, Request) -> Fut, Fut: Future>>, { - let services = state.services_for_request(&ctx); - let mut req = ctx.into_request(); + let state = AppState::from_request(&ctx)?; + let services = state.services_for_request(&ctx)?; + if ctx.parts().method == Method::POST { + let limit = match ctx.parts().uri.path() { + "/verify-signature" => { + Some(trusted_server_core::request_signing::endpoints::VERIFY_MAX_BODY_BYTES) + } + "/auction" => Some(trusted_server_core::auction::endpoints::MAX_AUCTION_BODY_SIZE), + "/first-party/sign" => Some(trusted_server_core::proxy::SIGN_MAX_BODY_BYTES), + "/first-party/proxy-rebuild" => { + Some(trusted_server_core::proxy::REBUILD_MAX_BODY_BYTES) + } + _ => None, + }; + if let Some(limit) = limit { + ctx.body_bytes(limit).await?; + } + } + let mut req = ctx.into_request()?; + apply_native_ingress(&mut req)?; if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &state.settings, &mut req, @@ -273,16 +278,11 @@ async fn dispatch_fallback( .await } -fn fallback_handler( - state: Arc, -) -> impl Fn(RequestContext) -> HandlerFuture + Clone + Send + Sync + 'static { +fn fallback_handler() -> impl Fn(RequestContext) -> HandlerFuture + Clone + Send + Sync + 'static { move |ctx: RequestContext| { - let state = Arc::clone(&state); - Box::pin(execute_handler( - state, - ctx, - |state, services, req| async move { dispatch_fallback(&state, &services, req).await }, - )) + Box::pin(execute_handler(ctx, |state, services, req| async move { + dispatch_fallback(&state, &services, req).await + })) } } @@ -442,13 +442,10 @@ fn named_routes() -> [NamedRoute; 17] { } fn named_route_handler( - state: Arc, handler: NamedRouteHandler, ) -> impl Fn(RequestContext) -> HandlerFuture + Clone + Send + Sync + 'static { move |ctx: RequestContext| { - let state = Arc::clone(&state); Box::pin(execute_handler( - state, ctx, move |state, services, req| async move { match handler { @@ -475,9 +472,8 @@ fn named_route_handler( Ok(resp) } NamedRouteHandler::AdminNotSupported => { - // Config/secret-store writes are backed by read-only env vars on the - // Axum dev server. Returning 501 is clearer than failing on the first - // store write. + // Native snapshots are immutable. Returning 501 is clearer + // than failing on the first management write. let body = edgezero_core::body::Body::from( "Admin key management is not supported on the Axum dev server.\n\ Use the Fastly adapter (via Viceroy or deployed) to rotate or deactivate keys.\n", @@ -575,32 +571,16 @@ fn publisher_fallback_methods() -> [Method; 7] { ] } -/// Returns a [`RouterService`] that responds to every route with the startup error. -fn startup_error_router(e: &Report) -> RouterService { - let message = Arc::new(format!("{}\n", e.current_context().user_message())); - let status = e.current_context().status_code(); - - let make_handler = |msg: Arc| { - move |_ctx: RequestContext| { - let body = edgezero_core::body::Body::from((*msg).clone()); - let mut resp = Response::new(body); - *resp.status_mut() = status; - resp.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - async move { Ok::(resp) } - } - }; +/// Only explicit-settings test routers capture state. Production state is +/// inserted into the framework App by the prepared-store initializer. +struct ExplicitStateMiddleware(Arc); - let mut router = RouterService::builder().middleware(FinalizeResponseMiddleware::new( - Arc::new(Settings::default()), - )); - for method in publisher_fallback_methods() { - router = router.route("/", method.clone(), make_handler(Arc::clone(&message))); - router = router.route("/{*rest}", method, make_handler(Arc::clone(&message))); +#[async_trait(?Send)] +impl Middleware for ExplicitStateMiddleware { + async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { + ctx.extensions_mut().insert(Arc::clone(&self.0)); + next.run(ctx).await } - router.build() } // --------------------------------------------------------------------------- @@ -616,24 +596,34 @@ impl Hooks for TrustedServerApp { } fn routes() -> RouterService { - let state = match build_state() { - Ok(s) => s, - Err(ref e) => { - log::error!("failed to build application state: {:?}", e); - return startup_error_router(e); - } - }; + build_router(None) + } + + fn stores() -> StoresMetadata { + StoresMetadata { + config: Some(StoreMetadata { + default: DEFAULT_CONFIG_STORE_ID, + ids: &[DEFAULT_CONFIG_STORE_ID], + }), + secrets: Some(StoreMetadata { + default: DEFAULT_SECRET_STORE_ID, + ids: &[DEFAULT_SECRET_STORE_ID], + }), + kv: None, + } + } - build_router(&state) + fn owns_logging() -> bool { + true } } impl TrustedServerApp { /// Build the full application router from explicit settings. /// - /// Testing seam: integration tests use this to drive the router with - /// known-good settings instead of the baked `get_settings()` result, - /// whose embedded placeholder secrets fail validation by design. + /// Testing seam with unavailable network/store dependencies. Tests needing + /// those dependencies must use [`Self::routes_with_settings_and_services`]. + /// This does not load prepared stores or read legacy environment settings. /// /// # Errors /// @@ -642,8 +632,11 @@ impl TrustedServerApp { pub fn routes_with_settings( settings: Settings, ) -> Result> { - let state = build_state_with_settings(settings)?; - Ok(build_router(&state)) + let state = build_state_with_services( + settings, + Some(crate::startup::explicit_settings_services()), + )?; + Ok(build_router(Some(state))) } /// Build the full router with explicit settings and runtime services. @@ -667,21 +660,22 @@ impl TrustedServerApp { services: RuntimeServices, ) -> Result> { let state = build_state_with_services(settings, Some(services))?; - Ok(build_router(&state)) + Ok(build_router(Some(state))) } } -fn build_router(state: &Arc) -> RouterService { - let fallback = fallback_handler(Arc::clone(state)); - - let mut router = RouterService::builder() - // Outermost middleware: strips the configured trusted-client-IP - // headers before anything else sees the request. Must stay first — - // any middleware registered ahead of it would observe the - // shared-secret authentication header. - .middleware(SanitizeRequestMiddleware::new(Arc::clone(&state.settings))) - .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) - .middleware(AuthMiddleware::new(Arc::clone(&state.settings))); +fn build_router(state: Option>) -> RouterService { + let fallback = fallback_handler(); + let mut router = RouterService::builder(); + if let Some(state) = state { + router = router.middleware(ExplicitStateMiddleware(state)); + } + // The test-only injector observes no headers. Sanitation remains the first + // request-processing middleware so trust credentials cannot leak inward. + router = router + .middleware(SanitizeRequestMiddleware::from_request()) + .middleware(FinalizeResponseMiddleware::from_request()) + .middleware(AuthMiddleware::from_request()); router = router.route("/health", Method::GET, |_ctx: RequestContext| async { Ok::( @@ -698,7 +692,7 @@ fn build_router(state: &Arc) -> RouterService { router = router.route( route.path, method.clone(), - named_route_handler(Arc::clone(state), route.handler), + named_route_handler(route.handler), ); } for method in publisher_fallback_methods() { diff --git a/crates/trusted-server-adapter-axum/src/lib.rs b/crates/trusted-server-adapter-axum/src/lib.rs index 2f15e566d2..b42b6ec9c6 100644 --- a/crates/trusted-server-adapter-axum/src/lib.rs +++ b/crates/trusted-server-adapter-axum/src/lib.rs @@ -1,12 +1,15 @@ -//! Native Axum adapter for local Trusted Server development. +//! Native Axum adapter with strict prepared-store startup. //! -//! Runs a full Axum HTTP server on `localhost` as a drop-in dev alternative to -//! the Fastly Compute adapter (via Viceroy). All routes and middleware mirror -//! the Fastly adapter; store and geo primitives fall back to env vars and no-ops. +//! Runtime settings and immutable store snapshots are validated before the RC +//! listener becomes ready. Reader geography remains unavailable on native. /// Application routing and handler registration for the Axum dev server. pub mod app; /// Request middleware (auth, response finalisation). pub mod middleware; -/// Platform-trait implementations backed by env vars and `reqwest`. +/// Managed outbound HTTP compatibility bridge. +pub mod outbound; +/// Native platform services and request-scoped transport/ingress bridges. pub mod platform; +/// Prepared-store initialization and immutable native snapshots. +pub mod startup; diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index 66d592e0b7..bb705c01a3 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -1,7 +1,7 @@ -use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumDevServerConfig}; -use edgezero_core::app::Hooks as _; +use edgezero_adapter_axum::dev_server::run_production_app_with_initializer; use log::LevelFilter; use trusted_server_adapter_axum::app::TrustedServerApp; +use trusted_server_adapter_axum::startup::initialize_native; /// Environment variable that sets the maximum log level. const LOG_LEVEL_ENV: &str = "RUST_LOG"; @@ -26,20 +26,15 @@ fn main() { eprintln!("warning: logger init failed: {e}"); } - let config = match port_from_env() { - // When PORT is set, bind to a specific address so integration tests - // can allocate a fresh OS port each run and avoid TIME_WAIT flakiness. - Some(port) => AxumDevServerConfig { - addr: std::net::SocketAddr::from(([127, 0, 0, 1], port)), - enable_ctrl_c: true, - }, - // Normal development path: read bind address from axum.toml. - None => AxumDevServerConfig::default(), - }; - - log::info!("Listening on http://{}", config.addr); - let router = TrustedServerApp::routes(); - if let Err(err) = AxumDevServer::with_config(router, config).run() { + if let Err(err) = run_production_app_with_initializer::(|app, stores| { + Box::pin(async move { + initialize_native(app, stores).await.inspect_err(|error| { + // RC classifies callback errors without their details. This safe + // application diagnostic tells operators which input to fix. + log::error!("Trusted Server initialization failed: {error}"); + }) + }) + }) { log::error!("trusted-server-adapter-axum failed: {err}"); std::process::exit(1); } @@ -65,23 +60,6 @@ fn parse_log_level(configured: Option<&str>) -> Result { } } -/// Read a port number from the `PORT` environment variable. -/// -/// Returns `None` when the variable is unset. Exits non-zero if the value -/// is set but cannot be parsed — silently falling back to a different port -/// would surprise tooling that expects the server at the requested address. -#[allow(clippy::print_stderr)] -fn port_from_env() -> Option { - let raw = std::env::var("PORT").ok()?; - match raw.parse() { - Ok(port) => Some(port), - Err(e) => { - eprintln!("error: PORT env var '{raw}' is not a valid u16: {e}"); - std::process::exit(1); - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index f099bc15b2..a281777e21 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -5,11 +5,23 @@ use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; -use trusted_server_core::auth::enforce_basic_auth; +use trusted_server_core::auth::enforce_basic_auth_parts; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; -use trusted_server_core::http_util::sanitize_trusted_client_ip_headers; +use trusted_server_core::http_util::sanitize_trusted_client_ip_header_map; use trusted_server_core::settings::Settings; +use crate::app::AppState; + +fn request_settings( + explicit: &Option>, + ctx: &RequestContext, +) -> Result, EdgeError> { + match explicit { + Some(settings) => Ok(Arc::clone(settings)), + None => Ok(Arc::clone(&AppState::from_request(ctx)?.settings)), + } +} + // --------------------------------------------------------------------------- // SanitizeRequestMiddleware // --------------------------------------------------------------------------- @@ -24,23 +36,32 @@ use trusted_server_core::settings::Settings; /// a shared configuration cannot leak the secret into publisher or integration /// request handling. pub struct SanitizeRequestMiddleware { - settings: Arc, + settings: Option>, } impl SanitizeRequestMiddleware { - /// Creates a new [`SanitizeRequestMiddleware`] with the given settings. + /// Creates an explicit-settings middleware for tests. #[must_use] pub fn new(settings: Arc) -> Self { - Self { settings } + Self { + settings: Some(settings), + } + } + + /// Reads initialized typed application state from each request. + #[must_use] + pub fn from_request() -> Self { + Self { settings: None } } } #[async_trait(?Send)] impl Middleware for SanitizeRequestMiddleware { async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - sanitize_trusted_client_ip_headers( - ctx.request_mut(), - self.settings.trusted_client_ip.as_ref(), + let settings = request_settings(&self.settings, &ctx)?; + sanitize_trusted_client_ip_header_map( + ctx.headers_mut(), + settings.trusted_client_ip.as_ref(), ); next.run(ctx).await } @@ -61,22 +82,31 @@ impl Middleware for SanitizeRequestMiddleware { /// [`AuthMiddleware`] so that every outgoing response — including auth-rejected /// ones — carries a consistent set of headers. pub struct FinalizeResponseMiddleware { - settings: Arc, + settings: Option>, } impl FinalizeResponseMiddleware { - /// Creates a new [`FinalizeResponseMiddleware`] with the given settings. + /// Creates an explicit-settings middleware for tests. #[must_use] pub fn new(settings: Arc) -> Self { - Self { settings } + Self { + settings: Some(settings), + } + } + + /// Reads initialized typed application state from each request. + #[must_use] + pub fn from_request() -> Self { + Self { settings: None } } } #[async_trait(?Send)] impl Middleware for FinalizeResponseMiddleware { async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + let settings = request_settings(&self.settings, &ctx)?; let mut response = next.run(ctx).await?; - apply_finalize_headers(&self.settings, &mut response); + apply_finalize_headers(&settings, &mut response); Ok(response) } } @@ -87,26 +117,35 @@ impl Middleware for FinalizeResponseMiddleware { /// Inner middleware: enforces basic-auth before the handler runs. /// -/// - `Ok(Some(response))` from [`enforce_basic_auth`] → auth failed; return the +/// - `Ok(Some(response))` from [`enforce_basic_auth_parts`] → auth failed; return the /// challenge response (bubbles through [`FinalizeResponseMiddleware`] for header injection). /// - `Ok(None)` → no auth required or credentials accepted; continue the chain. /// - `Err(report)` → internal error; log and convert to a 500 HTTP response. pub struct AuthMiddleware { - settings: Arc, + settings: Option>, } impl AuthMiddleware { - /// Creates a new [`AuthMiddleware`] with the given settings. + /// Creates an explicit-settings middleware for tests. #[must_use] pub fn new(settings: Arc) -> Self { - Self { settings } + Self { + settings: Some(settings), + } + } + + /// Reads initialized typed application state from each request. + #[must_use] + pub fn from_request() -> Self { + Self { settings: None } } } #[async_trait(?Send)] impl Middleware for AuthMiddleware { async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - match enforce_basic_auth(&self.settings, ctx.request_mut()) { + let settings = request_settings(&self.settings, &ctx)?; + match enforce_basic_auth_parts(&settings, ctx.parts_mut()) { Ok(Some(response)) => return Ok(response), Ok(None) => {} Err(report) => { @@ -275,8 +314,8 @@ mod tests { let handler_observed = Arc::clone(&handler_observed); async move { *handler_observed.lock().expect("should lock observation") = Some(( - ctx.request().headers().contains_key("x-reader-ip"), - ctx.request().headers().contains_key("x-reader-ip-auth"), + ctx.headers().contains_key("x-reader-ip"), + ctx.headers().contains_key("x-reader-ip-auth"), )); Ok::(empty_response()) } diff --git a/crates/trusted-server-adapter-axum/src/outbound.rs b/crates/trusted-server-adapter-axum/src/outbound.rs new file mode 100644 index 0000000000..75163de2fb --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/outbound.rs @@ -0,0 +1,292 @@ +//! Compatibility bridge from TS HTTP operations to the request-scoped RC client. + +use std::future::Future; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::time::Duration; + +use async_trait::async_trait; +use edgezero_core::body::{Body, BodyContent}; +use edgezero_core::http::header; +use edgezero_core::outbound::{ + HttpClient, OutboundBatch, OutboundBatchTermination, OutboundCachePolicy, OutboundRequest, + OutboundResponse, +}; +use edgezero_core::time::{Deadline, MonotonicClock}; +use error_stack::{Report, ResultExt as _}; +use futures::StreamExt as _; +use futures::future::{FutureExt as _, LocalBoxFuture, select_all}; +use trusted_server_core::platform::{ + PlatformError, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, + PlatformResponse, PlatformSelectResult, +}; + +/// TS HTTP client backed exclusively by the RC client's transport and policies. +/// +/// RC owns decoding and updates representation headers to match its decoded body. +/// Core must not decode it again. Redirects and origin authorization remain in TS +/// core; the RC native transport does not automatically follow redirects. +/// Remove this bridge when TS adopts the RC outbound contract directly. +pub struct AxumPlatformHttpClient { + client: HttpClient, + clock: MonotonicClock, +} + +impl AxumPlatformHttpClient { + /// Build the bridge using the incoming request's client and application clock. + #[must_use] + pub fn new(client: HttpClient, clock: MonotonicClock) -> Self { + Self { client, clock } + } + + fn prepare( + &self, + request: PlatformHttpRequest, + ) -> Result<(OutboundRequest, String, u64, Deadline), Report> { + if request.image_optimizer.is_some() { + return Err(http_error("native Image Optimizer is unavailable")); + } + let timeout_deadline = self + .clock + .deadline_after(request.timeout.unwrap_or(Duration::from_secs(30))); + let deadline = request.deadline.map_or(timeout_deadline, |caller| { + Deadline::at_instant(caller.instant().min(timeout_deadline.instant())) + }); + // TS has already constructed its outbound head, including any configured + // publisher virtual-host override. RC from_request drops Host, so carry + // that explicit outbound value through RC's checked authority API. + let mut hosts = request.request.headers().get_all(header::HOST).iter(); + let host = hosts + .next() + .map(|value| value.to_str().map(str::to_owned)) + .transpose() + .change_context(PlatformError::HttpClient)?; + if hosts.next().is_some() { + return Err(http_error( + "native outbound request has multiple Host values", + )); + } + let target = request.request.uri().clone(); + let mut outbound = OutboundRequest::from_request(request.request, target) + .change_context(PlatformError::HttpClient)? + .deadline(deadline) + .max_request_body_bytes(request.max_request_body_bytes) + .max_encoded_response_bytes(request.max_response_body_bytes) + .max_decoded_response_bytes(request.max_response_body_bytes) + .max_response_bytes(request.max_response_body_bytes) + .max_response_header_bytes(64 * 1024) + .max_response_header_count(128); + if let Some(host) = host { + outbound = outbound + .host_authority_override(&host) + .change_context(PlatformError::HttpClient)?; + } + if request.cache_intent.is_bypass() { + outbound = outbound.cache_policy(OutboundCachePolicy::Bypass); + } + if request.stream_response { + outbound = outbound.stream_response(); + } + Ok(( + outbound, + request.backend_name, + request.max_request_body_bytes, + deadline, + )) + } +} + +fn http_error(message: impl Into) -> Report { + Report::new(PlatformError::HttpClient).attach(message.into()) +} + +fn platform_response( + response: OutboundResponse, + backend_name: String, +) -> Result> { + // Use the final RC framing pass, including HEAD/204/304 handling, rather + // than rebuilding headers or losing duplicate Set-Cookie fields. + let response = response + .into_response() + .change_context(PlatformError::HttpClient)?; + Ok(PlatformResponse::new(response).with_backend_name(backend_name)) +} + +/// Finish the one-slot batch, including the driver's final EOF validation. +/// +/// TS permits independently dropping/reordering pending handles and starting new +/// ones between selects. RC batches are closed at creation and cancel as a unit. +/// Each TS handle therefore owns one RC batch, whose original input index is 0. +/// We never reinterpret a TS handle position as an RC index. Waiting for collect +/// also catches trailing driver failures after the sole terminal item. +async fn finish_batch(batch: OutboundBatch) -> Result> { + let mut results = batch.collect().await.map_err(|failure| { + http_error(format!( + "native outbound batch driver failed: {}", + failure.error + )) + })?; + if results.slots.len() != 1 { + return Err(http_error( + "native pending batch must have exactly one input slot", + )); + } + let slot = results.slots.remove(0); + match (results.termination, slot) { + (OutboundBatchTermination::Completed, Some(slot)) => { + slot.outcome.change_context(PlatformError::HttpClient) + } + (OutboundBatchTermination::Cutoff, _) => { + Err(http_error("native outbound observation cutoff expired")) + } + _ => Err(http_error( + "native outbound batch has no completed original slot 0", + )), + } +} + +struct NativePendingHandle { + backend_name: String, + // This future owns the RC batch. Dropping it drops adapter transport work; + // there is no detached Tokio task and no second cancellation owner. + completion: LocalBoxFuture<'static, Result>>, +} + +impl Future for NativePendingHandle { + type Output = (String, Result>); + + fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll { + match self.completion.as_mut().poll(context) { + Poll::Ready(result) => Poll::Ready((std::mem::take(&mut self.backend_name), result)), + Poll::Pending => Poll::Pending, + } + } +} + +async fn buffer_upload( + body: Body, + maximum: u64, + deadline: Deadline, + clock: &MonotonicClock, +) -> Result> { + match body.into_content() { + BodyContent::Once(bytes) => { + if bytes.len() as u64 > maximum { + return Err(http_error("native outbound upload exceeded caller limit")); + } + if deadline.is_expired_at(clock.now()) { + return Err(http_error( + "native outbound deadline expired collecting upload", + )); + } + Ok(Body::from(bytes)) + } + BodyContent::Stream(mut stream) => { + let mut bytes = Vec::new(); + loop { + let remaining = deadline.remaining_at(clock.now()).ok_or_else(|| { + http_error("native outbound deadline expired collecting upload") + })?; + let item = tokio::time::timeout(remaining, stream.next()) + .await + .map_err(|_| http_error("native outbound upload timed out"))?; + if deadline.is_expired_at(clock.now()) { + return Err(http_error( + "native outbound deadline expired collecting upload", + )); + } + let Some(item) = item else { + return Ok(Body::from(bytes)); + }; + let chunk = item.change_context(PlatformError::HttpClient)?; + let length = (bytes.len() as u64) + .checked_add(chunk.len() as u64) + .ok_or_else(|| http_error("native upload size accounting overflow"))?; + if length > maximum { + return Err(http_error("native outbound upload exceeded caller limit")); + } + bytes.extend_from_slice(&chunk); + } + } + } +} + +#[async_trait(?Send)] +impl PlatformHttpClient for AxumPlatformHttpClient { + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result> { + let (outbound, backend, _, _) = self.prepare(request)?; + let response = self + .client + .send(outbound) + .await + .change_context(PlatformError::HttpClient)?; + platform_response(response, backend) + } + + async fn send_async( + &self, + request: PlatformHttpRequest, + ) -> Result> { + if request.stream_response { + return Err(http_error( + "native pending streaming responses require RC send, not batches", + )); + } + let (outbound, backend_name, max_request_bytes, deadline) = self.prepare(request)?; + // RC batches require buffered uploads. Do bounded collection locally in + // the existing runtime before starting a batch, under the same deadline. + let mut parts = outbound.into_parts(); + parts.body = buffer_upload(parts.body, max_request_bytes, deadline, &self.clock).await?; + let outbound = + OutboundRequest::from_parts(parts).change_context(PlatformError::HttpClient)?; + let batch = self.client.start_batch_until(vec![outbound], deadline); + let handle = NativePendingHandle { + backend_name: backend_name.clone(), + completion: finish_batch(batch).boxed_local(), + }; + Ok(PlatformPendingRequest::new(handle).with_backend_name(backend_name)) + } + + async fn select( + &self, + pending_requests: Vec, + ) -> Result> { + if pending_requests.is_empty() { + return Err(http_error("select called without pending requests")); + } + let handles = pending_requests + .into_iter() + .map(|pending| { + pending + .downcast::() + .map_err(|_| http_error("pending request belongs to a different HTTP bridge")) + }) + .collect::, _>>()?; + let ((backend, outcome), _, remaining) = select_all(handles).await; + let ready = outcome.and_then(|response| platform_response(response, backend.clone())); + let failed_backend_name = ready.as_ref().err().map(|_| backend); + let remaining = remaining + .into_iter() + .map(|handle| { + let backend = handle.backend_name.clone(); + PlatformPendingRequest::new(handle).with_backend_name(backend) + }) + .collect(); + Ok(PlatformSelectResult { + ready, + remaining, + failed_backend_name, + }) + } + + fn supports_streaming_responses(&self) -> bool { + true + } + + fn has_enforceable_total_request_deadline(&self) -> bool { + true + } +} diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index e2713f47f2..fcf66facdd 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -1,159 +1,20 @@ -use std::future::Future; use std::net::IpAddr; -use std::pin::Pin; -use std::sync::Arc; -use std::task::{Context, Poll}; -use std::time::Duration; +use std::sync::{Arc, OnceLock}; -use async_trait::async_trait; -use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, header}; +use edgezero_adapter_axum::diagnostics::NativeIngressMetadata; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{HeaderValue, Request, Uri, header}; use error_stack::{Report, ResultExt as _}; +use trusted_server_core::http_util::RequestInfo; use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, - PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, - PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, - RuntimeServices, StoreId, StoreName, + BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformError, + PlatformGeo, PlatformKvStore, RuntimeServices, UnavailableKvStore, }; -// --------------------------------------------------------------------------- -// Env-var naming helpers -// --------------------------------------------------------------------------- +pub use crate::outbound::AxumPlatformHttpClient; -/// Normalize a store name or key for use as an environment-variable segment. -/// -/// Uppercases and replaces hyphens, dots, and spaces with underscores. -fn normalize_env_segment(s: &str) -> String { - s.to_uppercase().replace(['-', '.', ' '], "_") -} - -/// Returns the environment-variable name for a config store entry. -#[must_use] -pub fn config_env_var(store_name: &str, key: &str) -> String { - format!( - "TRUSTED_SERVER_CONFIG_{}_{}", - normalize_env_segment(store_name), - normalize_env_segment(key), - ) -} - -fn secret_env_var(store_name: &str, key: &str) -> String { - format!( - "TRUSTED_SERVER_SECRET_{}_{}", - normalize_env_segment(store_name), - normalize_env_segment(key), - ) -} - -// --------------------------------------------------------------------------- -// PlatformConfigStore -// --------------------------------------------------------------------------- - -/// Environment-variable–backed config store for the Axum dev server. -/// -/// Reads from `TRUSTED_SERVER_CONFIG_{STORE}_{KEY}` (uppercased, hyphens→underscores). -/// Write operations are unsupported in local development. -pub struct AxumPlatformConfigStore; - -impl PlatformConfigStore for AxumPlatformConfigStore { - fn get(&self, store_name: &StoreName, key: &str) -> Result> { - let var_name = config_env_var(store_name.as_ref(), key); - std::env::var(&var_name).map_err(|_| { - Report::new(PlatformError::ConfigStore).attach(format!( - "env var '{var_name}' not set — export it to supply this config value" - )) - }) - } - - fn put( - &self, - store_id: &StoreId, - key: &str, - _value: &str, - ) -> Result<(), Report> { - log::warn!( - "AxumPlatformConfigStore: write to store '{}' key '{}' ignored \ - (config store writes are not supported on the Axum dev server)", - store_id.as_ref(), - key - ); - Err(Report::new(PlatformError::ConfigStore) - .attach("config store writes are not supported on the Axum dev server")) - } - - fn delete(&self, store_id: &StoreId, key: &str) -> Result<(), Report> { - log::warn!( - "AxumPlatformConfigStore: delete from store '{}' key '{}' ignored \ - (config store deletes are not supported on the Axum dev server)", - store_id.as_ref(), - key - ); - Err(Report::new(PlatformError::ConfigStore) - .attach("config store deletes are not supported on the Axum dev server")) - } -} - -// --------------------------------------------------------------------------- -// PlatformSecretStore -// --------------------------------------------------------------------------- - -/// Environment-variable–backed secret store for the Axum dev server. -/// -/// Reads from `TRUSTED_SERVER_SECRET_{STORE}_{KEY}` as raw UTF-8 bytes. -/// Write operations are unsupported in local development. -pub struct AxumPlatformSecretStore; - -impl PlatformSecretStore for AxumPlatformSecretStore { - fn get_bytes( - &self, - store_name: &StoreName, - key: &str, - ) -> Result, Report> { - let var_name = secret_env_var(store_name.as_ref(), key); - std::env::var(&var_name) - .map(String::into_bytes) - .map_err(|_| { - Report::new(PlatformError::SecretStore).attach(format!( - "env var '{var_name}' not set — export it to supply this secret value" - )) - }) - } - - fn create( - &self, - store_id: &StoreId, - name: &str, - _value: &str, - ) -> Result<(), Report> { - log::warn!( - "AxumPlatformSecretStore: create '{}' in store '{}' ignored \ - (secret store writes are not supported on the Axum dev server)", - name, - store_id.as_ref() - ); - Err(Report::new(PlatformError::SecretStore) - .attach("secret store writes are not supported on the Axum dev server")) - } - - fn delete(&self, store_id: &StoreId, name: &str) -> Result<(), Report> { - log::warn!( - "AxumPlatformSecretStore: delete '{}' from store '{}' ignored \ - (secret store deletes are not supported on the Axum dev server)", - name, - store_id.as_ref() - ); - Err(Report::new(PlatformError::SecretStore) - .attach("secret store deletes are not supported on the Axum dev server")) - } -} - -// --------------------------------------------------------------------------- -// PlatformBackend -// --------------------------------------------------------------------------- - -/// No-op backend for the Axum dev server. -/// -/// Returns a deterministic name; `ensure` is a no-op returning the same name. -/// The Axum HTTP client sends directly to URIs and ignores backend names. +/// Backend naming for native requests, whose RC transport connects directly to the URI. pub struct AxumPlatformBackend; impl PlatformBackend for AxumPlatformBackend { @@ -173,11 +34,7 @@ impl PlatformBackend for AxumPlatformBackend { } } -// --------------------------------------------------------------------------- -// PlatformGeo -// --------------------------------------------------------------------------- - -/// No-op geo implementation — geographic lookup is unavailable in local development. +/// Reader geography is unavailable on native, regardless of deployment region. pub struct AxumPlatformGeo; impl PlatformGeo for AxumPlatformGeo { @@ -186,503 +43,117 @@ impl PlatformGeo for AxumPlatformGeo { } } -// --------------------------------------------------------------------------- -// PlatformHttpClient -// --------------------------------------------------------------------------- - -type SpawnedRequestResult = Result<(u16, Vec<(String, Vec)>, Vec), Report>; - -fn sanitized_response_headers(headers: &HeaderMap) -> Vec<(String, Vec)> { - let connection_tokens = connection_header_tokens(headers); - - headers - .iter() - .filter(|(name, _)| !is_hop_by_hop_response_header(name, &connection_tokens)) - .map(|(name, value)| (name.to_string(), value.as_bytes().to_vec())) - .collect() -} - -fn connection_header_tokens(headers: &HeaderMap) -> Vec { - headers - .get_all(header::CONNECTION) - .iter() - .filter_map(header_value_to_str) - .flat_map(|value| value.split(',')) - .map(str::trim) - .filter(|token| !token.is_empty()) - .filter_map(|token| HeaderName::from_bytes(token.as_bytes()).ok()) - .collect() -} - -fn header_value_to_str(value: &HeaderValue) -> Option<&str> { - value.to_str().ok() -} - -fn is_hop_by_hop_response_header(name: &HeaderName, connection_tokens: &[HeaderName]) -> bool { - name == header::CONNECTION - || name == header::PROXY_AUTHENTICATE - || name == header::PROXY_AUTHORIZATION - || name == header::TE - || name == header::TRAILER - || name == header::TRANSFER_ENCODING - || name == header::UPGRADE - || name.as_str().eq_ignore_ascii_case("keep-alive") - || connection_tokens.iter().any(|token| token == name) -} - -/// Buffered response parts from a spawned outbound request. +/// Install checked public URL facts without consuming or replacing the request body. /// -/// Stored inside [`PlatformPendingRequest`] so that [`AxumPlatformHttpClient::select`] -/// can poll multiple in-flight handles concurrently via -/// [`futures::future::select_all`]. -struct AxumPendingHandle { - backend_name: String, - handle: tokio::task::JoinHandle, -} - -impl Drop for AxumPendingHandle { - fn drop(&mut self) { - // Abort instead of detaching: when the orchestrator hits the auction - // deadline and drops the remaining pending requests, the abandoned - // bidder tasks would otherwise keep running for up to the 30s - // transport timeout. - self.handle.abort(); - } -} - -/// Resolves to the backend name together with the task result so that -/// [`futures::future::select_all`] callers never have to reconstruct which -/// backend a completion belongs to by position. `select_all` removes the -/// ready future with `swap_remove` and makes no ordering guarantee for the -/// remaining futures, so positional bookkeeping would mislabel them. -impl Future for AxumPendingHandle { - type Output = (String, Result); - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - match Pin::new(&mut self.handle).poll(cx) { - Poll::Ready(result) => { - let backend_name = std::mem::take(&mut self.backend_name); - Poll::Ready((backend_name, result)) - } - Poll::Pending => Poll::Pending, - } - } -} - -/// reqwest-backed HTTP client for the Axum dev server. +/// Raw forwarding headers are never consulted. Managed ingress validates proxy +/// trust and syntax before creating this metadata. Requests outside the native +/// runner remain unchanged, for explicit-services router tests and other adapters. /// -/// `send_async` buffers any `Body::Stream` in the calling context, then spawns -/// a `tokio` task for each outbound request so that multiple `send_async` calls -/// run concurrently. `select` uses [`futures::future::select_all`] to wait for -/// the first completing handle, preserving fan-out semantics. -pub struct AxumPlatformHttpClient { - client: reqwest::Client, -} - -impl AxumPlatformHttpClient { - /// Create a new client with sensible dev-server timeouts. - /// - /// # Panics - /// - /// Panics if the underlying `reqwest::Client` cannot be built (should not - /// happen with the default TLS configuration on any supported platform). - #[must_use] - pub fn new() -> Self { - Self { - client: reqwest::Client::builder() - .connect_timeout(Duration::from_secs(5)) - .timeout(Duration::from_secs(30)) - // Disable automatic redirects: core proxy code enforces redirect - // limits and allowed_domains checks itself. Without this, reqwest - // would follow Location headers internally and bypass those checks. - .redirect(reqwest::redirect::Policy::none()) - .build() - .expect("should build reqwest client"), - } - } - - /// Drain `body` to a `Vec`. - /// - /// For `Body::Stream` this awaits every chunk in the current async context - /// (where `LocalBoxStream` is valid) before the bytes are moved into a - /// `tokio::spawn` task that requires `Send`. - async fn buffer_body( - body: edgezero_core::body::Body, - ) -> Result, Report> { - match body { - edgezero_core::body::Body::Once(bytes) => Ok(bytes.to_vec()), - edgezero_core::body::Body::Stream(mut stream) => { - log::debug!("buffering Body::Stream into Vec for outbound request"); - use futures::StreamExt as _; - let mut buf = Vec::new(); - while let Some(chunk) = stream.next().await { - let bytes = chunk.map_err(|e| { - Report::new(PlatformError::HttpClient) - .attach(format!("failed to buffer outbound streaming body: {e}")) - })?; - buf.extend_from_slice(&bytes); - } - Ok(buf) - } - } - } - - async fn execute( - &self, - request: PlatformHttpRequest, - ) -> Result> { - let uri = request.request.uri().to_string(); - let method = reqwest::Method::from_bytes(request.request.method().as_str().as_bytes()) - .change_context(PlatformError::HttpClient)?; - - let mut builder = self.client.request(method, &uri); - for (name, value) in request.request.headers() { - builder = builder.header(name.as_str(), value.as_bytes()); - } - - let (_, body) = request.request.into_parts(); - let body_bytes = Self::buffer_body(body).await?; - if !body_bytes.is_empty() { - builder = builder.body(body_bytes); - } - - let resp = builder - .send() - .await - .change_context(PlatformError::HttpClient) - .attach(format!("outbound request to {uri} failed"))?; - - let status = resp.status().as_u16(); - let mut edge_builder = edgezero_core::http::response_builder().status(status); - for (name, value) in sanitized_response_headers(resp.headers()) { - edge_builder = edge_builder.header(name.as_str(), value.as_slice()); - } - let resp_bytes = resp - .bytes() - .await - .change_context(PlatformError::HttpClient)?; - // Upstream responses are buffered whole with no cap — acceptable for - // the dev server, but log the size so a large or hostile upstream is - // visible instead of silently growing the heap. - log::debug!( - "buffered {} upstream response bytes from {uri}", - resp_bytes.len() - ); - let edge_resp = edge_builder - .body(edgezero_core::body::Body::from(resp_bytes.to_vec())) - .change_context(PlatformError::HttpClient)?; - - Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name)) - } -} - -impl Default for AxumPlatformHttpClient { - fn default() -> Self { - Self::new() - } -} - -#[async_trait(?Send)] -impl PlatformHttpClient for AxumPlatformHttpClient { - async fn send( - &self, - request: PlatformHttpRequest, - ) -> Result> { - self.execute(request).await - } - - async fn send_async( - &self, - request: PlatformHttpRequest, - ) -> Result> { - let backend_name = request.backend_name.clone(); - - // Extract all Send-compatible parts before spawning. - let uri = request.request.uri().to_string(); - let method_bytes = request.request.method().as_str().as_bytes().to_vec(); - let headers: Vec<(String, Vec)> = request - .request - .headers() - .iter() - .map(|(n, v)| (n.to_string(), v.as_bytes().to_vec())) - .collect(); - - // Buffer any LocalBoxStream body here in the ?Send context before spawn. - let (_, body) = request.request.into_parts(); - let body_bytes = Self::buffer_body(body).await?; - - let client = self.client.clone(); - let handle = tokio::spawn(async move { - let method = reqwest::Method::from_bytes(&method_bytes) - .map_err(|e| Report::new(PlatformError::HttpClient).attach(e.to_string()))?; - let mut builder = client.request(method, &uri); - for (name, value) in &headers { - builder = builder.header(name.as_str(), value.as_slice()); - } - if !body_bytes.is_empty() { - builder = builder.body(body_bytes); - } - let resp = builder.send().await.map_err(|e| { - Report::new(PlatformError::HttpClient) - .attach(format!("outbound request to {uri} failed: {e}")) - })?; - let status = resp.status().as_u16(); - let resp_headers = sanitized_response_headers(resp.headers()); - let body = resp - .bytes() - .await - .map_err(|e| Report::new(PlatformError::HttpClient).attach(e.to_string()))? - .to_vec(); - // Same unbounded-buffering note as the synchronous path: log the - // size so large upstream responses are visible in dev. - log::debug!("buffered {} upstream response bytes from {uri}", body.len()); - Ok::<_, Report>((status, resp_headers, body)) - }); - - let pending = AxumPendingHandle { - backend_name: backend_name.clone(), - handle, - }; - Ok(PlatformPendingRequest::new(pending).with_backend_name(backend_name)) - } - - async fn select( - &self, - pending_requests: Vec, - ) -> Result> { - if pending_requests.is_empty() { - return Err(Report::new(PlatformError::HttpClient) - .attach("select called with an empty pending_requests list")); - } - - let handles: Vec = pending_requests - .into_iter() - .map(|pr| { - pr.downcast::().map_err(|_| { - Report::new(PlatformError::HttpClient) - .attach("unexpected inner type in AxumPlatformHttpClient::select") - }) - }) - .collect::, _>>()?; - - // Each AxumPendingHandle resolves to (backend_name, result), so the - // remaining handles keep their own backend names — no positional - // reconstruction (select_all does not preserve the order of the - // remaining futures). - let ((backend_name, result), _ready_idx, remaining_handles) = - futures::future::select_all(handles).await; - - let remaining: Vec = remaining_handles - .into_iter() - .map(|handle| { - let backend_name = handle.backend_name.clone(); - PlatformPendingRequest::new(handle).with_backend_name(backend_name) - }) - .collect(); - - // Map join panics and per-request errors into ready: Err(...) so that the - // auction orchestrator can log the failure and continue with remaining providers - // rather than treating one bad provider as a fatal select() failure. - let ready = result - .map_err(|e| { - Report::new(PlatformError::HttpClient) - .attach(format!("auction request task panicked: {e}")) - }) - .and_then(|inner| inner) - .and_then(|(status, headers, body)| { - let mut builder = edgezero_core::http::response_builder().status(status); - for (name, value) in &headers { - builder = builder.header(name.as_str(), value.as_slice()); - } - builder - .body(edgezero_core::body::Body::from(body)) - .change_context(PlatformError::HttpClient) - }) - .map(|edge_resp| { - PlatformResponse::new(edge_resp).with_backend_name(backend_name.clone()) - }); - - // Attribute the failure to its backend so the orchestrator can remove - // the provider and record a BidStatus::Error, matching the Fastly - // adapter. Without this, a failed provider silently vanishes through - // the orchestrator's "backend not identified" branch. - let failed_backend_name = ready.as_ref().err().map(|_| backend_name); - - Ok(PlatformSelectResult { - ready, - remaining, - failed_backend_name, - }) - } -} - -// --------------------------------------------------------------------------- -// build_runtime_services -// --------------------------------------------------------------------------- - -/// Construct [`RuntimeServices`] for an incoming Axum request. +/// # Errors +/// Returns a bad-request error when checked public URI/Host facts cannot be represented. +pub(crate) fn apply_native_ingress(request: &mut Request) -> Result<(), EdgeError> { + let Some(metadata) = request.extensions().get::() else { + return Ok(()); + }; + let info = RequestInfo { + host: metadata + .effective_host() + .authority() + .unwrap_or_default() + .to_owned(), + scheme: metadata.effective_scheme().as_str().to_owned(), + }; + // Some core consumers read URI/Host directly rather than RequestInfo. + // Normalize all three representations from the same checked facts so an + // absolute request target cannot override the selected public scheme. + let uri = Uri::builder() + .scheme(info.scheme.as_str()) + .authority(info.host.as_str()) + .path_and_query( + request + .uri() + .path_and_query() + .map_or("/", |path| path.as_str()), + ) + .build() + .map_err(|_| { + EdgeError::bad_request("native request has no representable checked public URI") + })?; + let host = HeaderValue::from_str(&info.host).map_err(|_| { + EdgeError::bad_request("native request has no representable checked public host") + })?; + *request.uri_mut() = uri; + request.headers_mut().insert(header::HOST, host); + request.extensions_mut().insert(info); + Ok(()) +} + +/// Construct request-local runtime services from initialized immutable stores. /// -/// # Degraded features in dev +/// The RC client and application clock come from the incoming request. Only +/// dependency handles are shared; checked visitor metadata is read afresh. +/// Generic KV and reader geography are intentionally unavailable in this demo. /// -/// The generic runtime KV slot uses -/// [`trusted_server_core::platform::UnavailableKvStore`]. A `warn` log is -/// emitted once per process. -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { - static KV_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); - KV_WARNED.get_or_init(|| { - log::warn!("Axum dev server: generic runtime KV is unavailable (UnavailableKvStore)."); - }); - - let client_ip = edgezero_adapter_axum::context::AxumRequestContext::get(ctx.request()) - .and_then(|c| c.remote_addr) - .map(|addr| addr.ip()); - - use trusted_server_core::platform::{ - PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformKvStore, PlatformSecretStore, - }; - - // Stateless shims are promoted to process-wide statics so callers clone - // an existing Arc instead of allocating a new one per request. - static CONFIG_STORE: std::sync::OnceLock> = - std::sync::OnceLock::new(); - static SECRET_STORE: std::sync::OnceLock> = - std::sync::OnceLock::new(); - static KV_STORE: std::sync::OnceLock> = std::sync::OnceLock::new(); - static BACKEND: std::sync::OnceLock> = std::sync::OnceLock::new(); - static GEO: std::sync::OnceLock> = std::sync::OnceLock::new(); - - RuntimeServices::builder() - .config_store(Arc::clone(CONFIG_STORE.get_or_init(|| { - Arc::new(AxumPlatformConfigStore) as Arc - }))) - .secret_store(Arc::clone(SECRET_STORE.get_or_init(|| { - Arc::new(AxumPlatformSecretStore) as Arc - }))) - .kv_store(Arc::clone(KV_STORE.get_or_init(|| { - Arc::new(trusted_server_core::platform::UnavailableKvStore) as Arc - }))) - .backend(Arc::clone(BACKEND.get_or_init(|| { - Arc::new(AxumPlatformBackend) as Arc - }))) - // Keep the HTTP client request-scoped in the dev adapter. Sharing a pooled - // client across requests previously regressed the Next.js server-action → - // API-route integration flow by reusing a poisoned connection after a - // truncated POST. Revisit pooling if profiling shows allocation cost. - .http_client(Arc::new(AxumPlatformHttpClient::new())) - .geo(Arc::clone(GEO.get_or_init(|| { - Arc::new(AxumPlatformGeo) as Arc - }))) +/// # Errors +/// Returns an error when startup has not installed the store snapshot or the +/// native runner has not supplied its HTTP client. There is no environment fallback. +pub fn build_runtime_services( + context: &RequestContext, +) -> Result> { + let snapshot = context + .extensions() + .get::>() + .ok_or_else(|| { + Report::new(PlatformError::ConfigStore) + .attach("native prepared-store snapshot is missing; initialize the application before serving") + })?; + let client = context.http_client().ok_or_else(|| { + Report::new(PlatformError::HttpClient) + .attach("request-scoped native HTTP client is missing") + })?; + let client_ip = context + .extensions() + .get::() + .and_then(NativeIngressMetadata::effective_client); + + static KV: OnceLock> = OnceLock::new(); + static BACKEND: OnceLock> = OnceLock::new(); + static GEO: OnceLock> = OnceLock::new(); + + Ok(RuntimeServices::builder() + .config_store(Arc::clone(&snapshot.config_store)) + .secret_store(Arc::clone(&snapshot.secret_store)) + .kv_store(Arc::clone(KV.get_or_init(|| Arc::new(UnavailableKvStore)))) + .backend(Arc::clone( + BACKEND.get_or_init(|| Arc::new(AxumPlatformBackend)), + )) + .http_client(Arc::new(AxumPlatformHttpClient::new( + client, + context.monotonic_clock(), + ))) + .geo(Arc::clone(GEO.get_or_init(|| Arc::new(AxumPlatformGeo)))) .client_info(ClientInfo { client_ip, - tls_protocol: None, - tls_cipher: None, ..ClientInfo::default() }) - .build() + .build()) } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - #[cfg(test)] mod tests { - use super::*; - use edgezero_core::body::Body as EdgeBody; use std::time::Duration; - use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; - #[test] - fn auction_http_capabilities_are_explicit() { - let client = AxumPlatformHttpClient::new(); - let capabilities = trusted_server_core::platform::AuctionTargetId::Axum - .descriptor() - .capabilities(); - assert!(client.supports_concurrent_fanout()); - assert!(capabilities.supports_concurrent_provider_fanout()); - assert!(!client.has_enforceable_total_request_deadline()); - assert!( - !capabilities.has_enforceable_total_request_deadline(), - "reqwest's transport timeout is not an adapter-enforced auction deadline" - ); - } - - #[test] - fn config_env_var_normalizes_store_and_key() { - assert_eq!( - config_env_var("my-store.name", "my key"), - "TRUSTED_SERVER_CONFIG_MY_STORE_NAME_MY_KEY", - "should normalize environment-variable segments" - ); - } + use edgezero_core::body::Body; + use edgezero_core::http::request_builder; - #[test] - fn config_store_reads_from_env_var() { - temp_env::with_var( - "TRUSTED_SERVER_CONFIG_MY_STORE_MY_KEY", - Some("test-value"), - || { - let store = AxumPlatformConfigStore; - let result = store - .get(&StoreName::from("my-store"), "my-key") - .expect("should read env var"); - assert_eq!(result, "test-value", "should return env var value"); - }, - ); - } - - #[test] - fn config_store_returns_error_for_missing_env_var() { - let store = AxumPlatformConfigStore; - let result = store.get( - &StoreName::from("nonexistent-store-zzz"), - "nonexistent-key-zzz", - ); - assert!(result.is_err(), "should error for missing env var"); - } - - #[test] - fn secret_store_reads_bytes_from_env_var() { - temp_env::with_var( - "TRUSTED_SERVER_SECRET_MY_SECRETS_MY_SECRET", - Some("hello"), - || { - let store = AxumPlatformSecretStore; - let result = store - .get_bytes(&StoreName::from("my-secrets"), "my-secret") - .expect("should read env var as bytes"); - assert_eq!(result, b"hello", "should return raw bytes"); - }, - ); - } - - #[test] - fn backend_predict_name_returns_deterministic_string() { - let backend = AxumPlatformBackend; - let spec = PlatformBackendSpec { - scheme: "https".to_string(), - host: "example.com".to_string(), - port: None, - certificate_check: true, - first_byte_timeout: Duration::from_secs(15), - between_bytes_timeout: Duration::from_secs(15), - host_header_override: None, - discriminator: None, - }; - let name1 = backend.predict_name(&spec).expect("should return a name"); - let name2 = backend - .predict_name(&spec) - .expect("should return same name"); - assert!(!name1.is_empty(), "should return a non-empty name"); - assert_eq!(name1, name2, "should be deterministic"); - } + use super::*; #[test] - fn backend_ensure_returns_same_name_as_predict() { + fn backend_ensure_matches_prediction() { let backend = AxumPlatformBackend; let spec = PlatformBackendSpec { - scheme: "https".to_string(), - host: "example.com".to_string(), + scheme: "https".to_owned(), + host: "example.com".to_owned(), port: None, certificate_check: true, first_byte_timeout: Duration::from_secs(15), @@ -691,173 +162,48 @@ mod tests { discriminator: None, }; assert_eq!( - backend.predict_name(&spec).expect("should return name"), - backend.ensure(&spec).expect("should return name"), - "ensure should equal predict_name" + backend.predict_name(&spec).expect("should predict backend"), + backend.ensure(&spec).expect("should ensure backend"), + "should preserve deterministic native backend names" ); } #[test] - fn geo_always_returns_none() { - let geo = AxumPlatformGeo; - let no_ip = geo.lookup(None).expect("should not error"); - assert!(no_ip.is_none(), "should return None for no IP"); - let with_ip = geo - .lookup(Some("127.0.0.1".parse().expect("should parse IP"))) - .expect("should not error"); - assert!(with_ip.is_none(), "should return None for any IP"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn http_client_surfaces_redirect_without_following() { - let url = serve_raw_response( - b"HTTP/1.1 302 Found\r\nLocation: https://redirect.example/next\r\nContent-Length: 0\r\n\r\n", - ) - .await; - let request = edgezero_core::http::request_builder() - .uri(url) - .body(EdgeBody::empty()) - .expect("should build outbound request"); - - let response = AxumPlatformHttpClient::new() - .send(PlatformHttpRequest::new(request, "test_backend")) - .await - .expect("should surface redirect") - .response; - - assert_eq!(response.status().as_u16(), 302); - assert_eq!( - response - .headers() - .get(edgezero_core::http::header::LOCATION) - .and_then(|value| value.to_str().ok()), - Some("https://redirect.example/next") - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn http_client_strips_hop_by_hop_response_headers() { - let url = serve_raw_response( - b"HTTP/1.1 200 OK\r\n\ - Transfer-Encoding: chunked\r\n\ - Connection: keep-alive, x-remove-me\r\n\ - Keep-Alive: timeout=5\r\n\ - X-Remove-Me: listed-by-connection\r\n\ - X-Preserve-Me: application-header\r\n\ - \r\n\ - 2\r\n\ - ok\r\n\ - 0\r\n\ - \r\n", - ) - .await; - - let request = edgezero_core::http::request_builder() - .uri(url) - .body(EdgeBody::empty()) - .expect("should build outbound request"); - - let response = AxumPlatformHttpClient::new() - .send(PlatformHttpRequest::new(request, "test_backend")) - .await - .expect("should proxy raw response") - .response; - - assert!( - response.headers().get(header::TRANSFER_ENCODING).is_none(), - "should strip transfer-encoding" - ); + fn reader_geography_remains_unavailable() { assert!( - response.headers().get(header::CONNECTION).is_none(), - "should strip connection" + AxumPlatformGeo + .lookup(None) + .expect("should look up geography") + .is_none() ); assert!( - response.headers().get("keep-alive").is_none(), - "should strip keep-alive" - ); - assert!( - response.headers().get("x-remove-me").is_none(), - "should strip headers named by connection" - ); - assert_eq!( - response - .headers() - .get("x-preserve-me") - .and_then(|value| value.to_str().ok()), - Some("application-header"), - "should preserve end-to-end headers" - ); - assert_eq!( - response - .into_body() - .into_bytes() - .unwrap_or_default() - .as_ref(), - b"ok", - "should preserve decoded response body" + AxumPlatformGeo + .lookup(Some("192.0.2.1".parse().expect("should parse client IP"))) + .expect("should look up geography") + .is_none() ); } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn select_attributes_failed_backend_name() { - // Bind and immediately drop a listener so the port is closed — the - // request fails with connection refused. - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("should bind probe listener"); - let addr = listener.local_addr().expect("should read local address"); - drop(listener); - - let request = edgezero_core::http::request_builder() - .uri(format!("http://{addr}/")) - .body(EdgeBody::empty()) - .expect("should build outbound request"); - - let client = AxumPlatformHttpClient::new(); - let pending = client - .send_async(PlatformHttpRequest::new(request, "failing_backend")) - .await - .expect("should spawn async request"); - - let result = client - .select(vec![pending]) - .await - .expect("select should surface the failure via ready, not a fatal error"); - - assert!( - result.ready.is_err(), - "request to a closed port should fail" - ); - assert_eq!( - result.failed_backend_name.as_deref(), - Some("failing_backend"), - "failed provider must be attributed to its backend so the orchestrator can record BidStatus::Error" - ); + #[test] + fn missing_prepared_snapshot_fails_without_environment_fallback() { + let request = request_builder() + .body(Body::empty()) + .expect("should build request"); + let context = RequestContext::new(request, edgezero_core::params::PathParams::default()); assert!( - result.remaining.is_empty(), - "no remaining requests expected" + build_runtime_services(&context).is_err(), + "should require prepared stores" ); } - async fn serve_raw_response(response: &'static [u8]) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("should bind raw HTTP test server"); - let addr = listener.local_addr().expect("should read local address"); - - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("should accept request"); - let mut request = [0; 1024]; - let _ = stream - .read(&mut request) - .await - .expect("should read request"); - stream - .write_all(response) - .await - .expect("should write response"); - }); - - format!("http://{addr}/") + #[test] + fn unmanaged_test_request_is_not_rewritten_from_raw_forwarding_headers() { + let mut request = request_builder() + .header("forwarded", "host=spoof.example.com;proto=https") + .body(Body::from("body")) + .expect("should build request"); + apply_native_ingress(&mut request).expect("should retain unmanaged test request"); + assert!(request.extensions().get::().is_none()); + assert_eq!(request.body().as_bytes(), Some(b"body".as_slice())); } } diff --git a/crates/trusted-server-adapter-axum/src/startup.rs b/crates/trusted-server-adapter-axum/src/startup.rs new file mode 100644 index 0000000000..d45c57ab6a --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/startup.rs @@ -0,0 +1,423 @@ +//! Strict native initialization from prepared stores. +//! +//! This finite snapshot bridge exists until core's platform store traits become +//! asynchronous. No provider is polled by synchronous request consumers. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use edgezero_adapter_axum::dev_server::PreparedStores; +use edgezero_core::app::App; +use edgezero_core::error::EdgeError; +use error_stack::Report; +use trusted_server_core::config_payload::{ + DEFAULT_CONFIG_STORE_ID, DEFAULT_SECRET_STORE_ID, VerifiedConfigBlob, +}; +use trusted_server_core::integrations::datadome::{DataDomeConfig, ProtectionMatcherConfig}; +use trusted_server_core::platform::{ + PlatformConfigStore, PlatformError, PlatformSecretStore, RuntimeServices, StoreId, StoreName, + UnavailableHttpClient, UnavailableKvStore, +}; +use trusted_server_core::settings::Settings; + +use crate::app::build_state_with_settings; +use crate::platform::{AxumPlatformBackend, AxumPlatformGeo}; + +/// Immutable inputs shared by per-request native runtime services. +/// +/// Only declared stores and explicitly loaded keys exist in these snapshots. +/// Missing keys and all management writes fail rather than consulting legacy +/// environment variables or opening another provider. +pub struct NativeStoreSnapshot { + pub(crate) config_store: Arc, + pub(crate) secret_store: Arc, +} + +struct ConfigSnapshot { + key: String, + envelope: String, +} + +impl PlatformConfigStore for ConfigSnapshot { + fn get(&self, store_name: &StoreName, key: &str) -> Result> { + if store_name.as_ref() == DEFAULT_CONFIG_STORE_ID && !self.key.is_empty() && key == self.key + { + Ok(self.envelope.clone()) + } else { + Err(Report::new(PlatformError::ConfigStore) + .attach("config key is not present in the immutable native startup snapshot")) + } + } + + fn put( + &self, + _store_id: &StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + Err(read_only_config()) + } + + fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { + Err(read_only_config()) + } +} + +struct SecretSnapshot(BTreeMap>); + +impl PlatformSecretStore for SecretSnapshot { + fn get_bytes( + &self, + store_name: &StoreName, + key: &str, + ) -> Result, Report> { + if store_name.as_ref() == DEFAULT_SECRET_STORE_ID + && let Some(value) = self.0.get(key) + { + return Ok(value.clone()); + } + Err(Report::new(PlatformError::SecretStore) + .attach("secret key is not present in the immutable native startup snapshot")) + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Err(read_only_secret()) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Err(read_only_secret()) + } +} + +/// Explicit-settings router seam. Network/store-dependent tests must supply +/// services through `routes_with_settings_and_services` instead. No env fallback. +pub(crate) fn explicit_settings_services() -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(ConfigSnapshot { + key: String::new(), + envelope: String::new(), + })) + .secret_store(Arc::new(SecretSnapshot(BTreeMap::new()))) + .kv_store(Arc::new(UnavailableKvStore)) + .backend(Arc::new(AxumPlatformBackend)) + .http_client(Arc::new(UnavailableHttpClient)) + .geo(Arc::new(AxumPlatformGeo)) + .client_info(Default::default()) + .build() +} + +fn read_only_config() -> Report { + Report::new(PlatformError::ConfigStore) + .attach("native startup snapshots are read-only; publish inputs and restart") +} + +fn read_only_secret() -> Report { + Report::new(PlatformError::SecretStore) + .attach("native startup snapshots are read-only; publish inputs and restart") +} + +fn configuration_error(message: &str) -> EdgeError { + EdgeError::validation(message) +} + +fn settings_error(error: Report) -> EdgeError { + // Core configuration errors deliberately omit secret values. Do not attach + // provider errors or a serialized config to native startup diagnostics. + let message = error.current_context().to_string(); + drop(error); + configuration_error(&message) +} + +fn validate_snapshot_consumers(settings: &Settings) -> Result<(), EdgeError> { + let origin = settings + .publisher + .origin_url + .parse::() + .map_err(|_| { + configuration_error("publisher.origin_url must be an absolute HTTP or HTTPS URI") + })?; + if !matches!(origin.scheme_str(), Some("http" | "https")) || origin.authority().is_none() { + return Err(configuration_error( + "publisher.origin_url must be an absolute HTTP or HTTPS URI", + )); + } + if settings + .request_signing + .as_ref() + .is_some_and(|signing| signing.enabled) + { + return Err(configuration_error( + "native snapshots do not support request_signing.enabled; disable signing or add declared jwks_store/signing_keys snapshots with KID and key-pair validation before readiness", + )); + } + if let Some(datadome) = settings + .integrations + .get_typed::("datadome") + .map_err(settings_error)? + && datadome.enable_protection + && (!datadome.protection_excluded_ip_cidr_sources.is_empty() + || datadome.protection_exclusion_rules.iter().any(|rule| { + rule.enabled && matches!(rule.matcher, ProtectionMatcherConfig::IpCidrSource { .. }) + })) + { + return Err(configuration_error( + "native snapshots do not support DataDome dynamic Config Store CIDR sources; use inline protection_excluded_ip_cidrs/ip_cidr rules or disable those sources before starting", + )); + } + Ok(()) +} + +/// Load and validate the finite native inputs before the framework listens. +/// +/// The framework retains ownership of the App, clock, policies, listener, +/// readiness, and signal/drain lifecycle. Selected-provider failures are fatal. +/// +/// # Errors +/// Returns an error for missing bindings/values, bounded-read failures, invalid +/// envelopes or runtime settings, unsupported store consumers, or invalid plans. +pub async fn initialize_native(app: &mut App, stores: &PreparedStores) -> Result<(), EdgeError> { + let limits = app.config_extraction_limits().validate()?; + let clock = app.monotonic_clock(); + let deadline = clock.deadline_after(limits.timeout); + let binding = stores + .config() + .and_then(|registry| registry.named_ref(DEFAULT_CONFIG_STORE_ID)) + .ok_or_else(|| { + configuration_error( + "required declared config store trusted_server_config is not prepared", + ) + })?; + let secrets = stores + .secrets() + .and_then(|registry| registry.named_ref(DEFAULT_SECRET_STORE_ID)) + .ok_or_else(|| { + configuration_error( + "required declared secret store trusted_server_secrets is not prepared", + ) + })?; + + let config_read = binding.handle.get_bounded( + &binding.default_key, + &clock, + deadline, + limits.max_backend_bytes.min(limits.max_total_bytes), + limits.max_blob_bytes.min(limits.max_total_bytes), + ).await.map_err(|_| configuration_error( + "failed bounded read of prepared Trusted Server config; check the selected provider, binding key, extraction limits and deadline; legacy environment fallback is disabled", + ))?; + let mut remaining = limits + .max_total_bytes + .checked_sub(config_read.backend_bytes) + .ok_or_else(|| { + configuration_error("native config extraction exceeds the total byte budget") + })?; + let envelope = config_read.value.ok_or_else(|| configuration_error( + "Trusted Server config envelope is missing at the prepared binding key; publish a TS BlobEnvelope before starting", + ))?; + let verified = VerifiedConfigBlob::parse(envelope.as_ref()).map_err(settings_error)?; + verified + .check_structure(limits.max_json_depth, limits.max_json_nodes) + .map_err(settings_error)?; + let keys = verified.required_secret_keys().map_err(settings_error)?; + let mut values = BTreeMap::new(); + for key in keys { + if remaining == 0 { + return Err(configuration_error( + "native secret extraction exhausts the total byte budget", + )); + } + let read = secrets.get_bytes_bounded( + &key, + &clock, + deadline, + limits.max_backend_bytes.min(remaining), + limits.max_secret_bytes.min(remaining), + ).await.map_err(|_| configuration_error( + "failed bounded read of a required app-config secret; check the selected trusted_server_secrets provider, required references, extraction limits and deadline; legacy environment fallback is disabled", + ))?; + remaining = remaining.checked_sub(read.backend_bytes).ok_or_else(|| { + configuration_error("native secret extraction exceeds the total byte budget") + })?; + let value = read.value.ok_or_else(|| configuration_error( + "a required app-config secret is missing in trusted_server_secrets; publish every active reference before starting", + ))?; + values.insert(key, value.to_vec()); + } + let secret_store = Arc::new(SecretSnapshot(values)); + let settings = verified + .into_settings( + secret_store.as_ref(), + &StoreName::from(DEFAULT_SECRET_STORE_ID), + ) + .map_err(settings_error)?; + validate_snapshot_consumers(&settings)?; + let state = build_state_with_settings(settings).map_err(settings_error)?; + if deadline.is_expired_at(clock.now()) { + return Err(configuration_error( + "native application initialization exceeds its extraction deadline", + )); + } + let snapshot = Arc::new(NativeStoreSnapshot { + config_store: Arc::new(ConfigSnapshot { + key: binding.default_key.clone(), + envelope: envelope.to_string(), + }), + secret_store, + }); + app.insert_state(snapshot); + app.insert_state(state); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use edgezero_core::app::Hooks as _; + + #[test] + fn signing_is_rejected_with_an_actionable_startup_error() { + let settings = Settings { + publisher: trusted_server_core::settings::Publisher { + origin_url: "https://origin.publisher.example.com".to_owned(), + ..Default::default() + }, + request_signing: Some(trusted_server_core::settings::RequestSigning { + enabled: true, + config_store_id: "fixture-config".to_owned(), + secret_store_id: "fixture-secrets".to_owned(), + }), + ..Settings::default() + }; + let error = validate_snapshot_consumers(&settings) + .expect_err("should reject signing before readiness"); + assert!(error.to_string().contains("disable signing")); + assert!(error.to_string().contains("KID")); + } + + #[test] + fn native_origin_requires_an_absolute_http_transport_uri() { + for origin in [ + "not-a-valid-url", + "ftp://origin.publisher.example.com", + "/relative", + ] { + let mut settings = datadome_settings(""); + settings.publisher.origin_url = origin.to_owned(); + let error = validate_snapshot_consumers(&settings) + .expect_err("should reject an unusable native origin before readiness"); + assert!(error.to_string().contains("publisher.origin_url")); + } + } + + fn datadome_settings(extra: &str) -> Settings { + Settings::from_toml(&format!( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "fixture-admin-password" + [publisher] + domain = "publisher.example.com" + cookie_domain = ".publisher.example.com" + origin_url = "https://origin.example.com" + proxy_secret = "fixture-proxy-secret-32-bytes-minimum" + [ec] + passphrase = "fixture-passphrase-32-bytes-minimum" + [integrations.datadome] + enabled = true + enable_protection = true + server_side_key_secret_name = "fixture-datadome-key" + {extra} + "# + )) + .expect("should parse consumer validation fixture") + } + + #[test] + fn dynamic_datadome_sources_are_rejected_but_inline_sources_are_supported() { + for extra in [ + "protection_excluded_ip_cidr_sources = [{ config_store = 'extra-store', key = 'cidrs' }]", + "protection_exclusion_rules = [{ id = 'fixture-rule', type = 'ip_cidr_source', config_store = 'extra-store', key = 'cidrs' }]", + ] { + let error = validate_snapshot_consumers(&datadome_settings(extra)) + .expect_err("should reject dynamic config reads before readiness"); + assert!(error.to_string().contains("use inline")); + } + validate_snapshot_consumers(&datadome_settings( + "protection_excluded_ip_cidrs = ['192.0.2.0/24']", + )) + .expect("should allow self-contained inline CIDRs"); + } + + #[test] + fn snapshots_reject_missing_keys_wrong_stores_and_management_writes() { + let config = ConfigSnapshot { + key: "config".to_owned(), + envelope: "envelope".to_owned(), + }; + let config_name = StoreName::from(DEFAULT_CONFIG_STORE_ID); + assert_eq!( + config + .get(&config_name, "config") + .expect("should read loaded envelope"), + "envelope" + ); + assert!(config.get(&config_name, "missing").is_err()); + assert!(config.get(&StoreName::from("other"), "config").is_err()); + assert!( + config + .put(&StoreId::from(DEFAULT_CONFIG_STORE_ID), "config", "new") + .is_err() + ); + assert!( + config + .delete(&StoreId::from(DEFAULT_CONFIG_STORE_ID), "config") + .is_err() + ); + let secrets = SecretSnapshot(BTreeMap::from([( + "token".to_owned(), + b"fixture-value".to_vec(), + )])); + let secret_name = StoreName::from(DEFAULT_SECRET_STORE_ID); + let mut returned = secrets + .get_bytes(&secret_name, "token") + .expect("should read loaded secret"); + returned.clear(); + assert_eq!( + secrets + .get_bytes(&secret_name, "token") + .expect("should retain immutable value"), + b"fixture-value" + ); + assert!(secrets.get_bytes(&secret_name, "missing").is_err()); + assert!( + secrets + .get_bytes(&StoreName::from("other"), "token") + .is_err() + ); + assert!( + secrets + .create(&StoreId::from(DEFAULT_SECRET_STORE_ID), "token", "new") + .is_err() + ); + assert!( + secrets + .delete(&StoreId::from(DEFAULT_SECRET_STORE_ID), "token") + .is_err() + ); + } + + #[tokio::test] + async fn missing_prepared_stores_fail_initialization() { + let mut app = App::new(crate::app::TrustedServerApp::routes()); + let error = initialize_native(&mut app, &PreparedStores::default()) + .await + .expect_err("should reject absent prepared stores"); + assert!(error.to_string().contains("not prepared")); + } +} diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/native/README.md b/crates/trusted-server-adapter-axum/tests/fixtures/native/README.md new file mode 100644 index 0000000000..aaa06124a5 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/fixtures/native/README.md @@ -0,0 +1,83 @@ +# Native acceptance fixtures + +`origin.rs` serves core's existing Next.js Flight fixture over local TCP. It also +records POST bodies, serves a deterministic API, and deliberately breaks one +POST response. `proxy.rs` is a real local gateway that replaces visitor +forwarding fields with fixed synthetic assertions. Neither needs AWS or DNS. + +Run the ordinary subprocess tests with: + +```sh +cargo test -p trusted-server-adapter-axum --target x86_64-unknown-linux-gnu \ + --test native_acceptance +``` + +The transport-sequence test aborts an inbound POST and also receives a truncated +origin POST response, then forwards three action-header POST/API pairs through +the same TS process and origin port. Its action origin is synthetic. This test +alone does not establish real Next.js server-action compatibility. + +## Real Next.js regression + +The existing integration Next.js fixture has no real server actions. Its unknown +ID scenario accepts 404/405, or a soft 404. That cannot prove that an action +executed after a truncated POST. + +The `nextjs/app` files add a real server action that checks its input and +redirects to `/?action=accepted`. The ignored acceptance test extracts the +compiled form action ID from the proxied page, aborts an upload, submits that +real action and requires its 303 redirect, then requires exact API JSON. The +origin explicitly allows only `publisher.example.com` for server actions, and +the test sends that public Host and Origin. This is normal reverse-proxy origin +configuration, not disabled CSRF protection. + +Prepare an isolated fixture with the existing integration fixture's lockfile. +Use the repository's pinned Node version. From the repository root: + +```sh +NEXT_FIXTURE_ROOT=$(mktemp -d /tmp/ts-native-nextjs.XXXXXX) +cp crates/trusted-server-integration-tests/fixtures/frameworks/nextjs/package{,-lock}.json \ + "$NEXT_FIXTURE_ROOT/" +cp -R crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app \ + "$NEXT_FIXTURE_ROOT/" +cp crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/next.config.mjs \ + "$NEXT_FIXTURE_ROOT/" +npm ci --prefix "$NEXT_FIXTURE_ROOT" --ignore-scripts +NATIVE_NEXTJS_PORT=$(node -e \ + 'const s=require("node:net").createServer(); s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close()})') +export NATIVE_NEXTJS_ORIGIN="http://127.0.0.1:$NATIVE_NEXTJS_PORT" +( + cd "$NEXT_FIXTURE_ROOT" + NEXT_TELEMETRY_DISABLED=1 timeout --signal=TERM --kill-after=3s 180s \ + node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 \ + --port "$NATIVE_NEXTJS_PORT" +) >"$NEXT_FIXTURE_ROOT/next.log" 2>&1 & +NEXT_FIXTURE_PID=$! +``` + +Prewarm `/` and `/api/data` directly before the two-second acceptance request +budget. These commands must return 200 before starting the Rust test: + +```sh +curl --fail --retry 20 --retry-connrefused --retry-delay 1 --max-time 60 \ + "$NATIVE_NEXTJS_ORIGIN/" >"$NEXT_FIXTURE_ROOT/page.html" +curl --fail --max-time 60 "$NATIVE_NEXTJS_ORIGIN/api/data" +cargo test -p trusted-server-adapter-axum --target x86_64-unknown-linux-gnu \ + --test native_acceptance real_nextjs_action_and_api_after_truncated_post \ + -- --ignored --exact --nocapture +``` + +The 180-second fixture limit includes compilation and the test. Start it again +if Cargo compilation consumed that budget. To clean up, allow `timeout` to drain +and kill the process group, then remove the temporary directory: + +```sh +wait "$NEXT_FIXTURE_PID" || true +rm -rf -- "$NEXT_FIXTURE_ROOT" +``` + +This opt-in test fails if setup is absent. A default-suite ignored result is not +proof of the required real Next.js regression. It covers an aborted inbound +POST before a real progressively enhanced action/API sequence. The ordinary +synthetic test separately covers a truncated upstream POST response. Neither +claims to reproduce the historical failure until executed and compared. diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/actions.js b/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/actions.js new file mode 100644 index 0000000000..c143c008f6 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/actions.js @@ -0,0 +1,10 @@ +'use server' + +import { redirect } from 'next/navigation' + +export async function acceptMessage(formData) { + if (formData.get('message') !== 'native-acceptance') { + throw new Error('Unexpected synthetic acceptance message') + } + redirect('/?action=accepted') +} diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/api/data/route.js b/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/api/data/route.js new file mode 100644 index 0000000000..06c255dc24 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/api/data/route.js @@ -0,0 +1,3 @@ +export async function GET() { + return Response.json({ native: 'real-nextjs-api' }) +} diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/layout.jsx b/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/layout.jsx new file mode 100644 index 0000000000..0ea378c6fc --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/layout.jsx @@ -0,0 +1,7 @@ +export default function Layout({ children }) { + return ( + + {children} + + ) +} diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/page.jsx b/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/page.jsx new file mode 100644 index 0000000000..84481db839 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/app/page.jsx @@ -0,0 +1,13 @@ +import { acceptMessage } from './actions' + +export default function Page() { + return ( +
+

Native real Next.js acceptance

+
+ + +
+
+ ) +} diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/next.config.mjs b/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/next.config.mjs new file mode 100644 index 0000000000..234915ea0f --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/next.config.mjs @@ -0,0 +1,6 @@ +// The local origin explicitly trusts only this fixture publisher for actions. +export default { + experimental: { + serverActions: { allowedOrigins: ['publisher.example.com'] }, + }, +} diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/native/origin.rs b/crates/trusted-server-adapter-axum/tests/fixtures/native/origin.rs new file mode 100644 index 0000000000..5ad9611924 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/fixtures/native/origin.rs @@ -0,0 +1,148 @@ +use std::io; +use std::net::TcpListener; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +use axum::Router; +use axum::body::{Body, Bytes, to_bytes}; +use axum::extract::{Request, State}; +use axum::http::Response; +use tokio::sync::oneshot; +use trusted_server_core::test_support::nextjs_auction; + +#[derive(Clone, Debug)] +pub struct Received { + pub path: String, + pub body: Vec, + pub next_action: Option, +} + +#[derive(Clone)] +struct OriginState { + base_url: String, + label: String, + received: Arc>>, +} + +pub struct Origin { + pub base_url: String, + pub received: Arc>>, + stop: Option>, + worker: Option>, +} + +impl Origin { + pub fn start(label: &str) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("should bind fixture origin"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("should get origin address") + ); + listener + .set_nonblocking(true) + .expect("should set nonblocking listener"); + let received = Arc::new(Mutex::new(Vec::new())); + let state = OriginState { + base_url: base_url.clone(), + label: label.to_owned(), + received: Arc::clone(&received), + }; + let (stop, stopped) = oneshot::channel(); + let worker = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("should build fixture runtime"); + runtime.block_on(async move { + let listener = tokio::net::TcpListener::from_std(listener) + .expect("should adopt fixture listener"); + let router = Router::new().fallback(origin).with_state(state); + tokio::select! { + result = axum::serve(listener, router) => result.expect("should serve fixture"), + _ = stopped => {} + } + }); + }); + Self { + base_url, + received, + stop: Some(stop), + worker: Some(worker), + } + } +} + +impl Drop for Origin { + fn drop(&mut self) { + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + if let Some(worker) = self.worker.take() { + worker.join().expect("should stop fixture runtime"); + } + } +} + +async fn origin(State(state): State, request: Request) -> Response { + let path = request.uri().path().to_owned(); + let next_action = request.headers().get("next-action").map(|value| { + value + .to_str() + .expect("should read action header") + .to_owned() + }); + let body = to_bytes(request.into_body(), 65536).await; + let Ok(body) = body else { + return Response::builder() + .status(400) + .body(Body::empty()) + .expect("should reject bad upload"); + }; + state + .received + .lock() + .expect("should lock origin observations") + .push(Received { + path: path.clone(), + body: body.to_vec(), + next_action: next_action.clone(), + }); + match path.as_str() { + // Force a response-framing failure after a fully received POST. The + // origin stays alive on the same port for the following action/API flow. + "/truncate-response" => Response::builder() + .header("content-type", "application/json") + .header("content-length", "1024") + .body(Body::from_stream(futures::stream::iter([ + Ok(Bytes::from_static(b"{\"partial\":")), + Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "synthetic truncated POST response", + )), + ]))) + .expect("should build truncated response"), + "/api/data" => Response::builder() + .header("content-type", "application/json") + .body(Body::from(format!("{{\"native\":\"{}\"}}", state.label))) + .expect("should build API response"), + "/action" if next_action.is_some() => Response::builder() + .header("content-type", "text/x-component") + .body(Body::from("0:{\"accepted\":true}\n")) + .expect("should build action response"), + _ => { + let html = nextjs_auction::origin_html() + .replace("https://origin.test-publisher.example.com", &state.base_url) + .replace( + "

prefix

", + &format!( + "

{}

asset", + state.label, state.base_url, + ), + ); + Response::builder() + .header("content-type", "text/html") + .body(Body::from(html)) + .expect("should build publisher page") + } + } +} diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/native/proxy.rs b/crates/trusted-server-adapter-axum/tests/fixtures/native/proxy.rs new file mode 100644 index 0000000000..2406339358 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/fixtures/native/proxy.rs @@ -0,0 +1,137 @@ +use std::net::TcpListener; +use std::thread::JoinHandle; +use std::time::Duration; + +use axum::Router; +use axum::body::{Body, to_bytes}; +use axum::extract::{Request, State}; +use axum::http::{Response, header}; +use tokio::sync::oneshot; + +#[derive(Clone)] +struct ProxyState { + upstream: String, + public_host: &'static str, + client: &'static str, +} + +/// A local gateway that replaces visitor assertions with its own fixed facts. +pub struct Proxy { + pub base_url: String, + stop: Option>, + worker: Option>, +} + +impl Proxy { + pub fn start(upstream: &str, public_host: &'static str, client: &'static str) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("should bind test gateway"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("should get gateway address") + ); + listener + .set_nonblocking(true) + .expect("should make gateway nonblocking"); + let state = ProxyState { + upstream: upstream.to_owned(), + public_host, + client, + }; + let (stop, stopped) = oneshot::channel(); + let worker = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("should build gateway runtime"); + runtime.block_on(async move { + let listener = tokio::net::TcpListener::from_std(listener) + .expect("should adopt gateway listener"); + let router = Router::new().fallback(relay).with_state(state); + tokio::select! { + result = axum::serve(listener, router) => result.expect("should serve gateway"), + _ = stopped => {} + } + }); + }); + Self { + base_url, + stop: Some(stop), + worker: Some(worker), + } + } +} + +impl Drop for Proxy { + fn drop(&mut self) { + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + if let Some(worker) = self.worker.take() { + worker.join().expect("should stop gateway"); + } + } +} + +async fn relay(State(state): State, request: Request) -> Response { + let (parts, body) = request.into_parts(); + let body = to_bytes(body, 65536) + .await + .expect("should read bounded gateway request"); + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(3)) + .build() + .expect("should build gateway HTTP client"); + let mut headers = parts.headers; + headers.remove("forwarded"); + let forwarding_names = headers + .keys() + .filter(|name| name.as_str().starts_with("x-forwarded-")) + .cloned() + .collect::>(); + for name in forwarding_names { + headers.remove(name); + } + headers.remove(header::HOST); + headers.remove(header::CONNECTION); + headers.insert( + "forwarded", + format!( + "for={};host=\"{}\";proto=https", + state.client, state.public_host, + ) + .parse() + .expect("should construct gateway assertion"), + ); + let url = format!( + "{}{}", + state.upstream, + parts + .uri + .path_and_query() + .map_or("/", |value| value.as_str()) + ); + let response = client + .request(parts.method, url) + .headers(headers) + .body(body) + .send() + .await + .expect("should relay request through real TS listener"); + let status = response.status(); + let headers = response.headers().clone(); + let body = response + .bytes() + .await + .expect("should read bounded test page"); + assert!( + body.len() < 2_000_000, + "should keep gateway fixture responses small" + ); + let mut response = Response::builder() + .status(status) + .body(Body::from(body)) + .expect("should construct relayed page"); + *response.headers_mut() = headers; + response +} diff --git a/crates/trusted-server-adapter-axum/tests/native_acceptance.rs b/crates/trusted-server-adapter-axum/tests/native_acceptance.rs new file mode 100644 index 0000000000..8f39717749 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/native_acceptance.rs @@ -0,0 +1,735 @@ +//! Subprocess acceptance for strict native startup and managed TS requests. + +#[path = "fixtures/native/origin.rs"] +mod origin; +#[path = "fixtures/native/proxy.rs"] +mod proxy; + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File}; +use std::io::{Read as _, Write as _}; +use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use edgezero_core::blob_envelope::BlobEnvelope; +use reqwest::blocking::{Client, Response}; +use serde_json::{Value, json}; +use tempfile::TempDir; +use trusted_server_core::config::TrustedServerAppConfig; +use trusted_server_core::config_payload::{ + CONFIG_BLOB_KEY, DEFAULT_CONFIG_STORE_ID, VerifiedConfigBlob, +}; +use trusted_server_core::test_support::nextjs_auction; + +const ADMIN_PASSWORD: &str = "native-admin-password-32-bytes-example"; +const PROXY_SECRET: &str = "native-proxy-secret-32-bytes-example"; +const EC_PASSPHRASE: &str = "native-ec-passphrase-32-bytes-example"; + +struct Prepared { + workspace: TempDir, + data: Value, + secrets: BTreeMap, +} + +impl Prepared { + fn new(origin: &str) -> Self { + // Reuse core's typed Next.js fixture and the production BlobEnvelope. + // Only the existing TS shape is customized, never another config format. + let mut settings = nextjs_auction::settings(); + settings.publisher.domain = "publisher.example.com".to_owned(); + settings.publisher.cookie_domain = ".publisher.example.com".to_owned(); + settings.publisher.origin_url = origin.to_owned(); + settings.auction.enabled = false; + settings.auction.providers.clear(); + settings.creative_opportunities = None; + let config = + TrustedServerAppConfig::new(settings).expect("should validate fixture settings"); + let mut data = serde_json::to_value(config).expect("should serialize typed TS config"); + data["handlers"][0]["password"] = json!("native_admin"); + let mut protected_handler = data["handlers"][0].clone(); + protected_handler["path"] = json!("^/protected"); + data["handlers"] + .as_array_mut() + .expect("should have fixture handlers") + .push(protected_handler); + data["publisher"]["proxy_secret"] = json!("native_proxy"); + data["ec"]["passphrase"] = json!("native_ec"); + let fixture = Self { + workspace: tempfile::tempdir().expect("should create isolated prepared-store root"), + data, + secrets: BTreeMap::from([ + ("native_admin".to_owned(), ADMIN_PASSWORD.to_owned()), + ("native_proxy".to_owned(), PROXY_SECRET.to_owned()), + ("native_ec".to_owned(), EC_PASSPHRASE.to_owned()), + ]), + }; + let verified = VerifiedConfigBlob::parse(&fixture.envelope()) + .expect("should verify production envelope"); + assert_eq!( + verified + .required_secret_keys() + .expect("should read active references"), + fixture.secrets.keys().cloned().collect::>(), + "should prepare exactly active fixture secrets" + ); + fixture.publish(); + fixture + } + + fn envelope(&self) -> String { + serde_json::to_string(&BlobEnvelope::new( + self.data.clone(), + "2026-10-20T00:00:00Z".to_owned(), + )) + .expect("should serialize production envelope") + } + + fn config_path(&self) -> PathBuf { + self.workspace + .path() + .join(format!("local-config-{DEFAULT_CONFIG_STORE_ID}.json")) + } + + fn publish(&self) { + fs::write( + self.config_path(), + serde_json::to_vec(&json!({ CONFIG_BLOB_KEY: self.envelope() })) + .expect("should serialize local store map"), + ) + .expect("should publish local prepared config"); + } + + fn spawn(&self, trusted_proxy: bool) -> Running { + let port = TcpListener::bind("127.0.0.1:0") + .expect("should reserve isolated port") + .local_addr() + .expect("should read reserved port") + .port(); + let address = SocketAddr::from(([127, 0, 0, 1], port)); + let root = self + .workspace + .path() + .canonicalize() + .expect("should use absolute store roots"); + let log_path = root.join(format!("native-{port}.log")); + let log = File::create(&log_path).expect("should create child log"); + let mut command = Command::new(env!("CARGO_BIN_EXE_trusted-server-axum")); + command + .env_clear() + .current_dir(&root) + .env("RUST_LOG", "info") + .env("EDGEZERO__ADAPTER__HOST", "127.0.0.1") + .env("EDGEZERO__ADAPTER__PORT", port.to_string()) + .env("EDGEZERO__ADAPTER__CONFIG_DIR", &root) + .env("EDGEZERO__ADAPTER__DATA_DIR", &root) + .env("EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS", "1") + // A valid old bridge cannot rescue missing prepared inputs. An + // invalid settings override cannot poison a valid prepared input. + .env( + "TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG", + self.envelope(), + ) + .env( + "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_NATIVE_ADMIN", + ADMIN_PASSWORD, + ) + .env( + "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_NATIVE_PROXY", + PROXY_SECRET, + ) + .env( + "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_NATIVE_EC", + EC_PASSPHRASE, + ) + .env( + "TRUSTED_SERVER__PUBLISHER__ORIGIN_URL", + "http://poison.example.com", + ) + .env("TRUSTED_SERVER__EC__PASSPHRASE", "poison") + .stdout(Stdio::from( + log.try_clone().expect("should clone child log"), + )) + .stderr(Stdio::from(log)); + for (key, value) in &self.secrets { + command.env( + format!("EDGEZERO__SECRETS__{}", key.to_ascii_uppercase()), + value, + ); + } + if trusted_proxy { + command + .env("EDGEZERO__ADAPTER__TRUSTED_PROXY_CIDRS", "127.0.0.1/32") + .env("EDGEZERO__ADAPTER__FORWARDING_HEADER_FAMILY", "forwarded"); + } + Running { + child: command + .spawn() + .expect("should launch real native TS binary"), + address, + log_path, + } + } +} + +struct Running { + child: Child, + address: SocketAddr, + log_path: PathBuf, +} + +impl Running { + fn base_url(&self) -> String { + format!("http://{}", self.address) + } + + fn log(&self) -> String { + fs::read_to_string(&self.log_path).unwrap_or_default() + } + + fn ready(&mut self) { + let client = client(None); + let deadline = Instant::now() + Duration::from_secs(15); + while Instant::now() < deadline { + assert!( + self.child + .try_wait() + .expect("should poll child exit") + .is_none(), + "should not exit before readiness: {}", + self.log() + ); + if let Ok(response) = client.get(format!("{}/health", self.base_url())).send() + && response.status().as_u16() == 200 + && response.text().is_ok_and(|body| body == "ok") + { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + assert!( + Instant::now() < deadline, + "should reach exact HTTP 200 health body ok: {}", + self.log() + ); + } + + fn rejected(&mut self, diagnostic: &str) { + let deadline = Instant::now() + Duration::from_secs(8); + loop { + assert!( + TcpStream::connect_timeout(&self.address, Duration::from_millis(30)).is_err(), + "should fail before opening listener: {}", + self.log() + ); + if let Some(status) = self.child.try_wait().expect("should poll rejected child") { + assert!(!status.success(), "should exit nonzero for invalid startup"); + let log = self.log(); + assert!( + log.contains(diagnostic), + "should report {diagnostic}: {log}" + ); + for secret in [ADMIN_PASSWORD, PROXY_SECRET, EC_PASSPHRASE] { + assert!(!log.contains(secret), "should not log secret values"); + } + return; + } + assert!( + Instant::now() < deadline, + "should reject startup within deadline: {}", + self.log() + ); + std::thread::sleep(Duration::from_millis(20)); + } + } +} + +impl Drop for Running { + fn drop(&mut self) { + let _ = self.child.kill(); + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline { + if matches!(self.child.try_wait(), Ok(Some(_))) { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + log::error!( + "native child did not exit within bounded teardown: {}", + self.log() + ); + } +} + +fn client(source: Option) -> Client { + Client::builder() + .no_proxy() + .local_address(source) + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(2)) + .build() + .expect("should build bounded local client") +} + +fn successful_body(response: Response) -> String { + assert_eq!( + response.status().as_u16(), + 200, + "should serve real TS request" + ); + response.text().expect("should collect TS response") +} + +fn page(client: &Client, base_url: &str) -> String { + successful_body( + client + .get(format!("{base_url}/")) + .send() + .expect("should fetch TS publisher page"), + ) +} + +fn assert_rewrite(body: &str, public_origin: &str, origin: &str) { + assert!( + body.contains(&format!("href=\"{public_origin}/asset\"")), + "should rewrite using request-local checked public URL: {body}" + ); + assert!( + !body.contains(&format!("href=\"{origin}/asset\"")), + "should not leak origin href" + ); +} + +#[test] +fn prepared_envelope_serves_real_health_tsjs_and_rewritten_page() { + let origin = origin::Origin::start("native-page"); + let fixture = Prepared::new(&origin.base_url); + let mut app = fixture.spawn(false); + app.ready(); + let client = client(None); + let body = page(&client, &app.base_url()); + assert!( + body.contains("native-page"), + "should reach actual configured origin" + ); + assert!( + body.contains("self.__next_f.push"), + "should retain core Next.js fixture Flight data" + ); + assert_rewrite(&body, &app.base_url(), &origin.base_url); + let start = body + .find("/static/tsjs=") + .expect("should inject real TSJS URL"); + let end = body[start..] + .find('"') + .expect("should terminate TSJS source attribute"); + let script = body[start..start + end].replace("&", "&"); + let response = client + .get(format!("{}{script}", app.base_url())) + .send() + .expect("should fetch injected TSJS URL"); + assert!( + response + .headers() + .get("content-type") + .expect("should serve script content type") + .to_str() + .expect("should read content type") + .contains("javascript") + ); + assert!( + successful_body(response).len() > 100, + "should serve embedded bundle, not an empty router" + ); +} + +#[test] +fn missing_prepared_inputs_and_invalid_runtime_config_exit_before_listen() { + let origin = origin::Origin::start("unused"); + let fixture = Prepared::new(&origin.base_url); + fs::remove_file(fixture.config_path()).expect("should remove required config snapshot"); + fixture.spawn(false).rejected("required snapshot missing"); + fs::write(fixture.config_path(), "{}").expect("should publish empty config map"); + fixture.spawn(false).rejected("config envelope is missing"); + fixture.publish(); + for key in ["native_admin", "native_proxy", "native_ec"] { + let mut incomplete = Prepared::new(&origin.base_url); + incomplete.secrets.remove(key); + incomplete + .spawn(false) + .rejected("a required app-config secret is missing"); + } + let mut invalid = Prepared::new(&origin.base_url); + invalid.data["publisher"]["origin_url"] = json!("not-a-valid-url"); + invalid.publish(); + invalid + .spawn(false) + .rejected("Trusted Server initialization failed"); + let mut invalid = Prepared::new(&origin.base_url); + invalid + .secrets + .insert("native_ec".to_owned(), "short".to_owned()); + invalid + .spawn(false) + .rejected("Trusted Server initialization failed"); +} + +#[test] +fn absolute_request_target_cannot_override_checked_public_scheme() { + let origin = origin::Origin::start("checked-target"); + let fixture = Prepared::new(&origin.base_url); + let mut app = fixture.spawn(false); + app.ready(); + let mut socket = + TcpStream::connect(app.address).expect("should connect directly to native ingress"); + socket + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("should bound the response read"); + socket.write_all(b"GET https://publisher.example.com/first-party/sign?url=%2F%2Fads.example.com%2Fcreative HTTP/1.1\r\nHost: publisher.example.com\r\nX-Forwarded-Proto: https\r\nConnection: close\r\n\r\n") + .expect("should submit an absolute request target with a spoofed scheme"); + let mut response = String::new(); + socket + .take(64 * 1024) + .read_to_string(&mut response) + .expect("should read a bounded native response"); + assert!( + response.starts_with("HTTP/1.1 200"), + "should execute the local signing route: {response}" + ); + assert!( + response.contains("http://ads.example.com"), + "should use checked direct HTTP, including URI-based consumers: {response}" + ); + assert!( + !response.contains("https://ads.example.com"), + "should not adopt raw target or forwarding scheme" + ); +} + +#[test] +fn managed_post_bodies_reach_buffered_api_handlers_and_keep_their_limits() { + let origin = origin::Origin::start("buffered-api"); + let fixture = Prepared::new(&origin.base_url); + let mut app = fixture.spawn(false); + app.ready(); + let client = client(None); + let auction = client + .post(format!("{}/auction", app.base_url())) + .json(&json!({"adUnits": []})) + .send() + .expect("should submit a lazy managed auction body"); + assert_eq!(auction.status().as_u16(), 200); + let auction: Value = auction + .json() + .expect("should receive a real OpenRTB no-bid response"); + assert!( + auction + .get("seatbid") + .is_none_or(|bids| bids.as_array().is_some_and(Vec::is_empty)) + ); + let signed = client + .post(format!("{}/first-party/sign", app.base_url())) + .json(&json!({"url": "https://ads.example.com/creative"})) + .send() + .expect("should submit first-party signing JSON"); + assert_eq!( + signed.status().as_u16(), + 200, + "should buffer the lazy request before the existing synchronous parser" + ); + let verified = client + .post(format!("{}/verify-signature", app.base_url())) + .json(&json!({"payload": "fixture", "signature": "invalid", "kid": "fixture"})) + .send() + .expect("should submit verification JSON"); + assert_eq!( + verified.status().as_u16(), + 200, + "missing key is a verification result, not a discarded-body parse failure" + ); + let oversized = client + .post(format!("{}/verify-signature", app.base_url())) + .json(&json!({"payload": "x".repeat(4096), "signature": "invalid", "kid": "fixture"})) + .send() + .expect("should reject the bounded API upload"); + assert_eq!(oversized.status().as_u16(), 413); +} + +#[test] +fn running_snapshot_is_immutable_and_restart_adopts_config_and_secret() { + let first = origin::Origin::start("snapshot-first"); + let second = origin::Origin::start("snapshot-second"); + let mut fixture = Prepared::new(&first.base_url); + let mut app = fixture.spawn(false); + app.ready(); + let client = client(None); + assert!(page(&client, &app.base_url()).contains("snapshot-first")); + fixture.data["publisher"]["origin_url"] = json!(second.base_url); + fixture.secrets.insert( + "native_admin".to_owned(), + "replacement-admin-password-32-bytes-example".to_owned(), + ); + fixture.publish(); + assert!( + page(&client, &app.base_url()).contains("snapshot-first"), + "should not reload edited config under a running app" + ); + let protected = format!("{}/protected", app.base_url()); + assert_eq!( + client + .get(&protected) + .basic_auth("admin", Some(ADMIN_PASSWORD)) + .send() + .expect("should authorize old snapshot password") + .status() + .as_u16(), + 200 + ); + assert_eq!( + client + .get(&protected) + .basic_auth("admin", fixture.secrets.get("native_admin")) + .send() + .expect("should reject unpublished child secret change") + .status() + .as_u16(), + 401 + ); + drop(app); + let mut app = fixture.spawn(false); + app.ready(); + assert!( + page(&client, &app.base_url()).contains("snapshot-second"), + "should adopt config on restart" + ); + let protected = format!("{}/protected", app.base_url()); + assert_eq!( + client + .get(&protected) + .basic_auth("admin", Some(ADMIN_PASSWORD)) + .send() + .expect("should reject superseded password") + .status() + .as_u16(), + 401 + ); + assert_eq!( + client + .get(&protected) + .basic_auth("admin", fixture.secrets.get("native_admin")) + .send() + .expect("should authorize restarted secret") + .status() + .as_u16(), + 200 + ); +} + +#[test] +fn managed_ingress_ignores_direct_spoof_and_isolates_two_gateway_clients() { + let origin = origin::Origin::start("ingress-page"); + let fixture = Prepared::new(&origin.base_url); + let mut app = fixture.spawn(false); + app.ready(); + let visitor = client(None); + let response = visitor + .get(app.base_url()) + .header( + "forwarded", + "for=192.0.2.99;host=spoof.example.com;proto=https", + ) + .header("x-forwarded-host", "spoof.example.com") + .header("x-forwarded-proto", "https") + .header("x-forwarded-for", "192.0.2.99") + .send() + .expect("should send untrusted forwarding assertions"); + let body = successful_body(response); + assert_rewrite(&body, &app.base_url(), &origin.base_url); + assert!(!body.contains("https://spoof.example.com/asset")); + drop(app); + + let mut app = fixture.spawn(true); + app.ready(); + // Only the gateways connect from the declared trusted 127.0.0.1 address. + // Visitors use distinct source addresses and independent client pools. + let alpha_client = client(Some( + "127.0.0.2".parse().expect("should parse alpha source"), + )); + let beta_client = client(Some("127.0.0.3".parse().expect("should parse beta source"))); + let direct = alpha_client + .get(app.base_url()) + .header( + "forwarded", + "for=192.0.2.99;host=spoof.example.com;proto=https", + ) + .send() + .expect("should bypass gateway from an untrusted peer"); + assert_rewrite(&successful_body(direct), &app.base_url(), &origin.base_url); + let alpha = proxy::Proxy::start(&app.base_url(), "alpha.example.com", "192.0.2.10"); + let beta = proxy::Proxy::start(&app.base_url(), "beta.example.com", "192.0.2.20"); + std::thread::scope(|scope| { + let alpha_request = scope.spawn(|| page(&alpha_client, &alpha.base_url)); + let beta_request = scope.spawn(|| page(&beta_client, &beta.base_url)); + let alpha_body = alpha_request.join().expect("should complete alpha request"); + let beta_body = beta_request.join().expect("should complete beta request"); + assert_rewrite(&alpha_body, "https://alpha.example.com", &origin.base_url); + assert_rewrite(&beta_body, "https://beta.example.com", &origin.base_url); + assert!(!alpha_body.contains("https://beta.example.com/asset")); + assert!(!beta_body.contains("https://alpha.example.com/asset")); + }); + assert_rewrite( + &page(&alpha_client, &alpha.base_url), + "https://alpha.example.com", + &origin.base_url, + ); +} + +fn truncate_inbound_post(app: &Running) { + let mut socket = TcpStream::connect_timeout(&app.address, Duration::from_secs(1)) + .expect("should connect partial POST"); + socket + .set_write_timeout(Some(Duration::from_secs(1))) + .expect("should bound partial write"); + write!(socket, + "POST /action HTTP/1.1\r\nHost: {}\r\nNext-Action: native-action\r\nContent-Type: text/plain\r\nContent-Length: 128\r\n\r\n[", + app.address, + ).expect("should write deliberately incomplete POST"); + // Closing without the declared bytes reproduces an aborted upload. + drop(socket); +} + +#[test] +fn truncated_post_then_action_and_api_keep_using_the_same_live_origin() { + let origin = origin::Origin::start("transport-sequence"); + let fixture = Prepared::new(&origin.base_url); + let mut app = fixture.spawn(false); + app.ready(); + let client = client(None); + truncate_inbound_post(&app); + let broken = client + .post(format!("{}/truncate-response", app.base_url())) + .body("[]") + .send(); + match broken { + Err(_) => {} + Ok(response) if response.status().is_server_error() => {} + Ok(response) => assert!( + response.bytes().is_err(), + "should expose truncated origin response" + ), + } + for _ in 0..3 { + let response = client + .post(format!("{}/action", app.base_url())) + .header("next-action", "native-action") + .header("content-type", "text/plain;charset=UTF-8") + .body("[\"synthetic-action\"]") + .send() + .expect("should forward action after truncated POST"); + assert_eq!(successful_body(response), "0:{\"accepted\":true}\n"); + let body = successful_body( + client + .get(format!("{}/api/data", app.base_url())) + .send() + .expect("should forward API after action"), + ); + assert_eq!( + serde_json::from_str::(&body).expect("should retain API JSON"), + json!({"native": "transport-sequence"}) + ); + } + let received = origin + .received + .lock() + .expect("should read origin observations"); + assert_eq!( + received + .iter() + .filter(|request| request.path == "/action" + && request.body == b"[\"synthetic-action\"]" + && request.next_action.as_deref() == Some("native-action")) + .count(), + 3, + "should deliver three complete action bodies despite preceding truncated POST" + ); +} + +/// The ordinary origin above checks wire contracts, not a real Next.js runtime. +/// Run this separately against the real server-action fixture described in +/// tests/fixtures/native/README.md. Missing external setup is never a pass. +#[test] +#[ignore = "requires the local real Next.js fixture and NATIVE_NEXTJS_ORIGIN"] +fn real_nextjs_action_and_api_after_truncated_post() { + let origin = std::env::var("NATIVE_NEXTJS_ORIGIN") + .expect("should explicitly supply local real Next.js origin"); + let url = reqwest::Url::parse(&origin).expect("should parse local Next.js URL"); + assert_eq!( + url.scheme(), + "http", + "should use account-free local HTTP fixture" + ); + assert!( + matches!(url.host_str(), Some("127.0.0.1" | "localhost")), + "should never contact external origin" + ); + let fixture = Prepared::new(&origin); + let mut app = fixture.spawn(false); + app.ready(); + let client = client(None); + // The fixture instructions prewarm Next's compiler before this test. + let body = page(&client, &app.base_url()); + let marker = "$ACTION_ID_"; + let start = body + .find(marker) + .expect("should find actual compiled server-action form") + + marker.len(); + let end = body[start..] + .find('"') + .expect("should terminate compiled action identifier"); + let action = &body[start..start + end]; + assert!( + !action.is_empty(), + "should extract nonempty real server-action ID" + ); + truncate_inbound_post(&app); + let boundary = "native-acceptance-boundary"; + let form = format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"$ACTION_ID_{action}\"\r\n\r\n\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"message\"\r\n\r\nnative-acceptance\r\n--{boundary}--\r\n" + ); + let response = client + .post(app.base_url()) + .header("origin", "http://publisher.example.com") + .header("host", "publisher.example.com") + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(form) + .send() + .expect("should invoke real Next.js server action after truncated upload"); + assert_eq!( + response.status().as_u16(), + 303, + "should execute real action redirect, not accept unknown-ID 404" + ); + assert!( + response + .headers() + .get("location") + .expect("should redirect from action") + .to_str() + .expect("should read action redirect") + .contains("action=accepted") + ); + let body = successful_body( + client + .get(format!("{}/api/data", app.base_url())) + .send() + .expect("should reach real Next.js API after real action"), + ); + assert_eq!( + serde_json::from_str::(&body).expect("should parse real API response"), + json!({"native": "real-nextjs-api"}) + ); +} diff --git a/crates/trusted-server-adapter-axum/tests/native_http.rs b/crates/trusted-server-adapter-axum/tests/native_http.rs new file mode 100644 index 0000000000..7305dd0861 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/native_http.rs @@ -0,0 +1,612 @@ +//! Native bridge regressions. These are transport tests, not full TS route acceptance. + +use std::collections::VecDeque; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; +use std::time::Duration; + +use async_trait::async_trait; +use edgezero_adapter_axum::outbound::AxumOutboundClient; +use edgezero_core::body::{Body, BodyContent}; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{HeaderMap, Method, StatusCode, header, request_builder}; +use edgezero_core::outbound::{ + ContentTypeProvenance, HttpClient, OutboundBatch, OutboundBatchDriverEvent, OutboundBatchItem, + OutboundHttpClient, OutboundRequest, OutboundResponse, OutboundSlotResult, +}; +use edgezero_core::time::{Deadline, MonotonicClock}; +use futures::{Stream, StreamExt as _, stream}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use trusted_server_adapter_axum::platform::AxumPlatformHttpClient; +use trusted_server_core::platform::{PlatformHttpClient as _, PlatformHttpRequest}; + +fn bridge() -> AxumPlatformHttpClient { + let clock = MonotonicClock::default(); + let client = AxumOutboundClient::try_with_clock(clock.clone()) + .expect("should construct native RC client"); + AxumPlatformHttpClient::new(HttpClient::with_client(client), clock) +} + +fn request(url: &str, backend: &str) -> PlatformHttpRequest { + PlatformHttpRequest::new( + request_builder() + .uri(url) + .body(Body::empty()) + .expect("should build request"), + backend, + ) +} + +async fn body_bytes(body: Body) -> Vec { + match body.into_content() { + BodyContent::Once(bytes) => bytes.to_vec(), + BodyContent::Stream(mut stream) => { + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + bytes.extend_from_slice(&chunk.expect("should read body chunk")); + } + bytes + } + } +} + +async fn read_request_head(socket: &mut tokio::net::TcpStream) { + let mut head = Vec::new(); + let mut chunk = [0; 2048]; + loop { + let count = socket + .read(&mut chunk) + .await + .expect("should read request head"); + assert!(count > 0, "should not close before the request head"); + head.extend_from_slice(&chunk[..count]); + assert!( + head.len() <= 64 * 1024, + "should bound fixture request headers" + ); + if head.windows(4).any(|window| window == b"\r\n\r\n") { + return; + } + } +} + +async fn raw_server(response: &'static [u8]) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind raw origin"); + let address = listener.local_addr().expect("should read origin address"); + tokio::spawn(async move { + let (mut socket, _) = listener + .accept() + .await + .expect("should accept origin request"); + read_request_head(&mut socket).await; + socket + .write_all(response) + .await + .expect("should send origin response"); + }); + format!("http://{address}/") +} + +#[tokio::test] +async fn redirects_are_exposed_for_core_per_hop_authorization() { + let url = raw_server( + b"HTTP/1.1 307 Temporary Redirect\r\nLocation: https://example.com/next\r\nContent-Length: 0\r\n\r\n", + ).await; + let response = bridge() + .send(request(&url, "redirect")) + .await + .expect("should return redirect without following it") + .response; + assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!( + response.headers()[header::LOCATION], + "https://example.com/next" + ); +} + +#[tokio::test] +async fn duplicate_cookies_survive_and_hop_headers_do_not() { + let url = raw_server( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nSet-Cookie: first=1\r\nSet-Cookie: second=2\r\nConnection: close, x-remove\r\nX-Remove: hidden\r\n\r\nok", + ).await; + let response = bridge() + .send(request(&url, "cookies")) + .await + .expect("should return response") + .response; + assert_eq!( + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .count(), + 2 + ); + assert!(!response.headers().contains_key(header::CONNECTION)); + assert!(!response.headers().contains_key("x-remove")); + assert_eq!(body_bytes(response.into_body()).await, b"ok"); +} + +#[tokio::test] +async fn rc_owns_gzip_decoding_and_ts_receives_matching_identity_metadata() { + let url = raw_server( + b"HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: 22\r\n\r\n\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03\xcb\xcf\x06\x00\x47\xdd\xdc\x79\x02\x00\x00\x00", + ).await; + let response = bridge() + .send(request(&url, "gzip")) + .await + .expect("should decode gzip once in RC") + .response; + assert!(!response.headers().contains_key(header::CONTENT_ENCODING)); + assert!(!response.headers().contains_key(header::CONTENT_LENGTH)); + assert_eq!(body_bytes(response.into_body()).await, b"ok"); +} + +#[tokio::test] +async fn dropping_streamed_response_releases_the_origin_connection() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind streaming origin"); + let address = listener.local_addr().expect("should read address"); + let origin = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("should accept request"); + let mut buffer = [0; 4096]; + read_request_head(&mut socket).await; + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1000000\r\n\r\nx") + .await + .expect("should write response head"); + tokio::time::timeout(Duration::from_secs(1), socket.read(&mut buffer)).await + }); + let response = bridge() + .send(request(&format!("http://{address}/"), "dropped-stream").with_stream_response()) + .await + .expect("should receive stream"); + drop(response); + let closed = origin + .await + .expect("should join origin") + .expect("should release origin promptly"); + assert!( + matches!(closed, Ok(0)) || closed.is_err(), + "should close rather than leak the unread response" + ); +} + +#[tokio::test] +async fn caller_response_bound_applies_to_send_and_pending() { + for pending in [false, true] { + let url = raw_server(b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nbody").await; + let mut outbound = request(&url, "bounded"); + outbound.max_response_body_bytes = 3; + let client = bridge(); + let result = if pending { + let handle = client + .send_async(outbound) + .await + .expect("should start bounded request"); + client.wait(handle).await + } else { + client.send(outbound).await + }; + assert!( + result.is_err(), + "should reject a response over the caller's bound" + ); + } +} + +#[tokio::test] +async fn streamed_upload_is_bounded_before_network_work() { + for pending in [false, true] { + let source = stream::iter([Ok(Body::from("oversized") + .into_bytes() + .expect("should have bytes"))]); + let edge_request = request_builder() + .method(Method::POST) + .uri("http://example.com/") + .body(Body::from_stream(source)) + .expect("should build streaming upload"); + let mut outbound = PlatformHttpRequest::new(edge_request, "upload"); + outbound.max_request_body_bytes = 3; + let rejected = if pending { + bridge().send_async(outbound).await.is_err() + } else { + bridge().send(outbound).await.is_err() + }; + assert!(rejected, "should bound upload before network work"); + } +} + +#[tokio::test] +async fn pending_post_preserves_streamed_upload_and_duplicate_request_headers() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind POST origin"); + let address = listener.local_addr().expect("should read origin address"); + let origin = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("should accept POST"); + let mut request = Vec::new(); + let mut buffer = [0; 4096]; + loop { + let read = socket + .read(&mut buffer) + .await + .expect("should read POST bytes"); + assert_ne!(read, 0, "should not truncate the uploaded body"); + request.extend_from_slice(&buffer[..read]); + if let Some(head_end) = request.windows(4).position(|window| window == b"\r\n\r\n") + && request.len() >= head_end + 4 + 7 + { + break; + } + } + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + .await + .expect("should respond to POST"); + request + }); + let source = stream::iter([ + Ok(Body::from("one") + .into_bytes() + .expect("should have first chunk")), + Ok(Body::from("-two") + .into_bytes() + .expect("should have second chunk")), + ]); + let outbound = request_builder() + .method(Method::POST) + .uri(format!("http://{address}/")) + .header(header::HOST, "publisher.example.com") + .header("x-example", "first") + .header("x-example", "second") + .body(Body::from_stream(source)) + .expect("should build streamed POST"); + let client = bridge(); + let pending = client + .send_async(PlatformHttpRequest::new(outbound, "post")) + .await + .expect("should start POST batch"); + tokio::time::timeout(Duration::from_secs(1), client.wait(pending)) + .await + .expect("should finish POST promptly") + .expect("should complete POST"); + let received = origin.await.expect("should join POST origin"); + let received = String::from_utf8(received).expect("should have textual fixture bytes"); + assert!(received.starts_with("POST / HTTP/1.1\r\n")); + assert!(received.ends_with("\r\n\r\none-two")); + assert!(received.contains("host: publisher.example.com\r\n")); + assert!(received.contains("x-example: first\r\n")); + assert!(received.contains("x-example: second\r\n")); +} + +#[tokio::test] +async fn expired_caller_deadline_is_rejected_before_network_work() { + for pending in [false, true] { + let clock = MonotonicClock::default(); + let transport = AxumOutboundClient::try_with_clock(clock.clone()) + .expect("should construct native transport"); + let client = AxumPlatformHttpClient::new(HttpClient::with_client(transport), clock.clone()); + let mut outbound = request("http://example.com/", "expired"); + outbound.deadline = Some(clock.deadline_after(Duration::ZERO)); + let rejected = if pending { + client.send_async(outbound).await.is_err() + } else { + client.send(outbound).await.is_err() + }; + assert!(rejected, "should reject expired absolute caller deadline"); + } +} + +#[tokio::test] +async fn bodyless_streaming_response_keeps_head_representation_length() { + let url = raw_server(b"HTTP/1.1 200 OK\r\nContent-Length: 9000\r\n\r\n").await; + let edge_request = request_builder() + .method(Method::HEAD) + .uri(url) + .body(Body::empty()) + .expect("should build HEAD request"); + let response = bridge() + .send(PlatformHttpRequest::new(edge_request, "head").with_stream_response()) + .await + .expect("should finish bodyless response") + .response; + assert_eq!(response.headers()[header::CONTENT_LENGTH], "9000"); + assert!(body_bytes(response.into_body()).await.is_empty()); +} + +#[tokio::test] +async fn total_timeout_covers_waiting_for_response_headers() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind stalled origin"); + let address = listener.local_addr().expect("should read address"); + let origin = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("should accept request"); + read_request_head(&mut socket).await; + tokio::time::sleep(Duration::from_secs(5)).await; + }); + let mut outbound = request(&format!("http://{address}/"), "timeout"); + outbound.timeout = Some(Duration::from_millis(30)); + let result = tokio::time::timeout(Duration::from_secs(1), bridge().send(outbound)) + .await + .expect("should enforce total transport timeout"); + origin.abort(); + assert!(result.is_err(), "should reject stalled origin"); +} + +#[tokio::test] +async fn streamed_body_retains_total_timeout_after_response_head() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind streaming origin"); + let address = listener.local_addr().expect("should read address"); + let origin = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("should accept request"); + read_request_head(&mut socket).await; + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nx") + .await + .expect("should write partial response"); + tokio::time::sleep(Duration::from_secs(5)).await; + }); + let mut outbound = + request(&format!("http://{address}/"), "stream-timeout").with_stream_response(); + outbound.timeout = Some(Duration::from_millis(100)); + let response = bridge() + .send(outbound) + .await + .expect("should receive response head") + .response; + let mut stream = response + .into_body() + .into_stream() + .expect("should preserve streamed response body"); + let result = tokio::time::timeout(Duration::from_secs(1), async { + while let Some(chunk) = stream.next().await { + if chunk.is_err() { + return true; + } + } + false + }) + .await + .expect("should enforce total streamed-body timeout"); + origin.abort(); + assert!(result, "should surface a body timeout instead of clean EOF"); +} + +#[tokio::test] +async fn separate_rc_batches_progress_concurrently_during_select() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind fanout origin"); + let address = listener.local_addr().expect("should read origin address"); + let origin = tokio::spawn(async move { + let mut sockets = Vec::new(); + for _ in 0..2 { + let (mut socket, _) = listener + .accept() + .await + .expect("should accept each fanout request"); + read_request_head(&mut socket).await; + sockets.push(socket); + } + // Neither origin answers until both requests arrive. Sequential sends + // would deadlock here and fail the bounded wait below. + for mut socket in sockets.into_iter().rev() { + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + .await + .expect("should answer fanout request"); + } + }); + let client = bridge(); + let first = client + .send_async(request(&format!("http://{address}/first"), "first")) + .await + .expect("should start first handle"); + let second = client + .send_async(request(&format!("http://{address}/second"), "second")) + .await + .expect("should start second handle"); + let selected = tokio::time::timeout(Duration::from_secs(1), client.select(vec![first, second])) + .await + .expect("should progress both requests concurrently") + .expect("should select a response"); + let completed_backend = selected + .ready + .expect("should complete response") + .backend_name + .expect("should identify backend"); + assert_eq!(selected.remaining.len(), 1); + let remaining_backend = selected.remaining[0] + .backend_name() + .expect("should retain other backend") + .to_owned(); + assert_ne!(completed_backend, remaining_backend); + client + .wait( + selected + .remaining + .into_iter() + .next() + .expect("should retain one handle"), + ) + .await + .expect("should complete second response"); + origin.await.expect("should join fanout origin"); +} + +fn terminal(index: usize) -> OutboundBatchDriverEvent { + OutboundBatchDriverEvent::Item(OutboundBatchItem::new( + index, + OutboundSlotResult::new( + Duration::ZERO, + Ok(OutboundResponse::new( + Method::GET, + StatusCode::OK, + HeaderMap::new(), + Body::from("ok"), + MonotonicClock::default(), + ContentTypeProvenance::Unknown, + )), + ), + )) +} + +enum Event { + Terminal(usize), + Cutoff, + Failure, +} + +impl Event { + fn into_driver_event(self) -> OutboundBatchDriverEvent { + match self { + Self::Terminal(index) => terminal(index), + Self::Cutoff => OutboundBatchDriverEvent::Cutoff, + Self::Failure => { + OutboundBatchDriverEvent::Failed(EdgeError::bad_request("trailing driver failure")) + } + } + } +} + +enum Driver { + Events(Vec), + Pending(Arc), +} + +struct PendingDriver(Arc); + +impl Stream for PendingDriver { + type Item = OutboundBatchDriverEvent; + fn poll_next(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Pending + } +} + +impl Drop for PendingDriver { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } +} + +struct TestClient(Mutex>); + +#[async_trait(?Send)] +impl OutboundHttpClient for TestClient { + async fn send(&self, _: OutboundRequest) -> Result { + Err(EdgeError::bad_request("test client only supports batches")) + } + + fn start_batch_until(&self, requests: Vec, _: Deadline) -> OutboundBatch { + assert_eq!( + requests.len(), + 1, + "should create one independently cancellable RC batch per TS handle" + ); + match self + .0 + .lock() + .expect("should lock scenarios") + .pop_front() + .expect("should have scenario") + { + Driver::Events(events) => OutboundBatch::from_driver( + 1, + stream::iter(events.into_iter().map(Event::into_driver_event)), + ), + Driver::Pending(dropped) => OutboundBatch::from_driver(1, PendingDriver(dropped)), + } + } +} + +fn scenario_bridge(drivers: Vec) -> AxumPlatformHttpClient { + AxumPlatformHttpClient::new( + HttpClient::with_client(TestClient(Mutex::new(VecDeque::from(drivers)))), + MonotonicClock::default(), + ) +} + +#[tokio::test] +async fn batch_cutoff_premature_eof_bad_index_and_trailing_failure_are_provider_errors() { + for events in [ + vec![Event::Cutoff], + vec![], + vec![Event::Terminal(1)], + vec![Event::Terminal(0), Event::Failure], + vec![Event::Terminal(0), Event::Terminal(0)], + ] { + let client = scenario_bridge(vec![Driver::Events(events)]); + let handle = client + .send_async(request("http://example.com/", "failed")) + .await + .expect("should construct pending handle"); + let selected = client + .select(vec![handle]) + .await + .expect("should return per-provider error"); + assert!( + selected.ready.is_err(), + "should not report success without valid driver EOF" + ); + assert_eq!(selected.failed_backend_name.as_deref(), Some("failed")); + assert!(selected.remaining.is_empty()); + } +} + +#[tokio::test] +async fn select_keeps_backend_names_when_inputs_are_reordered_and_drop_cancels_remaining() { + let cancelled = Arc::new(AtomicBool::new(false)); + let client = scenario_bridge(vec![ + Driver::Pending(Arc::clone(&cancelled)), + Driver::Events(vec![Event::Terminal(0)]), + ]); + let slow = client + .send_async(request("http://example.com/slow", "slow")) + .await + .expect("should start slow request"); + let fast = client + .send_async(request("http://example.com/fast", "fast")) + .await + .expect("should start fast request"); + let result = client + .select(vec![fast, slow]) + .await + .expect("should select completion"); + assert_eq!( + result + .ready + .expect("should finish original RC slot zero") + .backend_name + .as_deref(), + Some("fast") + ); + assert_eq!(result.remaining.len(), 1); + assert_eq!(result.remaining[0].backend_name(), Some("slow")); + assert!(!cancelled.load(Ordering::SeqCst)); + drop(result.remaining); + assert!( + cancelled.load(Ordering::SeqCst), + "should drop RC transport work with the TS handle" + ); +} + +#[tokio::test] +async fn dropping_pending_before_select_also_drops_the_rc_driver() { + let cancelled = Arc::new(AtomicBool::new(false)); + let client = scenario_bridge(vec![Driver::Pending(Arc::clone(&cancelled))]); + let pending = client + .send_async(request("http://example.com/", "cancelled")) + .await + .expect("should start pending request"); + drop(pending); + assert!(cancelled.load(Ordering::SeqCst)); +} diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index bcb31b7e44..60e8690e01 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -1,13 +1,47 @@ //! Integration tests for the Axum dev server. //! -//! Uses `EdgeZeroAxumService` directly (no live TCP server) so tests remain fast +//! Uses `RouterHarness` directly (no live TCP server) so tests remain fast //! and self-contained. Each test builds the full `TrustedServerApp` router and //! drives it through the Tower `Service` interface. use axum::body::Body as AxumBody; use axum::http::Request; -use edgezero_adapter_axum::service::EdgeZeroAxumService; -use tower::{Service as _, ServiceExt as _}; + +// Route/converter harness only. Managed native lifecycle is exercised by the +// production-binary acceptance tests, not by this in-process route seam. +struct RouterHarness(edgezero_core::router::RouterService); + +impl RouterHarness { + fn new(router: edgezero_core::router::RouterService) -> Self { + Self(router) + } + + async fn ready(&mut self) -> Result<&mut Self, std::convert::Infallible> { + Ok(self) + } + + async fn call( + &mut self, + request: axum::http::Request, + ) -> Result, String> { + let request = edgezero_adapter_axum::request::into_core_request(request).await?; + let response = self + .0 + .oneshot(request) + .await + .map_err(|error| error.to_string())?; + let (parts, body) = response.into_parts(); + let bytes = body + .into_bytes_bounded(16 * 1024 * 1024) + .await + .map_err(|error| error.to_string())?; + Ok(axum::http::Response::from_parts( + parts, + AxumBody::from(bytes), + )) + } +} + use trusted_server_adapter_axum::app::TrustedServerApp; const LEGACY_ADMIN_DENY_METHODS: &[&str] = @@ -44,8 +78,8 @@ fn test_router() -> edgezero_core::router::RouterService { .expect("should build router from test settings") } -fn make_service() -> EdgeZeroAxumService { - EdgeZeroAxumService::new(test_router()) +fn make_service() -> RouterHarness { + RouterHarness::new(test_router()) } fn registered_routes() -> Vec<(String, String)> { @@ -83,7 +117,7 @@ async fn aps_profile_serves_renderer_through_adapter_fallback() { ); let router = TrustedServerApp::routes_with_settings(settings) .expect("should build router with APS profile"); - let mut service = EdgeZeroAxumService::new(router); + let mut service = RouterHarness::new(router); let request = Request::builder() .method("GET") .uri("/integrations/aps/renderer") diff --git a/crates/trusted-server-adapter-axum/tests/startup.rs b/crates/trusted-server-adapter-axum/tests/startup.rs new file mode 100644 index 0000000000..c381d65794 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/startup.rs @@ -0,0 +1,319 @@ +//! Native startup contracts exercised through the real production binary. + +use std::fs; +use std::io::{Read as _, Write as _}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +use edgezero_core::app::{Hooks as _, StoreMetadata}; +use trusted_server_adapter_axum::app::TrustedServerApp; +use trusted_server_core::config_payload::{DEFAULT_CONFIG_STORE_ID, DEFAULT_SECRET_STORE_ID}; + +// The hash covers the sorted, compact data object, using the existing TS +// BlobEnvelope v1 format. All values are synthetic local fixture inputs. +const ENVELOPE: &str = r#"{"data":{"ec":{"passphrase":"ec_key"},"handlers":[{"password":"admin_key","path":"^/_ts/admin","username":"admin"}],"publisher":{"cookie_domain":".publisher.example.com","domain":"publisher.example.com","origin_url":"https://origin.publisher.example.com","proxy_secret":"proxy_key"},"request_signing":null},"sha256":"f4dc03017deb24bfd589efde7dce0f9f8a20d952c28ebcbdb6141b08661cd137","version":1,"generated_at":"2026-10-20T00:00:00Z"}"#; +static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0); + +struct ServerFixture { + root: PathBuf, + addr: SocketAddr, + child: Option, +} + +impl ServerFixture { + fn new(envelope: Option<&str>) -> Self { + let root = std::env::temp_dir().join(format!( + "ts-native-startup-{}-{}", + std::process::id(), + NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir(&root).expect("should create isolated native config directory"); + if let Some(envelope) = envelope { + // The fixed fixture is ASCII; Debug escaping yields the JSON string + // value required by the local provider's outer key-to-string map. + fs::write( + root.join(format!("local-config-{DEFAULT_CONFIG_STORE_ID}.json")), + format!("{{{DEFAULT_CONFIG_STORE_ID:?}:{envelope:?}}}"), + ) + .expect("should write a local config envelope map"); + } + let listener = TcpListener::bind("127.0.0.1:0").expect("should reserve a fresh test port"); + let addr = listener.local_addr().expect("should read the test address"); + drop(listener); + Self { + root, + addr, + child: None, + } + } + + fn spawn(&mut self, missing_secret: bool, short_passphrase: bool) { + let mut command = Command::new(env!("CARGO_BIN_EXE_trusted-server-axum")); + command + .env_clear() + .env("RUST_LOG", "info") + .env("EDGEZERO__ADAPTER__HOST", "127.0.0.1") + .env("EDGEZERO__ADAPTER__PORT", self.addr.port().to_string()) + .env("EDGEZERO__ADAPTER__CONFIG_DIR", &self.root) + .env( + "EDGEZERO__SECRETS__PROXY_KEY", + "native-fixture-proxy-secret-32-bytes", + ) + .env( + "EDGEZERO__SECRETS__ADMIN_KEY", + "native-fixture-admin-password", + ) + // These poisoned legacy values must never become native inputs. + .env( + "TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG", + "poisoned-legacy-config", + ) + .env( + "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_EC_KEY", + "poisoned-legacy-secret", + ) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if !missing_secret { + command.env( + "EDGEZERO__SECRETS__EC_KEY", + if short_passphrase { + "short" + } else { + "native-fixture-passphrase-32-bytes-minimum" + }, + ); + } + self.child = Some( + command + .spawn() + .expect("should spawn production native binary"), + ); + } + + fn assert_failed_before_listen(&mut self, expected: &str) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + assert!( + TcpStream::connect_timeout(&self.addr, Duration::from_millis(20)).is_err(), + "should not listen while required startup inputs are invalid" + ); + if let Some(status) = self + .child + .as_mut() + .expect("should own child") + .try_wait() + .expect("should poll production child") + { + assert!( + !status.success(), + "should exit nonzero on initialization failure" + ); + let output = self + .child + .take() + .expect("should own child output") + .wait_with_output() + .expect("should collect startup diagnostics"); + let diagnostic = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + diagnostic.contains(expected), + "should explain startup failure: {diagnostic}" + ); + assert!( + !diagnostic.contains("poisoned-legacy"), + "should not expose legacy inputs" + ); + assert!( + !diagnostic.contains("native-fixture-passphrase"), + "should not expose resolved secrets" + ); + return; + } + assert!( + Instant::now() < deadline, + "should fail startup within the test bound" + ); + thread::sleep(Duration::from_millis(20)); + } + } + + fn wait_ready(&mut self) -> String { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Ok(response) = self.get("/health") { + assert!( + response.starts_with("HTTP/1.1 200"), + "should become healthy only after successful initialization: {response}" + ); + return response; + } + let status = self + .child + .as_mut() + .expect("should own child") + .try_wait() + .expect("should poll child"); + let diagnostic = if status.is_some() { + let output = self + .child + .take() + .expect("should own failed child") + .wait_with_output() + .expect("should collect startup diagnostics"); + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + } else { + String::new() + }; + assert!( + status.is_none(), + "should keep running after valid prepared-store initialization: {diagnostic}" + ); + assert!( + Instant::now() < deadline, + "should become ready within the test bound" + ); + thread::sleep(Duration::from_millis(20)); + } + } + + fn get(&self, path: &str) -> std::io::Result { + let mut stream = TcpStream::connect_timeout(&self.addr, Duration::from_millis(100))?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + write!( + stream, + "GET {path} HTTP/1.1\r\nHost: publisher.example.com\r\nConnection: close\r\n\r\n" + )?; + let mut response = String::new(); + stream.read_to_string(&mut response)?; + Ok(response) + } +} + +impl Drop for ServerFixture { + fn drop(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + let _ = fs::remove_dir_all(&self.root); + } +} + +#[test] +fn missing_config_fails_before_listen_without_legacy_fallback() { + let mut server = ServerFixture::new(None); + server.spawn(false, false); + // Local preparation itself rejects an absent required file. + server.assert_failed_before_listen("startup"); +} + +#[test] +fn missing_secret_fails_before_listen_without_legacy_fallback() { + let mut server = ServerFixture::new(Some(ENVELOPE)); + server.spawn(true, false); + server.assert_failed_before_listen("required app-config secret is missing"); +} + +#[test] +fn runtime_secret_validation_fails_before_listen() { + let mut server = ServerFixture::new(Some(ENVELOPE)); + server.spawn(false, true); + server.assert_failed_before_listen("passphrase"); +} + +#[test] +fn tampered_envelope_fails_before_listen() { + let tampered = ENVELOPE.replace("f4dc0301", "00000000"); + let mut server = ServerFixture::new(Some(&tampered)); + server.spawn(false, false); + server.assert_failed_before_listen("integrity verification"); +} + +#[test] +fn valid_prepared_inputs_serve_real_routes_and_ignore_changed_config_until_restart() { + let mut server = ServerFixture::new(Some(ENVELOPE)); + server.spawn(false, false); + let health = server.wait_ready(); + assert!(health.to_ascii_lowercase().contains("x-ts-version:")); + let tsjs = server + .get("/static/tsjs=0000000000000000") + .expect("should reach TSJS route"); + assert!( + tsjs.starts_with("HTTP/1.1 404"), + "should execute unknown-hash TSJS handler: {tsjs}" + ); + assert!( + tsjs.to_ascii_lowercase() + .contains("x-geo-info-available: false") + ); + fs::write( + server + .root + .join(format!("local-config-{DEFAULT_CONFIG_STORE_ID}.json")), + "invalid-changed-config", + ) + .expect("should replace fixture publication input"); + assert!( + server + .get("/health") + .expect("should retain initialized state") + .starts_with("HTTP/1.1 200") + ); +} + +#[test] +fn handwritten_store_metadata_matches_the_manifest() { + let manifest = include_str!("../../../edgezero.toml"); + let stores = TrustedServerApp::stores(); + for (kind, metadata) in [("config", stores.config), ("secrets", stores.secrets)] { + let StoreMetadata { default, ids } = + metadata.expect("should declare required native store"); + let section = manifest + .split(&format!("[stores.{kind}]\n")) + .nth(1) + .expect("should declare the same store kind in the manifest") + .split("\n[") + .next() + .expect("should read store section"); + let quoted_values = |name: &str| -> Vec<&str> { + section + .lines() + .find_map(|line| line.trim().strip_prefix(&format!("{name} = "))) + .expect("should contain the portable store field") + .split('"') + .enumerate() + .filter_map(|(index, part)| (index % 2 == 1).then_some(part)) + .collect() + }; + assert_eq!(quoted_values("default"), vec![default]); + assert_eq!(quoted_values("ids"), ids); + } + assert_eq!( + stores.config.expect("should declare config").default, + DEFAULT_CONFIG_STORE_ID + ); + assert_eq!( + stores.secrets.expect("should declare secrets").default, + DEFAULT_SECRET_STORE_ID + ); + assert!(stores.kv.is_none(), "should not prepare unused generic KV"); + // The portable manifest is the union of adapter stores. Fastly still + // declares generic KV; native deliberately prepares only config/secrets. + assert!( + manifest.contains("\n[stores.kv]"), + "should preserve the existing Fastly KV declaration in the portable manifest" + ); +} diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 87b9567e7f..851044e0b4 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -276,7 +276,25 @@ where let f = f.clone(); Box::pin(async move { let services = s.services_for_request(&ctx); - let mut req = ctx.into_request(); + if ctx.parts().method == Method::POST { + let limit = match ctx.parts().uri.path() { + "/verify-signature" => { + Some(trusted_server_core::request_signing::endpoints::VERIFY_MAX_BODY_BYTES) + } + "/auction" => { + Some(trusted_server_core::auction::endpoints::MAX_AUCTION_BODY_SIZE) + } + "/first-party/sign" => Some(trusted_server_core::proxy::SIGN_MAX_BODY_BYTES), + "/first-party/proxy-rebuild" => { + Some(trusted_server_core::proxy::REBUILD_MAX_BODY_BYTES) + } + _ => None, + }; + if let Some(limit) = limit { + ctx.body_bytes(limit).await?; + } + } + let mut req = ctx.into_request()?; if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &s.settings, &mut req, @@ -512,7 +530,7 @@ fn build_router(state: &Arc) -> RouterService { ctx: RequestContext, ) -> Result { let services = state.services_for_request(&ctx); - let mut req = ctx.into_request(); + let mut req = ctx.into_request()?; if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); } diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index a22b51982e..6d13a8461f 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -5,9 +5,9 @@ use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; -use trusted_server_core::auth::enforce_basic_auth; +use trusted_server_core::auth::enforce_basic_auth_parts; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; -use trusted_server_core::http_util::sanitize_trusted_client_ip_headers; +use trusted_server_core::http_util::sanitize_trusted_client_ip_header_map; use trusted_server_core::settings::Settings; // --------------------------------------------------------------------------- @@ -38,8 +38,8 @@ impl SanitizeRequestMiddleware { #[async_trait(?Send)] impl Middleware for SanitizeRequestMiddleware { async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - sanitize_trusted_client_ip_headers( - ctx.request_mut(), + sanitize_trusted_client_ip_header_map( + ctx.headers_mut(), self.settings.trusted_client_ip.as_ref(), ); next.run(ctx).await @@ -75,7 +75,6 @@ impl FinalizeResponseMiddleware { impl Middleware for FinalizeResponseMiddleware { async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { let geo_available = ctx - .request() .headers() .get("cf-ipcountry") .and_then(|v| v.to_str().ok()) @@ -94,7 +93,7 @@ impl Middleware for FinalizeResponseMiddleware { /// Inner middleware: enforces basic-auth before the handler runs. /// -/// - `Ok(Some(response))` from [`enforce_basic_auth`] → auth failed; return the +/// - `Ok(Some(response))` from [`enforce_basic_auth_parts`] → auth failed; return the /// challenge response (bubbles through [`FinalizeResponseMiddleware`] for header injection). /// - `Ok(None)` → no auth required or credentials accepted; continue the chain. /// - `Err(report)` → internal error; log and convert to a 500 HTTP response. @@ -113,7 +112,7 @@ impl AuthMiddleware { #[async_trait(?Send)] impl Middleware for AuthMiddleware { async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - match enforce_basic_auth(&self.settings, ctx.request_mut()) { + match enforce_basic_auth_parts(&self.settings, ctx.parts_mut()) { Ok(Some(response)) => return Ok(response), Ok(None) => {} Err(report) => { @@ -306,8 +305,8 @@ mod tests { let handler_observed = Arc::clone(&handler_observed); async move { *handler_observed.lock().expect("should lock observation") = Some(( - ctx.request().headers().contains_key("x-reader-ip"), - ctx.request().headers().contains_key("x-reader-ip-auth"), + ctx.headers().contains_key("x-reader-ip"), + ctx.headers().contains_key("x-reader-ip-auth"), )); Ok::(empty_response()) } @@ -323,6 +322,54 @@ mod tests { ); } + #[test] + fn middleware_preserves_cached_managed_body_and_extensions() { + let settings = Arc::new(settings_with_response_headers(vec![])); + let request = request_builder() + .method(Method::POST) + .uri("/_ts/admin/keys") + .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") + .header("cf-ipcountry", "US") + .body(Body::from("null").with_buffered_json_policy( + std::num::NonZeroUsize::new(4).expect("should create a positive ceiling"), + true, + )) + .expect("should build a managed request"); + let mut ctx = RequestContext::new(request, PathParams::default()); + ctx.extensions_mut().insert(String::from("request marker")); + let middlewares: Vec> = vec![ + Arc::new(SanitizeRequestMiddleware::new(Arc::clone(&settings))), + Arc::new(FinalizeResponseMiddleware::new(Arc::clone(&settings))), + Arc::new(AuthMiddleware::new(settings)), + ]; + let handler = Arc::new(|ctx: RequestContext| async move { + assert!( + matches!( + ctx.body_kind(), + edgezero_core::context::BodyKind::Cached { len: 4 } + ), + "should preserve cached body provenance across middleware" + ); + assert_eq!( + ctx.extensions().get::().map(String::as_str), + Some("request marker"), + "should retain unrelated request extensions" + ); + assert_eq!( + ctx.body_bytes(4).await?.as_ref(), + b"null", + "should retain the cached managed body" + ); + Ok::(empty_response()) + }); + + block_on(async { + ctx.body_bytes(4).await?; + Next::new(&middlewares, &*handler).run(ctx).await + }) + .expect("should authenticate and preserve the managed request"); + } + #[test] fn emits_git_version_header() { let mut response = empty_response(); diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index 94616b2035..265593c5f6 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -98,6 +98,7 @@ impl PlatformConfigStore for ConfigStoreHandleAdapter { Report::new(PlatformError::ConfigStore) .attach(format!("config store lookup failed: {e}")) })? + .map(|value| value.as_str().to_owned()) .ok_or_else(|| { Report::new(PlatformError::ConfigStore).attach(format!("key not found: {key}")) }) @@ -331,9 +332,9 @@ impl CloudflareHttpClient { } let (_, body) = request.request.into_parts(); - let body_bytes = match body { - edgezero_core::body::Body::Once(bytes) => bytes.to_vec(), - edgezero_core::body::Body::Stream(_) => { + let body_bytes = match body.into_content() { + edgezero_core::body::BodyContent::Once(bytes) => bytes.to_vec(), + edgezero_core::body::BodyContent::Stream(_) => { return Err(Report::new(PlatformError::HttpClient) .attach("streaming request bodies are not supported on Cloudflare Workers")); } @@ -473,12 +474,12 @@ impl PlatformHttpClient for CloudflareHttpClient { .iter() .map(|(n, v)| (n.to_string(), v.as_bytes().to_vec())) .collect(); - let body_bytes = match response.response.into_body() { - edgezero_core::body::Body::Once(bytes) => bytes.to_vec(), + let body_bytes = match response.response.into_body().into_content() { + edgezero_core::body::BodyContent::Once(bytes) => bytes.to_vec(), // execute() always buffers via resp.bytes().await → Body::Once. // Return a typed error rather than panicking in the request path // in case that edgezero implementation detail ever changes. - edgezero_core::body::Body::Stream(_) => { + edgezero_core::body::BodyContent::Stream(_) => { return Err(Report::new(PlatformError::HttpClient).attach( "unexpected streaming body from CloudflareHttpClient::execute \ — expected a buffered Body::Once", @@ -616,14 +617,15 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R // Secrets: still requires wasm32-specific env.secret() (async/sync mismatch). #[cfg(target_arch = "wasm32")] - let secret_store: Arc = - edgezero_adapter_cloudflare::context::CloudflareRequestContext::get(ctx.request()) - .map(|cf_ctx| { - Arc::new(CloudflareSecretStoreAdapter { - env: cf_ctx.env().clone(), - }) as Arc - }) - .unwrap_or_else(|| Arc::new(NoopSecretStore)); + let secret_store: Arc = ctx + .extensions() + .get::() + .map(|cf_ctx| { + Arc::new(CloudflareSecretStoreAdapter { + env: cf_ctx.env().clone(), + }) as Arc + }) + .unwrap_or_else(|| Arc::new(NoopSecretStore)); #[cfg(not(target_arch = "wasm32"))] let secret_store: Arc = Arc::new(NoopSecretStore); @@ -684,7 +686,7 @@ impl PlatformGeo for CloudflareGeo { } fn build_geo(ctx: &edgezero_core::context::RequestContext) -> CloudflareGeo { - let headers = ctx.request().headers(); + let headers = ctx.headers(); let country = headers .get("cf-ipcountry") .and_then(|v| v.to_str().ok()) @@ -721,8 +723,7 @@ fn build_geo(ctx: &edgezero_core::context::RequestContext) -> CloudflareGeo { } fn extract_client_ip(ctx: &edgezero_core::context::RequestContext) -> Option { - ctx.request() - .headers() + ctx.headers() .get("cf-connecting-ip") .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse().ok()) diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index 710523766e..e77847358c 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -39,6 +39,7 @@ url = { workspace = true } urlencoding = { workspace = true } [dev-dependencies] +edgezero-adapter-fastly = { workspace = true, features = ["fastly", "test-utils"] } bytes = { workspace = true } edgezero-core = { workspace = true, features = ["test-utils"] } toml = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 1d58c229e4..fd1ac900a2 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -185,6 +185,7 @@ impl RuntimeStoreConfig { /// shared services live here; request-specific state must remain request-local. pub(crate) struct AppState { pub(crate) settings: Arc, + pub(crate) delivery_observation: crate::DeliveryObservation, pub(crate) orchestrator: Arc, pub(crate) registry: Arc, pub(crate) default_kv_store: Arc, @@ -229,6 +230,7 @@ pub(crate) fn build_state_from_settings( let default_kv_store = Arc::new(UnavailableKvStore) as Arc; Ok(Arc::new(AppState { + delivery_observation: crate::DeliveryObservation::default(), settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), @@ -262,12 +264,14 @@ fn warn_if_certificate_check_disabled(settings: &Settings) { /// `edgezero_main` after sanitization. fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> RuntimeServices { let client_info = ctx - .request() .extensions() .get::() .cloned() .unwrap_or_else(|| ClientInfo { - client_ip: FastlyRequestContext::get(ctx.request()).and_then(|c| c.client_ip), + client_ip: ctx + .extensions() + .get::() + .and_then(|c| c.client_ip), ..ClientInfo::default() }); @@ -545,7 +549,36 @@ async fn execute_named( handler: NamedRouteHandler, ) -> Result { let services = build_per_request_services(&state, &ctx); - let mut req = ctx.into_request(); + if ctx.parts().method == Method::POST { + let limit = match handler { + NamedRouteHandler::VerifySignature => { + Some(trusted_server_core::request_signing::endpoints::VERIFY_MAX_BODY_BYTES) + } + NamedRouteHandler::RotateKey | NamedRouteHandler::DeactivateKey => { + Some(trusted_server_core::request_signing::endpoints::ADMIN_MAX_BODY_BYTES) + } + NamedRouteHandler::Auction => { + Some(trusted_server_core::auction::endpoints::MAX_AUCTION_BODY_SIZE) + } + NamedRouteHandler::FirstPartySign => { + Some(trusted_server_core::proxy::SIGN_MAX_BODY_BYTES) + } + NamedRouteHandler::FirstPartyProxyRebuild => { + Some(trusted_server_core::proxy::REBUILD_MAX_BODY_BYTES) + } + NamedRouteHandler::AdminCachePurge => { + Some(trusted_server_core::cache_purge::PURGE_MAX_BODY_BYTES) + } + NamedRouteHandler::BatchSync => { + Some(trusted_server_core::ec::batch_sync::MAX_BODY_SIZE) + } + _ => None, + }; + if let Some(limit) = limit { + ctx.body_bytes(limit).await?; + } + } + let mut req = ctx.into_request()?; // S2S batch sync uses Bearer auth (not EC cookies), so it skips EC // context creation entirely — mirroring the dedicated early arm in the @@ -765,7 +798,7 @@ async fn execute_fallback( ctx: RequestContext, ) -> Result { let services = build_per_request_services(&state, &ctx); - let req = ctx.into_request(); + let req = ctx.into_request()?; Ok(dispatch_fallback(&state, &services, req).await) } @@ -1274,11 +1307,23 @@ pub struct TrustedServerApp; impl TrustedServerApp { pub(crate) fn build_app_with_state( stores: &RuntimeStoreConfig, - ) -> (App, Option>) { + ) -> Result<(App, Option>), EdgeError> { let (router, state) = Self::router_with_state(stores); - let mut app = App::with_name(router, Self::name()); - Self::configure(&mut app); - (app, state) + let mut app = App::with_name_and_platform( + router, + Self::name(), + edgezero_adapter_fastly::FASTLY_PLATFORM, + ); + Self::configure(&mut app)?; + if app.platform() != edgezero_adapter_fastly::FASTLY_PLATFORM { + return Err(EdgeError::internal(fastly::Error::msg( + "configure replaced Fastly platform metadata", + ))); + } + if let Some(state) = state.as_ref() { + app.set_response_egress_observer(state.delivery_observation.clone()); + } + Ok((app, state)) } fn router_with_state(stores: &RuntimeStoreConfig) -> (RouterService, Option>) { @@ -1577,6 +1622,7 @@ mod tests { let default_kv_store = Arc::new(crate::platform::UnavailableKvStore) as Arc; let state = Arc::new(super::AppState { + delivery_observation: crate::DeliveryObservation::default(), auction_telemetry_sink: Arc::new( trusted_server_core::auction::NoopAuctionTelemetrySink, ), @@ -2997,7 +3043,7 @@ mod tests { let registry = Arc::clone(®istry); let orchestrator = Arc::clone(&orchestrator); Box::pin(async move { - let request = ctx.into_request(); + let request = ctx.into_request()?; let method = request.method().clone(); let mut ec_context = match EcContext::read_from_request(&settings, &request, &services) { diff --git a/crates/trusted-server-adapter-fastly/src/compat.rs b/crates/trusted-server-adapter-fastly/src/compat.rs index 5ccd9c423a..812a4f202c 100644 --- a/crates/trusted-server-adapter-fastly/src/compat.rs +++ b/crates/trusted-server-adapter-fastly/src/compat.rs @@ -1,60 +1,12 @@ -//! Compatibility bridge between `fastly` SDK types and `http` crate types. +//! Fastly request trust-header normalization before managed admission. use std::net::IpAddr; -use edgezero_core::body::Body as EdgeBody; -use edgezero_core::http::Response as HttpResponse; use trusted_server_core::http_util::SPOOFABLE_FORWARDED_HEADERS; use trusted_server_core::settings::TrustedClientIpConfig; use crate::platform::resolve_client_ip; -/// Convert an [`HttpResponse`] into a `fastly::Response`. -pub(crate) fn to_fastly_response(resp: HttpResponse) -> fastly::Response { - let (parts, body) = resp.into_parts(); - let mut fastly_resp = fastly::Response::from_status(parts.status.as_u16()); - for (name, value) in &parts.headers { - fastly_resp.append_header(name.as_str(), value.as_bytes()); - } - - match body { - EdgeBody::Once(bytes) => { - if !bytes.is_empty() { - fastly_resp.set_body(bytes.to_vec()); - } - } - EdgeBody::Stream(_) => { - // Streaming bodies cannot cross the compat boundary. Both audited call sites - // (legacy_main buffered arm and edgezero_main after EdgeZero collapses bodies - // to Once) only pass Once bodies — a Stream here is a caller error. - // The assert is suppressed in test builds where the behavior-documentation - // test deliberately exercises this path. - #[cfg(not(test))] - debug_assert!( - false, - "to_fastly_response: streaming body will be silently dropped; \ - use to_fastly_response_skeleton + stream_to_client for streaming responses" - ); - log::warn!("streaming body in compat::to_fastly_response; body will be empty"); - } - } - - fastly_resp -} - -/// Convert an [`HttpResponse`] into a `fastly::Response` without a body. -/// -/// Use this when the caller will stream the body separately through -/// [`fastly::Response::stream_to_client`]. -pub(crate) fn to_fastly_response_skeleton(resp: HttpResponse) -> fastly::Response { - let (parts, _body) = resp.into_parts(); - let mut fastly_resp = fastly::Response::from_status(parts.status.as_u16()); - for (name, value) in &parts.headers { - fastly_resp.append_header(name.as_str(), value.as_bytes()); - } - fastly_resp -} - /// Sanitize forwarded headers on a `fastly::Request`. /// /// Strips configured trust headers and headers that clients can spoof before @@ -211,29 +163,4 @@ mod tests { "should strip the configured auth header after resolving" ); } - - #[test] - fn to_fastly_response_with_streaming_body_produces_empty_body() { - use edgezero_core::http::StatusCode; - - let stream = futures::stream::empty::(); - let stream_body = EdgeBody::stream(stream); - - let http_resp = edgezero_core::http::response_builder() - .status(StatusCode::OK) - .body(stream_body) - .expect("should build response"); - - let mut fastly_resp = to_fastly_response(http_resp); - - assert_eq!( - fastly_resp.get_status().as_u16(), - 200, - "should preserve status" - ); - assert!( - fastly_resp.take_body_bytes().is_empty(), - "should produce empty body for streaming response" - ); - } } diff --git a/crates/trusted-server-adapter-fastly/src/delivery_tests.rs b/crates/trusted-server-adapter-fastly/src/delivery_tests.rs new file mode 100644 index 0000000000..3ccbc518ef --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/delivery_tests.rs @@ -0,0 +1,245 @@ +//! Application hooks through the RC closed lifecycle with an injected writer. +//! +//! The writer is simulated so these tests can inspect terminal ownership. They +//! do not claim to exercise Fastly network disconnects. + +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use edgezero_adapter_fastly::request::FastlyService; +use edgezero_core::app::App; +use edgezero_core::body::Body; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{Response, header}; +use edgezero_core::ingress::AdmissionDecision; +use edgezero_core::response_egress::{ + ResponseEgressFallbackDisposition, ResponseEgressObserver, ResponseEgressOutcome, + ResponseEgressReport, +}; +use edgezero_core::router::RouterService; +use futures::executor::block_on; + +use super::{DeliveryObservation, apply_terminal_response_effects}; + +#[derive(Clone)] +struct TerminalObserver { + delivered: DeliveryObservation, + count: Arc, +} + +impl ResponseEgressObserver for TerminalObserver { + fn complete(&self, report: &ResponseEgressReport) { + self.count.fetch_add(1, Ordering::SeqCst); + self.delivered.complete(report); + } +} + +#[derive(Clone, Copy)] +enum WriterResult { + Application, + Fallback, + SourceFailure, +} + +fn router(source_failure: bool) -> RouterService { + RouterService::builder() + .get("/", move |_ctx: RequestContext| async move { + let body = if source_failure { + Body::from_external_stream(futures::stream::iter([ + Ok(bytes::Bytes::from_static(b"prefix")), + Err(std::io::Error::other("synthetic body source error")), + ])) + } else { + Body::stream(futures::stream::iter([ + bytes::Bytes::from_static(b"first"), + bytes::Bytes::from_static(b"second"), + ])) + }; + let mut response = Response::new(body); + response.headers_mut().append( + header::SET_COOKIE, + "fixture_a=one".parse().expect("should parse cookie"), + ); + response.headers_mut().append( + header::SET_COOKIE, + "fixture_b=two".parse().expect("should parse cookie"), + ); + response.headers_mut().insert( + header::CACHE_CONTROL, + "public, max-age=60" + .parse() + .expect("should parse cache policy"), + ); + Ok::<_, EdgeError>(response) + }) + .build() +} + +fn dispatch(case: WriterResult) -> (Result, fastly::Error>, TerminalObserver) { + let observer = TerminalObserver { + delivered: DeliveryObservation::default(), + count: Arc::new(AtomicUsize::new(0)), + }; + let mut app = App::new(router(matches!(case, WriterResult::SourceFailure))); + app.set_response_egress_observer(observer.clone()); + observer.delivered.reset(); + let before_delivery = observer.clone(); + let result = FastlyService::new(&app).run_request_with_hooks_for_test( + fastly::Request::get("http://publisher.example.com/"), + |raw, _extensions| { + raw.remove_header("x-forwarded-host"); + }, + |response| { + apply_terminal_response_effects(response, None); + assert_eq!( + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .count(), + 2 + ); + assert!( + !response.headers()[header::CACHE_CONTROL] + .to_str() + .expect("should read policy") + .contains("public") + ); + assert!( + !before_delivery.delivered.completed(), + "should not complete at handler return" + ); + 7 + }, + |envelope| { + let (prepared, _, mut attempt, clock) = envelope + .begin() + .map_err(|_| fastly::Error::msg("synthetic egress begin failure"))?; + if matches!(case, WriterResult::Fallback) { + assert!(attempt.begin_fallback(ResponseEgressOutcome::ConversionError)); + assert!(attempt.begin_writing()); + assert!( + attempt + .finish_fallback(ResponseEgressFallbackDisposition::Completed, clock.now()) + ); + } else { + assert!(attempt.begin_writing()); + let body = block_on( + prepared + .into_response() + .into_body() + .into_bytes_bounded(1024), + ); + if matches!(case, WriterResult::SourceFailure) { + assert!(body.is_err(), "should observe the actual body source error"); + assert!(attempt.terminate(ResponseEgressOutcome::SourceError, clock.now())); + assert!( + !attempt.complete(clock.now()), + "should reject a second terminal transition" + ); + return Err(fastly::Error::msg("synthetic postcommit source failure")); + } + assert_eq!( + body.expect("should collect streamed response").as_ref(), + b"firstsecond" + ); + assert!(attempt.complete(clock.now())); + } + assert!( + !attempt.complete(clock.now()), + "should complete at most once" + ); + Ok(()) + }, + ); + (result, observer) +} + +#[test] +fn pull_sync_requires_application_delivery_not_successful_fallback() { + let (success, observer) = dispatch(WriterResult::Application); + assert_eq!(success.expect("should deliver application"), Some(7)); + assert!(observer.delivered.completed()); + assert_eq!(observer.count.load(Ordering::SeqCst), 1); + + let (fallback, observer) = dispatch(WriterResult::Fallback); + assert_eq!(fallback.expect("should deliver fallback"), Some(7)); + assert!( + !observer.delivered.completed(), + "successful terminal call alone must not authorize pull sync" + ); + assert_eq!(observer.count.load(Ordering::SeqCst), 1); + + let (failure, observer) = dispatch(WriterResult::SourceFailure); + assert!(failure.is_err()); + assert!(!observer.delivered.completed()); + assert_eq!(observer.count.load(Ordering::SeqCst), 1); +} + +#[test] +fn detached_refusal_skips_routed_state_even_if_its_own_body_completes() { + let observer = TerminalObserver { + delivered: DeliveryObservation::default(), + count: Arc::new(AtomicUsize::new(0)), + }; + let mut app = App::new(router(false)); + app.set_response_egress_observer(observer.clone()); + let refusal_completions = Arc::new(AtomicUsize::new(0)); + let completed_refusals = Arc::clone(&refusal_completions); + app.set_ingress_admission_policy(move |_head| { + let count = Arc::clone(&completed_refusals); + AdmissionDecision::Refuse { + completion: edgezero_core::ResponseEgressCompletion::new(move |_report| { + count.fetch_add(1, Ordering::SeqCst); + }), + response: Response::new(Body::from("refused")), + } + }); + observer.delivered.0.store(true, Ordering::Release); + observer.delivered.reset(); + assert!( + !observer.delivered.completed(), + "should clear the previous serial dispatch" + ); + let finalized = AtomicUsize::new(0); + let result = FastlyService::new(&app) + .run_request_with_hooks_for_test( + fastly::Request::get("http://publisher.example.com/"), + |_raw, _extensions| {}, + |_response| { + finalized.fetch_add(1, Ordering::SeqCst); + 7 + }, + |envelope| { + let (prepared, _, mut attempt, clock) = envelope + .begin() + .map_err(|_| fastly::Error::msg("synthetic refusal begin failure"))?; + assert!(attempt.begin_writing()); + assert_eq!( + block_on( + prepared + .into_response() + .into_body() + .into_bytes_bounded(1024) + ) + .expect("should read refusal") + .as_ref(), + b"refused" + ); + assert!(attempt.complete(clock.now())); + Ok(()) + }, + ) + .expect("should finish refusal"); + assert_eq!(result, None, "no routed hook state means no post-send work"); + assert_eq!(finalized.load(Ordering::SeqCst), 0); + assert!( + !observer.delivered.completed(), + "detached refusal must not enter routed observation" + ); + assert_eq!(observer.count.load(Ordering::SeqCst), 0); + assert_eq!(refusal_completions.load(Ordering::SeqCst), 1); +} diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index b8f814a01c..0e54243f3d 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -1,12 +1,11 @@ use std::sync::Arc; use edgezero_adapter_fastly::config_store::FastlyConfigStore as EdgeZeroFastlyConfigStore; -use edgezero_adapter_fastly::request::into_core_request; -use edgezero_core::body::Body as EdgeBody; +use edgezero_adapter_fastly::request::send_request_with_registries_and_hooks; +use edgezero_core::app::Hooks as _; use edgezero_core::config_store::ConfigStoreHandle; -use edgezero_core::error::EdgeError; use edgezero_core::http::{Request as HttpRequest, Response as HttpResponse}; -use edgezero_core::response::IntoResponse; +use edgezero_core::response_egress::{ResponseEgressBodyKind, ResponseEgressOutcome}; use error_stack::Report; use fastly::http::Method as FastlyMethod; use fastly::{Request as FastlyRequest, Response as FastlyResponse}; @@ -23,7 +22,7 @@ use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::TrustedServerError; use trusted_server_core::integrations::RequestFilterEffects; use trusted_server_core::platform::PlatformGeo as _; -use trusted_server_core::proxy::{AssetProxyCachePolicy, stream_asset_body}; +use trusted_server_core::proxy::AssetProxyCachePolicy; use trusted_server_core::response_privacy::TerminalPrivateResponse; use trusted_server_core::settings::Settings; use trusted_server_core::version_header::{apply_git_version_header, git_version_header_value}; @@ -31,6 +30,8 @@ use trusted_server_core::version_header::{apply_git_version_header, git_version_ mod app; mod backend; mod compat; +#[cfg(test)] +mod delivery_tests; mod ec_kv; mod esi_assembly; mod logging; @@ -107,10 +108,12 @@ fn main() { // `run_custom` completes the callback's SDK result exactly once. // The callback sends its own response and returns `()`, so there // is no error for the SDK to turn into a second response. - let Ok(()) = edgezero_adapter_fastly::lifecycle::run_custom( + if let Err(error) = edgezero_adapter_fastly::lifecycle::run_custom( FastlyRequest::from_client(), |request, sandbox: &mut Sandbox| handle_request(request, sandbox, &mut startup), - ); + ) { + log::error!("Fastly request delivery failed: {error}"); + } } ServeMode::Reuse(limits) => serve_loop(limits, startup), } @@ -136,8 +139,8 @@ fn serve_loop(limits: crate::sandbox::SandboxLimits, mut startup: StartupDiagnos .with_timeout(limits.timeout), |request, sandbox: &mut Sandbox| { counters.observe(sandbox, |sandbox| { - handle_request(request, sandbox, &mut startup); - }); + handle_request(request, sandbox, &mut startup) + }) }, ); @@ -147,6 +150,9 @@ fn serve_loop(limits: crate::sandbox::SandboxLimits, mut startup: StartupDiagnos counters.requests(), counters.attempts() ); + if let Err(error) = summary.into_result() { + log::error!("Fastly serving terminated after delivery failure: {error}"); + } } #[cfg(not(feature = "reusable-sandbox"))] @@ -156,15 +162,19 @@ fn serve_loop(_limits: crate::sandbox::SandboxLimits, _startup: StartupDiagnosti /// Handles one request end to end, sending its own response. /// -/// Returns `()` rather than a response so the streaming publisher path can -/// call [`fastly::Response::stream_to_client`] itself. The SDK's -/// `HandlerResult` impl for `()` treats that as already sent. +/// Routed transmission belongs to the RC. A terminal error stops the SDK +/// callback without asking it to render a second response. Raw probes remain +/// explicit app-build-free exceptions. /// /// Every non-panicking path through this function must send exactly once. In a /// reused sandbox the SDK refuses to wait for the next request until the /// current one is complete, so a missed send stalls the loop rather than /// merely dropping one response. -fn handle_request(req: FastlyRequest, sandbox: &mut Sandbox, startup: &mut StartupDiagnostics) { +fn handle_request( + req: FastlyRequest, + sandbox: &mut Sandbox, + startup: &mut StartupDiagnostics, +) -> Result<(), fastly::Error> { // The framework counts the callback before invoking it, including early // returns, so this is already this request's 1-based ordinal. let ordinal = sandbox.requests(); @@ -172,7 +182,7 @@ fn handle_request(req: FastlyRequest, sandbox: &mut Sandbox, startup: &mut Start // Health probe bypasses logging, settings, and app construction as a cheap liveness signal. if let Some(response) = health_response(&req) { response.send_to_client(); - return; + return Ok(()); } // Marked complete only once installation succeeds, so a failed install is @@ -199,7 +209,7 @@ fn handle_request(req: FastlyRequest, sandbox: &mut Sandbox, startup: &mut Start .map(str::to_owned) .unwrap_or_else(|| format!("{}-{ordinal}", instance_id())); - edgezero_main(req, sandbox, ordinal, &request_id); + edgezero_main(req, sandbox, ordinal, &request_id) } /// Builds the counters snapshot response. @@ -322,7 +332,12 @@ fn heap_mib() -> String { } /// Handles a request through the `EdgeZero` router path. -fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, request_id: &str) { +fn edgezero_main( + mut req: FastlyRequest, + sandbox: &mut Sandbox, + ordinal: u64, + request_id: &str, +) -> Result<(), fastly::Error> { let runtime_stores = RuntimeStoreConfig::logical(); // Short-circuit the sandbox counters probe before app construction. It must @@ -344,7 +359,7 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re .send_to_client(); } } - return; + return Ok(()); } // Short-circuit the JA4 debug probe before app construction. Must run here @@ -365,7 +380,7 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re .send_to_client(); } } - return; + return Ok(()); } let config_store = @@ -376,7 +391,7 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) .with_body_text_plain("Internal Server Error") .send_to_client(); - return; + return Ok(()); } }; @@ -390,24 +405,34 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re // permanent error mode, and the next callback retries construction. let failed_build = sandbox .initialize(|| { - let (app, state) = TrustedServerApp::build_app_with_state(&runtime_stores); + let (app, state) = match TrustedServerApp::build_app_with_state(&runtime_stores) { + Ok(built) => built, + Err(error) => return Err(Err(error)), + }; match state { Some(state) => Ok(RetainedApp { app, state }), - None => Err(app), + None => Err(Ok(Box::new(app))), } }) .err(); let (app, app_state): (&edgezero_core::app::App, Option>) = match (failed_build.as_ref(), sandbox.state()) { - (Some(app), _) => (app, None), + (Some(Ok(app)), _) => (app.as_ref(), None), + (Some(Err(error)), _) => { + log::error!("failed to configure Fastly application: {error}"); + FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) + .with_body_text_plain("Internal Server Error") + .send_to_client(); + return Ok(()); + } (None, Some(retained)) => (&retained.app, Some(Arc::clone(&retained.state))), (None, None) => { log::error!("no application available after initialization"); FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) .with_body_text_plain("Internal Server Error") .send_to_client(); - return; + return Ok(()); } }; @@ -456,118 +481,101 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re let client_ip = client_info.client_ip; let device_signals = derive_device_signals(&req); - // Dispatch directly through the EdgeZero router without an intermediate - // fastly::Response conversion. That preserves duplicate header values such - // as multiple Set-Cookie headers. - let mut response = match into_core_request(req) { - Ok(mut core_req) => { - core_req.extensions_mut().insert(config_store); - core_req.extensions_mut().insert(device_signals); - core_req.extensions_mut().insert(client_info); - match futures::executor::block_on(app.router().oneshot(core_req)) { - Ok(response) => response, - Err(error) => edge_error_response(error), - } - } - Err(e) => { - log::error!("EdgeZero request conversion failed: {e}"); - FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) - .with_body_text_plain("Internal Server Error") - .send_to_client(); - return; - } - }; - - // Pop response extensions before the Fastly conversion, which drops them. - let ec_state = response.extensions_mut().remove::(); - let asset_cache_policy = response.extensions_mut().remove::(); - let request_filter_effects = response.extensions_mut().remove::(); - - if !take_finalize_sentinel(&mut response) { - if let Some(settings) = settings_snapshot.as_deref() { - apply_entry_point_finalize_headers(settings, &mut response, client_ip); - } else { - match load_settings_from_config_store(&runtime_stores) { - Ok(settings) => { - apply_entry_point_finalize_headers(&settings, &mut response, client_ip); - } - Err(e) => { - log::warn!("entry-point finalize skipped: failed to reload settings: {e:?}"); - // The compiled-in version needs no settings, so a deploy that cannot - // load its config still reports which build is serving. - apply_git_version_header(&mut response); - } - } - } - } - - if let Some(policy) = asset_cache_policy { - policy.apply_after_route_finalization(&mut response, EdgeCacheHeader::SurrogateControl); + // The SDK callback exclusively borrows the sandbox, so this bounded observer + // belongs to one dispatch at a time. Only the RC's terminal report establishes + // whether the application body, rather than a fallback, was delivered. + if let Some(state) = app_state.as_ref() { + state.delivery_observation.reset(); } + let delivery = send_request_with_registries_and_hooks( + app, + TrustedServerApp::stores(), + req, + |_request, extensions| { + extensions.insert(config_store); + extensions.insert(device_signals); + extensions.insert(client_info); + }, + |response| { + let ec_state = response.extensions_mut().remove::(); + let asset_cache_policy = response.extensions_mut().remove::(); + let request_filter_effects = response.extensions_mut().remove::(); + let settings = settings_snapshot.clone().or_else(|| { + load_settings_from_config_store(&runtime_stores) + .ok() + .map(Arc::new) + }); - if let Some(mut ec_state) = ec_state { - if let Some(settings) = settings_snapshot.as_deref() { - match apply_edgezero_ec_finalize(settings, &mut ec_state, &mut response) { - Ok(partner_registry) => { - send_edgezero_response( - response, - request_filter_effects.as_ref(), - counters.as_ref(), - ); - run_edgezero_pull_sync_after_send(settings, &partner_registry, &ec_state); - return; - } - Err(e) => { - log::error!( - "EdgeZero EC finalize skipped: failed to build partner registry: {e:?}" - ); + if !take_finalize_sentinel(response) { + if let Some(settings) = settings.as_deref() { + apply_entry_point_finalize_headers(settings, response, client_ip); + } else { + apply_git_version_header(response); } } - } else { - match load_settings_from_config_store(&runtime_stores) { - Ok(settings) => { - match apply_edgezero_ec_finalize(&settings, &mut ec_state, &mut response) { - Ok(partner_registry) => { - send_edgezero_response( - response, - request_filter_effects.as_ref(), - counters.as_ref(), - ); - run_edgezero_pull_sync_after_send( - &settings, - &partner_registry, - &ec_state, - ); - return; - } - Err(e) => { - log::error!( - "EdgeZero EC finalize skipped: failed to build partner registry: {e:?}" - ); + if let Some(policy) = asset_cache_policy { + policy.apply_after_route_finalization(response, EdgeCacheHeader::SurrogateControl); + } + let pull_sync = match (ec_state, settings) { + (Some(mut ec_state), Some(settings)) => { + match apply_edgezero_ec_finalize(&settings, &mut ec_state, response) { + Ok(registry) => Some((settings, registry, ec_state)), + Err(error) => { + log::error!("EdgeZero EC finalization failed: {error:?}"); + None } } } - Err(e) => { - log::warn!("EdgeZero EC finalize skipped: failed to reload settings: {e:?}"); - } + _ => None, + }; + apply_terminal_response_effects(response, request_filter_effects.as_ref()); + if let Some(counters) = counters.as_ref() { + attach_sandbox_counters(response, counters); } + pull_sync + }, + ); + + match delivery { + Ok(Some(Some((settings, registry, ec_state)))) + if app_state + .as_ref() + .is_some_and(|state| state.delivery_observation.completed()) => + { + run_edgezero_pull_sync_after_send(&settings, ®istry, &ec_state); + } + Ok(_) => {} + Err(error) => { + // The framework owns delivery, including post-commitment failure. + // Sending an SDK error response here would attempt a second response. + log::error!("EdgeZero request delivery failed: {error}"); + return Err(error); } } + Ok(()) +} - send_edgezero_response(response, request_filter_effects.as_ref(), counters.as_ref()); +/// Bounded terminal fact for the serial SDK sandbox callback. +#[derive(Clone, Default)] +pub(crate) struct DeliveryObservation(Arc); + +impl DeliveryObservation { + fn reset(&self) { + self.0.store(false, std::sync::atomic::Ordering::Release); + } + + fn completed(&self) -> bool { + self.0.load(std::sync::atomic::Ordering::Acquire) + } } -fn edge_error_response(error: EdgeError) -> HttpResponse { - log::error!("EdgeZero router returned error: {error:?}"); - match error.into_response() { - Ok(response) => response, - Err(error) => { - log::error!("failed to convert EdgeZero error into response: {error:?}"); - edgezero_core::http::response_builder() - .status(edgezero_core::http::StatusCode::INTERNAL_SERVER_ERROR) - .body(EdgeBody::from("Internal Server Error")) - .expect("should build EdgeZero error response") - } +impl edgezero_core::response_egress::ResponseEgressObserver for DeliveryObservation { + fn complete(&self, report: &edgezero_core::response_egress::ResponseEgressReport) { + self.0.store( + report.body_kind == ResponseEgressBodyKind::Application + && report.outcome == ResponseEgressOutcome::Completed, + std::sync::atomic::Ordering::Release, + ); } } @@ -660,69 +668,6 @@ where Some((context, kv)) } -/// Sends a finalized `EdgeZero` response to the client. -/// -/// Streaming `EdgeZero` bodies commit headers first, then pipe chunks to Fastly's -/// client stream so large asset and publisher-origin responses do not -/// materialize in the Wasm heap. -fn send_edgezero_response( - mut response: HttpResponse, - request_filter_effects: Option<&RequestFilterEffects>, - counters: Option<&SandboxCounters>, -) { - apply_terminal_response_effects(&mut response, request_filter_effects); - - // Captured before the body is consumed so post-commitment failures can be - // matched back to the response that carried these counters. - let counter_context = counters.map_or_else(String::new, |counters| { - format!( - " [instance={} ordinal={} request={}]", - instance_id(), - counters.ordinal, - counters.request_id - ) - }); - - // Before headers commit, including before `stream_to_client` below. - if let Some(counters) = counters.as_ref() { - attach_sandbox_counters(&mut response, counters); - } - - let (parts, body) = response.into_parts(); - - match body { - EdgeBody::Stream(_) => { - let skeleton = compat::to_fastly_response_skeleton(HttpResponse::from_parts( - parts, - EdgeBody::empty(), - )); - let mut streaming_body = skeleton.stream_to_client(); - match futures::executor::block_on(stream_asset_body(body, &mut streaming_body)) { - Ok(()) => { - if let Err(e) = streaming_body.finish() { - // Also post-commitment: same attribution as the - // streaming failure below. - log::error!( - "failed to finish EdgeZero streaming body{counter_context}: {e}" - ); - } - } - Err(e) => { - // After commitment: log and stop. Returning an error here - // would let the SDK attempt a second response. Counters - // already went out with the headers, so the failure is - // tagged with the same identity for reconciliation. - log::error!("EdgeZero streaming failed{counter_context}: {e:?}"); - drop(streaming_body); - } - } - } - once => { - compat::to_fastly_response(HttpResponse::from_parts(parts, once)).send_to_client(); - } - } -} - /// Apply every late response mutation, then restore privacy invariants before headers commit. fn apply_terminal_response_effects( response: &mut HttpResponse, diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 9bbc25d7ba..29f282e510 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -19,7 +19,7 @@ use edgezero_core::http::{HeaderValue, Response, StatusCode}; use edgezero_core::middleware::{Middleware, Next}; use edgezero_core::response::IntoResponse; use std::net::IpAddr; -use trusted_server_core::auth::enforce_basic_auth; +use trusted_server_core::auth::enforce_basic_auth_parts as enforce_basic_auth; use trusted_server_core::constants::{ ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_TS_ENV, HEADER_X_TS_FASTLY_VERSION, @@ -70,8 +70,12 @@ impl FinalizeResponseMiddleware { #[async_trait(?Send)] impl Middleware for FinalizeResponseMiddleware { async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { - let client_ip = ctx.request().extensions().get::().map_or_else( - || FastlyRequestContext::get(ctx.request()).and_then(|c| c.client_ip), + let client_ip = ctx.extensions().get::().map_or_else( + || { + ctx.extensions() + .get::() + .and_then(|c| c.client_ip) + }, |info| info.client_ip, ); @@ -131,7 +135,7 @@ impl AuthMiddleware { #[async_trait(?Send)] impl Middleware for AuthMiddleware { async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - match enforce_basic_auth(&self.settings, ctx.request_mut()) { + match enforce_basic_auth(&self.settings, ctx.parts_mut()) { Ok(Some(response)) => return Ok(response), Ok(None) => {} Err(report) => { @@ -591,16 +595,13 @@ mod tests { }), ); let mut ctx = empty_ctx(); - ctx.request_mut().extensions_mut().insert(ClientInfo { + ctx.extensions_mut().insert(ClientInfo { client_ip: Some(reader_ip), ..ClientInfo::default() }); - FastlyRequestContext::insert( - ctx.request_mut(), - FastlyRequestContext { - client_ip: Some(peer_ip), - }, - ); + ctx.extensions_mut().insert(FastlyRequestContext { + client_ip: Some(peer_ip), + }); let handler = Arc::new( |_ctx: RequestContext| async move { Ok::(empty_response()) }, @@ -627,15 +628,10 @@ mod tests { }), ); let mut ctx = empty_ctx(); - ctx.request_mut() - .extensions_mut() - .insert(ClientInfo::default()); - FastlyRequestContext::insert( - ctx.request_mut(), - FastlyRequestContext { - client_ip: Some(peer_ip), - }, - ); + ctx.extensions_mut().insert(ClientInfo::default()); + ctx.extensions_mut().insert(FastlyRequestContext { + client_ip: Some(peer_ip), + }); let handler = Arc::new( |_ctx: RequestContext| async move { Ok::(empty_response()) }, @@ -662,12 +658,9 @@ mod tests { }), ); let mut ctx = empty_ctx(); - FastlyRequestContext::insert( - ctx.request_mut(), - FastlyRequestContext { - client_ip: Some(peer_ip), - }, - ); + ctx.extensions_mut().insert(FastlyRequestContext { + client_ip: Some(peer_ip), + }); let handler = Arc::new( |_ctx: RequestContext| async move { Ok::(empty_response()) }, diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index d0f4ee9d0e..d12a41eb40 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -327,13 +327,13 @@ fn edge_request_to_fastly( fastly_req.append_header(name.as_str(), value.as_bytes()); } } - match body { - edgezero_core::body::Body::Once(bytes) => { + match body.into_content() { + edgezero_core::body::BodyContent::Once(bytes) => { if !bytes.is_empty() { fastly_req.set_body(bytes.to_vec()); } } - edgezero_core::body::Body::Stream(_) => { + edgezero_core::body::BodyContent::Stream(_) => { return Err(Report::new(PlatformError::HttpClient) .attach("streaming request body is not supported by Fastly request conversion")); } @@ -363,7 +363,7 @@ fn fastly_body_to_edge_stream(body: fastly::Body) -> edgezero_core::body::Body { } }); - edgezero_core::body::Body::from_stream(stream) + edgezero_core::body::Body::from_external_stream(stream) } /// Return whether a response is allowed to carry a body. @@ -1446,7 +1446,7 @@ mod tests { let request = request_builder() .method("POST") .uri("https://example.com/") - .body(Body::from_stream(futures::stream::empty::< + .body(Body::from_external_stream(futures::stream::empty::< Result<_, io::Error>, >())) .expect("should build streaming test request"); @@ -1521,7 +1521,7 @@ mod tests { let request = request_builder() .method("POST") .uri("https://example.com/") - .body(Body::from_stream(futures::stream::empty::< + .body(Body::from_external_stream(futures::stream::empty::< Result<_, io::Error>, >())) .expect("should build streaming test request"); diff --git a/crates/trusted-server-adapter-spin/Cargo.toml b/crates/trusted-server-adapter-spin/Cargo.toml index 81eeb94307..a8f205ec3f 100644 --- a/crates/trusted-server-adapter-spin/Cargo.toml +++ b/crates/trusted-server-adapter-spin/Cargo.toml @@ -31,6 +31,7 @@ flate2 = { workspace = true } futures = { workspace = true } http-body-util = { workspace = true } log = { workspace = true } +serde_json = { workspace = true } trusted-server-core = { workspace = true } trusted-server-js = { workspace = true } diff --git a/crates/trusted-server-adapter-spin/README.md b/crates/trusted-server-adapter-spin/README.md index fe33c90557..46a3755b50 100644 --- a/crates/trusted-server-adapter-spin/README.md +++ b/crates/trusted-server-adapter-spin/README.md @@ -1,23 +1,77 @@ # trusted-server-adapter-spin -Experimental Fermyon Spin adapter for Trusted Server. The component targets -`wasm32-wasip1` with the `spin` feature; native builds exercise route and -platform tests. - -The crate maps Spin HTTP, variables, and the `default` key-value store into the -shared runtime. Startup failures install a restricted 503 router that keeps -`/health` live. The adapter permits one enabled auction provider. Request-time -KV services beyond startup config loading remain unavailable. Its entry point -uses `anyhow::Result` only because the EdgeZero Spin FFI requires that type. +Experimental Fermyon Spin adapter for Trusted Server. This migration selects +exact `spin-sdk = 7.0.0`, Rust target `wasm32-wasip2`, and the P3 `wasi:http` +0.3.0 interface. It requires **Spin 4.1.0 or newer**. A successful wasip2 build +or native test does not establish compatibility with a P2-only host or Spin 4.0. Build and test from the repository root: ```bash -cargo build --package trusted-server-adapter-spin --target wasm32-wasip1 --features spin --release +cargo build --package trusted-server-adapter-spin --target wasm32-wasip2 --features spin --release cargo test-spin +spin up --from crates/trusted-server-adapter-spin ``` -Run `./scripts/smoke-spin.sh` for the isolated config, encoded-secret, failure, -and publisher-response checks. See the -[Spin deployment guide](../../docs/guide/spin.md) for the exact local-store -mapping and operational limitations. +Inspect the emitted component's imports/exports with `wasm-tools component wit` +and exercise it with the selected Spin host before deployment. `spin.toml` +loads `target/wasm32-wasip2/release/trusted_server_adapter_spin.wasm`. +Repository-wide aliases, toolchain targets and CI must select the same target. +Check `scripts/smoke-spin.sh` and the deployment guide against this bootstrap +contract before using them as migration proof. Legacy expectations of routed +503 responses or healthy failed-startup probes are no longer valid. + +## Invocation-local bootstrap + +Every invocation awaits the existing BlobEnvelope from the `default` KV store, +verifies it, removes inactive secret references, loads the required encoded +secret variables and validates resolved settings. Only then does it build the +app with Spin platform metadata and dispatch through EdgeZero admission/egress. +Routes and middleware read immutable typed state. No synchronous provider +executor or global live SDK-resource cache is used. + +Bootstrap failures, including a pending provider exceeding the budget, are +explicit **pre-admission exceptions** returned at the FFI boundary. They do not +install a startup-error router, answer `/health` successfully, or create routed +body/egress completion records. Once initialized, `/health` remains a routed +liveness endpoint. The concrete `SpinResponse` leaves transmission ownership +with EdgeZero. `anyhow::Result` is confined to that SDK-required FFI boundary. + +## Finite read-only snapshots + +The old config adapter mapped every logical config-store name to the same +`default` KV store and read keys verbatim. Preserve that mapping by snapshotting +that entire bounded store. This includes configured DataDome CIDR sources and +all stored public JWKs, so verification is not restricted to `active-kids`. +Use a dedicated config store, not one shared with arbitrary binary/application +KV data. Every enumerated config value must be UTF-8. + +Limits per invocation: + +- 5 seconds for bootstrap, shared across provider reads and key enumeration. +- 128 config keys, each containing 1 through 256 UTF-8 bytes. +- 1 MiB per config value, including the serialized envelope. +- JSON depth 64 and 100,000 nodes for verified envelope data. +- 64 distinct store/key secret addresses, including the current signing key. +- 64 KiB per nonempty UTF-8 secret and 4 MiB aggregate config/key/secret bytes, + including secret store/key names. + +SDK key enumeration is streamed and stops on the first excess key. The SDK +materializes individual keys and values before the adapter can reject excess bytes; +these are retained-value limits, not a bound on host allocation or total Wasm +heap use. A deadline drops guest provider futures, not a claim that the host +has cancelled its work. Multi-key/provider reads are not an atomic release. +Publish stable inputs before serving traffic. + +Secret names keep the existing collision-resistant store/key encoding, for +example `signing_keys` plus `kid-current` becomes +`v_signing_x5fkeys_v_kid_x2dcurrent`. The RC's flat lowercase secret-store mapping +is not used. Enabled signing loads `current-kid`, `active-kids`, public JWKs and +the current private key, then checks the current public/private pair with the +existing core parsers. Configured signing is not disabled for this adapter. +Missing snapshot values and management writes return typed errors; key rotation +and deactivation remain unsupported. Snapshots reload on the next invocation, +not during a request. Existing core consumer caches, including DataDome's parsed +CIDR TTL cache, retain their current policy. Explicit-settings/services builders +remain native test seams. The adapter still permits only one enabled auction +provider. diff --git a/crates/trusted-server-adapter-spin/spin.toml b/crates/trusted-server-adapter-spin/spin.toml index 7055f9563d..25b6798c0f 100644 --- a/crates/trusted-server-adapter-spin/spin.toml +++ b/crates/trusted-server-adapter-spin/spin.toml @@ -1,3 +1,4 @@ +# SDK 7 emits wasi:http 0.3.0 and requires Spin >= 4.1.0. spin_manifest_version = 2 [application] @@ -34,7 +35,7 @@ route = "/..." component = "trusted-server" [component.trusted-server] -source = "../../target/wasm32-wasip1/release/trusted_server_adapter_spin.wasm" +source = "../../target/wasm32-wasip2/release/trusted_server_adapter_spin.wasm" # Wildcard outbound is required because the first-party proxy and ad-serving # paths reach arbitrary, operator-configured publisher and ad origins that are # not known at build time. Both https and http are allowed because some upstream @@ -52,5 +53,5 @@ v_trusted_x5fserver_x5fsecrets_v_partner_x5fts_x5fpull_x5ftoken = "{{ v_trusted_ v_trusted_x5fserver_x5fsecrets_v_handler_x5fpassword = "{{ v_trusted_x5fserver_x5fsecrets_v_handler_x5fpassword }}" [component.trusted-server.build] -command = "cargo build --target wasm32-wasip1 --release -p trusted-server-adapter-spin --features spin" +command = "cargo build --target wasm32-wasip2 --release -p trusted-server-adapter-spin --features spin" watch = ["src/**/*.rs", "Cargo.toml", "../../edgezero.toml", "spin.toml"] diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 804a23b620..ad0aa20cc4 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -1,12 +1,8 @@ use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -use edgezero_adapter_spin::config_store::SpinConfigStore; use edgezero_adapter_spin::context::SpinRequestContext; use edgezero_core::app::Hooks; -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Method, Request, Response, StatusCode, header}; @@ -17,8 +13,6 @@ use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan, }; use trusted_server_core::cache_policy::EdgeCacheHeader; -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -use trusted_server_core::config_payload::settings_from_config_blob; use trusted_server_core::ec::EcContext; use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported, @@ -26,11 +20,9 @@ use trusted_server_core::ec::admin::{ }; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; -use trusted_server_core::http_util::sanitize_forwarded_headers; +use trusted_server_core::http_util::SPOOFABLE_FORWARDED_HEADERS; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; use trusted_server_core::platform::RuntimeServices; -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -use trusted_server_core::platform::{PlatformConfigStore, StoreName}; use trusted_server_core::proxy::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, @@ -44,84 +36,31 @@ use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -use trusted_server_core::settings_data::{default_config_key, default_secret_store_name}; use crate::middleware::{ AuthMiddleware, FinalizeResponseMiddleware, NormalizeMiddleware, SanitizeRequestMiddleware, }; use crate::platform::build_runtime_services; -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -use crate::platform::{ConfigStoreHandleAdapter, SpinSecretStoreAdapter}; // --------------------------------------------------------------------------- // AppState // --------------------------------------------------------------------------- -/// Spin auto-provides this key-value store label without runtime configuration. -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -const SPIN_DEFAULT_CONFIG_STORE: &str = "default"; - -/// Application state built once at startup and shared across all requests. +/// Validated application state shared only within one Spin invocation. pub struct AppState { - settings: Arc, + pub(crate) settings: Arc, orchestrator: Arc, registry: Arc, services: Option, } -/// Build the application state, loading settings and constructing all per-application components. -/// -/// # Errors -/// -/// Returns an error when settings, the auction orchestrator, or the integration -/// registry fail to initialise. -fn build_state() -> Result, Report> { - let settings = load_startup_settings()?; - build_state_with_settings(settings) -} - -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -fn load_startup_settings() -> Result> { - let config_store_name = StoreName::from(SPIN_DEFAULT_CONFIG_STORE); - let config_key = default_config_key(); - let config_store = - futures::executor::block_on(SpinConfigStore::open(config_store_name.as_ref().to_owned())) - .map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: "failed to open Spin Trusted Server config store".to_string(), - }) - .attach(error.to_string()) - })?; - let config_handle = ConfigStoreHandle::new(Arc::new(config_store)); - let config_adapter = ConfigStoreHandleAdapter(config_handle); - let raw_envelope = config_adapter - .get(&config_store_name, &config_key) - .map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: "failed to read Spin Trusted Server app-config blob".to_string(), - }) - .attach(error.to_string()) - })?; - let secret_store = SpinSecretStoreAdapter; - settings_from_config_blob(&raw_envelope, &secret_store, &default_secret_store_name()) -} - -#[cfg(not(all(feature = "spin", target_arch = "wasm32")))] -fn load_startup_settings() -> Result> { - Err(Report::new(TrustedServerError::Configuration { - message: "Spin startup settings require the production config store".to_string(), - }) - .attach("use TrustedServerApp::routes_with_settings for host tests")) -} - /// Build the application state from explicit settings. /// /// # Errors /// /// Returns an error when the auction orchestrator or the integration /// registry fail to initialise. -fn build_state_with_settings( +pub(crate) fn build_state_with_settings( settings: Settings, ) -> Result, Report> { build_state_with_services(settings, None) @@ -145,6 +84,14 @@ fn build_state_with_services( } impl AppState { + pub(crate) fn from_request(ctx: &RequestContext) -> Result, EdgeError> { + ctx.extensions().get::>().cloned().ok_or_else(|| { + EdgeError::service_unavailable( + "Spin async bootstrap has not initialized application state", + ) + }) + } + fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { self.services .clone() @@ -291,8 +238,12 @@ fn parse_client_addr(raw: &str) -> Option { // which rejects a relative path. Rebuilding an absolute URI from the trusted // scheme+host lets those handlers validate the signed target instead of failing // with "Invalid URL". -pub(crate) fn normalize_spin_request(req: &mut Request) { - sanitize_forwarded_headers(req); +pub(crate) fn normalize_spin_request(parts: &mut edgezero_core::http::RequestParts) { + for header in SPOOFABLE_FORWARDED_HEADERS { + if parts.headers.remove(*header).is_some() { + log::debug!("Stripped spoofable header: {header}"); + } + } // Strip any client-supplied `x-forwarded-for`. Spin exposes no trusted // upstream forwarded-for chain — the only trusted client IP comes from the @@ -300,7 +251,7 @@ pub(crate) fn normalize_spin_request(req: &mut Request) { // inbound `x-forwarded-for` to origins and Prebid copies it into its outbound // headers, so leaving it would let a Spin client spoof the IP seen by // downstream services while `device.ip` uses the trusted runtime address. - req.headers_mut().remove("x-forwarded-for"); + parts.headers.remove("x-forwarded-for"); // Re-derive the trusted client IP from the *last* `spin-client-addr` header // and overwrite `SpinRequestContext`, which `build_runtime_services` reads @@ -310,27 +261,24 @@ pub(crate) fn normalize_spin_request(req: &mut Request) { // its own `spin-client-addr` ahead of Spin's and forge the IP that EC ID // hashing, `/auction` `device.ip`, and integration `X-Forwarded-For` consume. // Selecting the last occurrence pins the value to the trusted runtime one. - let trusted_client_addr = req - .headers() + let trusted_client_addr = parts + .headers .get_all("spin-client-addr") .iter() .next_back() .and_then(|v| v.to_str().ok()) .and_then(parse_client_addr); - let trusted_full_url = req - .headers() + let trusted_full_url = parts + .headers .get_all("spin-full-url") .iter() .next_back() .and_then(|v| v.to_str().ok()) .map(str::to_owned); - SpinRequestContext::insert( - req, - SpinRequestContext { - client_addr: trusted_client_addr, - full_url: trusted_full_url.clone(), - }, - ); + parts.extensions.insert(SpinRequestContext { + client_addr: trusted_client_addr, + full_url: trusted_full_url.clone(), + }); // Spin's WASI HTTP bridge copies synthetic `spin-client-addr` and // `spin-full-url` headers onto the core request. They are runtime metadata, @@ -338,7 +286,7 @@ pub(crate) fn normalize_spin_request(req: &mut Request) { // values are captured into `SpinRequestContext` above; remove every copy here // so the shared publisher/integration handlers neither forward them to // publisher origins nor mistake them for client-controlled input. - req.headers_mut().remove("spin-client-addr"); + parts.headers.remove("spin-client-addr"); // Spin's WASI HTTP bridge appends its synthetic `spin-full-url` *after* the // original client headers, so when duplicate names are present the trusted @@ -348,7 +296,7 @@ pub(crate) fn normalize_spin_request(req: &mut Request) { // by publisher HTML rewriting, integration URL rewriting, and request // signing. Select the last occurrence as the trusted authority, then strip // every copy so none reach publisher origins or the shared handlers. - req.headers_mut().remove("spin-full-url"); + parts.headers.remove("spin-full-url"); let Some((scheme, host)) = trusted_full_url .as_deref() .and_then(scheme_host_from_spin_url) @@ -361,29 +309,29 @@ pub(crate) fn normalize_spin_request(req: &mut Request) { // incoming Host header (without it extract_request_host() returns "" and // classify_response_route falls back to BufferedUnmodified, skipping the HTML // processor), but when a Host *is* present it is client-controllable. Keeping - // it while rebuilding req.uri() from the spin-full-url host below would let the + // it while rebuilding parts.uri from the spin-full-url host below would let the // shared RequestInfo path (publisher HTML rewriting, integration URL rewriting, - // signing context) read one host while handlers parsing req.uri() see another. + // signing context) read one host while handlers parsing parts.uri see another. // Overriding from the single trusted authority keeps both consistent. if let Ok(hval) = HeaderValue::from_str(&host) { - req.headers_mut().insert(header::HOST, hval); + parts.headers.insert(header::HOST, hval); } // Without a trusted scheme signal, detect_request_scheme defaults to http and // rewrites HTTPS URLs as http. if let Ok(pval) = HeaderValue::from_str(&scheme) { - req.headers_mut().insert("x-forwarded-proto", pval); + parts.headers.insert("x-forwarded-proto", pval); } // Promote the path-only URI to an absolute one so the shared first-party - // proxy/click/sign handlers can parse `req.uri()` as a full URL. - let path_and_query = req - .uri() + // proxy/click/sign handlers can parse `parts.uri` as a full URL. + let path_and_query = parts + .uri .path_and_query() .map(|pq| pq.as_str().to_owned()) .unwrap_or_else(|| "/".to_string()); if let Ok(uri) = format!("{scheme}://{host}{path_and_query}").parse() { - *req.uri_mut() = uri; + parts.uri = uri; } } @@ -395,9 +343,8 @@ pub(crate) fn normalize_spin_request(req: &mut Request) { /// /// Mirrors the Fastly entry point and Axum adapter so deployments reusing /// Trusted Server health probes see identical behaviour on Spin. Served from -/// both the healthy router and the startup-error fallback so the probe answers -/// even before (or when) application state is usable, leaving Spin's -/// platform-provided `/.well-known/spin/health` untouched. +/// the initialized router only. Bootstrap failure occurs before dispatch and +/// does not serve a successful health response. Spin's platform health is separate. fn health_response() -> Response { let mut resp = Response::new(edgezero_core::body::Body::from("ok")); *resp.status_mut() = StatusCode::OK; @@ -498,46 +445,6 @@ fn legacy_admin_alias_denied() -> Response { response } -// --------------------------------------------------------------------------- -// Startup error fallback -// --------------------------------------------------------------------------- - -/// Returns a [`RouterService`] that responds to every route with a generic -/// 503 Service Unavailable. The startup error is logged but not echoed in the -/// response body so that deployment state is not leaked to anonymous callers. -fn startup_error_router(e: &Report) -> RouterService { - log::error!("startup failed, serving error fallback: {:?}", e); - - let handler = |_ctx: RequestContext| { - let body = edgezero_core::body::Body::from("Service Unavailable\n"); - let mut resp = Response::new(body); - *resp.status_mut() = StatusCode::SERVICE_UNAVAILABLE; - resp.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - async move { Ok::(resp) } - }; - - // Cover the full publisher fallback method set (GET, POST, HEAD, OPTIONS, - // PUT, PATCH, DELETE) so degraded behaviour stays consistent with the - // healthy router: every method on `/` and `/{*rest}` returns the generic - // 503 instead of a router-level 405 for HEAD/OPTIONS/PATCH. - let mut builder = RouterService::builder().middleware(FinalizeResponseMiddleware::new( - Arc::new(Settings::default()), - )); - // Keep the liveness probe answering 200 even while state construction is - // failing, matching the Fastly/Axum health behaviour. - builder = builder.get("/health", |_ctx: RequestContext| async { - Ok::(health_response()) - }); - for method in publisher_fallback_methods() { - builder = builder.route("/", method.clone(), handler); - builder = builder.route("/{*rest}", method, handler); - } - builder.build() -} - // --------------------------------------------------------------------------- // TrustedServerApp // --------------------------------------------------------------------------- @@ -551,15 +458,7 @@ impl Hooks for TrustedServerApp { } fn routes() -> RouterService { - let state = match build_state() { - Ok(s) => s, - Err(ref e) => { - log::error!("failed to build application state: {:?}", e); - return startup_error_router(e); - } - }; - - build_router(&state) + build_router(None) } } @@ -578,7 +477,7 @@ impl TrustedServerApp { settings: Settings, ) -> Result> { let state = build_state_with_settings(settings)?; - Ok(build_router(&state)) + Ok(build_router(Some(state))) } /// Build the full router with explicit settings and runtime services. @@ -602,36 +501,30 @@ impl TrustedServerApp { services: RuntimeServices, ) -> Result> { let state = build_state_with_services(settings, Some(services))?; - Ok(build_router(&state)) + Ok(build_router(Some(state))) } } -fn build_router(state: &Arc) -> RouterService { +fn build_router(state: Option>) -> RouterService { { - let state = Arc::clone(state); - // /.well-known/trusted-server.json - let s = Arc::clone(&state); - let discovery_handler = move |ctx: RequestContext| { - let s = Arc::clone(&s); - async move { - let services = s.services_for_request(&ctx); - let req = ctx.into_request(); - Ok(handle_trusted_server_discovery(&s.settings, &services, req) - .unwrap_or_else(|e| http_error(&e))) - } + let discovery_handler = |ctx: RequestContext| async move { + let s = AppState::from_request(&ctx)?; + let services = s.services_for_request(&ctx); + let req = ctx.into_request()?; + Ok(handle_trusted_server_discovery(&s.settings, &services, req) + .unwrap_or_else(|e| http_error(&e))) }; // /verify-signature - let s = Arc::clone(&state); - let verify_handler = move |ctx: RequestContext| { - let s = Arc::clone(&s); - async move { - let services = s.services_for_request(&ctx); - let req = ctx.into_request(); - Ok(handle_verify_signature(&s.settings, &services, req) - .unwrap_or_else(|e| http_error(&e))) - } + let verify_handler = |ctx: RequestContext| async move { + let s = AppState::from_request(&ctx)?; + let services = s.services_for_request(&ctx); + ctx.body_bytes(trusted_server_core::request_signing::endpoints::VERIFY_MAX_BODY_BYTES) + .await?; + let req = ctx.into_request()?; + Ok(handle_verify_signature(&s.settings, &services, req) + .unwrap_or_else(|e| http_error(&e))) }; let admin_not_supported_handler = |_ctx: RequestContext| async { @@ -647,29 +540,27 @@ fn build_router(state: &Arc) -> RouterService { // Admin EIDs echo: pure request inspection (no KV), so this adapter // serves the real handler. - let s = Arc::clone(&state); - let admin_eids_handler = move |ctx: RequestContext| { - let s = Arc::clone(&s); - async move { - let req = ctx.into_request(); - let result = PartnerRegistry::from_config(&s.settings.ec.partners) - .and_then(|registry| handle_admin_eids_lookup(®istry, &req)); - Ok::(result.unwrap_or_else(|e| http_error(&e))) - } + let admin_eids_handler = |ctx: RequestContext| async move { + let s = AppState::from_request(&ctx)?; + let req = ctx.into_request()?; + let result = PartnerRegistry::from_config(&s.settings.ec.partners) + .and_then(|registry| handle_admin_eids_lookup(®istry, &req)); + Ok::(result.unwrap_or_else(|e| http_error(&e))) }; // /auction - let s = Arc::clone(&state); - let auction_handler = move |ctx: RequestContext| { - let s = Arc::clone(&s); + let auction_handler = |ctx: RequestContext| { async move { + let s = AppState::from_request(&ctx)?; let services = s.services_for_request(&ctx); // Request normalization (forwarded-header stripping, trusted // Host/scheme/client-IP derivation) is applied centrally by // `NormalizeMiddleware` before this handler runs, so the signed // OpenRTB metadata that auction signing derives from // `RequestInfo::from_request` uses the trusted runtime authority. - let mut req = ctx.into_request(); + ctx.body_bytes(trusted_server_core::auction::endpoints::MAX_AUCTION_BODY_SIZE) + .await?; + let mut req = ctx.into_request()?; if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &s.settings, @@ -697,32 +588,27 @@ fn build_router(state: &Arc) -> RouterService { }; // GET /_ts/page-bids — SPA re-auction endpoint. - let s = Arc::clone(&state); - let page_bids_handler = move |ctx: RequestContext| { - let s = Arc::clone(&s); - async move { - let services = s.services_for_request(&ctx); - let mut req = ctx.into_request(); - if let Err(error) = - trusted_server_core::integrations::gpt_diagnostics::prepare_request( - &s.settings, - &mut req, - ) - { - return Ok(http_error(&error)); - } - let mut ec_context = build_ec_context(&s.settings, &services, &req); - let auction = AuctionDispatch { - orchestrator: &s.orchestrator, - slots: s.settings.creative_opportunity_slots(), - registry: None, - }; - Ok( - handle_page_bids(&s.settings, &services, None, auction, &mut ec_context, req) - .await - .unwrap_or_else(|e| http_error(&e)), - ) + let page_bids_handler = |ctx: RequestContext| async move { + let s = AppState::from_request(&ctx)?; + let services = s.services_for_request(&ctx); + let mut req = ctx.into_request()?; + if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( + &s.settings, + &mut req, + ) { + return Ok(http_error(&error)); } + let mut ec_context = build_ec_context(&s.settings, &services, &req); + let auction = AuctionDispatch { + orchestrator: &s.orchestrator, + slots: s.settings.creative_opportunity_slots(), + registry: None, + }; + Ok( + handle_page_bids(&s.settings, &services, None, auction, &mut ec_context, req) + .await + .unwrap_or_else(|e| http_error(&e)), + ) }; // OPTIONS /_ts/page-bids — deny the CORS preflight for this @@ -732,63 +618,59 @@ fn build_router(state: &Arc) -> RouterService { }; // GET /first-party/proxy - let s = Arc::clone(&state); - let fp_proxy_handler = move |ctx: RequestContext| { - let s = Arc::clone(&s); - async move { - let services = s.services_for_request(&ctx); - let req = ctx.into_request(); - Ok(handle_first_party_proxy(&s.settings, &services, req) - .await - .unwrap_or_else(|e| http_error(&e))) - } + let fp_proxy_handler = |ctx: RequestContext| async move { + let s = AppState::from_request(&ctx)?; + let services = s.services_for_request(&ctx); + let req = ctx.into_request()?; + Ok(handle_first_party_proxy(&s.settings, &services, req) + .await + .unwrap_or_else(|e| http_error(&e))) }; // /first-party/click - let s = Arc::clone(&state); - let fp_click_handler = move |ctx: RequestContext| { - let s = Arc::clone(&s); - async move { - let services = s.services_for_request(&ctx); - let req = ctx.into_request(); - Ok(handle_first_party_click(&s.settings, &services, req) - .await - .unwrap_or_else(|e| http_error(&e))) - } + let fp_click_handler = |ctx: RequestContext| async move { + let s = AppState::from_request(&ctx)?; + let services = s.services_for_request(&ctx); + let req = ctx.into_request()?; + Ok(handle_first_party_click(&s.settings, &services, req) + .await + .unwrap_or_else(|e| http_error(&e))) }; // GET + POST /first-party/sign — identical handler, cloned for both bindings - let s = Arc::clone(&state); - let fp_sign_handler = move |ctx: RequestContext| { - let s = Arc::clone(&s); - async move { - let services = s.services_for_request(&ctx); - let req = ctx.into_request(); - Ok(handle_first_party_proxy_sign(&s.settings, &services, req) - .await - .unwrap_or_else(|e| http_error(&e))) + let fp_sign_handler = |ctx: RequestContext| async move { + let s = AppState::from_request(&ctx)?; + let services = s.services_for_request(&ctx); + if ctx.parts().method == Method::POST { + ctx.body_bytes(trusted_server_core::proxy::SIGN_MAX_BODY_BYTES) + .await?; } + let req = ctx.into_request()?; + Ok(handle_first_party_proxy_sign(&s.settings, &services, req) + .await + .unwrap_or_else(|e| http_error(&e))) }; - let fp_sign_post_handler = fp_sign_handler.clone(); + let fp_sign_post_handler = fp_sign_handler; // GET + POST /first-party/proxy-rebuild — GET serves the click guard's // navigation fallback: the creative iframe is an opaque origin (sandbox // without `allow-same-origin`), so its JSON POST is blocked by CORS and // the guard navigates here for a 302 instead. - let s = Arc::clone(&state); - let fp_rebuild_handler = move |ctx: RequestContext| { - let s = Arc::clone(&s); - async move { - let services = s.services_for_request(&ctx); - let req = ctx.into_request(); - Ok( - handle_first_party_proxy_rebuild(&s.settings, &services, req) - .await - .unwrap_or_else(|e| http_error(&e)), - ) + let fp_rebuild_handler = |ctx: RequestContext| async move { + let s = AppState::from_request(&ctx)?; + let services = s.services_for_request(&ctx); + if ctx.parts().method == Method::POST { + ctx.body_bytes(trusted_server_core::proxy::REBUILD_MAX_BODY_BYTES) + .await?; } + let req = ctx.into_request()?; + Ok( + handle_first_party_proxy_rebuild(&s.settings, &services, req) + .await + .unwrap_or_else(|e| http_error(&e)), + ) }; - let fp_rebuild_post_handler = fp_rebuild_handler.clone(); + let fp_rebuild_post_handler = fp_rebuild_handler; // Shared fallback dispatch: routes to tsjs (GET only), integration proxy, or publisher. async fn dispatch( @@ -796,7 +678,7 @@ fn build_router(state: &Arc) -> RouterService { ctx: RequestContext, ) -> Result { let services = state.services_for_request(&ctx); - let mut req = ctx.into_request(); + let mut req = ctx.into_request()?; if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); } @@ -872,11 +754,8 @@ fn build_router(state: &Arc) -> RouterService { // Single publisher/integration fallback used for every method. The method // is read inside `dispatch`, so the same closure serves GET (tsjs/publisher) // and the other supported methods (integration proxy / publisher origin). - let s = Arc::clone(&state); - let fallback = move |ctx: RequestContext| { - let s = Arc::clone(&s); - dispatch(s, ctx) - }; + let fallback = + |ctx: RequestContext| async move { dispatch(AppState::from_request(&ctx)?, ctx).await }; let legacy_admin_deny = |_ctx: RequestContext| async { Ok::(legacy_admin_alias_denied()) }; @@ -885,9 +764,9 @@ fn build_router(state: &Arc) -> RouterService { // headers before anything else sees the request. Must stay first — // any middleware registered ahead of it would observe the // shared-secret authentication header. - .middleware(SanitizeRequestMiddleware::new(Arc::clone(&state.settings))) - .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) - .middleware(AuthMiddleware::new(Arc::clone(&state.settings))) + .middleware(SanitizeRequestMiddleware::from_request()) + .middleware(FinalizeResponseMiddleware::from_request()) + .middleware(AuthMiddleware::from_request()) // Innermost middleware: normalize every routed request (strip // spoofable forwarded headers, derive the trusted Host/scheme/client-IP // from Spin's synthetic runtime headers) so no handler can opt out of @@ -921,7 +800,7 @@ fn build_router(state: &Arc) -> RouterService { .get("/_ts/admin/ec/{id}", admin_ec_not_supported_handler) .get("/_ts/admin/eids", admin_eids_handler) .post("/auction", auction_handler) - .get(PAGE_BIDS_PATH, page_bids_handler.clone()) + .get(PAGE_BIDS_PATH, page_bids_handler) .route(PAGE_BIDS_PATH, Method::OPTIONS, page_bids_options_handler) // Deprecated double-underscore alias, kept so tsjs bundles served // before the `/_ts/page-bids` rename keep getting ads on SPA @@ -960,15 +839,18 @@ fn build_router(state: &Arc) -> RouterService { for (path, primary_methods) in named_fallback_paths() { for method in publisher_fallback_methods() { if !primary_methods.contains(&method) { - builder = builder.route(path, method, fallback.clone()); + builder = builder.route(path, method, fallback); } } } for method in publisher_fallback_methods() { - builder = builder.route("/", method.clone(), fallback.clone()); - builder = builder.route("/{*rest}", method, fallback.clone()); + builder = builder.route("/", method.clone(), fallback); + builder = builder.route("/{*rest}", method, fallback); } + if let Some(state) = state { + builder = builder.with_state(state); + } builder.build() } } @@ -1123,7 +1005,7 @@ mod tests { fn normalize_spin_request_strips_spoofed_headers_and_uses_runtime_url() { // Client sends an HTTPS spin-full-url but tries to spoof a downgraded // http scheme and an attacker-controlled host via forwarded headers. - let mut req = request_with(&[ + let req = request_with(&[ ("spin-full-url", "https://www.publisher.example/cars/"), ("spin-client-addr", "203.0.113.7:5000"), ("x-forwarded-proto", "http"), @@ -1131,7 +1013,9 @@ mod tests { ("forwarded", "host=evil.example;proto=http"), ]); - normalize_spin_request(&mut req); + let (mut parts, body) = req.into_parts(); + normalize_spin_request(&mut parts); + let req = Request::from_parts(parts, body); // Spoofable host overrides are stripped, leaving only the trusted Host. assert!( @@ -1182,12 +1066,14 @@ mod tests { // A client-supplied Host must not survive: it would diverge from the // absolute req.uri() rebuilt from the trusted spin-full-url host, leaving // RequestInfo and the first-party handlers reading different authorities. - let mut req = request_with(&[ + let req = request_with(&[ ("host", "client-supplied.example"), ("spin-full-url", "https://www.publisher.example/cars/"), ]); - normalize_spin_request(&mut req); + let (mut parts, body) = req.into_parts(); + normalize_spin_request(&mut parts); + let req = Request::from_parts(parts, body); assert_eq!( req.headers() @@ -1233,7 +1119,9 @@ mod tests { }, ); - normalize_spin_request(&mut req); + let (mut parts, body) = req.into_parts(); + normalize_spin_request(&mut parts); + let req = Request::from_parts(parts, body); // All `spin-full-url` copies are stripped after normalization. assert!( @@ -1290,7 +1178,9 @@ mod tests { }, ); - normalize_spin_request(&mut req); + let (mut parts, body) = req.into_parts(); + normalize_spin_request(&mut parts); + let req = Request::from_parts(parts, body); assert_eq!( SpinRequestContext::get(&req).and_then(|c| c.client_addr), @@ -1310,12 +1200,14 @@ mod tests { // so shared proxy/Prebid code cannot forward an attacker-controlled IP to // downstream services, while the trusted client IP is taken from // spin-client-addr. - let mut req = request_with(&[ + let req = request_with(&[ ("spin-client-addr", "203.0.113.7:5000"), ("x-forwarded-for", "198.51.100.99, 10.0.0.1"), ]); - normalize_spin_request(&mut req); + let (mut parts, body) = req.into_parts(); + normalize_spin_request(&mut parts); + let req = Request::from_parts(parts, body); assert!( req.headers().get("x-forwarded-for").is_none(), @@ -1338,73 +1230,19 @@ mod tests { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn startup_error_router_serves_503_for_all_fallback_methods() { - // The degraded router must answer every publisher-fallback method - // (including HEAD/OPTIONS/PATCH) on both "/" and nested paths with the - // generic 503, never a router-level 405, so startup-failure behaviour - // stays consistent with the healthy router. - let report = Report::new(TrustedServerError::BadRequest { - message: "startup failure".to_string(), - }); - let router = startup_error_router(&report); - - for method in ["GET", "POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"] { - for path in ["/", "/some/nested/page"] { - let req = edgezero_core::http::request_builder() - .method(method) - .uri(path) - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let status = router - .oneshot(req) - .await - .expect("should route startup-error request") - .status() - .as_u16(); - assert_eq!( - status, 503, - "{method} {path} must return 503 from the startup fallback, got {status}" - ); - } - } - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn startup_error_router_answers_health_with_200() { - // The liveness probe must keep returning 200 even while application state - // construction is failing, matching the Fastly/Axum health behaviour. - let report = Report::new(TrustedServerError::BadRequest { - message: "startup failure".to_string(), - }); - let router = startup_error_router(&report); - + async fn uninitialized_routes_cannot_report_healthy() { let req = edgezero_core::http::request_builder() - .method("GET") .uri("/health") .body(edgezero_core::body::Body::empty()) .expect("should build request"); - let resp = router + let response = TrustedServerApp::routes() .oneshot(req) .await - .expect("should route startup-error health request"); - - assert_eq!( - resp.status().as_u16(), - 200, - "GET /health must return 200 from the startup fallback" - ); - assert_eq!( - resp.headers() - .get("x-ts-version") - .and_then(|v| v.to_str().ok()), - trusted_server_core::constants::TS_GIT_VERSION, - "startup-fallback /health should report the compiled-in git version" - ); - let body = resp.into_body().into_bytes().unwrap_or_default(); - assert_eq!( - &body[..], - b"ok", - "startup-fallback health body should be `ok`" + .expect("should render missing state as a routed error for the test seam"); + assert_ne!( + response.status(), + StatusCode::OK, + "should not hide absent bootstrap behind healthy liveness" ); } } diff --git a/crates/trusted-server-adapter-spin/src/lib.rs b/crates/trusted-server-adapter-spin/src/lib.rs index f47877ff26..7b86044654 100644 --- a/crates/trusted-server-adapter-spin/src/lib.rs +++ b/crates/trusted-server-adapter-spin/src/lib.rs @@ -3,15 +3,23 @@ pub mod app; pub mod middleware; pub mod platform; +mod startup; #[cfg(all(feature = "spin", target_arch = "wasm32"))] -use spin_sdk::http::{IntoResponse, Request}; +use spin_sdk::http::Request; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use spin_sdk::http_service; #[cfg(all(feature = "spin", target_arch = "wasm32"))] #[http_service] -// FORCED: edgezero_adapter_spin::run_app returns anyhow::Result — EdgeZero SDK constraint, not a project choice. -async fn handle(req: Request) -> anyhow::Result { - edgezero_adapter_spin::run_app::(req).await +// EdgeZero requires anyhow only at this FFI boundary. +async fn handle(req: Request) -> anyhow::Result { + // Bootstrap errors are pre-admission exceptions, not routed readiness pages. + let app = startup::initialize().await.map_err(|error| { + anyhow::anyhow!( + "Spin bootstrap failed before dispatch: {}", + error.current_context() + ) + })?; + edgezero_adapter_spin::dispatch_app::(&app, req).await } diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index fc1b939bd5..b28ea1f0ef 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -5,11 +5,23 @@ use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; -use trusted_server_core::auth::enforce_basic_auth; +use trusted_server_core::auth::enforce_basic_auth_parts; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; -use trusted_server_core::http_util::sanitize_trusted_client_ip_headers; +use trusted_server_core::http_util::sanitize_trusted_client_ip_header_map; use trusted_server_core::settings::Settings; +use crate::app::AppState; + +fn request_settings( + explicit: &Option>, + ctx: &RequestContext, +) -> Result, EdgeError> { + match explicit { + Some(settings) => Ok(Arc::clone(settings)), + None => Ok(Arc::clone(&AppState::from_request(ctx)?.settings)), + } +} + // --------------------------------------------------------------------------- // SanitizeRequestMiddleware // --------------------------------------------------------------------------- @@ -24,23 +36,32 @@ use trusted_server_core::settings::Settings; /// a shared configuration cannot leak the secret into publisher or integration /// request handling. pub struct SanitizeRequestMiddleware { - settings: Arc, + settings: Option>, } impl SanitizeRequestMiddleware { - /// Creates a new [`SanitizeRequestMiddleware`] with the given settings. + /// Creates an explicit-settings middleware for tests. #[must_use] pub fn new(settings: Arc) -> Self { - Self { settings } + Self { + settings: Some(settings), + } + } + + /// Reads validated invocation-local state from the request. + #[must_use] + pub fn from_request() -> Self { + Self { settings: None } } } #[async_trait(?Send)] impl Middleware for SanitizeRequestMiddleware { async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - sanitize_trusted_client_ip_headers( - ctx.request_mut(), - self.settings.trusted_client_ip.as_ref(), + let settings = request_settings(&self.settings, &ctx)?; + sanitize_trusted_client_ip_header_map( + ctx.headers_mut(), + settings.trusted_client_ip.as_ref(), ); next.run(ctx).await } @@ -59,24 +80,33 @@ impl Middleware for SanitizeRequestMiddleware { /// [`AuthMiddleware`] so that every outgoing response — including auth-rejected /// ones — carries a consistent set of headers. pub struct FinalizeResponseMiddleware { - settings: Arc, + settings: Option>, } impl FinalizeResponseMiddleware { - /// Creates a new [`FinalizeResponseMiddleware`] with the given settings. + /// Creates an explicit-settings middleware for tests. #[must_use] pub fn new(settings: Arc) -> Self { - Self { settings } + Self { + settings: Some(settings), + } + } + + /// Reads validated invocation-local state from the request. + #[must_use] + pub fn from_request() -> Self { + Self { settings: None } } } #[async_trait(?Send)] impl Middleware for FinalizeResponseMiddleware { async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + let settings = request_settings(&self.settings, &ctx)?; let geo_available = false; let mut response = next.run(ctx).await?; - apply_finalize_headers(&self.settings, geo_available, &mut response); + apply_finalize_headers(&settings, geo_available, &mut response); Ok(response) } } @@ -87,26 +117,35 @@ impl Middleware for FinalizeResponseMiddleware { /// Inner middleware: enforces basic-auth before the handler runs. /// -/// - `Ok(Some(response))` from [`enforce_basic_auth`] → auth failed; return the +/// - `Ok(Some(response))` from [`enforce_basic_auth_parts`] → auth failed; return the /// challenge response (bubbles through [`FinalizeResponseMiddleware`] for header injection). /// - `Ok(None)` → no auth required or credentials accepted; continue the chain. /// - `Err(report)` → internal error; log and convert to a 500 HTTP response. pub struct AuthMiddleware { - settings: Arc, + settings: Option>, } impl AuthMiddleware { - /// Creates a new [`AuthMiddleware`] with the given settings. + /// Creates an explicit-settings middleware for tests. #[must_use] pub fn new(settings: Arc) -> Self { - Self { settings } + Self { + settings: Some(settings), + } + } + + /// Reads validated invocation-local state from the request. + #[must_use] + pub fn from_request() -> Self { + Self { settings: None } } } #[async_trait(?Send)] impl Middleware for AuthMiddleware { async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - match enforce_basic_auth(&self.settings, ctx.request_mut()) { + let settings = request_settings(&self.settings, &ctx)?; + match enforce_basic_auth_parts(&settings, ctx.parts_mut()) { Ok(Some(response)) => return Ok(response), Ok(None) => {} Err(report) => { @@ -149,7 +188,7 @@ impl NormalizeMiddleware { #[async_trait(?Send)] impl Middleware for NormalizeMiddleware { async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - crate::app::normalize_spin_request(ctx.request_mut()); + crate::app::normalize_spin_request(ctx.parts_mut()); next.run(ctx).await } } @@ -333,8 +372,8 @@ mod tests { let handler_observed = Arc::clone(&handler_observed); async move { *handler_observed.lock().expect("should lock observation") = Some(( - ctx.request().headers().contains_key("x-reader-ip"), - ctx.request().headers().contains_key("x-reader-ip-auth"), + ctx.headers().contains_key("x-reader-ip"), + ctx.headers().contains_key("x-reader-ip-auth"), )); Ok::(empty_response()) } @@ -350,6 +389,60 @@ mod tests { ); } + #[test] + fn middleware_preserves_cached_managed_body_and_extensions() { + let settings = Arc::new(settings_with_response_headers(vec![])); + let request = request_builder() + .method(Method::POST) + .uri("/_ts/admin/keys") + .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") + .header("spin-full-url", "https://publisher.example/_ts/admin/keys") + .body(Body::from("null").with_buffered_json_policy( + std::num::NonZeroUsize::new(4).expect("should create a positive ceiling"), + true, + )) + .expect("should build a managed request"); + let mut ctx = RequestContext::new(request, PathParams::default()); + ctx.extensions_mut().insert(String::from("request marker")); + let middlewares: Vec> = vec![ + Arc::new(SanitizeRequestMiddleware::new(Arc::clone(&settings))), + Arc::new(FinalizeResponseMiddleware::new(Arc::clone(&settings))), + Arc::new(AuthMiddleware::new(settings)), + Arc::new(NormalizeMiddleware::new()), + ]; + let handler = Arc::new(|ctx: RequestContext| async move { + assert!( + matches!( + ctx.body_kind(), + edgezero_core::context::BodyKind::Cached { len: 4 } + ), + "should preserve cached body provenance across middleware" + ); + assert_eq!( + ctx.extensions().get::().map(String::as_str), + Some("request marker"), + "should retain unrelated request extensions" + ); + assert_eq!( + ctx.parts().uri.to_string(), + "https://publisher.example/_ts/admin/keys", + "should normalize the trusted authority without rebuilding the context" + ); + assert_eq!( + ctx.body_bytes(4).await?.as_ref(), + b"null", + "should retain the cached managed body" + ); + Ok::(empty_response()) + }); + + block_on(async { + ctx.body_bytes(4).await?; + Next::new(&middlewares, &*handler).run(ctx).await + }) + .expect("should authenticate and preserve the managed request"); + } + #[test] fn emits_git_version_header() { let mut response = empty_response(); diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index b1ced0f70a..b440f90729 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use std::time::Duration; use bytes::Bytes; -use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::key_value_store::{KvHandle, KvPage, KvStore}; use error_stack::{Report, ResultExt as _}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -60,10 +59,8 @@ impl PlatformConfigStore for NoopConfigStore { } } -#[cfg(not(all(feature = "spin", target_arch = "wasm32")))] struct NoopSecretStore; -#[cfg(not(all(feature = "spin", target_arch = "wasm32")))] impl PlatformSecretStore for NoopSecretStore { fn get_bytes(&self, _: &StoreName, _: &str) -> Result, Report> { Err(Report::new(PlatformError::SecretStore).attach("secret store not available")) @@ -98,41 +95,9 @@ impl PlatformBackend for NoopBackend { } // --------------------------------------------------------------------------- -// edgezero handle adapters — injected by edgezero_adapter_spin::run_app +// Spin secret-variable naming and asynchronous KV handle adapter // --------------------------------------------------------------------------- -/// Bridges edgezero's [`ConfigStoreHandle`] to [`PlatformConfigStore`]. -/// -/// Spin config stores are KV-backed, so reads preserve the requested key -/// verbatim. Writes are unsupported on current Spin runtime config and return -/// typed errors. -pub(crate) struct ConfigStoreHandleAdapter(pub(crate) ConfigStoreHandle); - -impl PlatformConfigStore for ConfigStoreHandleAdapter { - fn get(&self, _store_name: &StoreName, key: &str) -> Result> { - futures::executor::block_on(self.0.get(key)) - .map_err(|error| { - Report::new(PlatformError::ConfigStore).attach(format!( - "config store lookup failed for key `{key}`: {error}" - )) - })? - .ok_or_else(|| { - Report::new(PlatformError::ConfigStore) - .attach(format!("key `{key}` not found in Spin config store")) - }) - } - - fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { - Err(Report::new(PlatformError::ConfigStore) - .attach("config store writes are not supported on Spin")) - } - - fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { - Err(Report::new(PlatformError::ConfigStore) - .attach("config store writes are not supported on Spin")) - } -} - #[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] fn spin_variable_name( key: &str, @@ -181,7 +146,7 @@ fn push_spin_variable_escape(out: &mut String, byte: u8) { } #[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -fn spin_secret_variable_name( +pub(crate) fn spin_secret_variable_name( store_name: &StoreName, key: &str, ) -> Result> { @@ -427,6 +392,19 @@ fn is_wasi_forbidden_outbound_header(name: &str) -> bool { || name.eq_ignore_ascii_case("proxy-connection") } +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +async fn buffer_request_body( + body: edgezero_core::body::Body, + maximum: u64, +) -> Result> { + let maximum = usize::try_from(maximum).map_err(|_| { + Report::new(PlatformError::HttpClient).attach("Spin upload byte limit is not representable") + })?; + body.into_bytes_bounded(maximum) + .await + .change_context(PlatformError::HttpClient) +} + /// Carries a completed response through `send_async` → `select`. #[cfg(all(feature = "spin", target_arch = "wasm32"))] struct SpinPendingResponse { @@ -496,18 +474,12 @@ impl SpinPlatformHttpClient { } let (_, body) = request.request.into_parts(); - let body_bytes = match body { - edgezero_core::body::Body::Once(bytes) => bytes.to_vec(), - edgezero_core::body::Body::Stream(_) => { - // TODO: streaming request bodies unsupported; large proxy POBs (e.g. - // /first-party/proxy-rebuild) will fail until Spin WASI HTTP buffering - // is added to this client. - return Err(Report::new(PlatformError::HttpClient) - .attach("streaming request bodies are not supported on Spin outbound HTTP")); - } - }; + // SDK7 ingress is lazy even for an empty GET body. Buffer uploads at + // this owned boundary under the caller's finite cap, rather than reject + // ordinary requests merely because their carrier is a managed stream. + let body_bytes = buffer_request_body(body, request.max_request_body_bytes).await?; let spin_request = builder - .body(spin_sdk::http::FullBody::new(Bytes::from(body_bytes))) + .body(spin_sdk::http::FullBody::new(body_bytes)) .map_err(|error| { Report::new(PlatformError::HttpClient) .attach(format!("failed to build Spin outbound request: {error}")) @@ -581,9 +553,9 @@ impl PlatformHttpClient for SpinPlatformHttpClient { .iter() .map(|(n, v)| (n.to_string(), v.as_bytes().to_vec())) .collect(); - let body_bytes = match response.response.into_body() { - edgezero_core::body::Body::Once(bytes) => bytes.to_vec(), - edgezero_core::body::Body::Stream(_) => { + let body_bytes = match response.response.into_body().into_content() { + edgezero_core::body::BodyContent::Once(bytes) => bytes.to_vec(), + edgezero_core::body::BodyContent::Stream(_) => { return Err(Report::new(PlatformError::HttpClient) .attach("SpinPlatformHttpClient::execute returned a streaming body")); } @@ -647,62 +619,15 @@ fn into_spin_method(method: &edgezero_core::http::Method) -> spin_sdk::http::Met .expect("should convert valid HTTP method") } -// --------------------------------------------------------------------------- -// SpinSecretStoreAdapter — WASM target + spin feature only -// --------------------------------------------------------------------------- - -/// Bridges Spin component variables to [`PlatformSecretStore`]. -/// -/// # Limitations -/// -/// - **UTF-8 only.** `spin_sdk::variables::get` returns a `String`, so all -/// secret values must be valid UTF-8. JSON-encoded signing keys work today; -/// raw binary secrets (e.g. bare Ed25519 bytes) would silently fail at the -/// Spin runtime layer. -/// -/// - **Plaintext at rest.** Spin component variables are unencrypted in the -/// application manifest by default. Production deployments must back variables -/// with a real secret-provider source (e.g. Vault, Azure Key Vault) to avoid -/// storing signing keys in plaintext on disk. -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -pub(crate) struct SpinSecretStoreAdapter; - -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -impl PlatformSecretStore for SpinSecretStoreAdapter { - fn get_bytes( - &self, - store_name: &StoreName, - key: &str, - ) -> Result, Report> { - let variable_name = spin_secret_variable_name(store_name, key)?; - match futures::executor::block_on(spin_sdk::variables::get(&variable_name)) { - Ok(value) => Ok(value.into_bytes()), - Err(error) => Err(Report::new(PlatformError::SecretStore).attach(format!( - "secret lookup failed for key `{key}` as Spin variable `{variable_name}`: {error}" - ))), - } - } - - fn create(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { - Err(Report::new(PlatformError::SecretStore) - .attach("secret store writes are not supported on Spin")) - } - - fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { - Err(Report::new(PlatformError::SecretStore) - .attach("secret store deletes are not supported on Spin")) - } -} - // --------------------------------------------------------------------------- // build_runtime_services // --------------------------------------------------------------------------- /// Construct [`RuntimeServices`] for an incoming Spin request. /// -/// Config and KV are sourced from the `EdgeZero` handles that `run_app` injects -/// before routing. Secrets are read synchronously from Spin component -/// variables because Trusted Server's platform secret trait is sync. +/// Config and secrets come only from the immutable async bootstrap snapshot. +/// Client metadata is derived after request normalization, never at startup. +/// KV remains an asynchronous request-local handle when one is injected. #[must_use] pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { let client_ip = extract_client_ip(ctx); @@ -712,9 +637,11 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R #[cfg(not(all(feature = "spin", target_arch = "wasm32")))] let http_client: Arc = Arc::new(UnavailableHttpClient); - let config_store: Arc = ctx - .config_store_default() - .map(|h| Arc::new(ConfigStoreHandleAdapter(h)) as Arc) + let snapshot = ctx + .extensions() + .get::>(); + let config_store: Arc = snapshot + .map(|snapshot| Arc::clone(snapshot) as Arc) .unwrap_or_else(|| Arc::new(NoopConfigStore)); let kv_store: Arc = ctx @@ -722,10 +649,9 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R .map(|h| Arc::new(KvHandleAdapter(h)) as Arc) .unwrap_or_else(|| Arc::new(UnavailableKvStore)); - #[cfg(all(feature = "spin", target_arch = "wasm32"))] - let secret_store: Arc = Arc::new(SpinSecretStoreAdapter); - #[cfg(not(all(feature = "spin", target_arch = "wasm32")))] - let secret_store: Arc = Arc::new(NoopSecretStore); + let secret_store: Arc = snapshot + .map(|snapshot| Arc::clone(snapshot) as Arc) + .unwrap_or_else(|| Arc::new(NoopSecretStore)); RuntimeServices::builder() .config_store(config_store) @@ -774,7 +700,8 @@ impl PlatformGeo for NullGeo { /// Cloudflare adapters receive. Deployments fronting Spin with such a hop must /// account for this. fn extract_client_ip(ctx: &edgezero_core::context::RequestContext) -> Option { - edgezero_adapter_spin::context::SpinRequestContext::get(ctx.request()) + ctx.extensions() + .get::() .and_then(|c| c.client_addr) } @@ -783,9 +710,35 @@ mod tests { use super::*; use edgezero_core::body::Body; - use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; + use edgezero_core::config_store::{ + BoundedStoreRead, ConfigStore, ConfigStoreError, ConfigValue, finish_bounded_config_read, + }; + use edgezero_core::time::{Deadline, MonotonicClock}; use trusted_server_core::platform::AuctionTargetId; + #[test] + fn managed_stream_uploads_are_buffered_under_the_caller_limit() { + futures::executor::block_on(async { + let upload = || { + Body::stream(futures::stream::iter([ + Bytes::from_static(b"a"), + Bytes::from_static(b"bc"), + ])) + }; + assert_eq!( + buffer_request_body(upload(), 3) + .await + .expect("should accept exact upload limit") + .as_ref(), + b"abc" + ); + assert!( + buffer_request_body(upload(), 2).await.is_err(), + "should reject an oversized upload before sending" + ); + }); + } + #[test] fn auction_http_capabilities_are_explicit() { let capabilities = AuctionTargetId::Spin.descriptor().capabilities(); @@ -806,11 +759,73 @@ mod tests { #[async_trait::async_trait(?Send)] impl ConfigStore for InMemoryConfigStore { - async fn get(&self, key: &str) -> Result, ConfigStoreError> { - Ok(self.0.get(key).cloned()) + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + Ok(self + .0 + .get(key) + .map(|value| ConfigValue::from(value.as_str()))) + } + + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + finish_bounded_config_read( + Ok(self.0.get(key).map(String::as_str)), + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) } } + #[test] + fn config_fixture_enforces_both_byte_caps_and_deadline() { + let store = InMemoryConfigStore(std::collections::BTreeMap::from([( + "config".to_owned(), + "value".to_owned(), + )])); + let clock = MonotonicClock::default(); + let deadline = Deadline::after(Duration::from_secs(1)); + + let read = futures::executor::block_on(store.get_bounded("config", &clock, deadline, 5, 5)) + .expect("should accept the exact byte caps"); + assert_eq!(read.backend_bytes, 5, "should account for backend bytes"); + assert_eq!( + read.value.as_deref(), + Some("value"), + "should read the value" + ); + for (backend_cap, value_cap) in [(4, 5), (5, 4)] { + let error = futures::executor::block_on(store.get_bounded( + "config", + &clock, + deadline, + backend_cap, + value_cap, + )) + .expect_err("should reject a value exceeding either cap"); + assert!(matches!(error, ConfigStoreError::ValueTooLarge)); + } + let error = futures::executor::block_on(store.get_bounded( + "config", + &clock, + Deadline::at_instant(clock.now()), + 0, + 0, + )) + .expect_err("should reject an expired read before checking byte caps"); + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + } + fn make_ctx_without_spin_context() -> RequestContext { let req = request_builder() .method("GET") @@ -927,29 +942,6 @@ mod tests { ); } - #[test] - fn config_store_handle_adapter_reads_verbatim_kv_key() { - let handle = ConfigStoreHandle::new(Arc::new(InMemoryConfigStore( - std::collections::BTreeMap::from([( - "trusted_server_config".to_owned(), - "blob-envelope".to_owned(), - )]), - ))); - let adapter = ConfigStoreHandleAdapter(handle); - - let value = adapter - .get( - &StoreName::from("trusted_server_config"), - "trusted_server_config", - ) - .expect("should read the verbatim config-store key"); - - assert_eq!( - value, "blob-envelope", - "should not translate a KV-backed config key into a Spin variable name" - ); - } - #[test] fn spin_variable_name_encodes_secret_key_components() { assert_eq!( diff --git a/crates/trusted-server-adapter-spin/src/startup.rs b/crates/trusted-server-adapter-spin/src/startup.rs new file mode 100644 index 0000000000..cb2e8fedaf --- /dev/null +++ b/crates/trusted-server-adapter-spin/src/startup.rs @@ -0,0 +1,421 @@ +//! Invocation-local async bootstrap for Spin's synchronous TS store consumers. +//! +//! Remove this read-only bridge when core's config and secret traits become async. + +use std::collections::BTreeMap; + +use error_stack::Report; +use trusted_server_core::platform::{ + PlatformConfigStore, PlatformError, PlatformSecretStore, StoreId, StoreName, +}; + +/// Immutable values, not SDK handles or pending work, owned by one invocation. +#[derive(Default)] +pub(crate) struct SpinStoreSnapshot { + // Preserve the old adapter's single-default-store mapping: logical config + // names all address the same KV store, and keys are not variable-encoded. + config: BTreeMap, + secrets: BTreeMap<(String, String), Vec>, +} + +impl PlatformConfigStore for SpinStoreSnapshot { + fn get(&self, _: &StoreName, key: &str) -> Result> { + self.config.get(key).cloned().ok_or_else(|| { + Report::new(PlatformError::ConfigStore) + .attach("key is absent from Spin config snapshot") + }) + } + + fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore).attach("Spin snapshots are read-only")) + } + + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore).attach("Spin snapshots are read-only")) + } +} + +impl PlatformSecretStore for SpinStoreSnapshot { + fn get_bytes(&self, store: &StoreName, key: &str) -> Result, Report> { + self.secrets + .get(&(store.as_ref().to_owned(), key.to_owned())) + .cloned() + .ok_or_else(|| { + Report::new(PlatformError::SecretStore) + .attach("key is absent from Spin secret snapshot") + }) + } + + fn create(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore).attach("Spin snapshots are read-only")) + } + + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore).attach("Spin snapshots are read-only")) + } +} + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub(crate) use loading::initialize; + +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +mod loading { + use std::collections::BTreeSet; + use std::future::Future; + use std::sync::Arc; + use std::time::Duration; + + use edgezero_core::app::App; + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + use edgezero_core::config_store::ConfigStore as _; + use edgezero_core::context::RequestContext; + use edgezero_core::http::request_builder; + use edgezero_core::params::PathParams; + use edgezero_core::time::{Deadline, MonotonicClock}; + use futures::future::{Either, select}; + use trusted_server_core::config_payload::VerifiedConfigBlob; + use trusted_server_core::error::TrustedServerError; + use trusted_server_core::request_signing::{ + JWKS_CONFIG_STORE_NAME, RequestSigner, SIGNING_SECRET_STORE_NAME, get_active_jwks, + verify_signature, + }; + use trusted_server_core::settings::Settings; + + use super::*; + use crate::app::{TrustedServerApp, build_state_with_settings}; + + const MAX_CONFIG_KEYS: usize = 128; + const MAX_KEY_BYTES: usize = 256; + const MAX_CONFIG_BYTES: u64 = 1024 * 1024; + const MAX_SECRET_KEYS: usize = 64; + const MAX_SECRET_BYTES: usize = 64 * 1024; + const MAX_SNAPSHOT_BYTES: usize = 4 * 1024 * 1024; + const BOOTSTRAP_BUDGET: Duration = Duration::from_secs(5); + + fn failure(message: &str) -> Report { + Report::new(TrustedServerError::Configuration { + message: message.to_owned(), + }) + } + + #[async_trait::async_trait(?Send)] + trait Source { + async fn config_keys(&self) -> Result, Report>; + async fn config( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + ) -> Result, Report>; + async fn secret( + &self, + store: &StoreName, + key: &str, + ) -> Result, Report>; + } + + fn check_key(key: &str) -> Result<(), Report> { + if key.is_empty() || key.len() > MAX_KEY_BYTES { + return Err(failure("Spin snapshot key must contain 1..=256 bytes")); + } + Ok(()) + } + + fn charge(total: &mut usize, bytes: usize) -> Result<(), Report> { + *total = total.saturating_add(bytes); + if *total > MAX_SNAPSHOT_BYTES { + return Err(failure( + "Spin snapshot exceeds the 4 MiB aggregate byte limit", + )); + } + Ok(()) + } + + async fn required_config( + source: &impl Source, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + ) -> Result> { + check_key(key)?; + let value = source.config(key, clock, deadline).await?.ok_or_else(|| { + failure("required Spin config key is missing or disappeared during bootstrap") + })?; + if value.len() as u64 > MAX_CONFIG_BYTES { + return Err(failure("Spin config value exceeds the 1 MiB limit")); + } + Ok(value) + } + + async fn load_secret( + source: &impl Source, + snapshot: &mut SpinStoreSnapshot, + total: &mut usize, + store: &StoreName, + key: &str, + ) -> Result<(), Report> { + check_key(key)?; + check_key(store.as_ref())?; + let address = (store.as_ref().to_owned(), key.to_owned()); + if snapshot.secrets.contains_key(&address) { + return Ok(()); + } + if snapshot.secrets.len() >= MAX_SECRET_KEYS { + return Err(failure("Spin snapshot exceeds the 64 secret limit")); + } + let value = source.secret(store, key).await?; + if value.is_empty() || value.len() > MAX_SECRET_BYTES { + return Err(failure( + "required Spin secret is empty or exceeds the 64 KiB limit", + )); + } + charge(total, value.len())?; + charge(total, store.as_ref().len() + key.len())?; + snapshot.secrets.insert(address, value); + Ok(()) + } + + async fn load( + source: &impl Source, + config_key: &str, + secret_store: &StoreName, + clock: &MonotonicClock, + deadline: Deadline, + ) -> Result<(Settings, Arc), Report> { + let envelope = required_config(source, config_key, clock, deadline).await?; + let verified = VerifiedConfigBlob::parse(&envelope)?; + verified.check_structure(64, 100_000)?; + let required_secrets = verified.required_secret_keys()?; + if required_secrets.len() > MAX_SECRET_KEYS { + return Err(failure("Spin config references more than 64 secrets")); + } + let mut snapshot = SpinStoreSnapshot::default(); + let mut total = 0; + charge(&mut total, envelope.len())?; + snapshot.config.insert(config_key.to_owned(), envelope); + for key in required_secrets { + load_secret(source, &mut snapshot, &mut total, secret_store, &key).await?; + } + let settings = verified.into_settings(&snapshot, secret_store)?; + + // Snapshot the whole bounded default config store. Verification accepts + // arbitrary existing public KIDs, not just active-kids. DataDome sources + // also use this store under the old adapter's single-store mapping. + let keys = source.config_keys().await?; + if keys.len() > MAX_CONFIG_KEYS { + return Err(failure( + "Spin default config store exceeds the 128 key limit", + )); + } + let keys: BTreeSet<_> = keys.into_iter().collect(); + if !keys.contains(config_key) { + return Err(failure("Spin config envelope disappeared during bootstrap")); + } + for key in keys { + check_key(&key)?; + charge(&mut total, key.len())?; + if key == config_key { + continue; + } + let value = required_config(source, &key, clock, deadline).await?; + charge(&mut total, value.len())?; + snapshot.config.insert(key, value); + } + if settings + .request_signing + .as_ref() + .is_some_and(|signing| signing.enabled) + { + let jwks_store = StoreName::from(JWKS_CONFIG_STORE_NAME); + let current = + PlatformConfigStore::get(&snapshot, &jwks_store, "current-kid").map_err(|_| { + failure("Spin signing requires current-kid in the default config store") + })?; + let active = + PlatformConfigStore::get(&snapshot, &jwks_store, "active-kids").map_err(|_| { + failure("Spin signing requires active-kids in the default config store") + })?; + if !active.split(',').any(|kid| kid.trim() == current) { + return Err(failure("Spin current signing KID must be in active-kids")); + } + load_secret( + source, + &mut snapshot, + &mut total, + &StoreName::from(SIGNING_SECRET_STORE_NAME), + ¤t, + ) + .await?; + } + let snapshot = Arc::new(snapshot); + if settings + .request_signing + .as_ref() + .is_some_and(|signing| signing.enabled) + { + validate_signing(&snapshot)?; + } + if deadline.is_expired_at(clock.now()) { + return Err(failure("Spin bootstrap deadline exceeded")); + } + Ok((settings, snapshot)) + } + + fn validate_signing( + snapshot: &Arc, + ) -> Result<(), Report> { + // Exercise the same parsers and key formats that request consumers use. + let mut request = request_builder() + .uri("/") + .body(edgezero_core::body::Body::empty()) + .map_err(|_| failure("failed to construct Spin signing validation context"))?; + request.extensions_mut().insert(Arc::clone(snapshot)); + let context = RequestContext::new(request, PathParams::default()); + let services = crate::platform::build_runtime_services(&context); + let jwks = get_active_jwks(&services)?; + let _: serde_json::Value = + serde_json::from_str(&jwks).map_err(|_| failure("Spin active JWKS is malformed"))?; + let signer = RequestSigner::from_services(&services)?; + let challenge = b"Spin bootstrap signing key consistency"; + let signature = signer.sign(challenge)?; + if !verify_signature(challenge, &signature, &signer.kid, &services)? { + return Err(failure( + "Spin current public/private signing keys do not match", + )); + } + Ok(()) + } + + // A timer around the entire bootstrap also bounds a pending variable read, + // KV open, key stream or key-stream terminal result. Dropping guest work + // does not assert cancellation inside the host provider. + async fn within_deadline( + work: F, + clock: &MonotonicClock, + deadline: Deadline, + mut make_timer: MakeTimer, + ) -> Result> + where + F: Future>>, + MakeTimer: FnMut(Duration) -> Timer, + Timer: Future, + { + futures::pin_mut!(work); + loop { + let remaining = deadline + .remaining_at(clock.now()) + .ok_or_else(|| failure("Spin bootstrap deadline exceeded"))?; + let timer = make_timer(remaining); + futures::pin_mut!(timer); + match select(timer, work.as_mut()).await { + Either::Left(((), _)) => continue, + Either::Right((result, _)) => { + if deadline.is_expired_at(clock.now()) { + return Err(failure("Spin bootstrap deadline exceeded")); + } + return result; + } + } + } + } + + fn assemble( + settings: Settings, + snapshot: Arc, + ) -> Result> { + let state = build_state_with_settings(settings)?; + let mut app = App::build::(edgezero_adapter_spin::SPIN_PLATFORM) + .map_err(|_| failure("Spin application configuration failed"))?; + app.insert_state(state); + app.insert_state(snapshot); + Ok(app) + } + + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + struct SpinSource; + + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + #[async_trait::async_trait(?Send)] + impl Source for SpinSource { + async fn config_keys(&self) -> Result, Report> { + let store = spin_sdk::key_value::Store::open("default") + .await + .map_err(|_| failure("failed to open Spin default config store"))?; + let mut stream = store.get_keys().await; + let mut keys = Vec::new(); + while let Some(key) = stream.next().await { + check_key(&key)?; + if keys.len() == MAX_CONFIG_KEYS { + return Err(failure( + "Spin default config store exceeds the 128 key limit", + )); + } + keys.push(key); + } + stream + .result() + .await + .map_err(|_| failure("Spin config key listing failed"))?; + Ok(keys) + } + + async fn config( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + ) -> Result, Report> { + edgezero_adapter_spin::config_store::SpinConfigStore::for_label("default".to_owned()) + .get_bounded(key, clock, deadline, MAX_CONFIG_BYTES, MAX_CONFIG_BYTES) + .await + .map(|read| read.value.map(|value| value.as_str().to_owned())) + .map_err(|_| failure(&format!("Spin bounded config read failed for key `{key}`"))) + } + + async fn secret( + &self, + store: &StoreName, + key: &str, + ) -> Result, Report> { + let variable = crate::platform::spin_secret_variable_name(store, key) + .map_err(|_| failure("Spin secret store/key cannot be encoded as a variable"))?; + spin_sdk::variables::get(&variable) + .await + .map(String::into_bytes) + .map_err(|_| { + failure(&format!( + "required Spin secret variable `{variable}` is unavailable" + )) + }) + } + } + + /// Await all providers before constructing the dispatchable app. + /// + /// Failures here are explicit pre-admission exceptions. No routed response, + /// readiness state or RC body/egress completion is fabricated for them. + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + pub(crate) async fn initialize() -> Result> { + let clock = MonotonicClock::default(); + let deadline = clock.deadline_after(BOOTSTRAP_BUDGET); + let config_key = trusted_server_core::settings_data::default_config_key(); + let secret_store = trusted_server_core::settings_data::default_secret_store_name(); + let (settings, snapshot) = within_deadline( + load(&SpinSource, &config_key, &secret_store, &clock, deadline), + &clock, + deadline, + spin_sdk::time::sleep, + ) + .await?; + let app = assemble(settings, snapshot)?; + if deadline.is_expired_at(clock.now()) { + return Err(failure( + "Spin bootstrap deadline exceeded during app assembly", + )); + } + Ok(app) + } + + #[cfg(test)] + mod tests; +} diff --git a/crates/trusted-server-adapter-spin/src/startup/loading/tests.rs b/crates/trusted-server-adapter-spin/src/startup/loading/tests.rs new file mode 100644 index 0000000000..9181852704 --- /dev/null +++ b/crates/trusted-server-adapter-spin/src/startup/loading/tests.rs @@ -0,0 +1,541 @@ +use std::cell::{Cell, RefCell}; +use std::future::pending; +use std::rc::Rc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use edgezero_core::app::Hooks as _; +use edgezero_core::blob_envelope::BlobEnvelope; +use trusted_server_core::config_payload::{CONFIG_BLOB_KEY, DEFAULT_SECRET_STORE_ID}; +use trusted_server_core::request_signing::Keypair; + +use super::*; + +struct Fixture { + config: BTreeMap, + variables: BTreeMap>, + secret_reads: RefCell>, + pending_at: Option<&'static str>, + polled: Cell, + dropped: Rc>, +} + +struct DropProbe(Rc>); + +impl Drop for DropProbe { + fn drop(&mut self) { + self.0.set(true); + } +} + +impl Fixture { + fn new(signing: bool) -> Self { + let data = serde_json::json!({ + "ec": { "passphrase": "ec_key" }, + "handlers": [{ "path": "^/_ts/admin", "username": "admin", "password": "admin_key" }], + "publisher": { + "domain": "publisher.example.com", "cookie_domain": ".publisher.example.com", + "origin_url": "https://origin.publisher.example.com", "proxy_secret": "proxy_key" + }, + "request_signing": if signing { serde_json::json!({ + "enabled": true, "config_store_id": "fixture-config", "secret_store_id": "fixture-secrets" + }) } else { serde_json::Value::Null } + }); + let envelope = BlobEnvelope::new(data, "2026-10-20T00:00:00Z".to_owned()); + let mut fixture = Self { + config: BTreeMap::from([( + CONFIG_BLOB_KEY.to_owned(), + serde_json::to_string(&envelope).expect("should serialize fixture envelope"), + )]), + variables: BTreeMap::new(), + secret_reads: RefCell::new(Vec::new()), + pending_at: None, + polled: Cell::new(false), + dropped: Rc::new(Cell::new(false)), + }; + let store = StoreName::from(DEFAULT_SECRET_STORE_ID); + for (key, value) in [ + ("ec_key", "fixture-passphrase-32-bytes-minimum"), + ("proxy_key", "fixture-proxy-secret-32-bytes-minimum"), + ("admin_key", "fixture-admin-password"), + ] { + fixture.set_secret(&store, key, value.as_bytes().to_vec()); + } + fixture + } + + fn set_secret(&mut self, store: &StoreName, key: &str, value: Vec) { + let variable = crate::platform::spin_secret_variable_name(store, key) + .expect("should encode fixture variable"); + self.variables.insert(variable, value); + } + + async fn pause(&self, stage: &'static str) { + if self.pending_at == Some(stage) { + let _probe = DropProbe(Rc::clone(&self.dropped)); + self.polled.set(true); + pending::<()>().await; + } + } +} + +#[async_trait::async_trait(?Send)] +impl Source for Fixture { + async fn config_keys(&self) -> Result, Report> { + self.pause("keys").await; + Ok(self.config.keys().cloned().collect()) + } + + async fn config( + &self, + key: &str, + _: &MonotonicClock, + _: Deadline, + ) -> Result, Report> { + self.pause("config").await; + Ok(self.config.get(key).cloned()) + } + + async fn secret( + &self, + store: &StoreName, + key: &str, + ) -> Result, Report> { + self.pause("secret").await; + let variable = crate::platform::spin_secret_variable_name(store, key) + .expect("should encode existing secret naming contract"); + self.secret_reads.borrow_mut().push(variable.clone()); + self.variables + .get(&variable) + .cloned() + .ok_or_else(|| failure("missing fixture secret")) + } +} + +async fn load_fixture( + fixture: &Fixture, +) -> Result< + ( + trusted_server_core::settings::Settings, + Arc, + ), + Report, +> { + let clock = MonotonicClock::default(); + let deadline = clock.deadline_after(BOOTSTRAP_BUDGET); + load( + fixture, + CONFIG_BLOB_KEY, + &StoreName::from(DEFAULT_SECRET_STORE_ID), + &clock, + deadline, + ) + .await +} + +#[tokio::test] +async fn envelope_and_encoded_secrets_build_an_initialized_app() { + let mut fixture = Fixture::new(false); + fixture + .config + .insert("cidr-list".to_owned(), "198.51.100.0/24".to_owned()); + let (settings, snapshot) = load_fixture(&fixture) + .await + .expect("should await finite startup inputs"); + assert!( + fixture + .secret_reads + .borrow() + .contains(&"v_trusted_x5fserver_x5fsecrets_v_ec_x5fkey".to_owned()), + "should keep the encoded store/key variable contract" + ); + assert_eq!( + PlatformConfigStore::get(&*snapshot, &StoreName::from("custom-store"), "cidr-list") + .expect("should preserve single-default-store config mapping"), + "198.51.100.0/24" + ); + fixture + .config + .insert("cidr-list".to_owned(), "203.0.113.0/24".to_owned()); + assert_eq!( + PlatformConfigStore::get(&*snapshot, &StoreName::from("custom-store"), "cidr-list") + .expect("should read immutable snapshot"), + "198.51.100.0/24" + ); + assert!(PlatformConfigStore::get(&*snapshot, &StoreName::from("default"), "absent").is_err()); + assert!( + PlatformSecretStore::get_bytes(&*snapshot, &StoreName::from("other-store"), "ec_key") + .is_err() + ); + assert!( + PlatformConfigStore::put(&*snapshot, &StoreId::from("default"), "key", "value").is_err() + ); + assert!( + PlatformSecretStore::create(&*snapshot, &StoreId::from("default"), "key", "value").is_err() + ); + assert!(PlatformConfigStore::delete(&*snapshot, &StoreId::from("default"), "key").is_err()); + assert!(PlatformSecretStore::delete(&*snapshot, &StoreId::from("default"), "key").is_err()); + let app = assemble(settings, snapshot).expect("should assemble after successful bootstrap"); + assert_eq!(app.platform(), edgezero_adapter_spin::SPIN_PLATFORM); + let request = request_builder() + .uri("/health") + .body(edgezero_core::body::Body::empty()) + .expect("should build health request"); + let response = app + .router() + .clone() + .oneshot(request) + .await + .expect("should dispatch initialized health"); + assert_eq!(response.status(), edgezero_core::http::StatusCode::OK); +} + +#[tokio::test] +async fn inactive_secret_references_do_not_trigger_provider_reads() { + let mut fixture = Fixture::new(false); + let envelope: BlobEnvelope = serde_json::from_str( + fixture + .config + .get(CONFIG_BLOB_KEY) + .expect("should have fixture envelope"), + ) + .expect("should parse fixture envelope"); + let mut data = envelope.into_data(); + data["tinybird"] = serde_json::json!({ + "enabled": false, "auction_token_secret": "unused_auction", "access_token_secret": "unused_access" + }); + fixture.config.insert( + CONFIG_BLOB_KEY.to_owned(), + serde_json::to_string(&BlobEnvelope::new(data, "2026-10-20T00:00:00Z".to_owned())) + .expect("should serialize changed fixture envelope"), + ); + load_fixture(&fixture) + .await + .expect("should ignore disabled Tinybird secret references"); + assert_eq!( + fixture.secret_reads.borrow().len(), + 3, + "should await only active config references" + ); +} + +#[tokio::test] +async fn expired_bootstrap_never_polls_a_provider() { + let fixture = Fixture::new(false); + let clock = MonotonicClock::default(); + let deadline = Deadline::at_instant(clock.now()); + let store = StoreName::from(DEFAULT_SECRET_STORE_ID); + let result = within_deadline( + load(&fixture, CONFIG_BLOB_KEY, &store, &clock, deadline), + &clock, + deadline, + |_| pending::<()>(), + ) + .await; + assert!( + result.is_err(), + "should reject expired bootstrap before first provider poll" + ); + assert!(fixture.secret_reads.borrow().is_empty()); +} + +#[tokio::test] +async fn missing_required_secret_fails_before_a_dispatchable_app_exists() { + let mut fixture = Fixture::new(false); + fixture.variables.clear(); + let result = load_fixture(&fixture).await; + assert!( + result.is_err(), + "should return bootstrap error, not a startup-error router" + ); +} + +#[tokio::test] +async fn tampered_envelope_never_reads_secrets() { + let mut fixture = Fixture::new(false); + let envelope = fixture + .config + .get_mut(CONFIG_BLOB_KEY) + .expect("should have envelope"); + *envelope = envelope.replace("publisher.example.com", "changed.example.com"); + assert!( + load_fixture(&fixture).await.is_err(), + "should reject integrity failure" + ); + assert!( + fixture.secret_reads.borrow().is_empty(), + "should verify before any secret reads" + ); +} + +#[tokio::test] +async fn signing_snapshot_preserves_active_and_inactive_public_keys_and_current_private_key() { + let mut fixture = Fixture::new(true); + let keypair = Keypair::generate(); + fixture + .config + .insert("current-kid".to_owned(), "kid-current".to_owned()); + fixture + .config + .insert("active-kids".to_owned(), "kid-current".to_owned()); + for kid in ["kid-current", "kid-retired"] { + fixture.config.insert( + kid.to_owned(), + serde_json::to_string(&keypair.get_jwk(kid.to_owned())) + .expect("should serialize public JWK"), + ); + } + fixture.set_secret( + &StoreName::from(SIGNING_SECRET_STORE_NAME), + "kid-current", + STANDARD.encode(keypair.signing_key.to_bytes()).into_bytes(), + ); + let (settings, snapshot) = load_fixture(&fixture) + .await + .expect("should load configured signing inputs"); + validate_signing(&snapshot).expect("should preserve current key parsing and matching"); + let mut request = request_builder() + .uri("/") + .body(edgezero_core::body::Body::empty()) + .expect("should build signing context"); + request.extensions_mut().insert(Arc::clone(&snapshot)); + let context = RequestContext::new(request, PathParams::default()); + let services = crate::platform::build_runtime_services(&context); + let signature = RequestSigner::from_services(&services) + .expect("should load signer") + .sign(b"fixture payload") + .expect("should sign fixture payload"); + assert!( + verify_signature(b"fixture payload", &signature, "kid-retired", &services) + .expect("should verify with stored non-active public KID") + ); + assert!( + PlatformConfigStore::get( + &*snapshot, + &StoreName::from(JWKS_CONFIG_STORE_NAME), + "kid-retired" + ) + .is_ok(), + "should not limit verification to active-kids" + ); + assert!( + fixture + .secret_reads + .borrow() + .contains(&"v_signing_x5fkeys_v_kid_x2dcurrent".to_owned()), + "should preserve signing secret variable name" + ); + assemble(settings, snapshot).expect("should retain signing-enabled app support"); +} + +#[tokio::test] +async fn signing_rejects_missing_private_key_and_mismatched_key_material() { + let mut fixture = Fixture::new(true); + fixture + .config + .insert("current-kid".to_owned(), "kid-current".to_owned()); + fixture + .config + .insert("active-kids".to_owned(), "kid-current".to_owned()); + let public = Keypair::generate(); + fixture.config.insert( + "kid-current".to_owned(), + serde_json::to_string(&public.get_jwk("kid-current".to_owned())) + .expect("should serialize public key"), + ); + assert!( + load_fixture(&fixture).await.is_err(), + "should fail before dispatch with missing signing secret" + ); + let other = Keypair::generate(); + fixture.set_secret( + &StoreName::from(SIGNING_SECRET_STORE_NAME), + "kid-current", + STANDARD.encode(other.signing_key.to_bytes()).into_bytes(), + ); + assert!( + load_fixture(&fixture).await.is_err(), + "should reject public/private mismatch" + ); +} + +#[tokio::test] +async fn pending_provider_work_times_out_and_is_dropped_at_every_bootstrap_stage() { + for stage in ["config", "secret", "keys"] { + let mut fixture = Fixture::new(false); + fixture.pending_at = Some(stage); + let expired = Arc::new(AtomicBool::new(false)); + let start = edgezero_core::time::MonotonicInstant::now(); + let end = start + .checked_add(BOOTSTRAP_BUDGET) + .expect("should make deadline"); + let clock_expired = Arc::clone(&expired); + let clock = MonotonicClock::new(move || { + if clock_expired.load(Ordering::SeqCst) { + end + } else { + start + } + }); + let deadline = Deadline::at_instant(end); + let store = StoreName::from(DEFAULT_SECRET_STORE_ID); + let result = within_deadline( + load(&fixture, CONFIG_BLOB_KEY, &store, &clock, deadline), + &clock, + deadline, + |_| { + let expired = Arc::clone(&expired); + async move { + tokio::task::yield_now().await; + expired.store(true, Ordering::SeqCst); + } + }, + ) + .await; + assert!( + result.is_err(), + "should bound a genuinely pending {stage} provider" + ); + assert!( + fixture.polled.get(), + "should poll pending {stage} work before timing out" + ); + assert!( + fixture.dropped.get(), + "should drop pending {stage} work on timeout" + ); + } +} + +#[tokio::test] +async fn snapshots_enforce_key_value_secret_and_aggregate_caps() { + let mut fixture = Fixture::new(false); + for index in 0..MAX_CONFIG_KEYS { + fixture + .config + .insert(format!("key-{index}"), "value".to_owned()); + } + assert!( + load_fixture(&fixture).await.is_err(), + "should cap key count including envelope" + ); + let mut fixture = Fixture::new(false); + fixture.config.insert( + "large".to_owned(), + "x".repeat(MAX_CONFIG_BYTES as usize + 1), + ); + assert!( + load_fixture(&fixture).await.is_err(), + "should cap each materialized config value" + ); + let mut fixture = Fixture::new(false); + fixture.set_secret( + &StoreName::from(DEFAULT_SECRET_STORE_ID), + "ec_key", + vec![b'x'; MAX_SECRET_BYTES + 1], + ); + assert!( + load_fixture(&fixture).await.is_err(), + "should cap each materialized secret value" + ); + let mut total = 0; + charge(&mut total, MAX_SNAPSHOT_BYTES).expect("should allow exact aggregate cap"); + assert!( + charge(&mut total, 1).is_err(), + "should reject aggregate cap plus one" + ); +} + +#[tokio::test] +async fn separate_invocations_keep_secrets_and_client_metadata_local() { + let first = Fixture::new(false); + let mut second = Fixture::new(false); + second.set_secret( + &StoreName::from(DEFAULT_SECRET_STORE_ID), + "ec_key", + b"second-fixture-passphrase-32-bytes".to_vec(), + ); + let (_, first_snapshot) = load_fixture(&first) + .await + .expect("should load first invocation"); + let (_, second_snapshot) = load_fixture(&second) + .await + .expect("should load second invocation"); + for (snapshot, ip, expected) in [ + ( + first_snapshot, + "198.51.100.8", + b"fixture-passphrase-32-bytes-minimum".as_slice(), + ), + ( + second_snapshot, + "203.0.113.9", + b"second-fixture-passphrase-32-bytes".as_slice(), + ), + ] { + let mut request = request_builder() + .uri("/") + .header("spin-client-addr", "192.0.2.1:1234") + .header("spin-client-addr", format!("{ip}:5678")) + .header("spin-full-url", "https://publisher.example.com/") + .body(edgezero_core::body::Body::empty()) + .expect("should build invocation request"); + request.extensions_mut().insert(snapshot); + let (mut parts, body) = request.into_parts(); + crate::app::normalize_spin_request(&mut parts); + let context = RequestContext::new( + edgezero_core::http::Request::from_parts(parts, body), + edgezero_core::params::PathParams::default(), + ); + let services = crate::platform::build_runtime_services(&context); + assert_eq!( + services.client_info().client_ip, + Some(ip.parse().expect("should parse client IP")), + "should use trusted invocation peer" + ); + assert_eq!( + services + .secret_store() + .get_bytes(&StoreName::from(DEFAULT_SECRET_STORE_ID), "ec_key") + .expect("should read this invocation's secret"), + expected, + "should never reuse another invocation's snapshot" + ); + } +} + +#[test] +fn production_provider_bridge_has_no_synchronous_executor() { + for source in [ + include_str!("../../platform.rs"), + include_str!("../../app.rs"), + ] { + let production = source + .split("#[cfg(test)]") + .next() + .expect("should have production section"); + assert!( + !production.contains("block_on"), + "should never reintroduce production provider block_on" + ); + } + let startup = include_str!("../../startup.rs"); + assert!( + !startup.contains("block_on"), + "should await bootstrap providers with the host executor" + ); + assert!( + !startup.contains("static "), + "should not retain live global SDK resources" + ); + let entrypoint = include_str!("../../lib.rs"); + assert!( + entrypoint.find("startup::initialize().await") + < entrypoint.find("dispatch_app::"), + "should propagate bootstrap failures before routed admission" + ); + assert!(entrypoint.contains("startup::initialize().await")); + assert!(entrypoint.contains("dispatch_app::")); + assert!(entrypoint.contains("anyhow::Result")); + let _ = TrustedServerApp::routes(); +} diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 613832b059..31e8c2fe90 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -14,9 +14,7 @@ use trusted_server_core::settings::Settings; /// Build the full application router from explicit test settings. /// -/// The settings baked into the binary contain placeholder secrets that -/// `get_settings()` rejects by design, which would turn every route into a -/// startup error page (and its route table into the fallback-only set). +/// Production settings arrive through async invocation-local bootstrap. /// The handler regex is the production-shaped `^/_ts/admin`, matching /// `Settings::ADMIN_ENDPOINTS` and the default config, so the canonical /// `/_ts/admin/keys/*` routes are auth-gated exactly as in production. @@ -50,8 +48,7 @@ async fn route(router: RouterService, req: Request) -> Response { #[test] fn routes_build_without_panic() { - // build_state() may fail (no real settings in CI) — startup_error_router - // is the fallback. Either way, routes() must not panic. + // Route construction performs no provider reads and does not fabricate readiness. let _router = TrustedServerApp::routes(); } diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index f364464561..ea6985b099 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -43,7 +43,8 @@ const MAX_CLIENT_EID_SOURCE_BYTES: usize = 255; /// the largest realistic Prebid-derived auction request (hundreds of ad units /// with EID arrays) while preventing an authenticated client from consuming /// arbitrary WASM linear memory. -const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024; +/// Maximum request bytes buffered by adapters for the auction endpoint. +pub const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024; /// Handle auction request from `POST /auction`. /// @@ -145,7 +146,12 @@ pub async fn handle_auction( } let (parts, body) = req.into_parts(); - let body_bytes = body.into_bytes().unwrap_or_default(); + let body_bytes = body.into_bytes().ok_or_else(|| { + Report::new(TrustedServerError::BadRequest { + message: "auction request body must be buffered by the adapter under its byte limit" + .to_owned(), + }) + })?; if body_bytes.len() > MAX_AUCTION_BODY_SIZE { return Response::builder() .status(StatusCode::PAYLOAD_TOO_LARGE) diff --git a/crates/trusted-server-core/src/auction/provider.rs b/crates/trusted-server-core/src/auction/provider.rs index 62b2ddc8fc..13326ca61f 100644 --- a/crates/trusted-server-core/src/auction/provider.rs +++ b/crates/trusted-server-core/src/auction/provider.rs @@ -381,7 +381,15 @@ impl GenericOpenRtbProvider { } let pending = services .http_client() - .send_async(PlatformHttpRequest::new(outbound, backend_name.clone())) + .send_async( + PlatformHttpRequest::new(outbound, backend_name.clone()) + .with_response_limit( + crate::integrations::UPSTREAM_RTB_MAX_RESPONSE_BYTES as u64, + ) + .with_timeout(std::time::Duration::from_millis(u64::from( + transport_timeout_ms, + ))), + ) .await .change_context(TrustedServerError::Auction { message: format!("Provider {} request launch failed", self.provider_name()), diff --git a/crates/trusted-server-core/src/auth.rs b/crates/trusted-server-core/src/auth.rs index 9bbd40b69a..d5f6cbf644 100644 --- a/crates/trusted-server-core/src/auth.rs +++ b/crates/trusted-server-core/src/auth.rs @@ -80,24 +80,63 @@ pub fn enforce_basic_auth( settings: &Settings, req: &mut Request, ) -> Result>, Report> { - // Cleared before any early return so no inherited marker can survive a call - // that did not itself validate a credential. Without this, a request marked - // upstream and then routed to an unprotected path would keep an assertion - // nothing checked. req.extensions_mut().remove::(); + let decision = check_basic_auth(settings, req.uri().path(), req.headers())?; + Ok(apply_auth_decision(decision, req.extensions_mut())) +} + +/// Enforces Basic authentication without accessing or replacing the request body. +/// +/// Clears any inherited authorization marker before checking the request head. +/// +/// # Errors +/// +/// Returns a configuration error for an invalid handler or an unprotected admin path. +pub fn enforce_basic_auth_parts( + settings: &Settings, + parts: &mut http::request::Parts, +) -> Result>, Report> { + parts.extensions.remove::(); + let decision = check_basic_auth(settings, parts.uri.path(), &parts.headers)?; + Ok(apply_auth_decision(decision, &mut parts.extensions)) +} - let path = req.uri().path(); +enum AuthDecision { + Unprotected, + Authorized(EdgeTerminatedAuthorization), + Challenge, +} + +fn apply_auth_decision( + decision: AuthDecision, + extensions: &mut http::Extensions, +) -> Option> { + match decision { + AuthDecision::Unprotected => None, + AuthDecision::Authorized(marker) => { + extensions.insert(marker); + None + } + AuthDecision::Challenge => Some(unauthorized_response()), + } +} + +fn check_basic_auth( + settings: &Settings, + path: &str, + headers: &http::HeaderMap, +) -> Result> { let Some(handler) = settings.handler_for_path(path)? else { if Settings::is_admin_path(path) { return Err(Report::new(TrustedServerError::Configuration { message: format!("Admin path `{path}` has no configured handler"), })); } - return Ok(None); + return Ok(AuthDecision::Unprotected); }; - let Some((username, password, authorization_digest)) = extract_credentials(req) else { - return Ok(Some(unauthorized_response())); + let Some((username, password, authorization_digest)) = extract_credentials(headers) else { + return Ok(AuthDecision::Challenge); }; // Hash before comparing to normalise lengths — `ct_eq` on raw byte slices @@ -113,14 +152,12 @@ pub fn enforce_basic_auth( .ct_eq(&Sha256::digest(password.as_bytes())); if bool::from(username_match & password_match) { - // Record that TS itself consumed this credential, so the shared template - // cache can distinguish it from a credential meant for the origin. - req.extensions_mut() - .insert(EdgeTerminatedAuthorization(authorization_digest)); - Ok(None) + Ok(AuthDecision::Authorized(EdgeTerminatedAuthorization( + authorization_digest, + ))) } else { - log::warn!("Basic auth failed for path: {}", req.uri().path()); - Ok(Some(unauthorized_response())) + log::warn!("Basic auth failed for path: {path}"); + Ok(AuthDecision::Challenge) } } @@ -134,11 +171,11 @@ pub fn enforce_basic_auth( /// operator credential was used. #[must_use] pub fn authenticated_username(req: &Request) -> Option { - extract_credentials(req).map(|(username, _password, _digest)| username) + extract_credentials(req.headers()).map(|(username, _password, _digest)| username) } -fn extract_credentials(req: &Request) -> Option<(String, String, [u8; 32])> { - let mut header_values = req.headers().get_all(header::AUTHORIZATION).iter(); +fn extract_credentials(headers: &http::HeaderMap) -> Option<(String, String, [u8; 32])> { + let mut header_values = headers.get_all(header::AUTHORIZATION).iter(); let header_value = header_values.next()?; if header_values.next().is_some() { return None; diff --git a/crates/trusted-server-core/src/cache_purge.rs b/crates/trusted-server-core/src/cache_purge.rs index f77f43e4dd..606ffffc08 100644 --- a/crates/trusted-server-core/src/cache_purge.rs +++ b/crates/trusted-server-core/src/cache_purge.rs @@ -27,7 +27,8 @@ use crate::http_util::enforce_max_body_size; use crate::platform::{RuntimeServices, reader_url_surrogate_key}; /// Purge bodies name a scope and, for a URL purge, one URL. Nothing here is unbounded. -const PURGE_MAX_BODY_BYTES: usize = 4096; +/// Maximum request bytes buffered by adapters for template-cache purge. +pub const PURGE_MAX_BODY_BYTES: usize = 4096; /// The request body, before its scope is validated. /// diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index c9690b01f5..ae849caea1 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -5,13 +5,15 @@ //! stored [`edgezero_core::blob_envelope::BlobEnvelope`] and reconstruct //! [`Settings`] from its data value. +use std::collections::BTreeSet; + use edgezero_core::blob_envelope::BlobEnvelope; use error_stack::Report; use crate::config::TrustedServerAppConfig; use crate::error::TrustedServerError; use crate::platform::{PlatformSecretStore, StoreName}; -use crate::secret_resolution::resolve_secret_references; +use crate::secret_resolution::{resolve_secret_references, secret_reference_keys}; use crate::settings::Settings; /// Canonical logical secret store used by Trusted Server app-config secrets. @@ -46,29 +48,98 @@ pub fn settings_from_config_blob( secret_store: &dyn PlatformSecretStore, default_secret_store_name: &StoreName, ) -> Result> { - let envelope: BlobEnvelope = serde_json::from_str(envelope_json).map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: "failed to parse Trusted Server app-config blob envelope".to_string(), - }) - .attach(error.to_string()) - })?; - envelope.verify().map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: "Trusted Server app-config blob failed integrity verification".to_string(), - }) - .attach(error.to_string()) - })?; - - let mut data = envelope.into_data(); - remove_inactive_secret_references(&mut data); - resolve_secret_references::( - &mut data, - secret_store, - default_secret_store_name, - )?; - let settings = Settings::from_json_value(data)?; - crate::config::validate_settings_for_runtime(&settings)?; - Ok(settings) + VerifiedConfigBlob::parse(envelope_json)?.into_settings(secret_store, default_secret_store_name) +} + +/// Verified app-config with inactive secret references removed. +/// +/// Native startup can collect required keys before awaiting providers. Remove +/// this bridge when platform config and secret consumers become asynchronous. +pub struct VerifiedConfigBlob { + data: serde_json::Value, +} + +impl VerifiedConfigBlob { + /// Verify an envelope and remove references belonging to inactive features. + /// + /// # Errors + /// Returns a configuration error for malformed or tampered envelopes. + pub fn parse(envelope_json: &str) -> Result> { + let envelope: BlobEnvelope = serde_json::from_str(envelope_json).map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: "failed to parse Trusted Server app-config blob envelope".to_string(), + }) + .attach(error.to_string()) + })?; + envelope.verify().map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: "Trusted Server app-config blob failed integrity verification".to_string(), + }) + .attach(error.to_string()) + })?; + + let mut data = envelope.into_data(); + remove_inactive_secret_references(&mut data); + Ok(Self { data }) + } + + /// Return the distinct required secret keys without consulting a provider. + /// + /// # Errors + /// Returns a configuration error for malformed secret paths or references. + pub fn required_secret_keys(&self) -> Result, Report> { + secret_reference_keys::(&self.data) + } + + /// Enforce finite JSON structure limits before traversing secret metadata. + /// + /// # Errors + /// Returns a configuration error when depth or node count exceeds the limits. + pub fn check_structure( + &self, + max_depth: u32, + max_nodes: u32, + ) -> Result<(), Report> { + let mut pending = vec![(&self.data, 1_u32)]; + let mut nodes = 0_u32; + while let Some((value, depth)) = pending.pop() { + nodes = nodes.saturating_add(1); + if depth > max_depth || nodes > max_nodes { + return Err(Report::new(TrustedServerError::Configuration { + message: "Trusted Server app-config exceeds JSON structure limits".to_owned(), + })); + } + match value { + serde_json::Value::Array(items) => { + pending.extend(items.iter().map(|item| (item, depth + 1))) + } + serde_json::Value::Object(items) => { + pending.extend(items.values().map(|item| (item, depth + 1))) + } + _ => {} + } + } + Ok(()) + } + + /// Resolve references from a finite store and validate runtime settings. + /// + /// # Errors + /// Returns a configuration error for missing secrets or invalid resolved settings. + pub fn into_settings( + mut self, + secret_store: &dyn PlatformSecretStore, + default_secret_store_name: &StoreName, + ) -> Result> { + resolve_secret_references::( + &mut self.data, + secret_store, + default_secret_store_name, + )?; + let settings = Settings::from_json_value(self.data)?; + crate::config::validate_settings_for_runtime(&settings)?; + Ok(settings) + } } fn remove_inactive_secret_references(data: &mut serde_json::Value) { @@ -656,12 +727,19 @@ mod tests { .partners .push(partner_with_pull_sync(false, "unused-partner-pull-token")); - let reconstructed = settings_from_config_blob( - &envelope_json(&original), - &UnifiedSecretStore, - &StoreName::from("ts_secrets"), - ) - .expect("should skip inactive optional feature references"); + let blob = VerifiedConfigBlob::parse(&envelope_json(&original)) + .expect("should verify before collecting required references"); + let keys = blob + .required_secret_keys() + .expect("should collect active secret keys"); + assert!(keys.contains("partner-api-token-key")); + assert!( + !keys.iter().any(|key| key.starts_with("unused-")), + "should remove inactive references before awaiting a provider" + ); + let reconstructed = blob + .into_settings(&UnifiedSecretStore, &StoreName::from("ts_secrets")) + .expect("should skip inactive optional feature references"); assert!(reconstructed.tinybird.auction_token_secret.is_none()); assert!(reconstructed.ec.partners[0].ts_pull_token.is_none()); @@ -850,6 +928,22 @@ mod tests { .expect("runtime should accept disabled browser bidder ownership overlap"); } + #[test] + fn verified_blob_enforces_structure_limits_before_key_collection() { + let blob = VerifiedConfigBlob::parse(&envelope_json(&test_settings())) + .expect("should verify valid envelope"); + blob.check_structure(64, 8192) + .expect("should accept bounded fixture structure"); + assert!( + blob.check_structure(1, 8192).is_err(), + "should reject excessive depth" + ); + assert!( + blob.check_structure(64, 1).is_err(), + "should reject excessive node count" + ); + } + #[test] fn tampered_blob_hash_is_rejected() { let mut envelope: BlobEnvelope = diff --git a/crates/trusted-server-core/src/ec/batch_sync.rs b/crates/trusted-server-core/src/ec/batch_sync.rs index 4903589b29..b643e5e7de 100644 --- a/crates/trusted-server-core/src/ec/batch_sync.rs +++ b/crates/trusted-server-core/src/ec/batch_sync.rs @@ -29,6 +29,9 @@ use super::log_id; use super::rate_limiter::RateLimiter; use super::registry::PartnerRegistry; +/// Maximum request bytes buffered by the adapter for existing batch sync. +pub const MAX_BODY_SIZE: usize = 2 * 1024 * 1024; + const REASON_INVALID_EC_ID: &str = "invalid_ec_id"; const REASON_INVALID_PARTNER_UID: &str = "invalid_partner_uid"; const REASON_INELIGIBLE: &str = "ineligible"; @@ -141,7 +144,6 @@ fn handle_batch_sync_with_writer( } // 3. Parse body (with size limit to prevent OOM before validation) - const MAX_BODY_SIZE: usize = 2 * 1024 * 1024; // 2 MB if content_length_exceeds_limit(&req, MAX_BODY_SIZE) { return Ok(error_response( StatusCode::PAYLOAD_TOO_LARGE, @@ -149,7 +151,12 @@ fn handle_batch_sync_with_writer( )); } - let body_bytes = req.into_body().into_bytes().unwrap_or_default(); + let body_bytes = req.into_body().into_bytes().ok_or_else(|| { + Report::new(TrustedServerError::BadRequest { + message: "batch-sync request body must be buffered by the adapter under its byte limit" + .to_owned(), + }) + })?; if body_bytes.len() > MAX_BODY_SIZE { return Ok(error_response( StatusCode::PAYLOAD_TOO_LARGE, diff --git a/crates/trusted-server-core/src/http_util.rs b/crates/trusted-server-core/src/http_util.rs index 457e503568..2b78aa5060 100644 --- a/crates/trusted-server-core/src/http_util.rs +++ b/crates/trusted-server-core/src/http_util.rs @@ -57,10 +57,18 @@ pub const SPOOFABLE_FORWARDED_HEADERS: &[&str] = &[ pub fn sanitize_trusted_client_ip_headers( req: &mut Request, config: Option<&TrustedClientIpConfig>, +) { + sanitize_trusted_client_ip_header_map(req.headers_mut(), config); +} + +/// Removes configured client-IP trust headers without accessing the request body. +pub fn sanitize_trusted_client_ip_header_map( + headers: &mut http::HeaderMap, + config: Option<&TrustedClientIpConfig>, ) { if let Some(config) = config { - req.headers_mut().remove(config.ip_header.as_str()); - req.headers_mut().remove(config.auth_header.as_str()); + headers.remove(config.ip_header.as_str()); + headers.remove(config.auth_header.as_str()); } } @@ -152,6 +160,9 @@ impl RequestInfo { /// [`sanitize_forwarded_headers`] at the edge, so `Host` and /// [`ClientInfo`] TLS detection are the only sources that fire. pub fn from_request(req: &Request, client_info: &ClientInfo) -> Self { + if let Some(info) = req.extensions().get::() { + return info.clone(); + } let host = extract_request_host(req); let scheme = detect_request_scheme( req, diff --git a/crates/trusted-server-core/src/integrations/google_tag_manager.rs b/crates/trusted-server-core/src/integrations/google_tag_manager.rs index 3b1c051a37..967198c6b4 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -15,7 +15,7 @@ use std::sync::{Arc, LazyLock}; use async_trait::async_trait; -use edgezero_core::body::Body as EdgeBody; +use edgezero_core::body::{Body as EdgeBody, BodyContent}; use error_stack::{Report, ResultExt}; use futures::StreamExt as _; use http::{Method, Request, Response, StatusCode, header}; @@ -836,8 +836,8 @@ impl GoogleTagManagerIntegration { body: EdgeBody, max_size: usize, ) -> Result, PayloadSizeError> { - match body { - EdgeBody::Once(bytes) => { + match body.into_content() { + BodyContent::Once(bytes) => { if bytes.len() > max_size { log::warn!( "POST body size {} exceeds max {} (rejected before proxy)", @@ -851,7 +851,7 @@ impl GoogleTagManagerIntegration { } Ok(bytes.to_vec()) } - EdgeBody::Stream(mut stream) => { + BodyContent::Stream(mut stream) => { let mut body_bytes = Vec::new(); while let Some(chunk_result) = stream.next().await { let chunk = chunk_result.map_err(|error| { @@ -1094,6 +1094,67 @@ mod tests { use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; + #[test] + fn managed_beacon_bodies_obey_explicit_size_limit() { + futures::executor::block_on(async { + for streaming in [false, true] { + for limit in [3, 4] { + let body = if streaming { + EdgeBody::stream(futures::stream::iter([ + bytes::Bytes::from_static(b"ab"), + bytes::Bytes::from_static(b"cd"), + ])) + } else { + EdgeBody::from("abcd") + } + .with_buffered_json_policy( + std::num::NonZeroUsize::new(1).expect("should be positive"), + false, + ); + let result = + GoogleTagManagerIntegration::collect_request_body_bounded(body, limit) + .await; + if limit == 4 { + assert_eq!( + result.expect("should accept beacon at limit"), + b"abcd", + "should collect managed beacon content with the beacon limit" + ); + } else { + assert!( + matches!( + result, + Err(PayloadSizeError::TooLarge { actual: 4, max: 3 }) + ), + "should retain beacon payload-too-large classification" + ); + } + } + } + }); + } + + #[test] + fn managed_beacon_body_reports_external_stream_error() { + futures::executor::block_on(async { + let body = EdgeBody::from_external_stream(futures::stream::iter([ + Ok(bytes::Bytes::from_static(b"ab")), + Err(std::io::Error::other("fixture stream failure")), + ])) + .with_buffered_json_policy( + std::num::NonZeroUsize::new(1).expect("should be positive"), + false, + ); + let error = GoogleTagManagerIntegration::collect_request_body_bounded(body, 4) + .await + .expect_err("should report beacon stream error"); + assert!( + matches!(error, PayloadSizeError::StreamRead(_)), + "should retain beacon stream-read classification" + ); + }); + } + #[test] fn container_id_validation_matches_gtm_pattern() { use validator::Validate as _; diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 820e2c24b8..83fedfef8c 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -2,7 +2,7 @@ use std::time::Duration; -use edgezero_core::body::Body as EdgeBody; +use edgezero_core::body::{Body as EdgeBody, BodyContent}; use error_stack::{Report, ResultExt}; use futures::StreamExt as _; use url::Url; @@ -184,8 +184,8 @@ pub(crate) async fn collect_body_bounded( max_bytes: usize, integration: &'static str, ) -> Result, Report> { - match body { - EdgeBody::Once(bytes) => { + match body.into_content() { + BodyContent::Once(bytes) => { if bytes.len() > max_bytes { return Err(Report::new(TrustedServerError::RequestTooLarge { message: format!( @@ -196,7 +196,7 @@ pub(crate) async fn collect_body_bounded( } Ok(bytes.to_vec()) } - EdgeBody::Stream(mut stream) => { + BodyContent::Stream(mut stream) => { let mut body_bytes = Vec::new(); while let Some(chunk_result) = stream.next().await { let chunk = chunk_result.map_err(|error| { @@ -244,8 +244,8 @@ pub(crate) async fn collect_response_bounded( max_bytes: usize, integration: &'static str, ) -> Result, Report> { - match body { - EdgeBody::Once(bytes) => { + match body.into_content() { + BodyContent::Once(bytes) => { if bytes.len() > max_bytes { return Err(Report::new(TrustedServerError::Integration { integration: integration.to_string(), @@ -257,7 +257,7 @@ pub(crate) async fn collect_response_bounded( } Ok(bytes.to_vec()) } - EdgeBody::Stream(mut stream) => { + BodyContent::Stream(mut stream) => { let mut body_bytes = Vec::new(); while let Some(chunk_result) = stream.next().await { let chunk = chunk_result.map_err(|error| { @@ -347,3 +347,101 @@ pub(crate) fn builders() -> &'static [IntegrationBuilder] { pub(crate) fn registered_builder_ids() -> impl Iterator { builders().iter().map(|builder| builder.id) } + +#[cfg(test)] +mod tests { + use std::num::NonZeroUsize; + + use bytes::Bytes; + + use super::*; + + fn managed_body(streaming: bool) -> EdgeBody { + let body = if streaming { + EdgeBody::stream(futures::stream::iter([ + Bytes::from_static(b"ab"), + Bytes::from_static(b"cd"), + ])) + } else { + EdgeBody::from("abcd") + }; + body.with_buffered_json_policy(NonZeroUsize::new(1).expect("should be positive"), true) + } + + #[test] + fn managed_collectors_use_explicit_limits_and_preserve_error_contexts() { + futures::executor::block_on(async { + for streaming in [false, true] { + assert_eq!( + collect_body_bounded(managed_body(streaming), 4, "test") + .await + .expect("should collect request at limit"), + b"abcd", + "should collect managed request content with the caller's limit" + ); + assert_eq!( + collect_response_bounded(managed_body(streaming), 4, "test") + .await + .expect("should collect response at limit"), + b"abcd", + "should collect managed response content with the caller's limit" + ); + let request_error = collect_body_bounded(managed_body(streaming), 3, "test") + .await + .expect_err("should reject oversized request"); + assert!( + matches!( + request_error.current_context(), + TrustedServerError::RequestTooLarge { .. } + ), + "should retain request-too-large classification" + ); + let response_error = collect_response_bounded(managed_body(streaming), 3, "test") + .await + .expect_err("should reject oversized response"); + assert!( + matches!( + response_error.current_context(), + TrustedServerError::Integration { .. } + ), + "should retain upstream integration error classification" + ); + } + }); + } + + #[test] + fn managed_collectors_report_external_stream_errors() { + futures::executor::block_on(async { + for response in [false, true] { + let body = EdgeBody::from_external_stream(futures::stream::iter([ + Ok(Bytes::from_static(b"ab")), + Err(std::io::Error::other("fixture stream failure")), + ])) + .with_buffered_json_policy(NonZeroUsize::new(1).expect("should be positive"), true); + let result = if response { + collect_response_bounded(body, 4, "test").await + } else { + collect_body_bounded(body, 4, "test").await + }; + let error = result.expect_err("should report external stream error"); + assert!( + matches!( + error.current_context(), + TrustedServerError::Integration { .. } + ), + "should retain integration error classification" + ); + let expected = if response { + "Failed to read response body" + } else { + "Failed to read request body" + }; + assert!( + format!("{error:?}").contains(expected), + "should retain request or response read context" + ); + } + }); + } +} diff --git a/crates/trusted-server-core/src/integrations/sourcepoint.rs b/crates/trusted-server-core/src/integrations/sourcepoint.rs index aa88ac08a9..7085d2aade 100644 --- a/crates/trusted-server-core/src/integrations/sourcepoint.rs +++ b/crates/trusted-server-core/src/integrations/sourcepoint.rs @@ -2655,10 +2655,10 @@ mod tests { } fn take_body_bytes(resp: Response) -> Vec { - match resp.into_body() { - EdgeBody::Once(b) => b.to_vec(), - EdgeBody::Stream(_) => vec![], - } + resp.into_body() + .into_bytes() + .expect("should carry a buffered test response body") + .to_vec() } #[test] diff --git a/crates/trusted-server-core/src/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs index b47abaebbb..dbcfda7db4 100644 --- a/crates/trusted-server-core/src/platform/http.rs +++ b/crates/trusted-server-core/src/platform/http.rs @@ -74,6 +74,14 @@ pub struct PlatformHttpRequest { /// Adapters that cannot preserve streaming response bodies should return an /// error rather than silently buffering large asset responses. pub stream_response: bool, + /// Maximum encoded outbound request bytes accepted by managed transports. + pub max_request_body_bytes: u64, + /// Maximum response bytes accepted by managed transports. + pub max_response_body_bytes: u64, + /// Absolute deadline on the application's monotonic clock, when supplied. + pub deadline: Option, + /// Finite total transport budget, independent of first-byte progress. + pub timeout: Option, } impl PlatformHttpRequest { @@ -86,9 +94,39 @@ impl PlatformHttpRequest { image_optimizer: None, cache_intent: PlatformCacheIntent::Default, stream_response: false, + max_request_body_bytes: 8 * 1024 * 1024, + max_response_body_bytes: 16 * 1024 * 1024, + deadline: None, + timeout: Some(std::time::Duration::from_secs(30)), } } + /// Sets the response byte ceiling without changing streaming selection. + #[must_use] + pub fn with_response_limit(mut self, max_bytes: u64) -> Self { + self.max_response_body_bytes = max_bytes; + self + } + + /// Sets the total transport budget, including connection and body reads. + #[must_use] + pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self { + self.timeout = Some(timeout); + self + } + + /// Sets an absolute application deadline and a finite transport budget. + #[must_use] + pub fn with_deadline( + mut self, + deadline: edgezero_core::time::Deadline, + timeout: std::time::Duration, + ) -> Self { + self.deadline = Some(deadline); + self.timeout = Some(timeout); + self + } + /// Attach Image Optimizer metadata to the request. /// /// The current Fastly adapter supports this on [`PlatformHttpClient::send`] diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 6a30645aba..a07eae6447 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -1,7 +1,7 @@ use crate::http_util::{ RequestInfo, compute_encrypted_sha256_token, ct_str_eq, enforce_max_body_size, }; -use edgezero_core::body::Body as EdgeBody; +use edgezero_core::body::{Body as EdgeBody, BodyContent}; use edgezero_core::http::{Uri as EdgeUri, request_builder as edge_request_builder}; use error_stack::{Report, ResultExt}; use futures::StreamExt as _; @@ -37,8 +37,10 @@ const STREAMING_CHUNK_SIZE: usize = 8192; /// Fallback `Content-Type` for image-like responses missing an origin type. const IMAGE_FALLBACK_CONTENT_TYPE: &str = "application/octet-stream"; -const SIGN_MAX_BODY_BYTES: usize = 65536; -const REBUILD_MAX_BODY_BYTES: usize = 65536; +/// Maximum request bytes buffered by adapters for first-party signing. +pub const SIGN_MAX_BODY_BYTES: usize = 65536; +/// Maximum request bytes buffered by adapters for first-party proxy rebuilding. +pub const REBUILD_MAX_BODY_BYTES: usize = 65536; fn body_as_reader(body: EdgeBody) -> Cursor { Cursor::new(body.into_bytes().unwrap_or_default()) @@ -249,9 +251,9 @@ pub(crate) fn platform_response_to_fastly( platform_resp: PlatformResponse, ) -> Result, Report> { let (parts, body) = platform_resp.response.into_parts(); - let body_bytes = match body { - EdgeBody::Once(bytes) => bytes.to_vec(), - EdgeBody::Stream(_) => { + let body_bytes = match body.into_content() { + BodyContent::Once(bytes) => bytes.to_vec(), + BodyContent::Stream(_) => { return Err(Report::new(TrustedServerError::Proxy { message: "streaming platform response body is not supported by Fastly response conversion" .to_string(), @@ -264,14 +266,14 @@ pub(crate) fn platform_response_to_fastly( fn platform_response_to_fastly_asset(platform_resp: PlatformResponse) -> AssetProxyResponse { let (parts, body) = platform_resp.response.into_parts(); - match body { - EdgeBody::Once(bytes) => AssetProxyResponse::origin_controlled(Response::from_parts( + match body.into_content() { + BodyContent::Once(bytes) => AssetProxyResponse::origin_controlled(Response::from_parts( parts, EdgeBody::from(bytes.to_vec()), )), - stream_body @ EdgeBody::Stream(_) => { + stream_body @ BodyContent::Stream(_) => { let resp = Response::from_parts(parts, EdgeBody::empty()); - AssetProxyResponse::origin_controlled_stream(resp, stream_body) + AssetProxyResponse::origin_controlled_stream(resp, EdgeBody::from(stream_body)) } } } @@ -289,15 +291,15 @@ pub async fn stream_asset_body( body: EdgeBody, output: &mut W, ) -> Result<(), Report> { - match body { - EdgeBody::Once(bytes) => { + match body.into_content() { + BodyContent::Once(bytes) => { output .write_all(bytes.as_ref()) .change_context(TrustedServerError::Proxy { message: "failed to write buffered platform response body".to_string(), })?; } - EdgeBody::Stream(mut stream) => { + BodyContent::Stream(mut stream) => { while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|err| { Report::new(TrustedServerError::Proxy { @@ -5933,10 +5935,68 @@ mod tests { }); } + #[test] + fn managed_platform_response_conversion_preserves_asset_body() { + futures::executor::block_on(async { + for streaming in [false, true] { + let make_response = || { + let body = if streaming { + EdgeBody::stream(futures::stream::iter([ + Bytes::from_static(b"ab"), + Bytes::from_static(b"cd"), + ])) + } else { + EdgeBody::from("abcd") + } + .with_buffered_json_policy( + std::num::NonZeroUsize::new(1).expect("should be positive"), + false, + ); + PlatformResponse { + response: Response::new(body), + backend_name: None, + } + }; + let buffered = super::platform_response_to_fastly(make_response()); + if streaming { + assert!( + buffered.is_err(), + "should reject managed streams in buffered conversion" + ); + } else { + assert_eq!( + buffered + .expect("should convert managed buffer") + .body() + .as_bytes(), + Some(&b"abcd"[..]), + "should retain managed buffered response content" + ); + } + + let asset = super::platform_response_to_fastly_asset(make_response()); + let (response, stream) = asset.into_response_and_body(); + assert_eq!( + stream.is_some(), + streaming, + "should preserve logical stream ownership" + ); + let mut output = Vec::new(); + stream_asset_body(stream.unwrap_or_else(|| response.into_body()), &mut output) + .await + .expect("should write managed asset body"); + assert_eq!( + output, b"abcd", + "should retain managed asset response content" + ); + } + }); + } + #[test] fn stream_asset_body_reports_mid_stream_origin_errors() { futures::executor::block_on(async { - let body = EdgeBody::from_stream(futures::stream::iter(vec![ + let body = EdgeBody::from_external_stream(futures::stream::iter(vec![ Ok::(Bytes::from_static(b"partial")), Err(io::Error::other("origin stream failed")), ])); @@ -5990,7 +6050,7 @@ mod tests { // the client renders nothing behind committed headers. let events = Rc::new(RefCell::new(Vec::new())); let source_events = Rc::clone(&events); - let body = EdgeBody::from_stream(async_stream::stream! { + let body = EdgeBody::from_external_stream(async_stream::stream! { for (index, segment) in ["", ""].into_iter().enumerate() { source_events .borrow_mut() diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index e87d8939d1..06fb66d1ac 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -28,7 +28,7 @@ use brotli::Decompressor; use brotli::enc::BrotliEncoderParams; use brotli::enc::writer::CompressorWriter; use cookie::CookieJar; -use edgezero_core::body::Body as EdgeBody; +use edgezero_core::body::{Body as EdgeBody, BodyContent}; use error_stack::{Report, ResultExt}; use flate2::read::ZlibDecoder; use flate2::write::{GzEncoder, ZlibEncoder}; @@ -175,7 +175,7 @@ fn body_as_reader( } struct BodyChunkSource { - body: Option, + body: Option, chunk_size: usize, max_bytes: usize, bytes_seen: usize, @@ -185,7 +185,7 @@ struct BodyChunkSource { impl BodyChunkSource { fn new(body: EdgeBody, chunk_size: usize) -> Self { Self { - body: Some(body), + body: Some(body.into_content()), chunk_size, max_bytes: usize::MAX, bytes_seen: 0, @@ -204,7 +204,7 @@ impl BodyChunkSource { // silently reporting end-of-stream on the next call. let pulled = match &mut self.body { None => Ok(None), - Some(EdgeBody::Once(bytes)) => { + Some(BodyContent::Once(bytes)) => { let end = (self.once_offset + self.chunk_size).min(bytes.len()); if self.once_offset >= end { Ok(None) @@ -214,7 +214,7 @@ impl BodyChunkSource { Ok(Some(chunk)) } } - Some(EdgeBody::Stream(stream)) => match stream.next().await { + Some(BodyContent::Stream(stream)) => match stream.next().await { Some(Ok(chunk)) => Ok(Some(chunk)), Some(Err(err)) => Err(Report::new(TrustedServerError::Proxy { message: format!("Failed to read publisher origin body stream: {err}"), @@ -2530,7 +2530,7 @@ pub async fn publisher_response_into_streaming_response( yield bytes::Bytes::from(trailer); } }; - *response.body_mut() = EdgeBody::from_stream::<_, std::io::Error>(stream); + *response.body_mut() = EdgeBody::from_external_stream::<_, std::io::Error>(stream); Ok(response) } PublisherResponse::PassThrough { mut response, body } => { @@ -2740,7 +2740,7 @@ pub async fn publisher_response_into_streaming_response( } } }; - *response.body_mut() = EdgeBody::from_stream::<_, std::io::Error>(stream); + *response.body_mut() = EdgeBody::from_external_stream::<_, std::io::Error>(stream); Ok(response) } } @@ -10583,18 +10583,15 @@ mod tests { /// can be stored, and one it refused keeps streaming exactly like `inline`. A /// helper that assumed either would silently only ever test one of them. async fn body_of(response: Response) -> Vec { - match response.into_body() { - EdgeBody::Stream(mut stream) => { + match response.into_body().into_content() { + BodyContent::Stream(mut stream) => { let mut out = Vec::new(); while let Some(chunk) = stream.next().await { out.extend_from_slice(&chunk.expect("stream chunk should read")); } out } - other => other - .into_bytes() - .expect("a non-stream body has its bytes in hand") - .to_vec(), + BodyContent::Once(bytes) => bytes.to_vec(), } } @@ -14586,7 +14583,7 @@ mod tests { // So pull exactly one chunk and prove the auction has not been collected // yet — `ad_bids_state` is only written by the collector. No timing // involved, so nothing to be flaky about. - let EdgeBody::Stream(mut stream) = warm.into_body() else { + let BodyContent::Stream(mut stream) = warm.into_body().into_content() else { panic!("a cache hit must stream, not buffer"); }; let first = stream @@ -19919,7 +19916,7 @@ mod tests { gpt_diagnostics: None, suppress_datadome_client_side_tag: false, }; - let body = EdgeBody::from_stream(futures::stream::iter(vec![Ok::<_, io::Error>( + let body = EdgeBody::from_external_stream(futures::stream::iter(vec![Ok::<_, io::Error>( bytes::Bytes::from_static(b"live"), )])); let mut output = Vec::new(); @@ -19956,6 +19953,45 @@ mod tests { }); } + #[test] + fn body_chunk_source_reads_managed_content_with_explicit_cap() { + futures::executor::block_on(async { + for streaming in [false, true] { + let body = if streaming { + EdgeBody::stream(futures::stream::iter([ + bytes::Bytes::from_static(b"abc"), + bytes::Bytes::from_static(b"def"), + ])) + } else { + EdgeBody::from("abcdef") + } + .with_buffered_json_policy( + std::num::NonZeroUsize::new(1).expect("should be positive"), + true, + ); + let mut source = BodyChunkSource::new(body, 3).with_max_bytes(5); + + assert_eq!( + source + .next_chunk() + .await + .expect("should read first chunk") + .as_deref(), + Some(&b"abc"[..]), + "should read managed content without applying context JSON policy" + ); + let error = source + .next_chunk() + .await + .expect_err("should reject cumulative overflow"); + assert!( + format!("{error:?}").contains("publisher origin body exceeded"), + "should enforce the publisher limit for managed buffered and streamed bodies" + ); + } + }); + } + #[test] fn body_chunk_source_preserves_stream_chunks() { futures::executor::block_on(async { @@ -20498,46 +20534,56 @@ mod tests { #[test] fn body_chunk_source_resumes_after_cancelled_poll() { futures::executor::block_on(async { - let mut pending_once = true; - let mut yielded = false; - let stream = futures::stream::poll_fn(move |cx| { - if pending_once { - pending_once = false; - cx.waker().wake_by_ref(); - return std::task::Poll::Pending; - } - if yielded { - return std::task::Poll::Ready(None); + for managed in [false, true] { + let mut pending_once = true; + let mut yielded = false; + let stream = futures::stream::poll_fn(move |cx| { + if pending_once { + pending_once = false; + cx.waker().wake_by_ref(); + return std::task::Poll::Pending; + } + if yielded { + return std::task::Poll::Ready(None); + } + yielded = true; + std::task::Poll::Ready(Some(Ok::<_, io::Error>(bytes::Bytes::from_static( + b"chunk", + )))) + }); + let body = EdgeBody::from_external_stream(stream); + let body = if managed { + body.with_buffered_json_policy( + std::num::NonZeroUsize::new(1).expect("should be positive"), + true, + ) + } else { + body + }; + let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE); + + { + // Poll the pull future once (Pending), then drop it — + // simulating a cancelled await (select/timeout wrapper). + let mut pull = Box::pin(source.next_chunk()); + let waker = futures::task::noop_waker(); + let mut context = std::task::Context::from_waker(&waker); + assert!( + pull.as_mut().poll(&mut context).is_pending(), + "first poll should be pending" + ); } - yielded = true; - std::task::Poll::Ready(Some(Ok::<_, io::Error>(bytes::Bytes::from_static( - b"chunk", - )))) - }); - let body = EdgeBody::from_stream(stream); - let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE); - { - // Poll the pull future once (Pending), then drop it — - // simulating a cancelled await (select/timeout wrapper). - let mut pull = Box::pin(source.next_chunk()); - let waker = futures::task::noop_waker(); - let mut context = std::task::Context::from_waker(&waker); - assert!( - pull.as_mut().poll(&mut context).is_pending(), - "first poll should be pending" + let chunk = source + .next_chunk() + .await + .expect("should read after cancelled poll"); + assert_eq!( + chunk.as_deref(), + Some(&b"chunk"[..]), + "cancelled pull must not lose the origin stream" ); } - - let chunk = source - .next_chunk() - .await - .expect("should read after cancelled poll"); - assert_eq!( - chunk.as_deref(), - Some(&b"chunk"[..]), - "cancelled pull must not lose the origin stream" - ); }); } @@ -20853,7 +20899,7 @@ mod tests { /// the document head but not yet finished the response. fn origin_chunk_then_pending(chunk: bytes::Bytes) -> EdgeBody { let mut sent = false; - EdgeBody::from_stream(futures::stream::poll_fn(move |_cx| { + EdgeBody::from_external_stream(futures::stream::poll_fn(move |_cx| { if sent { return std::task::Poll::Pending; } diff --git a/crates/trusted-server-core/src/request_signing/endpoints.rs b/crates/trusted-server-core/src/request_signing/endpoints.rs index d35e51a151..82ca9314aa 100644 --- a/crates/trusted-server-core/src/request_signing/endpoints.rs +++ b/crates/trusted-server-core/src/request_signing/endpoints.rs @@ -98,8 +98,10 @@ pub struct VerifySignatureResponse { pub error: Option, } -const VERIFY_MAX_BODY_BYTES: usize = 4096; -const ADMIN_MAX_BODY_BYTES: usize = 4096; +/// Maximum buffered request bytes for signature verification. +pub const VERIFY_MAX_BODY_BYTES: usize = 4096; +/// Maximum buffered request bytes for signing-key management endpoints. +pub const ADMIN_MAX_BODY_BYTES: usize = 4096; /// Will verify a signature given a payload and kid /// Useful for testing integration with signatures diff --git a/crates/trusted-server-core/src/secret_resolution.rs b/crates/trusted-server-core/src/secret_resolution.rs index f362a0b7b2..e45079ff07 100644 --- a/crates/trusted-server-core/src/secret_resolution.rs +++ b/crates/trusted-server-core/src/secret_resolution.rs @@ -4,6 +4,8 @@ //! public `EdgeZero` metadata contract and replaces those names only in the //! in-memory value used to build runtime [`crate::settings::Settings`]. +use std::collections::BTreeSet; + use edgezero_core::app_config::{AppConfigMeta, SecretField, SecretKind, SecretPathSegment}; use error_stack::Report; use serde_json::Value; @@ -27,20 +29,57 @@ pub fn resolve_secret_references( default_store_name: &StoreName, ) -> Result<(), Report> { let mut resolved_data = data.clone(); + visit_secret_references::(&mut resolved_data, &mut |key, path| { + let resolved = secret_store + .get_string(default_store_name, key) + .map_err(|_| { + configuration_error(format!( + "failed to resolve secret reference at `{path}` from secret store \ + `{default_store_name}`" + )) + })?; + if resolved.is_empty() { + return Err(configuration_error(format!( + "resolved secret at `{path}` must not be empty" + ))); + } + Ok(resolved) + })?; + *data = resolved_data; + Ok(()) +} + +/// Collect the finite, deduplicated keys required by an app-config value. +/// +/// Uses the same metadata and structural checks as runtime resolution without +/// reading a store or changing the supplied value. +/// +/// # Errors +/// Returns a configuration error for missing or malformed secret references. +pub fn secret_reference_keys( + data: &Value, +) -> Result, Report> { + let mut keys = BTreeSet::new(); + visit_secret_references::(&mut data.clone(), &mut |key, _path| { + keys.insert(key.to_owned()); + Ok(key.to_owned()) + })?; + Ok(keys) +} + +type ResolveReference<'a> = + dyn FnMut(&str, &str) -> Result> + 'a; + +fn visit_secret_references( + data: &mut Value, + resolve: &mut ResolveReference<'_>, +) -> Result<(), Report> { for field in C::secret_fields() { if matches!(field.kind, SecretKind::StoreRef) { continue; } - resolve_field( - &mut resolved_data, - &field, - &field.path, - "", - secret_store, - default_store_name, - )?; + resolve_field(data, &field, &field.path, "", resolve)?; } - *data = resolved_data; Ok(()) } @@ -49,30 +88,17 @@ fn resolve_field( field: &SecretField, remaining: &[SecretPathSegment], rendered_path: &str, - secret_store: &dyn PlatformSecretStore, - default_store_name: &StoreName, + resolve: &mut ResolveReference<'_>, ) -> Result<(), Report> { match remaining.split_first() { - Some((SecretPathSegment::Field(name), [])) => resolve_leaf( - node, - field, - name.as_ref(), - rendered_path, - secret_store, - default_store_name, - ), + Some((SecretPathSegment::Field(name), [])) => { + resolve_leaf(node, field, name.as_ref(), rendered_path, resolve) + } Some((SecretPathSegment::OptionalField(name), [])) => { if matches!(node.get(name.as_ref()), None | Some(Value::Null)) { return Ok(()); } - resolve_leaf( - node, - field, - name.as_ref(), - rendered_path, - secret_store, - default_store_name, - ) + resolve_leaf(node, field, name.as_ref(), rendered_path, resolve) } Some((SecretPathSegment::Field(name), rest)) => { let next_path = join_field(rendered_path, name.as_ref()); @@ -83,14 +109,7 @@ fn resolve_field( if child.is_null() { return Err(missing_path(&next_path)); } - resolve_field( - child, - field, - rest, - &next_path, - secret_store, - default_store_name, - ) + resolve_field(child, field, rest, &next_path, resolve) } Some((SecretPathSegment::OptionalField(name), rest)) => { let next_path = join_field(rendered_path, name.as_ref()); @@ -103,14 +122,7 @@ fn resolve_field( if child.is_null() { return Ok(()); } - resolve_field( - child, - field, - rest, - &next_path, - secret_store, - default_store_name, - ) + resolve_field(child, field, rest, &next_path, resolve) } Some((SecretPathSegment::ArrayEach, rest)) => { let items = node.as_array_mut().ok_or_else(|| { @@ -118,14 +130,7 @@ fn resolve_field( })?; for (index, item) in items.iter_mut().enumerate() { let indexed_path = format!("{rendered_path}[{index}]"); - resolve_field( - item, - field, - rest, - &indexed_path, - secret_store, - default_store_name, - )?; + resolve_field(item, field, rest, &indexed_path, resolve)?; } Ok(()) } @@ -142,8 +147,7 @@ fn resolve_leaf( field: &SecretField, key: &str, rendered_parent: &str, - secret_store: &dyn PlatformSecretStore, - default_store_name: &StoreName, + resolve: &mut ResolveReference<'_>, ) -> Result<(), Report> { let leaf_path = join_field(rendered_parent, key); let object = parent.as_object_mut().ok_or_else(|| { @@ -166,20 +170,7 @@ fn resolve_leaf( } }; - let resolved = secret_store - .get_string(default_store_name, &key_name) - .map_err(|_| { - configuration_error(format!( - "failed to resolve secret reference at `{leaf_path}` from secret store \ - `{default_store_name}`" - )) - })?; - if resolved.is_empty() { - return Err(configuration_error(format!( - "resolved secret at `{leaf_path}` must not be empty" - ))); - } - + let resolved = resolve(&key_name, &leaf_path)?; object.insert(key.to_owned(), Value::String(resolved)); Ok(()) } @@ -281,6 +272,30 @@ mod tests { } } + #[test] + fn collects_deduplicated_keys_without_mutating_data() { + let data = serde_json::json!({ + "outer": [{"token": "token-a"}, {"token": "token-a", "optional": "token-b"}] + }); + let original = data.clone(); + let keys = + secret_reference_keys::(&data).expect("should collect finite references"); + assert_eq!( + keys, + BTreeSet::from(["token-a".to_owned(), "token-b".to_owned()]) + ); + assert_eq!( + data, original, + "should not mutate references during collection" + ); + } + + #[test] + fn collection_rejects_malformed_required_paths() { + let data = serde_json::json!({"outer": [{"token": false}]}); + assert!(secret_reference_keys::(&data).is_err()); + } + #[test] fn resolves_nested_array_values_and_skips_optional_nulls() { let mut data = serde_json::json!({ diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 3efc157701..06e7ed1fda 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -3051,9 +3051,15 @@ impl Settings { /// - [`TrustedServerError::Configuration`] if the TOML is invalid or missing required fields #[cfg(test)] pub fn from_toml_and_env(toml_str: &str) -> Result> { + // Cargo exports rustc-env build metadata to native test processes too. + // It is not an application setting and must not enter strict deserialization. + let source = std::env::vars() + .filter(|(name, _)| name != "TRUSTED_SERVER__GIT_VERSION") + .collect(); let environment = Environment::default() .prefix(ENVIRONMENT_VARIABLE_PREFIX) - .separator(ENVIRONMENT_VARIABLE_SEPARATOR); + .separator(ENVIRONMENT_VARIABLE_SEPARATOR) + .source(Some(source)); let toml = File::from_str(toml_str, FileFormat::Toml); let config = Config::builder() @@ -6258,6 +6264,14 @@ source_domain = "partner.example.com" ); } + #[test] + fn legacy_settings_loader_ignores_compiled_git_metadata() { + temp_env::with_var("TRUSTED_SERVER__GIT_VERSION", Some("fixture-build"), || { + Settings::from_toml_and_env(&crate_test_settings_str()) + .expect("should ignore build metadata in the test-only legacy settings loader"); + }); + } + #[test] fn test_set_env() { temp_env::with_var( diff --git a/crates/trusted-server-integration-tests/Cargo.toml b/crates/trusted-server-integration-tests/Cargo.toml index 39f985860a..a49d9c676a 100644 --- a/crates/trusted-server-integration-tests/Cargo.toml +++ b/crates/trusted-server-integration-tests/Cargo.toml @@ -41,6 +41,7 @@ log = { workspace = true } reqwest = { workspace = true, features = ["blocking", "cookies"] } scraper = { workspace = true } testcontainers = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread"] } tower = { workspace = true, features = ["util"] } trusted-server-adapter-axum = { path = "../trusted-server-adapter-axum" } diff --git a/crates/trusted-server-integration-tests/tests/environments/axum.rs b/crates/trusted-server-integration-tests/tests/environments/axum.rs index 6e1388cea3..ee6ad0c297 100644 --- a/crates/trusted-server-integration-tests/tests/environments/axum.rs +++ b/crates/trusted-server-integration-tests/tests/environments/axum.rs @@ -2,51 +2,41 @@ use crate::common::config::integration_app_config_envelope; use crate::common::runtime::{ RuntimeEnvironment, RuntimeProcess, RuntimeProcessHandle, TestError, TestResult, origin_port, }; -use error_stack::ResultExt as _; +use error_stack::{Report, ResultExt as _}; use std::io::{BufRead as _, BufReader}; use std::path::Path; use std::process::{Child, Command, Stdio}; -use trusted_server_adapter_axum::platform::config_env_var; -use trusted_server_core::settings_data::{default_config_key, default_config_store_name}; +use std::time::{Duration, Instant}; +use trusted_server_core::config_payload::{CONFIG_BLOB_KEY, DEFAULT_CONFIG_STORE_ID}; -/// Default port the Axum dev server binds to when no `PORT` env var is supplied. -const AXUM_DEFAULT_PORT: u16 = 8787; - -/// Secret-store entries referenced by the integration app-config fixture. -const INTEGRATION_SECRET_ENV: &[(&str, &str)] = &[ +/// Local prepared-secret entries referenced by the integration app-config fixture. +const INTEGRATION_SECRETS: &[(&str, &str)] = &[ ( - "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_ADMIN_PASSWORD", + "EDGEZERO__SECRETS__INTEGRATION_ADMIN_PASSWORD", "integration-admin-password-32-bytes-ok", ), ( - "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_PROXY_SECRET", + "EDGEZERO__SECRETS__INTEGRATION_PROXY_SECRET", "integration-test-proxy-secret-32-bytes-ok", ), ( - "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_EC_PASSPHRASE", + "EDGEZERO__SECRETS__INTEGRATION_EC_PASSPHRASE", "integration-test-ec-secret-padded-32", ), ( - "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_PARTNER_TOKEN_ALPHA", + "EDGEZERO__SECRETS__INTEGRATION_PARTNER_TOKEN_ALPHA", "integration-test-token-alpha-32-bytes-ok", ), ( - "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_PARTNER_TOKEN_BRAVO", + "EDGEZERO__SECRETS__INTEGRATION_PARTNER_TOKEN_BRAVO", "integration-test-token-bravo-32-bytes-ok", ), ]; -/// Axum native dev-server runtime environment. -/// -/// Spawns the pre-built `trusted-server-axum` binary directly (no WASM, no -/// Viceroy). The binary must have been built before running integration tests: -/// -/// ```sh -/// cargo build -p trusted-server-adapter-axum -/// ``` +/// Native runtime using only the config and secret stores declared by TS Hooks. /// -/// The WASM binary path argument is unused — it exists only to satisfy the -/// [`RuntimeEnvironment`] trait shared with Fastly. +/// Build `trusted-server-axum` first, or set `AXUM_BINARY_PATH`. The WASM path +/// argument is unused by this runtime. pub struct AxumDevServer; impl RuntimeEnvironment for AxumDevServer { @@ -56,30 +46,38 @@ impl RuntimeEnvironment for AxumDevServer { fn spawn(&self, _wasm_path: &Path) -> TestResult { let binary = self.binary_path(); - let port = super::find_available_port().unwrap_or(AXUM_DEFAULT_PORT); - + let port = super::find_available_port()?; + let workspace = tempfile::tempdir().change_context(TestError::ConfigGeneration)?; + let root = workspace + .path() + .canonicalize() + .change_context(TestError::ConfigGeneration)?; let app_config = integration_app_config_envelope(origin_port())?; - let store_name = default_config_store_name(); - let config_key = default_config_key(); - let config_variable = config_env_var(store_name.as_ref(), &config_key); + let config = serde_json::to_vec(&serde_json::json!({ CONFIG_BLOB_KEY: app_config })) + .change_context(TestError::ConfigGeneration)?; + std::fs::write( + root.join(format!("local-config-{DEFAULT_CONFIG_STORE_ID}.json")), + config, + ) + .change_context(TestError::ConfigGeneration)?; let mut child = Command::new(&binary) - .env("PORT", port.to_string()) - .env(config_variable, app_config) - .envs(INTEGRATION_SECRET_ENV.iter().copied()) + .env_clear() + .env("EDGEZERO__ADAPTER__HOST", "127.0.0.1") + .env("EDGEZERO__ADAPTER__PORT", port.to_string()) + .env("EDGEZERO__ADAPTER__CONFIG_DIR", &root) + .env("EDGEZERO__ADAPTER__DATA_DIR", &root) + .env("EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS", "1") + .envs(INTEGRATION_SECRETS.iter().copied()) .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() .change_context(TestError::RuntimeSpawn) - .attach(format!( - "Failed to spawn trusted-server-axum binary at {}", - binary.display() - ))?; + .attach(format!("Failed to spawn Axum at {}", binary.display()))?; if let Some(stderr) = child.stderr.take() { std::thread::spawn(move || { - let reader = BufReader::new(stderr); - for line in reader.lines().map_while(Result::ok) { + for line in BufReader::new(stderr).lines().map_while(Result::ok) { if !line.is_empty() { log::debug!("axum: {line}"); } @@ -87,13 +85,13 @@ impl RuntimeEnvironment for AxumDevServer { }); } - let handle = AxumHandle { child }; + // Establish ownership before readiness so every error path tears down. + let mut handle = AxumHandle { + child, + _workspace: workspace, + }; let base_url = format!("http://127.0.0.1:{port}"); - - // The Axum dev server returns 403 at root (no publisher config in test env), - // so we poll until we get any HTTP response rather than a specific status. - wait_for_any_response(&base_url)?; - + wait_for_health(&base_url, &mut handle.child)?; Ok(RuntimeProcess { inner: Box::new(handle), base_url, @@ -106,45 +104,46 @@ impl RuntimeEnvironment for AxumDevServer { } impl AxumDevServer { - /// Resolve the path to the compiled `trusted-server-axum` binary. - /// - /// Respects the `AXUM_BINARY_PATH` environment variable for CI overrides. - /// Falls back to the workspace `target/debug/` directory. fn binary_path(&self) -> std::path::PathBuf { if let Ok(path) = std::env::var("AXUM_BINARY_PATH") { return std::path::PathBuf::from(path); } - - // CARGO_MANIFEST_DIR is crates/trusted-server-integration-tests -> go up two levels to workspace root std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../target/debug/trusted-server-axum") } } -/// Poll until the Axum dev server responds with any HTTP status code. -/// -/// The Axum server returns 403 at root when no publisher config is present, -/// which is neither success nor 404, so the standard [`super::wait_for_ready`] -/// helper cannot be used. Any HTTP response means the server is up. -fn wait_for_any_response(base_url: &str) -> TestResult<()> { - use error_stack::Report; - - let url = format!("{base_url}/"); - for _ in 0..30 { - if reqwest::blocking::get(&url).is_ok() { +fn wait_for_health(base_url: &str, child: &mut Child) -> TestResult<()> { + let client = reqwest::blocking::Client::builder() + .no_proxy() + .timeout(Duration::from_millis(300)) + .build() + .change_context(TestError::RuntimeNotReady)?; + let deadline = Instant::now() + Duration::from_secs(15); + while Instant::now() < deadline { + if let Some(status) = child + .try_wait() + .change_context(TestError::RuntimeNotReady)? + { + return Err(Report::new(TestError::RuntimeNotReady) + .attach(format!("Axum exited before readiness: {status}"))); + } + if let Ok(response) = client.get(format!("{base_url}/health")).send() + && response.status().as_u16() == 200 + && response.text().is_ok_and(|body| body == "ok") + { return Ok(()); } - std::thread::sleep(std::time::Duration::from_millis(500)); + std::thread::sleep(Duration::from_millis(100)); } - Err(Report::new(TestError::RuntimeNotReady) - .attach(format!("Axum dev server at {base_url} not ready after 15s"))) + Err(Report::new(TestError::RuntimeNotReady).attach(format!( + "Axum at {base_url} did not return HTTP 200 /health body ok within 15s" + ))) } -/// Process handle for a running Axum dev-server instance. -/// -/// Implements [`Drop`] to ensure the process is killed on test cleanup. struct AxumHandle { child: Child, + _workspace: tempfile::TempDir, } impl RuntimeProcessHandle for AxumHandle {} @@ -152,6 +151,13 @@ impl RuntimeProcessHandle for AxumHandle {} impl Drop for AxumHandle { fn drop(&mut self) { let _ = self.child.kill(); - let _ = self.child.wait(); + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline { + if matches!(self.child.try_wait(), Ok(Some(_))) { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + log::error!("Axum child did not exit within bounded kill deadline"); } } diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index 2428d7799b..053ccb7c17 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -12,11 +12,46 @@ use std::sync::Arc; use axum::body::Body as AxumBody; use axum::http::Request as AxumRequest; -use edgezero_adapter_axum::service::EdgeZeroAxumService; + +// Route/converter harness only. Managed native lifecycle is exercised by the +// production-binary acceptance tests, not by this in-process route seam. +struct RouterHarness(edgezero_core::router::RouterService); + +impl RouterHarness { + fn new(router: edgezero_core::router::RouterService) -> Self { + Self(router) + } + + async fn ready(&mut self) -> Result<&mut Self, std::convert::Infallible> { + Ok(self) + } + + async fn call( + &mut self, + request: axum::http::Request, + ) -> Result, String> { + let request = edgezero_adapter_axum::request::into_core_request(request).await?; + let response = self + .0 + .oneshot(request) + .await + .map_err(|error| error.to_string())?; + let (parts, body) = response.into_parts(); + let bytes = body + .into_bytes_bounded(16 * 1024 * 1024) + .await + .map_err(|error| error.to_string())?; + Ok(axum::http::Response::from_parts( + parts, + AxumBody::from(bytes), + )) + } +} + use edgezero_core::http::request_builder; use edgezero_core::router::RouterService; use http::HeaderMap; -use tower::{Service as _, ServiceExt as _}; + use trusted_server_adapter_axum::app::TrustedServerApp as AxumApp; use trusted_server_adapter_cloudflare::app::TrustedServerApp as CloudflareApp; use trusted_server_adapter_spin::app::TrustedServerApp as SpinApp; @@ -72,7 +107,7 @@ fn spin_router() -> RouterService { /// Send a GET request to the Axum adapter and return (status, headers). async fn axum_get(uri: &str) -> (u16, HeaderMap) { - let mut svc = EdgeZeroAxumService::new(axum_router()); + let mut svc = RouterHarness::new(axum_router()); let req = AxumRequest::builder() .method("GET") .uri(uri) @@ -91,7 +126,7 @@ async fn axum_get(uri: &str) -> (u16, HeaderMap) { /// Send a POST request to the Axum adapter and return (status, headers, body bytes). async fn axum_post(uri: &str, body: &str) -> (u16, HeaderMap, bytes::Bytes) { use http_body_util::BodyExt as _; - let mut svc = EdgeZeroAxumService::new(axum_router()); + let mut svc = RouterHarness::new(axum_router()); let req = AxumRequest::builder() .method("POST") .uri(uri) @@ -222,7 +257,7 @@ async fn spin_post_with_headers( /// Send an authorized JSON request to the Axum adapter and return (status, headers). async fn axum_authorized_json(method: &str, uri: &str, body: &str) -> (u16, HeaderMap) { - let mut svc = EdgeZeroAxumService::new(axum_router()); + let mut svc = RouterHarness::new(axum_router()); let req = AxumRequest::builder() .method(method) .uri(uri) @@ -270,7 +305,7 @@ async fn spin_authorized_json(method: &str, uri: &str, body: &str) -> (u16, Head /// Send an OPTIONS request to the Axum adapter and return (status, headers). async fn axum_options(uri: &str) -> (u16, HeaderMap) { - let mut svc = EdgeZeroAxumService::new(axum_router()); + let mut svc = RouterHarness::new(axum_router()); let req = AxumRequest::builder() .method("OPTIONS") .uri(uri) @@ -339,7 +374,7 @@ async fn discovery_route_body_is_json_parity() { use serde_json::Value; let (axum_status, axum_body_bytes) = { - let mut svc = EdgeZeroAxumService::new(axum_router()); + let mut svc = RouterHarness::new(axum_router()); let req = AxumRequest::builder() .method("GET") .uri("/.well-known/trusted-server.json") diff --git a/docs/guide/native-rc-demo.md b/docs/guide/native-rc-demo.md new file mode 100644 index 0000000000..fe5e262fe5 --- /dev/null +++ b/docs/guide/native-rc-demo.md @@ -0,0 +1,230 @@ +# Native EdgeZero RC demo + +This is a compatibility/demo candidate, not production certification or an AWS +deployment. It starts from Trusted Server main +`6ef07ae52bec3e695ec57fe090243b1a281c314f` and pins EdgeZero to +`61bfe3b931fd97f5300287e4a91e33f32aa0fbee`. PRs #1410, #1411 and #1412, +including their #777 module/configuration/permission stack, are excluded. + +The native entry point is `trusted-server-axum` in package +`trusted-server-adapter-axum`. It runs the RC production initializer and listener, +not the former environment-backed development router. Required configuration and +secret reads finish before listening. Selected-provider failures are fatal. +Running snapshots are immutable; restart to adopt a changed configuration or +secret. Management writes, native signing and automatic key rotation are outside +this demo. A configuration requiring unsupported stores or signing is rejected. + +## Dependency graph + +All six workspace EdgeZero declarations use one Git URL and the full revision +above. `cargo metadata --locked` resolves its core, macros, adapter crates, CLI +and AWS store crate to that single source. + +```mermaid +flowchart TD + TS[TS workspace] --> Adapters[RC adapters and CLI] + TS --> Core[RC core] + Adapters --> Core + Adapters --> Stores[RC AWS store crate with default-off providers] + Core --> Macros[RC macros] +``` + +Shared identities resolve to bytes `1.12.1`, Fastly/log-fastly `0.13.1`, Spin SDK +`7.0.0`, validator `0.21.0` and Worker `0.8.5`. ESI `0.7.2` uses the dependency-only +Fastly SDK patch at `75bfbb396d4cb7792610f1287cbec61675f13f38`; remove it when +upstream releases the SDK range change. TS cryptographic algorithms, crate +majors and serialized byte contracts are unchanged. AWS SDK dependencies are +absent from the default native and all three WASM adapter graphs. + +## Prepare the existing configuration envelope + +Use a disposable project directory containing `edgezero.toml` and an ordinary +`trusted-server.toml`. Keep secrets as references in the configuration. The +tracked `trusted-server.example.toml` describes that existing schema. Do not copy +private operator configuration into build contexts or test fixtures. + +With a separately built host `ts` CLI, use its existing local export: + +```sh +ts config validate +ts config push --adapter axum --local --no-diff --yes +``` + +The default local output is +`.edgezero/local-config-trusted_server_config.json`. It is an outer key-to-string +JSON map. The `trusted_server_config` value is the existing serialized +BlobEnvelope, including its version and SHA-256, not a new configuration format. +Extract the same envelope as an operator publication input: + +```sh +node --input-type=module -e ' +import { readFileSync, writeFileSync } from "node:fs"; +const map = JSON.parse(readFileSync(".edgezero/local-config-trusted_server_config.json", "utf8")); +const text = map.trusted_server_config; +if (typeof text !== "string") throw new Error("missing envelope"); +JSON.parse(text); +writeFileSync("publication-envelope.json", text); +' +``` + +This extraction checks JSON shape only. TS startup verifies the envelope digest +and resolves active secret references. `ts config push --local` is not an AWS +publisher. Publishing the extracted document, provisioning providers, credentials, +IAM, TLS and release orchestration remain operator work outside this change. +AppConfig Agent supplies the envelope document itself, not the outer local map. +Secrets Manager supplies each whole secret value, not an arbitrary JSON field. + +## Native launch inputs + +Native declares only `trusted_server_config` and `trusted_server_secrets`. The +portable manifest retains `trusted_server_kv` for Fastly; native intentionally +does not prepare it. A metadata test checks both native IDs against the manifest. +The default configuration key is `trusted_server_config`. No EC graph or +DynamoDB persistence is added. + +Set these before launch: + +| Input | Meaning | +| ------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `EDGEZERO__ADAPTER__CONFIG_DIR` | Absolute directory containing `local-config-trusted_server_config.json` for the local config provider | +| `EDGEZERO__ADAPTER__DATA_DIR` | Optional writable data directory; unused without native KV | +| `EDGEZERO__ADAPTER__HOST` | Explicit listen address, `0.0.0.0` in containers | +| `EDGEZERO__ADAPTER__PORT` | Listen port, image default `8787` | +| `EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS` | RC in-flight drain grace | +| `EDGEZERO__STORE_BINDINGS_FILE` | Optional runtime-only TOML selecting providers and their mappings | +| `EDGEZERO__SECRETS__` | Local secret provider value, key uppercased | + +Legacy `TRUSTED_SERVER_CONFIG_*`, `TRUSTED_SERVER_SECRET_*` and runtime +`TRUSTED_SERVER__*` values cannot rescue missing prepared inputs. Typed +`TRUSTED_SERVER__*` overrides still belong to the CLI configuration workflow. + +The local RC secret provider reads process environment, not a mounted-secret +file API. For the local container example below, a runtime shell loads a trusted, +read-only mounted shell environment file, then `exec`s the binary as PID 1. +The file must contain only trusted shell assignments and be readable by UID 10001. This is not a general-purpose untrusted secret-file parser. Environment +secrets remain visible to Docker administrators. Remote Secrets Manager bindings +avoid this local file-to-environment bridge. + +## Build and run the image + +Build from the workspace root. Rust `1.98.1`, Node `24.12.0` and the runtime base +have registry-resolved digest pins in `Dockerfile`. `npm ci` and `cargo build +--release --locked` run inside the builder. Host `target`, `node_modules` and +`dist` are excluded. The JS build script creates and validates fresh bundles in +its private Cargo `OUT_DIR` and embeds them in the actual TS binary. + +```sh +docker build --build-arg TRUSTED_SERVER__GIT_VERSION="$(git rev-parse HEAD)" \ + -t ts-edgezero431-local:local . +# Optional, explicit server provider forwarding, default is local providers only: +docker build --build-arg TRUSTED_SERVER__GIT_VERSION="$(git rev-parse HEAD)" \ + --build-arg NATIVE_FEATURES=aws-appconfig-agent,aws-secrets-manager \ + -t ts-edgezero431-local:providers . +``` + +Only `aws-appconfig-agent` and `aws-secrets-manager` are accepted by this recipe. +CLI features do not compile server providers. There is no DynamoDB feature or EC +graph claim here. Build outputs include `/usr/local/share/ts-build-inputs.sha256` +for comparing the exact copied build inputs with later working-tree changes. +Digest-pinned bases do not make subsequent Debian security updates bit-reproducible. +Build from a clean committed checkout when supplying the existing git-version +build input. Without that optional input, an exported container tree has no git +metadata and omits `X-TS-Version` instead of inventing a version. + +Prepare an absolute `DEMO_CONFIG_DIR` containing the outer local JSON map and a +`DEMO_SECRET_FILE` containing local synthetic assignments, for example +`EDGEZERO__SECRETS__EC_KEY=synthetic-ec-passphrase-at-least-32-bytes`. Its key names +must match the references in your envelope. Also supply the publisher proxy and +configured handler passwords. Then run: + +```sh +docker run --rm --name ts-native-demo \ + --read-only --cap-drop=ALL --security-opt=no-new-privileges \ + --publish 127.0.0.1:8787:8787 \ + --mount "type=bind,src=$DEMO_CONFIG_DIR,dst=/config,readonly" \ + --mount "type=bind,src=$DEMO_SECRET_FILE,dst=/run/secrets/local.env,readonly" \ + --tmpfs /data:rw,noexec,nosuid,size=16m,uid=10001,gid=10001,mode=0700 \ + --tmpfs /tmp:rw,noexec,nosuid,size=16m,uid=10001,gid=10001,mode=0700 \ + --env EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS=3 \ + --entrypoint /bin/sh ts-edgezero431-local:local \ + -ec 'set -a; . /run/secrets/local.env; exec /usr/local/bin/app' +``` + +The image runs as numeric `10001:10001`, includes CA roots, and contains no Node +or Rust toolchain. Read-only root is a launch constraint, not something an image +can force by itself. `/config` and any provider bindings or credentials must be +runtime-only mounts. `/data` and `/tmp` are explicit bounded writable mounts. +The synthetic smoke uses an isolated Docker network and a pinned Node origin +fixture container. Node exists only in that fixture and the builder, never the +TS runtime image. Ordinary deployments must supply their own permitted origin +and network policy. + +## Repeatable acceptance + +```sh +mkdir -p "$HOME/.cache/ts431-container-tmp" +TMPDIR="$HOME/.cache/ts431-container-tmp" \ + node scripts/test-native-container.mjs ts-edgezero431-local:local +``` + +The smoke creates synthetic fixtures only, starts a local HTML origin container +and uses randomly named owned containers, an isolated network and an ephemeral +published port. It checks +invalid-envelope and missing-secret failure before listen, health readiness, +page rewriting and the injected embedded TSJS URL, non-loopback container +reachability, runtime hardening and an in-flight SIGTERM drain with bounded normal +exit. It removes only its own containers, network and scratch directory, not +images, shared build caches or unrelated temporary files. It needs Docker, host +Node and published-port connectivity. Failed checks are failures, not skipped +acceptance. + +Independent provider acceptance uses a separately built absolute-path native +binary with both AWS features enabled: + +```sh +ABS_BINARY=/absolute/path/to/trusted-server-axum +node scripts/test-native-providers.mjs "$ABS_BINARY" +``` + +That script uses synthetic isolated AppConfig Agent and Secrets Manager fixtures, +not AWS accounts. Provider acceptance and native Next.js transport tests are +separate from the container smoke. The container smoke does not certify cloud +providers, auction outcomes, TLS, both architectures or production capacity. +The local image build and smoke were exercised on `linux/amd64` with Docker +Engine `29.7.2`. The `linux/arm64` image and optional AWS-feature image build +remain unverified here. + +## Metadata and adapter limits + +```mermaid +flowchart TD + A[Runtime config and selected stores] --> B[Finite startup reads] + B --> C[Verify envelope and resolve active secrets] + C --> D[Validate native snapshot] + D --> E[RC listener and request admission] + E --> F[Request-local checked ingress metadata] + F --> G[TS routes and RC outbound client] + G --> H[RC response completion and signal drain] +``` + +Native client IP and public URL metadata come from checked RC +`NativeIngressMetadata`. RequestInfo, URI and Host are normalized together from +those checked facts, including absolute-form request targets. Raw Forwarded-family +headers do not grant trust. Only +explicitly configured trusted proxies may supply forwarding metadata. Reader geo +is unavailable on native. An AWS region is not reader geography, and consent +remains fail-closed. This does not reproduce Fastly geo behavior. + +Keep `RUST_LOG` at `info` or quieter for ordinary runs. AWS SDK dependencies can +expose credential-source metadata, ARNs or bodies at DEBUG/TRACE, and AppConfig +Agent diagnostics can include the configured request URL. TS does not suppress +these dependency events. Do not enable verbose dependency logging around +operator credentials or sensitive configuration. + +Fastly retains the raw SDK `/health`, `/_ts/debug/ja4` and feature-gated sandbox +metrics probe exceptions. They remain outside RC admission/egress accounting and +do not build the application. Native container evidence does not establish their +behavior. Spin migrates to exact SDK `7.0.0`, `wasm32-wasip2`, P3 `wasi:http` +`0.3.0` and Spin `>=4.1.0`; its component/host acceptance is independent of this +native image. No registry publication, cloud deployment or release automation is +part of this guide. diff --git a/docs/superpowers/specs/2026-10-20-edgezero-431-compatibility-design.md b/docs/superpowers/specs/2026-10-20-edgezero-431-compatibility-design.md new file mode 100644 index 0000000000..1f41daf08a --- /dev/null +++ b/docs/superpowers/specs/2026-10-20-edgezero-431-compatibility-design.md @@ -0,0 +1,228 @@ +# EdgeZero RC431 compatibility and native demo + +## Contract + +Start from Trusted Server main `6ef07ae52bec3e695ec57fe090243b1a281c314f`. +Use `https://github.com/IABTechLab/edgezero.git` at +`61bfe3b931fd97f5300287e4a91e33f32aa0fbee` for all six workspace declarations. +This is a temporary integration candidate, not production certification or an +implementation of the broader production-support epic #1236. + +The approved handoff supplies the requirements and implementation authority. +PRs #1410, #1411, #1412 and their #777 module/configuration/permission stack are +excluded. No code or architecture from those branches is a base for this work. +Existing page rewriting, TSJS, consent and auction behavior remains the product. + +## Choices + +A dependency-only update leaves native startup and outbound traffic outside the +new framework guarantees. A wholesale module/configuration rewrite exceeds the +approved scope. Use narrow compatibility adapters instead, with strict native +initialization and immutable prepared-store snapshots. Remove the synchronous +snapshot bridge when TS adopts asynchronous config/secret read traits. + +Keep cryptographic algorithms and serialized bytes unchanged. Align only the +shared SDK and trait identities required by RC431, including validator, Fastly +and Spin. Pin Rust to the RC toolchain. Review the resolved graph and retain one +EdgeZero Git source. An ESI dependency patch may be needed for Fastly SDK identity; +use the existing reviewed dependency-only patch from #1123, not its EdgeZero tag. + +## Native startup and state + +Production routes must not load environment-backed TS settings or capture a +startup-error state. Define the route graph independently of initialization. +Resolve typed application state from the request for handlers and middleware. +Native preflight also requires an absolute HTTP/HTTPS publisher origin URI. +Keep explicit-settings/services builders as test seams. + +```mermaid +flowchart TD + A[Build app and routes] --> B[Prepare declared stores] + B --> C[Read bounded config envelope] + C --> D[Verify envelope and remove inactive secret references] + D --> E[Await required secrets and validate runtime settings] + E --> F[Compile auction and integration plans] + F --> G[Insert immutable typed state] + G --> H[Bind listener and become ready] + B --> X[Startup error before listening] + C --> X + D --> X + E --> X + F --> X +``` + +Use `run_production_app_with_initializer`, retaining the framework App, platform, +clock and policies. Only declared logical IDs may be read. Metadata and manifest +must agree, enforced by a test. A selected provider error is fatal; no legacy +`TRUSTED_SERVER_*` fallback. Local config uses a key-to-string map containing the +existing TS BlobEnvelope. AppConfig supplies the envelope document itself. + +Await finite required inputs at startup and preserve TS's envelope verification, +inactive-reference cleanup and resolved-runtime validation. Generic deploy +validation is not a substitute. Synchronous request consumers receive immutable +read-only maps. No arbitrary provider `block_on` is allowed in handlers. Missing +snapshot values are errors, and management writes remain unsupported. + +The minimum demo omits signing and mutable integration-specific external stores. +If a selected configuration requires signing or another undeclared store, reject +it before listening with an actionable error. Supporting signing requires a +separate bounded addition declaring `jwks_store` and `signing_keys`, validating +all KIDs and matching public/private material before ready. Do not silently +serve incomplete signing or automatic rotation. + +Reuse dependency handles, but derive client metadata per request. Do not install +one startup RuntimeServices value with a fixed client IP in production. Native +readiness follows successful initialization and framework listener readiness; +liveness does not imply valid application configuration. Use RC signal/drain +ownership and prove bounded SIGTERM behavior. + +## Bodies, request heads and adapters + +Consume owned bodies through exhaustive `BodyContent`; preserve managed inbound +limits and provenance during reconstruction. External-error streams use +`from_external_stream`. Chunk polling must remain cancellation-safe. Propagate +fallible body/request extraction. Head-only sanitation/auth helpers must work +without extracting or replacing managed bodies. Existing buffered JSON API +handlers receive bounded context-cached bodies before reconstruction, under +limits exported by their core handlers. Publisher/proxy streaming bodies remain +lazy. Raw streams cannot silently become empty auction or batch-sync input. + +Axum receives the full strict startup migration. Fastly, Cloudflare and Spin +must use the same RC and preserve current behavior, subject to the explicit +Spin host-version migration below. An upstream API gap or a required wider +redesign is a blocker to report, not permission to weaken the contract. + +Fastly retains the SDK serving loop and sandbox retirement policy. Replace its +routed custom dispatcher and writer with +`send_request_with_registries_and_hooks`. Preparation sanitizes trust headers +and inserts checked client/TLS/device metadata without reading the body. The +borrowed-response finalizer preserves TS's header, EC, asset-policy, terminal +privacy and sandbox-counter order. It returns only owned pull-sync inputs. +The RC owns actual transmission and completion. Never send another response +after it returns an error. Pull sync requires an observed Completed Application +egress report, not merely `Ok(Some(state))`, which can also mean a fallback was +sent. A bounded retained-app observer may record that terminal fact because the +SDK sandbox callback exclusively borrows the sandbox and runs serially; reset +it before each dispatch and retain no body or native resource. Detached refusal, +fallback and delivery failure must not run pull sync. + +The existing `/health`, `/_ts/debug/ja4` and feature-gated sandbox metrics probes, +including their early errors, remain raw SDK exceptions outside RC admission +and egress accounting to preserve app-build-free probes. App construction must +install FASTLY_PLATFORM before fallible configure, propagate errors, and reject +replacement of that metadata. + +Spin selects exact SDK 7.0.0, Rust `wasm32-wasip2`, the P3 `wasi:http` 0.3.0 +interface and Spin >=4.1.0. Fastly stays on wasip1. Update Spin aliases, component +artifact paths, manifests, CI and operator docs together. Return the concrete RC +SpinResponse so the framework's response ownership is visible. Inspect the +emitted component interface and run it under the selected Spin host; compile +and native tests do not establish host compatibility. + +Spin SDK7 config/variable reads are asynchronous. Do not keep provider block_on +in routes or handlers. Use invocation-local async bootstrap with bounded finite +reads and immutable snapshots for synchronous TS consumers. Preserve the +existing encoded store/key variable naming, not the RC flat lowercase secret +convention. An initial pre-dispatch bootstrap may fail without routed admission; +name and test that exception rather than implying it has the RC request-body +lifecycle. Preserve currently supported signing/JWKS reads when configured on +Spin, independently of the native demo's signing exclusion. No global cache of +live SDK resources or pending provider work is allowed. + +## Native HTTP and ingress + +Bridge TS's native HTTP abstraction to the request-scoped RC client. Retain TS +allowlists and per-hop redirect policy, POST replay, duplicate Set-Cookie, +bodyless responses, codec ownership and caller byte limits. RC batch indices, +cutoffs and driver failure need explicit TS pending/select semantics. One layer +owns cancellation and one owns transmission completion; returning a handler is +not delivery completion. Use the app clock for deadlines. + +Before accepting pooled transport, reproduce truncated POST followed by the +Next.js server-action/API sequence. Test real native requests for redirects, +disallowed origins, response limits, timeout, disconnect and streaming cleanup. + +Use checked `NativeIngressMetadata` for client IP and public host/scheme. +Normalize neutral RequestInfo, URI and Host together so URI-based consumers +cannot prefer a spoofed absolute-form scheme over checked ingress facts. Do not +trust raw Forwarded families, which the runner strips. Direct spoofing must not +change public URL or cookie behavior. Explicitly trusted proxies may supply that +metadata. Test two different clients for request-local isolation. Reader geo +remains unavailable on native; AWS region does not change consent, which stays +fail-closed. + +## Providers and publication input + +Add default-off native server features forwarding `aws-appconfig-agent`, +`aws-secrets-manager`, and independent `aws-dynamodb` only when KV is wired. +CLI features do not compile server providers. Keep AWS dependencies out of WASM +builds. Use recognized manifest build/run shapes or explicit Docker features. + +Exercise real TS envelopes and secret references with local stores, then +account-free independent provider fixtures using synthetic credentials and +isolated state. Reject selected-provider failures without fallback. Running +snapshots do not change until restart. Secrets Manager returns whole values, +not selected JSON fields. Existing `ts config push` exports a local envelope; +it is not an AWS publisher. Document extraction of that same envelope as the +operator publication input, not a new format. + +If generic KV is included, verify read/write, TTL, pagination and persistence. +Do not claim DynamoDB ordinary KV implements an EC graph, CAS, generations or +exactly-once semantics. + +## Container and rollout + +After native acceptance, build fresh embedded JS bundles with pinned Node and +locked dependencies, then a locked Rust release binary for the actual TS bin. +Use the RC hardened runtime recipe with CA roots, numeric non-root identity, +read-only root and explicit runtime-only configuration and writable mounts. +No Rust, Node, credentials or operator configuration belongs in the runtime. +Validate non-loopback reachability, invalid-input failure before listen and +in-flight bounded SIGTERM. Record tested architecture. + +This changes native launch configuration from the old development environment +bridge to prepared stores. Update launchers and smoke expectations explicitly; +accepting any HTTP response as readiness would hide initialization failures. +Keep cloud provisioning, AWS deployment, registry publication, release automation, +new identity endpoints, module frameworks, cookie issuance, alternate asset +routing and live vendor integrations outside this PR. + +## Proof and delivery + +Maintain an exact-command ledger with tested revision/input fingerprint, outcome +and log. First reach a core compiler checkpoint, then native API compilation and +focused body/proxy/auth tests. Prove fail-before-listen and real TS routes using +subprocesses with poisoned legacy environment values. Add provider and transport +regressions at their behavioral checkpoints. + +At integration checkpoints run applicable repository gates in AGENTS.md, +including all target checks/lints/tests, parity, JS, build-digest, docs and static +checks. Unavailable tooling and pre-existing failures remain named coverage gaps. +Framework RC evidence is not TS acceptance evidence. + +Fastly proof includes preparation/finalization order, duplicate Set-Cookie, +stream success/failure and exactly one terminal completion, detached refusal, +late privacy effects, post-send gating, failed-build retry, probe laziness and +default/reusable sandbox modes. Spin proof includes the emitted component +interface, real config envelope and encoded variables, missing required secret, +pending read/deadline behavior, configured signing reads and two isolated +invocations. Unavailable compatible runtime tooling remains a named gap. + +Review the complete diff, commit only this task's files, push +`rc/edgezero-431-demo`, and open a draft PR against main. Include exact SHAs, +excluded scope, observed outcomes and unresolved checks. Use Refs rather than +closing broad production issues. Leave reviewers and auto-merge unset. + +## Review log + +- Round 1, independent Pi agent using openai-codex/gpt-6.1-sol. Found ambiguous + Fastly transmission ownership and fallback/post-send behavior, and missing + Spin target/host/async-read contracts. Spec now selects closed RC transmission, + Completed Application gating, named raw-probe exceptions, and the SDK7 + wasip2/P3/Spin 4.1 migration with invocation-local async bootstrap. No upstream + API defect was established. Source/path review only, not runtime evidence. +- Round 2, fresh independent Pi agent using openai-codex/gpt-6.1-sol. Verified + the revised Fastly observer gate, serial callback assumption, probe exceptions, + and Spin SDK7 target/async bootstrap contracts against the exact RC and SDK + sources. No remaining design blocker or demonstrated upstream API gap. Runtime + and implementation acceptance remain separate required checks. diff --git a/edgezero.toml b/edgezero.toml index 64e6e0f1f0..172324b64c 100644 --- a/edgezero.toml +++ b/edgezero.toml @@ -92,11 +92,11 @@ manifest = "crates/trusted-server-adapter-spin/spin.toml" component = "trusted-server" [adapters.spin.build] -target = "wasm32-wasip1" +target = "wasm32-wasip2" profile = "release" features = ["spin"] [adapters.spin.commands] -build = "cargo build --package trusted-server-adapter-spin --target wasm32-wasip1 --features spin --release" +build = "cargo build --package trusted-server-adapter-spin --target wasm32-wasip2 --features spin --release" serve = "spin up --from crates/trusted-server-adapter-spin" deploy = "spin deploy --from crates/trusted-server-adapter-spin" diff --git a/rust-toolchain.toml b/rust-toolchain.toml index e72500aa19..2af1a179c2 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.95.0" -targets = [ "wasm32-wasip1", "wasm32-unknown-unknown" ] +channel = "1.98.1" +targets = [ "wasm32-wasip1", "wasm32-wasip2", "wasm32-unknown-unknown" ] diff --git a/scripts/smoke-axum.sh b/scripts/smoke-axum.sh index 3632dc3ff7..0585c4d715 100755 --- a/scripts/smoke-axum.sh +++ b/scripts/smoke-axum.sh @@ -19,8 +19,31 @@ BASE_PORT=${AXUM_SMOKE_PORT:-18890} ORIGIN_PID="" APP_PID="" +stop_app() { + [ -n "$APP_PID" ] || return 0 + kill -TERM "$APP_PID" 2>/dev/null || true + local attempt=0 + while kill -0 "$APP_PID" 2>/dev/null && [ "$attempt" -lt 30 ]; do + sleep 0.1 + attempt=$((attempt + 1)) + done + kill -KILL "$APP_PID" 2>/dev/null || true + # Do not enter an unbounded wait if even SIGKILL has not completed. + attempt=0 + while kill -0 "$APP_PID" 2>/dev/null && [ "$attempt" -lt 20 ]; do + sleep 0.1 + attempt=$((attempt + 1)) + done + if ! kill -0 "$APP_PID" 2>/dev/null; then + wait "$APP_PID" 2>/dev/null || true + else + echo "smoke: Axum did not exit within the teardown deadline" >&2 + fi + APP_PID="" +} + cleanup() { - smoke_stop_process "$APP_PID" + stop_app smoke_stop_process "$ORIGIN_PID" smoke_remove_workspace "$WORKSPACE" } @@ -61,55 +84,97 @@ run_case() { local handler_value="$4" local proxy_value="$5" local ec_value="$6" - local expected_status="$7" + local expected_outcome="$7" local expected_diagnostic="${8:-}" local log_path="$WORKSPACE/$case_name.log" local body_path="$WORKSPACE/$case_name.body" local headers_path="$WORKSPACE/$case_name.headers" + local config_dir="$WORKSPACE/$case_name-config" + local data_dir="$WORKSPACE/$case_name-data" + mkdir -p "$config_dir" "$data_dir" + config_dir=$(cd "$config_dir" && pwd -P) + data_dir=$(cd "$data_dir" && pwd -P) + # Local config is an outer key-to-string map, not the envelope document. + jq --null-input --arg envelope "$config_value" \ + 'if $envelope == "" then {} else {trusted_server_config: $envelope} end' \ + >"$config_dir/local-config-trusted_server_config.json" local env_args=( - env - -u "$SMOKE_CONFIG_ENV" - -u "$SMOKE_HANDLER_ENV" - -u "$SMOKE_PROXY_ENV" - -u "$SMOKE_EC_ENV" + env -i + "PATH=$PATH" + "RUST_LOG=info" + "EDGEZERO__ADAPTER__HOST=127.0.0.1" + "EDGEZERO__ADAPTER__PORT=$port" + "EDGEZERO__ADAPTER__CONFIG_DIR=$config_dir" + "EDGEZERO__ADAPTER__DATA_DIR=$data_dir" + "EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS=1" + # Valid legacy values must never rescue a missing prepared input. + "$SMOKE_CONFIG_ENV=$ENVELOPE" + "$SMOKE_HANDLER_ENV=$SMOKE_HANDLER_VALUE" + "$SMOKE_PROXY_ENV=$SMOKE_PROXY_VALUE" + "$SMOKE_EC_ENV=$SMOKE_EC_VALUE" + "TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=http://poison.example.com" ) - [ -z "$config_value" ] || env_args+=("$SMOKE_CONFIG_ENV=$config_value") - [ -z "$handler_value" ] || env_args+=("$SMOKE_HANDLER_ENV=$handler_value") - [ -z "$proxy_value" ] || env_args+=("$SMOKE_PROXY_ENV=$proxy_value") - [ -z "$ec_value" ] || env_args+=("$SMOKE_EC_ENV=$ec_value") + [ -z "$handler_value" ] || env_args+=("EDGEZERO__SECRETS__HANDLER_PASSWORD=$handler_value") + [ -z "$proxy_value" ] || env_args+=("EDGEZERO__SECRETS__PUBLISHER_PROXY_SECRET=$proxy_value") + [ -z "$ec_value" ] || env_args+=("EDGEZERO__SECRETS__EC_PASSPHRASE=$ec_value") smoke_assert_process_alive "$ORIGIN_PID" "stub origin" "$WORKSPACE/origin.log" - "${env_args[@]}" \ - PORT="$port" \ - "$AXUM_BINARY" >"$log_path" 2>&1 & + "${env_args[@]}" "$AXUM_BINARY" >"$log_path" 2>&1 & APP_PID=$! - smoke_wait_http "http://127.0.0.1:$port/" "$expected_status" \ - "$body_path" "$headers_path" - smoke_assert_process_alive "$APP_PID" "Axum" "$log_path" - smoke_assert_process_alive "$ORIGIN_PID" "stub origin" "$WORKSPACE/origin.log" - smoke_stop_process "$APP_PID" - APP_PID="" - - if [ -n "$expected_diagnostic" ]; then - smoke_assert_failure "$expected_status" "$expected_diagnostic" "$log_path" + local attempt=0 + local status="000" + if [ "$expected_outcome" = "exit" ]; then + while kill -0 "$APP_PID" 2>/dev/null && [ "$attempt" -lt 100 ]; do + status=$(curl --silent --max-time 0.2 --output /dev/null \ + --write-out '%{http_code}' "http://127.0.0.1:$port/health" || true) + [ "$status" = "000" ] || smoke_die "$case_name listened before rejecting startup" + attempt=$((attempt + 1)) + sleep 0.05 + done + if kill -0 "$APP_PID" 2>/dev/null; then + smoke_die "$case_name did not fail within the startup deadline" + fi + if wait "$APP_PID"; then + smoke_die "$case_name exited successfully instead of rejecting startup" + fi + APP_PID="" + smoke_wait_log "$log_path" "$expected_diagnostic" else + while [ "$attempt" -lt 100 ]; do + smoke_assert_process_alive "$APP_PID" "Axum" "$log_path" + status=$(curl --silent --max-time 0.2 --output "$body_path" \ + --write-out '%{http_code}' "http://127.0.0.1:$port/health" || true) + if [ "$status" = "200" ] && [ "$(<"$body_path")" = "ok" ] && \ + [ "$(wc -c <"$body_path")" -eq 2 ]; then + break + fi + attempt=$((attempt + 1)) + sleep 0.1 + done + [ "$attempt" -lt 100 ] || smoke_die "Axum health did not become exactly 200/ok" + SMOKE_HTTP_STATUS=$(curl --silent --show-error --max-time 5 \ + --output "$body_path" --dump-header "$headers_path" \ + --write-out '%{http_code}' "http://127.0.0.1:$port/") smoke_assert_success "$body_path" "$ORIGIN_PORT" "$port" + smoke_assert_process_alive "$APP_PID" "Axum" "$log_path" + stop_app fi + smoke_assert_process_alive "$ORIGIN_PID" "stub origin" "$WORKSPACE/origin.log" } run_case missing-config "$BASE_PORT" "" \ - "$SMOKE_HANDLER_VALUE" "$SMOKE_PROXY_VALUE" "$SMOKE_EC_VALUE" 500 \ - "env var '$SMOKE_CONFIG_ENV' not set" + "$SMOKE_HANDLER_VALUE" "$SMOKE_PROXY_VALUE" "$SMOKE_EC_VALUE" exit \ + "Trusted Server config envelope is missing" run_case missing-handler "$((BASE_PORT + 1))" "$ENVELOPE" \ - "" "$SMOKE_PROXY_VALUE" "$SMOKE_EC_VALUE" 500 \ - "failed to resolve secret reference at \`handlers[0].password\`" + "" "$SMOKE_PROXY_VALUE" "$SMOKE_EC_VALUE" exit \ + "a required app-config secret is missing" run_case missing-proxy "$((BASE_PORT + 2))" "$ENVELOPE" \ - "$SMOKE_HANDLER_VALUE" "" "$SMOKE_EC_VALUE" 500 \ - "failed to resolve secret reference at \`publisher.proxy_secret\`" + "$SMOKE_HANDLER_VALUE" "" "$SMOKE_EC_VALUE" exit \ + "a required app-config secret is missing" run_case missing-ec "$((BASE_PORT + 3))" "$ENVELOPE" \ - "$SMOKE_HANDLER_VALUE" "$SMOKE_PROXY_VALUE" "" 500 \ - "failed to resolve secret reference at \`ec.passphrase\`" + "$SMOKE_HANDLER_VALUE" "$SMOKE_PROXY_VALUE" "" exit \ + "a required app-config secret is missing" run_case positive "$((BASE_PORT + 4))" "$ENVELOPE" \ "$SMOKE_HANDLER_VALUE" "$SMOKE_PROXY_VALUE" "$SMOKE_EC_VALUE" 200 diff --git a/scripts/smoke-spin.sh b/scripts/smoke-spin.sh index 382f02ca83..06a4b0698c 100755 --- a/scripts/smoke-spin.sh +++ b/scripts/smoke-spin.sh @@ -28,12 +28,12 @@ cleanup() { trap cleanup EXIT INT TERM smoke_resolve_ts_binary "$REPO_ROOT" -SPIN_WASM=${SPIN_WASM_PATH:-$REPO_ROOT/target/wasm32-wasip1/release/trusted_server_adapter_spin.wasm} +SPIN_WASM=${SPIN_WASM_PATH:-$REPO_ROOT/target/wasm32-wasip2/release/trusted_server_adapter_spin.wasm} if [ ! -f "$SPIN_WASM" ]; then cargo build \ --manifest-path "$REPO_ROOT/Cargo.toml" \ --package trusted-server-adapter-spin \ - --target wasm32-wasip1 \ + --target wasm32-wasip2 \ --features spin \ --release fi @@ -46,7 +46,7 @@ smoke_initialize_config "$REPO_ROOT" "$WORKSPACE" "$ORIGIN_PORT" SPIN_WORK="$WORKSPACE/spin" mkdir -p "$SPIN_WORK" sed \ - "s|source = \"../../target/wasm32-wasip1/release/trusted_server_adapter_spin.wasm\"|source = \"$SPIN_WASM\"|" \ + "s|source = \"../../target/wasm32-wasip2/release/trusted_server_adapter_spin.wasm\"|source = \"$SPIN_WASM\"|" \ "$REPO_ROOT/crates/trusted-server-adapter-spin/spin.toml" >"$SPIN_WORK/spin.toml" sed \ -e "s|= \"crates/|= \"$REPO_ROOT/crates/|g" \ @@ -92,6 +92,20 @@ run_case() { "$body_path" "$headers_path" smoke_assert_process_alive "$APP_PID" "Spin" "$log_path" smoke_assert_process_alive "$ORIGIN_PID" "stub origin" "$WORKSPACE/origin.log" + if [ -n "$expected_diagnostic" ]; then + # Spin owns the listener. Failed invocation bootstrap is a host 500, + # never a routed TS error page or healthy application liveness. + smoke_wait_http "http://127.0.0.1:$port/health" 500 \ + "$WORKSPACE/$case_name-health.body" "$WORKSPACE/$case_name-health.headers" + if grep -i --quiet '^x-ts-version:' "$WORKSPACE/$case_name-health.headers"; then + smoke_die "failed Spin bootstrap reached routed TS finalization" + fi + else + smoke_wait_http "http://127.0.0.1:$port/health" 200 \ + "$WORKSPACE/$case_name-health.body" "$WORKSPACE/$case_name-health.headers" + [ "$(tr -d '\r\n' < "$WORKSPACE/$case_name-health.body")" = ok ] || + smoke_die "Spin application health did not return ok" + fi smoke_stop_process "$APP_PID" APP_PID="" @@ -108,14 +122,15 @@ run_case() { smoke_assert_failure \ "$expected_status" \ "$expected_diagnostic" \ - "$component_log_dir/trusted-server_stderr.txt" + "$log_path" + smoke_wait_log "$log_path" 'Spin bootstrap failed before dispatch:' else smoke_assert_success "$body_path" "$ORIGIN_PORT" "$port" fi } -run_case missing-config "$BASE_PORT" "" 503 \ - 'failed to read Spin Trusted Server app-config blob' +run_case missing-config "$BASE_PORT" "" 500 \ + 'required Spin config key is missing or disappeared during bootstrap' EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME=default \ "$SMOKE_TS_BIN" config push \ @@ -127,12 +142,12 @@ EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME=default \ --no-diff CONFIG_PUSHED=true -run_case missing-handler "$((BASE_PORT + 1))" "$SPIN_HANDLER_VAR" 503 \ - "resolved secret at \`handlers[0].password\` must not be empty" -run_case missing-proxy "$((BASE_PORT + 2))" "$SPIN_PROXY_VAR" 503 \ - "resolved secret at \`publisher.proxy_secret\` must not be empty" -run_case missing-ec "$((BASE_PORT + 3))" "$SPIN_EC_VAR" 503 \ - "resolved secret at \`ec.passphrase\` must not be empty" +run_case missing-handler "$((BASE_PORT + 1))" "$SPIN_HANDLER_VAR" 500 \ + 'required Spin secret is empty or exceeds the 64 KiB limit' +run_case missing-proxy "$((BASE_PORT + 2))" "$SPIN_PROXY_VAR" 500 \ + 'required Spin secret is empty or exceeds the 64 KiB limit' +run_case missing-ec "$((BASE_PORT + 3))" "$SPIN_EC_VAR" 500 \ + 'required Spin secret is empty or exceeds the 64 KiB limit' run_case positive "$((BASE_PORT + 4))" "" 200 echo "Spin first-success smoke passed" diff --git a/scripts/test-native-container.mjs b/scripts/test-native-container.mjs new file mode 100644 index 0000000000..c484a44380 --- /dev/null +++ b/scripts/test-native-container.mjs @@ -0,0 +1,380 @@ +#!/usr/bin/env node +// Synthetic, account-free smoke. Usage: node scripts/test-native-container.mjs IMAGE +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import http from 'node:http' +import net from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { promisify } from 'node:util' + +const exec = promisify(execFile) +const image = process.argv[2] +assert(image, 'supply a built local image tag') +const work = await mkdtemp(join(tmpdir(), 'ts-container-')) +const owned = new Set() +let interrupted = false +const interrupt = () => { + interrupted = true +} +process.on('SIGINT', interrupt) +process.on('SIGTERM', interrupt) +const docker = async (...args) => + ( + await exec('docker', args, { timeout: 20000, maxBuffer: 1024 * 1024 }) + ).stdout.trim() +const check = () => assert(!interrupted, 'interrupted') +const bounded = async (operation, milliseconds, message) => { + const end = Date.now() + milliseconds + while (Date.now() < end) { + check() + if (await operation()) return + await delay(50) + } + throw new Error(message) +} +function sorted(value) { + if (Array.isArray(value)) return value.map(sorted) + if (value !== null && typeof value === 'object') + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, sorted(value[key])]) + ) + return value +} +function envelope(data) { + data = sorted(data) + return JSON.stringify({ + version: 1, + generated_at: '2026-10-20T00:00:00Z', + data, + sha256: createHash('sha256').update(JSON.stringify(data)).digest('hex'), + }) +} +async function request(endpoint, path) { + // Direct HTTP avoids inherited operator proxy settings and redirect following. + return new Promise((resolve, reject) => { + const req = http.get(`${endpoint}${path}`, { agent: false }, (res) => { + const chunks = [] + let size = 0 + res.on('data', (chunk) => { + size += chunk.length + if (size > 2 * 1024 * 1024) req.destroy(new Error('response limit')) + else chunks.push(chunk) + }) + res.on('error', reject) + res.on('end', () => + resolve({ + status: res.statusCode, + headers: new Headers( + Object.entries(res.headers).map(([key, value]) => [ + key, + String(value), + ]) + ), + body: Buffer.concat(chunks).toString('utf8'), + }) + ) + }) + const timer = setTimeout( + () => req.destroy(new Error('request deadline')), + 10000 + ) + req.on('close', () => clearTimeout(timer)) + req.on('error', reject) + }) +} +const network = `ts-container-network-${randomUUID()}` +let networkCreated = false +const originName = `ts-container-origin-${randomUUID()}` +try { + await chmod(work, 0o755) + // Register before creation so a timed-out Docker client cannot orphan the network. + networkCreated = true + await docker('network', 'create', network) + await writeFile( + join(work, 'origin.mjs'), + ` +import http from 'node:http'; +import { writeFileSync } from 'node:fs'; +http.createServer((req, res) => { + const send = () => { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end('synthetic-origincontainer-fixture'); + }; + if (req.url === '/slow') { + writeFileSync('/tmp/slow-started', 'started'); + setTimeout(send, 1200); + } else send(); +}).listen(8788, '0.0.0.0'); +` + ) + await chmod(join(work, 'origin.mjs'), 0o444) + owned.add(originName) + await docker( + 'run', + '-d', + '--name', + originName, + '--network', + network, + '--network-alias', + 'origin.publisher.example.com', + '--read-only', + '--user', + '10001:10001', + '--cap-drop=ALL', + '--security-opt=no-new-privileges', + '--tmpfs', + '/tmp:rw,noexec,nosuid,size=1m,uid=10001,gid=10001,mode=0700', + '--mount', + `type=bind,src=${work}/origin.mjs,dst=/origin.mjs,readonly`, + 'node:24.12.0-bookworm-slim@sha256:7326fb2dbdce998edd72140946851be64ef4a643e8715e138ca467e8e9d92c99', + 'node', + '/origin.mjs' + ) + const fixture = { + ec: { passphrase: 'ec_key' }, + handlers: [ + { path: '^/_ts/admin', username: 'admin', password: 'admin_key' }, + ], + publisher: { + domain: 'publisher.example.com', + cookie_domain: '.publisher.example.com', + origin_url: 'http://origin.publisher.example.com:8788', + proxy_secret: 'proxy_key', + }, + request_signing: null, + response_headers: { 'x-container-snapshot': 'synthetic' }, + } + const secrets = + 'EDGEZERO__SECRETS__EC_KEY=synthetic-container-ec-passphrase-32-bytes\nEDGEZERO__SECRETS__PROXY_KEY=synthetic-container-proxy-secret-32-bytes\nEDGEZERO__SECRETS__ADMIN_KEY=synthetic-container-admin-secret-32-bytes\n' + const launch = async (label, data, secretText) => { + check() + const root = join(work, label) + await mkdir(root, { mode: 0o755 }) + await writeFile( + join(root, 'local-config-trusted_server_config.json'), + JSON.stringify({ trusted_server_config: data }) + ) + await writeFile(join(root, 'local.env'), secretText) + // Synthetic values only. Make fixtures readable by the image's numeric UID, + // independently of the caller's umask; bind mounts remain read-only. + await chmod(root, 0o755) + await chmod(join(root, 'local-config-trusted_server_config.json'), 0o444) + await chmod(join(root, 'local.env'), 0o444) + const reservation = net.createServer() + await new Promise((resolve, reject) => { + reservation.once('error', reject) + reservation.listen(0, '127.0.0.1', resolve) + }) + const hostPort = reservation.address().port + await new Promise((resolve) => reservation.close(resolve)) + const name = `ts-container-${randomUUID()}` + // Register before launch so even an interrupted/failed Docker client is cleaned up. + owned.add(name) + await docker( + 'run', + '-d', + '--name', + name, + '--read-only', + '--cap-drop=ALL', + '--security-opt=no-new-privileges', + '--network', + network, + '--publish', + `127.0.0.1:${hostPort}:8787`, + '--mount', + `type=bind,src=${root},dst=/config,readonly`, + '--mount', + `type=bind,src=${root}/local.env,dst=/run/secrets/local.env,readonly`, + '--tmpfs', + '/data:rw,noexec,nosuid,size=16m,uid=10001,gid=10001,mode=0700', + '--tmpfs', + '/tmp:rw,noexec,nosuid,size=16m,uid=10001,gid=10001,mode=0700', + '--env', + 'EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS=3', + '--env', + 'TRUSTED_SERVER__EC__PASSPHRASE=poison', + '--env', + 'TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://poison.example.com', + '--entrypoint', + '/bin/sh', + image, + '-ec', + 'set -a; . /run/secrets/local.env; exec /usr/local/bin/app' + ) + // Fast startup rejection can remove Docker's active port mapping before inspect. + return { name, endpoint: `http://127.0.0.1:${hostPort}` } + } + const state = async (name) => + JSON.parse(await docker('inspect', '--format={{json .State}}', name)) + for (const [label, data, secretText] of [ + ['invalid-envelope', 'invalid', secrets], + ['missing-secret', envelope(fixture), ''], + ]) { + const app = await launch(label, data, secretText) + await bounded( + async () => { + // A response of any status is a failure, not readiness. + let response + try { + response = await request(app.endpoint, '/health') + } catch { + /* Connection refusal is expected. */ + } + assert(!response, `${label} must not listen`) + const current = await state(app.name) + if (current.Running) return false + assert.notEqual(current.ExitCode, 0) + assert.equal(current.OOMKilled, false) + return true + }, + 15000, + `${label} startup rejection deadline` + ) + const logs = await docker('logs', app.name) + assert.match( + logs, + /Trusted Server initialization failed/, + 'must reject application inputs, not fail the launcher' + ) + assert.doesNotMatch( + logs, + /phase=ready/, + 'invalid inputs must never reach readiness' + ) + console.log(`PASS ${label} fails before listen`) + console.log(logs) + } + const app = await launch('valid', envelope(fixture), secrets) + await bounded( + async () => { + assert((await state(app.name)).Running, 'valid startup exited') + let result + try { + result = await request(app.endpoint, '/health') + } catch { + return false + } + assert.equal(result.status, 200) + assert.equal(result.body, 'ok') + assert.equal(result.headers.get('x-container-snapshot'), 'synthetic') + return true + }, + 15000, + 'valid readiness deadline' + ) + console.log('PASS published port reaches non-loopback container listener') + const page = await request(app.endpoint, '/') + assert.equal(page.status, 200) + assert.match(page.body, /container-fixture/) + const jsPath = page.body.match(/\/static\/tsjs=[^"'\s<>]+/)?.[0] + assert(jsPath, 'page must inject actual TSJS URL') + const js = await request(app.endpoint, jsPath) + assert.equal(js.status, 200) + assert(js.body.length > 100, 'embedded TSJS must not be empty') + assert.equal(js.headers.get('x-geo-info-available'), 'false') + console.log('PASS ordinary TS envelope, page rewrite and embedded TSJS') + await docker( + 'exec', + app.name, + 'sh', + '-ec', + 'test $(id -u) = 10001; test -s /etc/ssl/certs/ca-certificates.crt; ! command -v node; ! command -v rustc; ! touch /app/read-only-proof; touch /data/write-proof /tmp/write-proof' + ) + console.log( + 'PASS numeric non-root, CA roots, no Node/Rust, read-only root and explicit writable mounts' + ) + const inFlight = request(app.endpoint, '/slow') + // Attach immediately to avoid an unhandled rejection during signal testing. + const outcome = inFlight.then( + (value) => ({ value }), + (error) => ({ error }) + ) + await bounded( + async () => { + try { + await docker('exec', originName, 'test', '-f', '/tmp/slow-started') + return true + } catch { + return false + } + }, + 5000, + 'in-flight origin deadline' + ) + const started = Date.now() + await docker('kill', '--signal=TERM', app.name) + await bounded( + async () => !(await state(app.name)).Running, + 7000, + 'SIGTERM exit deadline' + ) + const final = await state(app.name) + assert.equal(final.ExitCode, 0, 'RC signal hooks should exit normally') + const completed = await outcome + assert(!completed.error, 'in-flight request must complete during drain') + assert.equal(completed.value.status, 200) + assert.match(completed.value.body, /container-fixture/) + console.log( + `PASS in-flight SIGTERM drains and exits normally within ${Date.now() - started}ms` + ) + const runtimeLogs = await docker('logs', app.name) + assert.match(runtimeLogs, /phase=draining reason=drain_requested/) + assert.match(runtimeLogs, /phase=stopped reason=stopped/) + assert( + runtimeLogs.indexOf('phase=draining') < + runtimeLogs.lastIndexOf('request_terminal'), + 'the in-flight request must finish after RC starts draining' + ) + console.log(runtimeLogs) + console.log( + await docker( + 'image', + 'inspect', + '--format={{.Id}} {{.Os}}/{{.Architecture}} user={{.Config.User}}', + image + ) + ) +} catch (error) { + for (const name of owned) { + try { + console.error( + `Logs for owned fixture ${name}:\n${await docker('logs', name)}` + ) + } catch { + /* Launch may have failed before container creation. */ + } + } + throw error +} finally { + for (const name of [...owned].reverse()) { + try { + await docker('rm', '-f', name) + } catch (error) { + console.error( + `cleanup failed for owned container ${name}: ${error.message}` + ) + process.exitCode = 1 + } + } + if (networkCreated) { + try { + await docker('network', 'rm', network) + } catch (error) { + console.error( + `cleanup failed for owned network ${network}: ${error.message}` + ) + process.exitCode = 1 + } + } + await rm(work, { recursive: true, force: true }) + process.off('SIGINT', interrupt) + process.off('SIGTERM', interrupt) +} diff --git a/scripts/test-native-providers.mjs b/scripts/test-native-providers.mjs new file mode 100644 index 0000000000..afdf378cf3 --- /dev/null +++ b/scripts/test-native-providers.mjs @@ -0,0 +1,638 @@ +#!/usr/bin/env node +// Usage: node scripts/test-native-providers.mjs /absolute/path/to/trusted-server-axum +// Requires a prebuilt production binary with aws-appconfig-agent,aws-secrets-manager. +// No builds, external AWS, DynamoDB, or CLI-export acceptance are performed here. +// The ASCII fixture uses the existing BlobEnvelope v1 sorted compact JSON hash. +// CLI export/publication input acceptance remains a separate check. +import { spawn, execFile } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import { access, mkdtemp, mkdir, writeFile, rename, rm } from 'node:fs/promises' +import http from 'node:http' +import net from 'node:net' +import { tmpdir } from 'node:os' +import { isAbsolute, join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' + +const exec = promisify(execFile) +export const images = { + moto: 'ghcr.io/getmoto/motoserver@sha256:d8ae5edc2bf080e7e4c13f9bd4b29b53ac3b4427e92956318db3dbe23ec43eb7', + agent: + 'public.ecr.aws/aws-appconfig/aws-appconfig-agent@sha256:cd604064652c56d9d6dc8435aa0d4bf3e90aa348830fa3b8a7b8650e3500ec3e', +} +const configId = 'trusted_server_config' +const secretId = 'trusted_server_secrets' +const token = 'synthetic-agent-token' +const keys = ['ec_key', 'proxy_key', 'admin_key'] +const secretValues = (phase) => ({ + ec_key: `synthetic-ec-passphrase-32-bytes-minimum-${phase}`, + proxy_key: `synthetic-proxy-secret-32-bytes-minimum-${phase}`, + // A whole JSON document, including whitespace, must be the password. + admin_key: ` { "password": "synthetic-admin-${phase}" }\n`, +}) +// Only these application-owned assertions may reach diagnostics. Never print +// arbitrary subprocess, environment, SDK or HTTP error messages. +class FixtureAssertion extends Error {} +function requireCheck(condition, stage) { + if (!condition) throw new FixtureAssertion(stage) +} +export function publishedEndpoint(address) { + const match = /^127\.0\.0\.1:([0-9]+)$/.exec(address.trim()) + requireCheck( + match && Number(match[1]) > 0 && Number(match[1]) <= 65535, + 'invalid published port' + ) + return `http://127.0.0.1:${Number(match[1])}` +} +export function sorted(value) { + if (Array.isArray(value)) return value.map(sorted) + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, sorted(value[key])]) + ) + } + return value +} +export function envelope(data) { + const canonical = sorted(data) + return JSON.stringify({ + data: canonical, + sha256: createHash('sha256') + .update(JSON.stringify(canonical)) + .digest('hex'), + version: 1, + generated_at: '2026-10-20T00:00:00Z', + }) +} +export function fixtureEnvelope(phase) { + return envelope({ + ec: { passphrase: 'ec_key' }, + handlers: [ + { path: '^/_ts/admin', username: 'admin', password: 'admin_key' }, + { path: '^/health$', username: 'admin', password: 'admin_key' }, + ], + publisher: { + domain: 'publisher.example.com', + cookie_domain: '.publisher.example.com', + origin_url: 'https://origin.publisher.example.com', + proxy_secret: 'proxy_key', + }, + request_signing: null, + response_headers: { 'x-provider-snapshot': phase }, + }) +} +export function isolatedEnvironment(work, port, endpoints, bindings, phase) { + const env = { + HOME: work, + AWS_CONFIG_FILE: join(work, 'aws-config'), + AWS_SHARED_CREDENTIALS_FILE: join(work, 'aws-credentials'), + AWS_EC2_METADATA_DISABLED: 'true', + AWS_ACCESS_KEY_ID: 'DUMMYLOCALACCESS123', + AWS_SECRET_ACCESS_KEY: 'DUMMYLOCALSECRET456', + AWS_REGION: 'us-east-1', + AWS_DEFAULT_REGION: 'us-east-1', + AWS_ENDPOINT_URL: endpoints.moto, + AWS_ENDPOINT_URL_SECRETS_MANAGER: endpoints.moto, + AWS_IGNORE_CONFIGURED_ENDPOINT_URLS: 'false', + APPCONFIG_AGENT_TOKEN: token, + EDGEZERO__STORE_BINDINGS_FILE: bindings, + EDGEZERO__ADAPTER__HOST: '127.0.0.1', + EDGEZERO__ADAPTER__PORT: String(port), + EDGEZERO__ADAPTER__CONFIG_DIR: work, + EDGEZERO__ADAPTER__DATA_DIR: work, + EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS: '1', + EDGEZERO__LOGGING__LEVEL: 'off', + RUST_LOG: 'off', + TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG: + fixtureEnvelope(phase), + TRUSTED_SERVER__EC__PASSPHRASE: 'poison', + TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: 'https://poison.example.com', + } + for (const [key, value] of Object.entries(secretValues(phase))) { + // Valid local and legacy values deliberately cannot rescue selected providers. + env[`EDGEZERO__SECRETS__${key.toUpperCase()}`] = value + env[`TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_${key.toUpperCase()}`] = + value + } + return env +} + +// Direct HTTP only: no environment proxy lookup, redirects, or credential lookup. +async function request( + endpoint, + path, + { body, headers = {}, method = 'GET' } = {} +) { + return new Promise((resolve, reject) => { + const req = http.request( + `${endpoint}${path}`, + { method, headers }, + (res) => { + const chunks = [] + let size = 0 + res.on('data', (chunk) => { + size += chunk.length + if (size > 1024 * 1024) req.destroy(new Error('response limit')) + else chunks.push(chunk) + }) + res.on('error', () => reject(new Error('response read'))) + res.on('end', () => + resolve({ + status: res.statusCode, + headers: res.headers, + body: Buffer.concat(chunks).toString('utf8'), + }) + ) + } + ) + const timer = setTimeout( + () => req.destroy(new Error('request deadline')), + 2500 + ) + req.on('close', () => clearTimeout(timer)) + req.on('error', () => reject(new Error('local request'))) + req.end(body) + }) +} +async function secrets(endpoint, operation, payload = {}) { + const body = JSON.stringify(payload) + const date = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '') + const response = await request(endpoint, '/', { + method: 'POST', + body, + headers: { + 'Content-Type': 'application/x-amz-json-1.1', + 'Content-Length': Buffer.byteLength(body), + 'X-Amz-Target': `secretsmanager.${operation}`, + 'X-Amz-Date': date, + Authorization: `AWS4-HMAC-SHA256 Credential=DUMMYLOCAL/${date.slice(0, 8)}/us-east-1/secretsmanager/aws4_request, SignedHeaders=host;x-amz-date, Signature=${'0'.repeat(64)}`, + }, + }) + requireCheck(response.status === 200, 'fixture administration') + try { + return JSON.parse(response.body) + } catch { + throw new Error('fixture administration response') + } +} +async function freePort() { + const server = net.createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const port = server.address().port + await new Promise((resolve) => server.close(resolve)) + return port +} +async function listening(port) { + return new Promise((resolve) => { + const socket = net.connect({ host: '127.0.0.1', port }) + const finish = (value) => { + socket.destroy() + resolve(value) + } + socket.setTimeout(100, () => finish(false)) + socket.once('connect', () => finish(true)) + socket.once('error', () => finish(false)) + }) +} +export async function stopChild(child) { + if (!child || child.exitCode !== null || child.signalCode !== null) return + child.kill('SIGTERM') + const deadline = Date.now() + 2000 + while ( + child.exitCode === null && + child.signalCode === null && + Date.now() < deadline + ) + await delay(25) + if (child.exitCode === null && child.signalCode === null) + child.kill('SIGKILL') + const killed = Date.now() + 2000 + while ( + child.exitCode === null && + child.signalCode === null && + Date.now() < killed + ) + await delay(25) + requireCheck( + child.exitCode !== null || child.signalCode !== null, + 'child teardown deadline' + ) +} +function bindingsText( + mode, + endpoints, + profile, + secretArns, + missingSecret = false +) { + let text = 'version = 1\n' + if (mode !== 'secrets') + text += `[config.${configId}]\nprovider = "aws-appconfig-agent"\ndefault_key = "${configId}"\nendpoint = "${endpoints.agent}"\naccess_token_env = "APPCONFIG_AGENT_TOKEN"\n[config.${configId}.documents.${configId}]\napplication = "ts"\nenvironment = "test"\nprofile = "${profile}"\n` + if (mode !== 'config') { + text += `[secrets.${secretId}]\nprovider = "aws-secrets-manager"\nregion = "us-east-1"\n` + for (const key of keys) { + const arn = secretArns[key] + requireCheck( + typeof arn === 'string' && + arn.startsWith('arn:aws:secretsmanager:us-east-1:'), + 'synthetic secret ARN mapping' + ) + const target = + missingSecret && key === 'admin_key' + ? arn.replace(/:secret:.+$/, ':secret:missing-A1b2c3') + : arn + text += `[secrets.${secretId}.entries.${key}]\nsecret_id = "${target}"\n` + } + } + return text +} +async function publish(path, contents) { + await writeFile(`${path}.next`, contents) + await rename(`${path}.next`, path) +} + +export async function main(binary) { + requireCheck( + typeof binary === 'string' && isAbsolute(binary), + 'supply absolute prebuilt binary path' + ) + await access(binary) + const work = await mkdtemp(join(tmpdir(), 'ts-native-providers-')) + const prefix = `ts-providers-${randomUUID()}` + const names = { moto: `${prefix}-moto`, agent: `${prefix}-agent` } + const configs = join(work, 'configs') + const children = new Set() + const commands = new Set() + const created = new Set() + let interrupted = false + let stage = 'setup' + let failed = false + let cleaning = false + let interruptTimer + const interrupt = () => { + if (interrupted) return + interrupted = true + if (!cleaning) for (const command of commands) command.abort() + for (const child of children) child.kill('SIGTERM') + interruptTimer = setTimeout(() => { + for (const child of children) { + if (child.exitCode === null && child.signalCode === null) + child.kill('SIGKILL') + } + }, 2000) + } + process.on('SIGINT', interrupt) + process.on('SIGTERM', interrupt) + const check = () => requireCheck(!interrupted, 'interrupted') + // Do not inherit AWS, proxies, Docker config credentials, or arbitrary endpoints. + const dockerEnv = { PATH: process.env.PATH, HOME: work } + const docker = async (args, timeout = 30_000) => { + const controller = new AbortController() + commands.add(controller) + try { + const { stdout } = await exec('docker', args, { + env: dockerEnv, + timeout, + signal: controller.signal, + killSignal: 'SIGKILL', + maxBuffer: 1024 * 1024, + }) + return stdout.trim() + } catch { + throw new Error('container command') + } finally { + commands.delete(controller) + } + } + const launch = async (directory, endpoints, bindingPath, phase) => { + check() + const port = await freePort() + const child = spawn(binary, [], { + cwd: directory, + env: isolatedEnvironment(directory, port, endpoints, bindingPath, phase), + stdio: 'ignore', + }) + let spawnFailed = false + child.on('error', () => { + spawnFailed = true + }) + children.add(child) + return { + child, + port, + endpoint: `http://127.0.0.1:${port}`, + spawnFailed: () => spawnFailed, + } + } + const stop = async (app) => { + await stopChild(app.child) + children.delete(app.child) + } + const health = (app, password) => + request(app.endpoint, '/health', { + headers: { + Authorization: `Basic ${Buffer.from(`admin:${password}`).toString('base64')}`, + }, + }) + const ready = async (app, phase) => { + const until = Date.now() + 20_000 + while (Date.now() < until) { + check() + requireCheck( + !app.spawnFailed() && + app.child.exitCode === null && + app.child.signalCode === null, + 'startup exited' + ) + let response + try { + response = await health(app, secretValues(phase).admin_key) + } catch { + /* Retry connection refusal, not a wrong HTTP response. */ + } + if (response) { + requireCheck( + response.status === 200 && + response.body === 'ok' && + response.headers['x-ts-version'] && + response.headers['x-provider-snapshot'] === phase, + 'health snapshot readiness' + ) + return + } + await delay(100) + } + throw new Error('startup deadline') + } + const snapshot = async (app, configPhase, secretPhase, rejectedPhase) => { + const response = await health(app, secretValues(secretPhase).admin_key) + requireCheck( + response.status === 200 && + response.body === 'ok' && + response.headers['x-provider-snapshot'] === configPhase, + 'snapshot observation' + ) + requireCheck( + (await health(app, secretValues(rejectedPhase).admin_key)).status === 401, + 'old or future secret rejection' + ) + requireCheck( + (await health(app, `synthetic-admin-${secretPhase}`)).status === 401, + 'whole secret semantics' + ) + const js = await request(app.endpoint, '/static/tsjs=0000000000000000') + requireCheck( + js.status === 404 && + js.headers['x-geo-info-available'] === 'false' && + js.headers['x-provider-snapshot'] === configPhase, + 'TSJS route observation' + ) + } + const rejected = async (app) => { + const until = Date.now() + 20_000 + while (Date.now() < until) { + check() + requireCheck(!(await listening(app.port)), 'invalid startup listened') + requireCheck(!app.spawnFailed(), 'binary launch') + if (app.child.exitCode !== null || app.child.signalCode !== null) { + requireCheck( + app.child.exitCode !== 0 && app.child.signalCode === null, + 'invalid startup exit' + ) + return + } + await delay(25) + } + throw new Error('invalid startup deadline') + } + try { + await mkdir(configs) + await writeFile(join(work, 'aws-config'), '') + await writeFile(join(work, 'aws-credentials'), '') + await docker(['info'], 20_000) + for (const image of Object.values(images)) { + check() + await docker(['pull', image], 180_000) + } + for (const service of ['moto', 'agent']) { + check() + created.add(names[service]) + const args = [ + 'run', + '-d', + '--name', + names[service], + '--publish', + `127.0.0.1::${service === 'moto' ? 5000 : 2772}`, + ] + if (service === 'agent') + args.push( + '--mount', + `type=bind,source=${configs},target=/configs,readonly`, + '--env', + 'LOCAL_DEVELOPMENT_DIRECTORY=/configs', + '--env', + 'SERVICE_REGION=us-east-1', + '--env', + 'AWS_ACCESS_KEY_ID=DUMMYLOCAL', + '--env', + 'AWS_SECRET_ACCESS_KEY=DUMMYLOCAL', + '--env', + 'AWS_EC2_METADATA_DISABLED=true', + '--env', + `ACCESS_TOKEN=${token}`, + '--env', + 'HTTP_HOST=0.0.0.0', + '--env', + 'POLL_INTERVAL=1s' + ) + args.push(images[service]) + await docker(args) + } + const endpoints = {} + for (const [service, port] of [ + ['moto', 5000], + ['agent', 2772], + ]) + endpoints[service] = publishedEndpoint( + await docker(['port', names[service], `${port}/tcp`]) + ) + stage = 'service readiness' + await publish(join(configs, 'probe:test:ready'), 'agent-ready\n') + let servicesReady = false + const deadline = Date.now() + 60_000 + while (Date.now() < deadline) { + check() + try { + await secrets(endpoints.moto, 'ListSecrets') + const probe = await request( + endpoints.agent, + '/applications/probe/environments/test/configurations/ready', + { headers: { Authorization: `Bearer ${token}` } } + ) + servicesReady = probe.status === 200 && probe.body === 'agent-ready\n' + if (servicesReady) break + } catch { + /* Bounded service startup retry. */ + } + await delay(200) + } + requireCheck(servicesReady, 'service readiness deadline') + const secretArns = {} + for (const [key, value] of Object.entries(secretValues('A'))) { + const createdSecret = await secrets(endpoints.moto, 'CreateSecret', { + Name: key, + SecretString: value, + }) + secretArns[key] = createdSecret.ARN + } + for (const mode of ['config', 'secrets', 'combined']) { + stage = `${mode} snapshots` + check() + const directory = join(work, mode) + await mkdir(directory) + await writeFile(join(directory, 'aws-config'), '') + await writeFile(join(directory, 'aws-credentials'), '') + const bindingPath = join(directory, 'bindings.toml') + const localPath = join(directory, `local-config-${configId}.json`) + const document = join(configs, `ts:test:${mode}`) + const publishPhase = async (phase) => { + await publish(document, fixtureEnvelope(phase)) + await publish( + localPath, + JSON.stringify({ [configId]: fixtureEnvelope(phase) }) + ) + for (const [key, value] of Object.entries(secretValues(phase))) { + await secrets(endpoints.moto, 'PutSecretValue', { + SecretId: key, + SecretString: value, + ClientRequestToken: randomUUID(), + }) + const actual = await secrets(endpoints.moto, 'GetSecretValue', { + SecretId: key, + }) + requireCheck( + actual.SecretString === value, + 'independent secret readback' + ) + } + } + await publishPhase('A') + await writeFile( + bindingPath, + bindingsText(mode, endpoints, mode, secretArns) + ) + const app = await launch(directory, endpoints, bindingPath, 'A') + await ready(app, 'A') + await snapshot(app, 'A', 'A', 'B') + await publishPhase('B') + // Wait for the independent Agent cache, not only the mounted file write. + const until = Date.now() + 15_000 + let adopted = false + while (Date.now() < until) { + check() + const response = await request( + endpoints.agent, + `/applications/ts/environments/test/configurations/${mode}`, + { headers: { Authorization: `Bearer ${token}` } } + ) + adopted = + response.status === 200 && response.body === fixtureEnvelope('B') + if (adopted) break + await delay(200) + } + requireCheck(adopted, 'independent config readback') + await snapshot(app, 'A', 'A', 'B') + await stop(app) + const restarted = await launch(directory, endpoints, bindingPath, 'B') + await ready(restarted, 'B') + await snapshot(restarted, 'B', 'B', 'A') + await stop(restarted) + console.log( + `PASS ${mode}: real TS health/auth/TSJS; immutable snapshots; restart adoption` + ) + if (mode !== 'secrets') { + stage = `${mode} config failure` + await writeFile( + bindingPath, + bindingsText(mode, endpoints, 'missing', secretArns) + ) + const invalid = await launch(directory, endpoints, bindingPath, 'B') + await rejected(invalid) + await stop(invalid) + console.log( + `PASS ${mode}: selected config failure before listen, no local/legacy fallback` + ) + } + if (mode !== 'config') { + stage = `${mode} secret failure` + await writeFile( + bindingPath, + bindingsText(mode, endpoints, mode, secretArns, true) + ) + const invalid = await launch(directory, endpoints, bindingPath, 'B') + await rejected(invalid) + await stop(invalid) + console.log( + `PASS ${mode}: selected secret failure before listen, no local/legacy fallback` + ) + } + } + } catch (error) { + // stage and assertion messages are application-owned bounded labels. + const reason = + error instanceof FixtureAssertion ? error.message : 'operation failed' + console.error(`FAIL provider acceptance stage: ${stage}; ${reason}`) + failed = true + } finally { + cleaning = true + for (const child of children) { + try { + await stopChild(child) + } catch { + failed = true + } + } + for (const name of created) { + try { + await docker(['rm', '-f', name], 10_000) + } catch { + failed = true + } + } + try { + await rm(work, { recursive: true, force: true }) + } catch { + failed = true + } + clearTimeout(interruptTimer) + process.off('SIGINT', interrupt) + process.off('SIGTERM', interrupt) + } + // Never print child output, arbitrary caught errors, endpoint bytes, or values. + requireCheck( + !failed && !interrupted, + `provider acceptance failed during ${stage}; child/provider diagnostics suppressed` + ) + console.log( + 'PASS provider acceptance; CLI-export acceptance and DynamoDB are not covered' + ) +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main(process.argv[2]).catch(() => { + console.error( + 'Native provider acceptance failed; check binary/features, Docker availability, and bounded stage output. Raw diagnostics suppressed.' + ) + process.exitCode = 1 + }) +} diff --git a/scripts/tests/test-native-providers.test.mjs b/scripts/tests/test-native-providers.test.mjs new file mode 100644 index 0000000000..82402d45c0 --- /dev/null +++ b/scripts/tests/test-native-providers.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { test } from 'node:test' +import { + envelope, + fixtureEnvelope, + images, + isolatedEnvironment, + publishedEndpoint, + sorted, + stopChild, +} from '../test-native-providers.mjs' + +test('published ports must be exclusively literal loopback and errors omit input', () => { + assert.equal(publishedEndpoint('127.0.0.1:12345\n'), 'http://127.0.0.1:12345') + for (const input of [ + '127.0.0.1:0', + '127.0.0.1:65536', + '0.0.0.0:12345', + '[::]:12345', + 'https://secret.example.com', + '127.0.0.1:80\n[::]:80', + ]) { + assert.throws(() => publishedEndpoint(input), { + message: 'invalid published port', + }) + } +}) + +test("existing TS startup fixture's BlobEnvelope hash matches Node serialization", () => { + const data = { + ec: { passphrase: 'ec_key' }, + handlers: [ + { password: 'admin_key', path: '^/_ts/admin', username: 'admin' }, + ], + publisher: { + cookie_domain: '.publisher.example.com', + domain: 'publisher.example.com', + origin_url: 'https://origin.publisher.example.com', + proxy_secret: 'proxy_key', + }, + request_signing: null, + } + // Exact golden vector from crates/trusted-server-adapter-axum/tests/startup.rs. + assert.equal( + JSON.parse(envelope(data)).sha256, + 'f4dc03017deb24bfd589efde7dce0f9f8a20d952c28ebcbdb6141b08661cd137' + ) + assert.deepEqual(sorted({ b: [{ z: 1, a: 2 }], a: null }), { + a: null, + b: [{ a: 2, z: 1 }], + }) + const fixture = JSON.parse(fixtureEnvelope('A')) + assert.equal(fixture.version, 1) + assert.equal(fixture.data.ec.passphrase, 'ec_key') + assert.equal(fixture.data.handlers[1].password, 'admin_key') + assert.equal(fixture.data.publisher.proxy_secret, 'proxy_key') + assert.equal(fixture.data.response_headers['x-provider-snapshot'], 'A') +}) + +test('binary environment never inherits real credentials, proxy settings, or endpoints', () => { + const previous = process.env.AWS_SESSION_TOKEN + process.env.AWS_SESSION_TOKEN = 'sentinel-not-to-be-inherited' + try { + const env = isolatedEnvironment( + '/tmp/synthetic', + 12345, + { moto: 'http://127.0.0.1:12346' }, + '/tmp/synthetic/bindings.toml', + 'A' + ) + assert.equal(env.AWS_SESSION_TOKEN, undefined) + assert.equal(env.HTTP_PROXY, undefined) + assert.equal(env.PATH, undefined) + assert.equal(env.AWS_EC2_METADATA_DISABLED, 'true') + assert.equal(env.AWS_ACCESS_KEY_ID, 'DUMMYLOCALACCESS123') + assert.equal(env.AWS_ENDPOINT_URL_SECRETS_MANAGER, 'http://127.0.0.1:12346') + assert.equal(env.HOME, '/tmp/synthetic') + assert.equal( + env.AWS_SHARED_CREDENTIALS_FILE, + '/tmp/synthetic/aws-credentials' + ) + assert.ok(env.EDGEZERO__SECRETS__EC_KEY.length >= 32) + assert.ok(env.EDGEZERO__SECRETS__ADMIN_KEY.startsWith(' { "password":')) + assert.ok(!JSON.stringify(env).includes('sentinel-not-to-be-inherited')) + } finally { + if (previous === undefined) delete process.env.AWS_SESSION_TOKEN + else process.env.AWS_SESSION_TOKEN = previous + } +}) + +test('only the two independently pinned provider services are selected', () => { + assert.deepEqual(Object.keys(images).sort(), ['agent', 'moto']) + for (const image of Object.values(images)) + assert.match(image, /@sha256:[a-f0-9]{64}$/) +}) + +test( + 'child teardown is idempotent and escalates for an ignored SIGTERM', + { timeout: 7000 }, + async () => { + const child = spawn( + process.execPath, + [ + '-e', + 'process.on("SIGTERM", () => {}); console.log("ready"); setInterval(() => {}, 1000);', + ], + { env: {}, stdio: ['ignore', 'pipe', 'ignore'] } + ) + try { + await once(child.stdout, 'data') + await stopChild(child) + assert.equal(child.signalCode, 'SIGKILL') + await stopChild(child) + } finally { + if (child.exitCode === null && child.signalCode === null) + child.kill('SIGKILL') + } + } +) From 4a486a0d11525c2466e37b393634b1653ac2c802 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 20 Oct 2026 08:16:28 -0500 Subject: [PATCH 2/4] fix(adapters): preserve bounded lazy uploads across RC runtimes --- .dockerignore | 2 + .github/workflows/integration-tests.yml | 5 +- .github/workflows/native-container.yml | 45 ++++++++++ .tool-versions | 3 +- AGENTS.md | 3 +- .../tests/fixtures/native/README.md | 3 +- .../tests/startup.rs | 6 +- .../src/lib.rs | 3 + .../src/logging.rs | 36 ++++++++ .../src/platform.rs | 1 + .../trusted-server-adapter-fastly/src/app.rs | 16 ++-- .../src/platform.rs | 18 ++-- .../src/platform.rs | 41 +-------- .../src/startup.rs | 5 +- .../src/startup/loading/tests.rs | 2 + .../tests/routes.rs | 12 +-- .../trusted-server-core/src/platform/http.rs | 86 ++++++++++++++++++- docs/guide/native-rc-demo.md | 12 +-- edgezero.toml | 4 - scripts/fixtures/smoke-origin.mjs | 47 ++++++++++ scripts/smoke-cloudflare.sh | 80 +++++++++++++---- scripts/smoke-fastly.sh | 25 +++++- 22 files changed, 358 insertions(+), 97 deletions(-) create mode 100644 .github/workflows/native-container.yml create mode 100644 crates/trusted-server-adapter-cloudflare/src/logging.rs create mode 100644 scripts/fixtures/smoke-origin.mjs diff --git a/.dockerignore b/.dockerignore index 407d2ec25b..fff9d078b8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,6 +20,8 @@ **/target **/node_modules **/dist +crates/trusted-server-adapter-cloudflare/build +crates/trusted-server-adapter-cloudflare/pkg **/.env **/.env.* **/*.pem diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 157a3582d3..9a0987993f 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -96,7 +96,10 @@ jobs: node-version: ${{ steps.shared-setup.outputs.node-version }} - name: Install wrangler - run: npm install -g wrangler + run: | + version=$(awk '$1 == "wrangler" { print $2 }' .tool-versions) + test -n "$version" + npm install -g "wrangler@$version" - name: Run integration tests run: >- diff --git a/.github/workflows/native-container.yml b/.github/workflows/native-container.yml new file mode 100644 index 0000000000..344e727173 --- /dev/null +++ b/.github/workflows/native-container.yml @@ -0,0 +1,45 @@ +name: Native RC container + +on: + pull_request: + paths: + - Dockerfile + - .dockerignore + - .tool-versions + - rust-toolchain.toml + - Cargo.toml + - Cargo.lock + - edgezero.toml + - .cargo/** + - crates/trusted-server-adapter-axum/** + - crates/trusted-server-core/** + - crates/trusted-server-js/** + - crates/trusted-server-openrtb*/** + - scripts/test-native-container.mjs + - .github/workflows/native-container.yml + workflow_dispatch: + +permissions: + contents: read + +jobs: + local-provider-image: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Read pinned Node version + id: node + run: | + version=$(awk '$1 == "nodejs" { print $2 }' .tool-versions) + test -n "$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@v4 + with: + node-version: ${{ steps.node.outputs.version }} + - name: Build local-provider image + run: docker build --build-arg TRUSTED_SERVER__GIT_VERSION="${{ github.sha }}" -t ts-rc-local:test . + - name: Check real TS routes, startup rejection and drain + run: node scripts/test-native-container.mjs ts-rc-local:test diff --git a/.tool-versions b/.tool-versions index c2a1f4e061..94e660a617 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,8 +1,9 @@ -fastly 15.1.0 +fastly 16.0.0 rust 1.98.1 nodejs 24.12.0 viceroy 0.21.0 wasmtime 48.0.2 spin-framework 4.1.0 +wrangler 4.151.3 awscli 2.36.45 terraform 1.16.2 diff --git a/AGENTS.md b/AGENTS.md index b8295859a3..3ba1c154dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,10 +36,11 @@ Supporting files: `edgezero.toml`, `fastly.toml`, | Rust | 1.98.1 (pinned in `rust-toolchain.toml`) | | WASM target | Fastly `wasm32-wasip1`; Spin `wasm32-wasip2` | | Node | 24.12.0 (from `.tool-versions`) | -| Fastly CLI | 15.1.0 (from `.tool-versions`) | +| Fastly CLI | 16.0.0 (from `.tool-versions`) | | Viceroy | 0.21.0 (from `.tool-versions`) | | Wasmtime | 48.0.2 (from `.tool-versions`) | | Spin | 4.1.0 (from `.tool-versions`) | +| Wrangler | 4.151.3 (from `.tool-versions`) | | AWS CLI | 2.36.45 (from `.tool-versions`) | | Terraform | 1.16.2 (from `.tool-versions`) | diff --git a/crates/trusted-server-adapter-axum/tests/fixtures/native/README.md b/crates/trusted-server-adapter-axum/tests/fixtures/native/README.md index aaa06124a5..84a86f44c5 100644 --- a/crates/trusted-server-adapter-axum/tests/fixtures/native/README.md +++ b/crates/trusted-server-adapter-axum/tests/fixtures/native/README.md @@ -45,7 +45,8 @@ cp crates/trusted-server-adapter-axum/tests/fixtures/native/nextjs/next.config.m npm ci --prefix "$NEXT_FIXTURE_ROOT" --ignore-scripts NATIVE_NEXTJS_PORT=$(node -e \ 'const s=require("node:net").createServer(); s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close()})') -export NATIVE_NEXTJS_ORIGIN="http://127.0.0.1:$NATIVE_NEXTJS_PORT" +NATIVE_NEXTJS_ORIGIN=http://127.0.0.1 +export NATIVE_NEXTJS_ORIGIN="$NATIVE_NEXTJS_ORIGIN:$NATIVE_NEXTJS_PORT" ( cd "$NEXT_FIXTURE_ROOT" NEXT_TELEMETRY_DISABLED=1 timeout --signal=TERM --kill-after=3s 180s \ diff --git a/crates/trusted-server-adapter-axum/tests/startup.rs b/crates/trusted-server-adapter-axum/tests/startup.rs index c381d65794..fcbab5ef57 100644 --- a/crates/trusted-server-adapter-axum/tests/startup.rs +++ b/crates/trusted-server-adapter-axum/tests/startup.rs @@ -310,10 +310,8 @@ fn handwritten_store_metadata_matches_the_manifest() { DEFAULT_SECRET_STORE_ID ); assert!(stores.kv.is_none(), "should not prepare unused generic KV"); - // The portable manifest is the union of adapter stores. Fastly still - // declares generic KV; native deliberately prepares only config/secrets. assert!( - manifest.contains("\n[stores.kv]"), - "should preserve the existing Fastly KV declaration in the portable manifest" + !manifest.contains("\n[stores.kv]"), + "should not declare unused generic KV and accidentally require a local or remote provider" ); } diff --git a/crates/trusted-server-adapter-cloudflare/src/lib.rs b/crates/trusted-server-adapter-cloudflare/src/lib.rs index 980a9b2099..c87e252f7c 100644 --- a/crates/trusted-server-adapter-cloudflare/src/lib.rs +++ b/crates/trusted-server-adapter-cloudflare/src/lib.rs @@ -9,6 +9,8 @@ compile_error!( ); pub mod app; +#[cfg(target_arch = "wasm32")] +mod logging; pub mod middleware; pub mod platform; @@ -24,6 +26,7 @@ use worker::{Context, Env, Request, Response, Result, event}; /// Returns a Workers runtime error when the fallback error response cannot be /// constructed. pub async fn main(req: Request, env: Env, ctx: Context) -> Result { + logging::initialize(); if let Ok(config) = env.var("TRUSTED_SERVER_CONFIG") { app::set_cloudflare_config_json(config.to_string()); } diff --git a/crates/trusted-server-adapter-cloudflare/src/logging.rs b/crates/trusted-server-adapter-cloudflare/src/logging.rs new file mode 100644 index 0000000000..e94faa165f --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/src/logging.rs @@ -0,0 +1,36 @@ +//! Minimal Workers console backend for existing application diagnostics. + +use log::{Level, LevelFilter, Log, Metadata, Record}; + +struct WorkerLogger; + +static LOGGER: WorkerLogger = WorkerLogger; + +impl Log for WorkerLogger { + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + metadata.level() <= Level::Info + } + + fn log(&self, record: &Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + match record.level() { + Level::Error | Level::Warn => { + worker::console_error!("[{}] {}", record.target(), record.args()); + } + _ => { + worker::console_log!("[{}] {}", record.target(), record.args()); + } + } + } + + fn flush(&self) {} +} + +/// Install the console backend once, preserving any previously installed logger. +pub(crate) fn initialize() { + if log::set_logger(&LOGGER).is_ok() { + log::set_max_level(LevelFilter::Info); + } +} diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index 265593c5f6..617b81f7a8 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -310,6 +310,7 @@ impl CloudflareHttpClient { )); } + let request = request.buffer_body().await?; let cache_mode = outbound_cache_mode(&request.cache_intent); let uri = request.request.uri().to_string(); diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index fd1ac900a2..23dd7a43b5 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -1395,10 +1395,9 @@ impl Hooks for TrustedServerApp { default: DEFAULT_CONFIG_STORE_ID, ids: &[DEFAULT_CONFIG_STORE_ID], }), - kv: Some(StoreMetadata { - default: "trusted_server_kv", - ids: &["trusted_server_kv"], - }), + // Generic KV remains unavailable in TS RuntimeServices. EC uses its + // existing dedicated backend, not this unused logical registry. + kv: None, secrets: Some(StoreMetadata { default: DEFAULT_SECRET_STORE_ID, ids: &[DEFAULT_SECRET_STORE_ID], @@ -1461,13 +1460,20 @@ mod tests { .and_then(toml::Value::as_table) .expect("manifest should declare stores"); let metadata = TrustedServerApp::stores(); + assert!( + metadata.kv.is_none(), + "unused generic KV must not be prepared" + ); + assert!( + manifest_stores.get("kv").is_none(), + "manifest must not require unused generic KV" + ); for (kind, runtime_store) in [ ( "config", metadata.config.expect("should declare config stores"), ), - ("kv", metadata.kv.expect("should declare KV stores")), ( "secrets", metadata.secrets.expect("should declare secret stores"), diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index d12a41eb40..b114630e9d 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -508,6 +508,7 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { &self, request: PlatformHttpRequest, ) -> Result> { + let request = request.buffer_body().await?; let backend_name = request.backend_name.clone(); let image_optimizer = request.image_optimizer; let stream_response = request.stream_response; @@ -533,6 +534,7 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { return Err(Report::new(PlatformError::HttpClient) .attach("Image Optimizer is not supported with Fastly send_async")); } + let request = request.buffer_body().await?; let stream_response = request.stream_response; let request_method = request.request.method().clone(); let cache_intent = request.cache_intent.clone(); @@ -1441,7 +1443,7 @@ mod tests { } #[test] - fn fastly_platform_http_client_send_returns_error_for_streaming_body() { + fn fastly_platform_http_client_send_buffers_lazy_upload_before_dispatch() { let client = FastlyPlatformHttpClient; let request = request_builder() .method("POST") @@ -1454,7 +1456,7 @@ mod tests { let err = futures::executor::block_on( client.send(PlatformHttpRequest::new(request, "nonexistent-backend")), ) - .expect_err("should reject streaming request bodies before sending"); + .expect_err("should fail at the missing backend after bounded upload collection"); assert!( matches!(err.current_context(), &PlatformError::HttpClient), @@ -1462,8 +1464,8 @@ mod tests { err.current_context() ); assert!( - format!("{err:?}").contains("streaming request body"), - "should describe the unsupported streaming body: {err:?}" + err.contains::(), + "should reach SDK dispatch instead of rejecting the lazy body: {err:?}" ); } @@ -1516,7 +1518,7 @@ mod tests { } #[test] - fn fastly_platform_http_client_send_async_returns_error_for_streaming_body() { + fn fastly_platform_http_client_send_async_buffers_lazy_upload_before_dispatch() { let client = FastlyPlatformHttpClient; let request = request_builder() .method("POST") @@ -1529,7 +1531,7 @@ mod tests { let err = futures::executor::block_on( client.send_async(PlatformHttpRequest::new(request, "nonexistent-backend")), ) - .expect_err("should reject streaming request bodies before launching async send"); + .expect_err("should fail at the missing backend after bounded upload collection"); assert!( matches!(err.current_context(), &PlatformError::HttpClient), @@ -1537,8 +1539,8 @@ mod tests { err.current_context() ); assert!( - format!("{err:?}").contains("streaming request body"), - "should describe the unsupported streaming body: {err:?}" + err.contains::(), + "should reach SDK dispatch instead of rejecting the lazy body: {err:?}" ); } diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index b440f90729..7ae48e589a 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -392,19 +392,6 @@ fn is_wasi_forbidden_outbound_header(name: &str) -> bool { || name.eq_ignore_ascii_case("proxy-connection") } -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -async fn buffer_request_body( - body: edgezero_core::body::Body, - maximum: u64, -) -> Result> { - let maximum = usize::try_from(maximum).map_err(|_| { - Report::new(PlatformError::HttpClient).attach("Spin upload byte limit is not representable") - })?; - body.into_bytes_bounded(maximum) - .await - .change_context(PlatformError::HttpClient) -} - /// Carries a completed response through `send_async` → `select`. #[cfg(all(feature = "spin", target_arch = "wasm32"))] struct SpinPendingResponse { @@ -446,6 +433,7 @@ impl SpinPlatformHttpClient { request: PlatformHttpRequest, ) -> Result> { reject_unsupported_request_contracts(&request)?; + let request = request.buffer_body().await?; let method = request.request.method().clone(); let uri = request.request.uri().to_string(); @@ -477,7 +465,9 @@ impl SpinPlatformHttpClient { // SDK7 ingress is lazy even for an empty GET body. Buffer uploads at // this owned boundary under the caller's finite cap, rather than reject // ordinary requests merely because their carrier is a managed stream. - let body_bytes = buffer_request_body(body, request.max_request_body_bytes).await?; + let body_bytes = body.into_bytes().ok_or_else(|| { + Report::new(PlatformError::HttpClient).attach("Spin buffered upload invariant was lost") + })?; let spin_request = builder .body(spin_sdk::http::FullBody::new(body_bytes)) .map_err(|error| { @@ -716,29 +706,6 @@ mod tests { use edgezero_core::time::{Deadline, MonotonicClock}; use trusted_server_core::platform::AuctionTargetId; - #[test] - fn managed_stream_uploads_are_buffered_under_the_caller_limit() { - futures::executor::block_on(async { - let upload = || { - Body::stream(futures::stream::iter([ - Bytes::from_static(b"a"), - Bytes::from_static(b"bc"), - ])) - }; - assert_eq!( - buffer_request_body(upload(), 3) - .await - .expect("should accept exact upload limit") - .as_ref(), - b"abc" - ); - assert!( - buffer_request_body(upload(), 2).await.is_err(), - "should reject an oversized upload before sending" - ); - }); - } - #[test] fn auction_http_capabilities_are_explicit() { let capabilities = AuctionTargetId::Spin.descriptor().capabilities(); diff --git a/crates/trusted-server-adapter-spin/src/startup.rs b/crates/trusted-server-adapter-spin/src/startup.rs index cb2e8fedaf..34910b4f85 100644 --- a/crates/trusted-server-adapter-spin/src/startup.rs +++ b/crates/trusted-server-adapter-spin/src/startup.rs @@ -81,7 +81,10 @@ mod loading { }; use trusted_server_core::settings::Settings; - use super::*; + use error_stack::Report; + use trusted_server_core::platform::{PlatformConfigStore, StoreName}; + + use super::SpinStoreSnapshot; use crate::app::{TrustedServerApp, build_state_with_settings}; const MAX_CONFIG_KEYS: usize = 128; diff --git a/crates/trusted-server-adapter-spin/src/startup/loading/tests.rs b/crates/trusted-server-adapter-spin/src/startup/loading/tests.rs index 9181852704..e3baa4f5bb 100644 --- a/crates/trusted-server-adapter-spin/src/startup/loading/tests.rs +++ b/crates/trusted-server-adapter-spin/src/startup/loading/tests.rs @@ -1,4 +1,5 @@ use std::cell::{Cell, RefCell}; +use std::collections::BTreeMap; use std::future::pending; use std::rc::Rc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -7,6 +8,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use edgezero_core::app::Hooks as _; use edgezero_core::blob_envelope::BlobEnvelope; use trusted_server_core::config_payload::{CONFIG_BLOB_KEY, DEFAULT_SECRET_STORE_ID}; +use trusted_server_core::platform::{PlatformSecretStore, StoreId}; use trusted_server_core::request_signing::Keypair; use super::*; diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 31e8c2fe90..31c078bce9 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -63,15 +63,9 @@ fn edgezero_manifest_loads_and_resolves_spin_stores() { manifest.stores.config.is_some(), "Spin EdgeZero manifest must enable config store injection" ); - assert_eq!( - manifest - .stores - .kv - .as_ref() - .expect("should declare a KV store") - .default_id(), - "trusted_server_kv", - "Spin KV declaration must expose its default logical store id" + assert!( + manifest.stores.kv.is_none(), + "unused generic KV must not become a required provider; Spin config retains its dedicated default-store bootstrap" ); assert!( manifest.secret_store_enabled(edgezero_core::app::SPIN_ADAPTER), diff --git a/crates/trusted-server-core/src/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs index dbcfda7db4..d02f5514a7 100644 --- a/crates/trusted-server-core/src/platform/http.rs +++ b/crates/trusted-server-core/src/platform/http.rs @@ -2,7 +2,7 @@ use std::any::Any; use std::fmt; use edgezero_core::http::{Method, Request as EdgeRequest, Response as EdgeResponse}; -use error_stack::Report; +use error_stack::{Report, ResultExt as _}; use super::PlatformError; use super::image_optimizer::PlatformImageOptimizerOptions; @@ -101,6 +101,28 @@ impl PlatformHttpRequest { } } + /// Buffer an owned upload under its caller-supplied byte ceiling. + /// + /// SDK adapters with buffered uploads use this at conversion, including for + /// lazy empty bodies. Request parts/extensions are unchanged, and source + /// failures and inherited managed read deadlines propagate. This does not + /// add a runtime timer; native streaming transport retains its own path. + /// + /// # Errors + /// Returns an HTTP-client error for an unrepresentable cap, excess bytes or + /// a failed source read. + pub async fn buffer_body(mut self) -> Result> { + let maximum = usize::try_from(self.max_request_body_bytes) + .change_context(PlatformError::HttpClient)?; + let body = std::mem::replace(self.request.body_mut(), edgezero_core::body::Body::empty()); + let bytes = body + .into_bytes_bounded(maximum) + .await + .change_context(PlatformError::HttpClient)?; + *self.request.body_mut() = edgezero_core::body::Body::from(bytes); + Ok(self) + } + /// Sets the response byte ceiling without changing streaming selection. #[must_use] pub fn with_response_limit(mut self, max_bytes: u64) -> Self { @@ -477,6 +499,68 @@ mod tests { use super::*; + #[test] + fn buffered_upload_accepts_lazy_empty_and_exact_bodies_but_rejects_excess() { + futures::executor::block_on(async { + for chunks in [ + vec![], + vec![ + bytes::Bytes::from_static(b"a"), + bytes::Bytes::from_static(b"bc"), + ], + ] { + let body = Body::stream(futures::stream::iter(chunks)); + let mut request = PlatformHttpRequest::new( + request_builder() + .body(body) + .expect("should build lazy request"), + "fixture", + ); + request.max_request_body_bytes = 3; + request.request.headers_mut().insert( + "x-fixture", + "retained".parse().expect("should parse header"), + ); + let buffered = request + .buffer_body() + .await + .expect("should buffer bounded lazy upload"); + assert_eq!(buffered.request.headers()["x-fixture"], "retained"); + assert!( + buffered.request.body().as_bytes().is_some(), + "should normalize only the owned body" + ); + } + let mut oversized = PlatformHttpRequest::new( + request_builder() + .body(Body::from("abcd")) + .expect("should build oversized request"), + "fixture", + ); + oversized.max_request_body_bytes = 3; + assert!( + oversized.buffer_body().await.is_err(), + "should reject oversized upload" + ); + let source_error = + Body::from_external_stream(futures::stream::iter([Err::( + std::io::Error::other("synthetic upload source error"), + )])); + assert!( + PlatformHttpRequest::new( + request_builder() + .body(source_error) + .expect("should build failed source request"), + "fixture" + ) + .buffer_body() + .await + .is_err(), + "should propagate source errors without an empty fallback" + ); + }); + } + #[test] fn platform_http_request_cache_bypass_defaults_to_false() { let request = PlatformHttpRequest::new( diff --git a/docs/guide/native-rc-demo.md b/docs/guide/native-rc-demo.md index fe5e262fe5..ac090c4669 100644 --- a/docs/guide/native-rc-demo.md +++ b/docs/guide/native-rc-demo.md @@ -76,11 +76,13 @@ Secrets Manager supplies each whole secret value, not an arbitrary JSON field. ## Native launch inputs -Native declares only `trusted_server_config` and `trusted_server_secrets`. The -portable manifest retains `trusted_server_kv` for Fastly; native intentionally -does not prepare it. A metadata test checks both native IDs against the manifest. -The default configuration key is `trusted_server_config`. No EC graph or -DynamoDB persistence is added. +The portable manifest and native Hooks declare only `trusted_server_config` +and `trusted_server_secrets`. A metadata test checks both native IDs against the +manifest. The unused generic `trusted_server_kv` declaration is removed, because +the RC correctly treats a declared ID as required. Remove obsolete +`trusted_server_kv` mappings from native binding files. Existing Fastly EC storage +continues through its separate backend. The default configuration key is +`trusted_server_config`. No EC graph or DynamoDB persistence is added. Set these before launch: diff --git a/edgezero.toml b/edgezero.toml index 172324b64c..c7e040da28 100644 --- a/edgezero.toml +++ b/edgezero.toml @@ -20,10 +20,6 @@ version = "0.1.0" # Spin via its runtime config, Cloudflare via bindings), so the ids here do not # have to match any one platform's names. `default` is the primary logical id. -[stores.kv] -ids = ["trusted_server_kv"] -default = "trusted_server_kv" - [stores.config] ids = ["trusted_server_config"] default = "trusted_server_config" diff --git a/scripts/fixtures/smoke-origin.mjs b/scripts/fixtures/smoke-origin.mjs new file mode 100644 index 0000000000..a140d544f7 --- /dev/null +++ b/scripts/fixtures/smoke-origin.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +// Owned loopback origin for real SDK upload and duplicate-header smoke checks. +import assert from 'node:assert/strict' +import http from 'node:http' + +const port = Number(process.argv[2]) +assert(Number.isInteger(port) && port > 0 && port <= 65535) +const server = http.createServer(async (request, response) => { + if (request.method === 'POST' && request.url === '/native-upload') { + const chunks = [] + let size = 0 + for await (const chunk of request) { + size += chunk.length + if (size > 65536) { + response.writeHead(413).end() + return + } + chunks.push(chunk) + } + const body = JSON.stringify({ + received: Buffer.concat(chunks).toString('utf8'), + }) + response + .writeHead(200, { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body), + 'set-cookie': ['fixture_a=one; Path=/', 'fixture_b=two; Path=/'], + }) + .end(body) + return + } + const body = `SMOKE_ORIGIN_SENTINELasset` + response + .writeHead(200, { + 'content-type': 'text/html', + 'content-length': Buffer.byteLength(body), + }) + .end(body) +}) +server.listen(port, '127.0.0.1') +for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, () => { + server.closeIdleConnections() + server.close(() => process.exit(0)) + setTimeout(() => process.exit(1), 2000).unref() + }) +} diff --git a/scripts/smoke-cloudflare.sh b/scripts/smoke-cloudflare.sh index 161823217f..6a6521801d 100755 --- a/scripts/smoke-cloudflare.sh +++ b/scripts/smoke-cloudflare.sh @@ -11,15 +11,14 @@ smoke_require_command bash smoke_require_command cargo smoke_require_command curl smoke_require_command jq -smoke_require_command python3 +smoke_require_command node +smoke_require_command setsid smoke_require_command wrangler smoke_require_command pgrep EXPECTED_WRANGLER=$(awk '$1 == "wrangler" { print $2 }' "$REPO_ROOT/.tool-versions") -[ -n "$EXPECTED_WRANGLER" ] || smoke_die "missing Wrangler pin in .tool-versions" -ACTUAL_WRANGLER=$(wrangler --version | sed -nE 's/^([0-9]+\.[0-9]+\.[0-9]+)$/\1/p') -[ "$ACTUAL_WRANGLER" = "$EXPECTED_WRANGLER" ] || - smoke_die "Wrangler $EXPECTED_WRANGLER is required; found ${ACTUAL_WRANGLER:-unknown}" +EXPECTED_WRANGLER=${EXPECTED_WRANGLER:-${WRANGLER_VERSION:-}} +[ -n "$EXPECTED_WRANGLER" ] || smoke_die "missing Wrangler pin in .tool-versions or WRANGLER_VERSION" WORKSPACE=$(smoke_make_workspace cloudflare) ORIGIN_PORT=${CLOUDFLARE_SMOKE_ORIGIN_PORT:-19089} @@ -27,23 +26,59 @@ BASE_PORT=${CLOUDFLARE_SMOKE_PORT:-19080} ORIGIN_PID="" APP_PID="" +stop_worker() { + [ -n "$APP_PID" ] || return 0 + kill -TERM -- "-$APP_PID" 2>/dev/null || true + local attempt + for attempt in {1..50}; do + kill -0 -- "-$APP_PID" 2>/dev/null || break + sleep 0.1 + done + kill -KILL -- "-$APP_PID" 2>/dev/null || true + wait "$APP_PID" 2>/dev/null || true + APP_PID="" +} + cleanup() { - smoke_stop_process "$APP_PID" + stop_worker smoke_stop_process "$ORIGIN_PID" - smoke_remove_workspace "$WORKSPACE" + if [ "${CLOUDFLARE_SMOKE_KEEP_WORKSPACE:-0}" = 1 ]; then + echo "Cloudflare smoke artifacts: $WORKSPACE" + else + smoke_remove_workspace "$WORKSPACE" + fi } -trap cleanup EXIT INT TERM +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +# Local-only Wrangler must not inherit operator credentials or configuration. +while IFS= read -r name; do + case "$name" in + CLOUDFLARE_*TOKEN*|CLOUDFLARE_*KEY*|CLOUDFLARE_ACCOUNT_ID|CLOUDFLARE_EMAIL|CF_*) unset "$name" ;; + esac +done < <(compgen -e) +export HOME="$WORKSPACE/home" +export XDG_CONFIG_HOME="$HOME/.config" +export XDG_CACHE_HOME="$HOME/.cache" +export WRANGLER_HOME="$HOME/.wrangler" +export CLOUDFLARE_SEND_METRICS=false +mkdir -p "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$WRANGLER_HOME" +ACTUAL_WRANGLER=$(wrangler --version | sed -nE 's/^([0-9]+\.[0-9]+\.[0-9]+)$/\1/p') +[ "$ACTUAL_WRANGLER" = "$EXPECTED_WRANGLER" ] || + smoke_die "Wrangler $EXPECTED_WRANGLER is required; found ${ACTUAL_WRANGLER:-unknown}" smoke_resolve_ts_binary "$REPO_ROOT" CLOUDFLARE_ROOT="$REPO_ROOT/crates/trusted-server-adapter-cloudflare" -if [ ! -f "$CLOUDFLARE_ROOT/build/index.js" ]; then - (cd "$CLOUDFLARE_ROOT" && bash build.sh) -fi [ -f "$CLOUDFLARE_ROOT/build/index.js" ] || smoke_die "Cloudflare Worker bundle not found: $CLOUDFLARE_ROOT/build/index.js" -smoke_start_origin "$WORKSPACE" "$ORIGIN_PORT" -ORIGIN_PID=$SMOKE_ORIGIN_PID +node "$REPO_ROOT/scripts/fixtures/smoke-origin.mjs" "$ORIGIN_PORT" \ + >"$WORKSPACE/origin.log" 2>&1 & +ORIGIN_PID=$! +smoke_wait_http "http://127.0.0.1:$ORIGIN_PORT/" 200 \ + "$WORKSPACE/origin-ready.body" "$WORKSPACE/origin-ready.headers" +smoke_assert_process_alive "$ORIGIN_PID" "stub origin" "$WORKSPACE/origin.log" smoke_initialize_config "$REPO_ROOT" "$WORKSPACE" "$ORIGIN_PORT" CLOUDFLARE_WORK="$WORKSPACE/cloudflare" @@ -140,7 +175,7 @@ run_case() { write_runtime_config "$config_path" "$config_json" "$missing_key" ( cd "$CLOUDFLARE_WORK" - exec wrangler dev \ + exec setsid wrangler dev --local \ --config "$config_path" \ --port "$port" \ --ip 127.0.0.1 @@ -150,8 +185,21 @@ run_case() { "$body_path" "$headers_path" smoke_assert_process_alive "$APP_PID" "Wrangler" "$log_path" smoke_assert_process_alive "$ORIGIN_PID" "stub origin" "$WORKSPACE/origin.log" - smoke_stop_process "$APP_PID" - APP_PID="" + if [ -z "$expected_diagnostic" ]; then + local upload_status + upload_status=$(curl --silent --show-error --max-time 5 \ + --data-binary 'ts-lazy-upload-fixture' \ + --header 'Content-Type: application/octet-stream' \ + --dump-header "$WORKSPACE/upload.headers" \ + --output "$WORKSPACE/upload.body" --write-out '%{http_code}' \ + "http://127.0.0.1:$port/native-upload") + [ "$upload_status" = 200 ] || smoke_die "publisher POST did not reach the origin" + jq --exit-status '.received == "ts-lazy-upload-fixture"' \ + "$WORKSPACE/upload.body" >/dev/null || smoke_die "publisher POST body was lost" + [ "$(grep -ic '^set-cookie:' "$WORKSPACE/upload.headers")" = 2 ] || + smoke_die "Cloudflare delivery lost duplicate Set-Cookie fields" + fi + stop_worker if [ -n "$expected_diagnostic" ]; then grep --fixed-strings --quiet 'Your Worker has access to the following bindings:' "$log_path" || diff --git a/scripts/smoke-fastly.sh b/scripts/smoke-fastly.sh index 65635c2d23..c0437e3aaf 100755 --- a/scripts/smoke-fastly.sh +++ b/scripts/smoke-fastly.sh @@ -10,7 +10,8 @@ REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) smoke_require_command cargo smoke_require_command curl smoke_require_command fastly -smoke_require_command python3 +smoke_require_command node +smoke_require_command jq smoke_require_command pgrep WORKSPACE=$(smoke_make_workspace fastly) @@ -65,8 +66,12 @@ if [ ! -f "$WASM_BINARY" ]; then fi [ -f "$WASM_BINARY" ] || smoke_die "Fastly Wasm artifact not found: $WASM_BINARY" -smoke_start_origin "$WORKSPACE" "$ORIGIN_PORT" -ORIGIN_PID=$SMOKE_ORIGIN_PID +node "$REPO_ROOT/scripts/fixtures/smoke-origin.mjs" "$ORIGIN_PORT" \ + >"$WORKSPACE/origin.log" 2>&1 & +ORIGIN_PID=$! +smoke_wait_http "http://127.0.0.1:$ORIGIN_PORT/" 200 \ + "$WORKSPACE/origin-ready.body" "$WORKSPACE/origin-ready.headers" +smoke_assert_process_alive "$ORIGIN_PID" "stub origin" "$WORKSPACE/origin.log" smoke_initialize_config "$FASTLY_PROJECT" "$WORKSPACE" "$ORIGIN_PORT" run_case() { @@ -95,6 +100,20 @@ run_case() { "$body_path" "$headers_path" smoke_assert_process_alive "$APP_PID" "Fastly CLI" "$log_path" smoke_assert_process_alive "$ORIGIN_PID" "stub origin" "$WORKSPACE/origin.log" + if [ -z "$expected_diagnostic" ]; then + local upload_status + upload_status=$(curl --silent --show-error --max-time 5 \ + --data-binary 'ts-lazy-upload-fixture' \ + --header 'Content-Type: application/octet-stream' \ + --dump-header "$WORKSPACE/upload.headers" \ + --output "$WORKSPACE/upload.body" --write-out '%{http_code}' \ + "http://127.0.0.1:$port/native-upload") + [ "$upload_status" = 200 ] || smoke_die "publisher POST did not reach the origin" + jq --exit-status '.received == "ts-lazy-upload-fixture"' \ + "$WORKSPACE/upload.body" >/dev/null || smoke_die "publisher POST body was lost" + [ "$(grep -ic '^set-cookie:' "$WORKSPACE/upload.headers")" = 2 ] || + smoke_die "closed Fastly delivery lost duplicate Set-Cookie fields" + fi smoke_stop_process "$APP_PID" APP_PID="" From ec735a24fcbb71ed8a46f04f5a3a58ba50f82d43 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 20 Oct 2026 08:32:48 -0500 Subject: [PATCH 3/4] test(cloudflare): build CLI before isolating local runtime home --- scripts/smoke-cloudflare.sh | 4 +- .../tests/test-smoke-cloudflare-home.test.mjs | 49 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 scripts/tests/test-smoke-cloudflare-home.test.mjs diff --git a/scripts/smoke-cloudflare.sh b/scripts/smoke-cloudflare.sh index 6a6521801d..416ec81e57 100755 --- a/scripts/smoke-cloudflare.sh +++ b/scripts/smoke-cloudflare.sh @@ -52,6 +52,9 @@ trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM +# Build a missing CLI with the caller's Cargo/rustup homes before isolating Wrangler. +smoke_resolve_ts_binary "$REPO_ROOT" + # Local-only Wrangler must not inherit operator credentials or configuration. while IFS= read -r name; do case "$name" in @@ -68,7 +71,6 @@ ACTUAL_WRANGLER=$(wrangler --version | sed -nE 's/^([0-9]+\.[0-9]+\.[0-9]+)$/\1/ [ "$ACTUAL_WRANGLER" = "$EXPECTED_WRANGLER" ] || smoke_die "Wrangler $EXPECTED_WRANGLER is required; found ${ACTUAL_WRANGLER:-unknown}" -smoke_resolve_ts_binary "$REPO_ROOT" CLOUDFLARE_ROOT="$REPO_ROOT/crates/trusted-server-adapter-cloudflare" [ -f "$CLOUDFLARE_ROOT/build/index.js" ] || smoke_die "Cloudflare Worker bundle not found: $CLOUDFLARE_ROOT/build/index.js" diff --git a/scripts/tests/test-smoke-cloudflare-home.test.mjs b/scripts/tests/test-smoke-cloudflare-home.test.mjs new file mode 100644 index 0000000000..0d0257043b --- /dev/null +++ b/scripts/tests/test-smoke-cloudflare-home.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +test( + 'Cloudflare smoke builds a missing CLI before isolating HOME', + { + skip: process.platform !== 'linux', + }, + () => { + const root = mkdtempSync(join(tmpdir(), 'ts-cloudflare-home-')); + try { + const scripts = join(root, 'scripts'); + const bin = join(root, 'bin'); + const home = join(root, 'caller-home'); + for (const directory of [scripts, bin, home]) mkdirSync(directory); + for (const name of ['smoke-cloudflare.sh', 'smoke-common.sh']) { + copyFileSync(fileURLToPath(new URL(`../${name}`, import.meta.url)), join(scripts, name)); + } + writeFileSync(join(root, '.tool-versions'), 'wrangler 4.151.3\n'); + writeFileSync(join(bin, 'wrangler'), '#!/bin/sh\necho 4.151.3\n', { mode: 0o755 }); + writeFileSync( + join(bin, 'cargo'), + '#!/bin/sh\nprintf "%s" "$HOME" > "$HOME_TRACE"\nexit 42\n', + { mode: 0o755 } + ); + const trace = join(root, 'cargo-home'); + const result = spawnSync('bash', [join(scripts, 'smoke-cloudflare.sh')], { + env: { + PATH: `${bin}:${process.env.PATH}`, + HOME: home, + TMPDIR: root, + HOME_TRACE: trace, + }, + encoding: 'utf8', + timeout: 10_000, + }); + assert.equal(result.error, undefined); + assert.equal(result.status, 42, result.stderr); + assert.equal(readFileSync(trace, 'utf8'), home); + } finally { + rmSync(root, { recursive: true, force: true }); + } + } +); From 39b5ac20bae478afbbf1f2becedb94f1a1f448cb Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 20 Oct 2026 08:51:11 -0500 Subject: [PATCH 4/4] docs: record RC431 validation evidence and remaining limits --- .github/workflows/format.yml | 3 + docs/guide/native-rc-demo.md | 3 + docs/guide/native-rc-validation.md | 189 +++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+) create mode 100644 docs/guide/native-rc-validation.md diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index bb6e5ffcb1..d7b4152c9f 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -196,3 +196,6 @@ jobs: - name: Test proposal scripts run: scripts/docs-proposal/test.sh + + - name: Test native and local smoke helpers + run: node --test scripts/tests/*.test.mjs diff --git a/docs/guide/native-rc-demo.md b/docs/guide/native-rc-demo.md index ac090c4669..3890716542 100644 --- a/docs/guide/native-rc-demo.md +++ b/docs/guide/native-rc-demo.md @@ -6,6 +6,9 @@ deployment. It starts from Trusted Server main `61bfe3b931fd97f5300287e4a91e33f32aa0fbee`. PRs #1410, #1411 and #1412, including their #777 module/configuration/permission stack, are excluded. +The [validation record](./native-rc-validation.md) lists executed checks, +resolved failures and remaining limits. + The native entry point is `trusted-server-axum` in package `trusted-server-adapter-axum`. It runs the RC production initializer and listener, not the former environment-backed development router. Required configuration and diff --git a/docs/guide/native-rc-validation.md b/docs/guide/native-rc-validation.md new file mode 100644 index 0000000000..fa42e32014 --- /dev/null +++ b/docs/guide/native-rc-validation.md @@ -0,0 +1,189 @@ +# EdgeZero RC431 validation record + +Validation date: 2026-10-20. This records local checks, not a deployment or a +production certification. See [the operator guide](./native-rc-demo.md) for inputs +and launch commands. + +## Inputs + +| Input | Revision | +| --------------------------------------------- | ---------------------------------------------------------------------------------------- | +| TS main base | `6ef07ae52bec3e695ec57fe090243b1a281c314f` | +| Reviewed implementation | `067d0fe1bfd32b57acf64d5e09a3f545e61e6bc7` | +| Final Rust and image input commit | `4a486a0d11525c2466e37b393634b1653ac2c802` | +| Cloudflare smoke HOME fix and regression test | `ec735a24fcbb71ed8a46f04f5a3a58ba50f82d43` | +| All six EdgeZero declarations | `https://github.com/IABTechLab/edgezero.git`, `61bfe3b931fd97f5300287e4a91e33f32aa0fbee` | +| ESI dependency-only patch | `https://github.com/stackpop/esi`, `75bfbb396d4cb7792610f1287cbec61675f13f38` | + +Later smoke, CI and documentation changes do not alter the image's copied inputs or +Rust sources. Checks were reused when their relevant inputs had not changed. +`cargo metadata --locked --format-version 1` confirms one EdgeZero Git source +across nine resolved EdgeZero packages. Normal native, Fastly, Cloudflare and Spin +graphs contain no `aws-sdk-*` packages. Optional native provider features were +checked separately and together. + +Tools used: Rust 1.98.1, Node 24.12.0, Fastly CLI 16.0.0, Viceroy 0.21.0, +Spin 4.1.0, worker-build 0.8.5, wasm-bindgen 0.2.126 and Wrangler 4.151.3. +Spin's emitted component imports and exports `wasi:http@0.3.0`. + +PRs #1410 through #1412 and the #777 module/configuration/permission design are +not part of this branch. It does not use #1228 as its base. + +## Rust and static checks + +All commands below passed. Counts exclude ignored cases. + +| Command, from repository root unless stated | Result | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| `cargo test -p trusted-server-core --lib` | 2,990 passed | +| `cargo test-axum` | Native unit, HTTP, lifecycle, routing, startup and subprocess suites passed | +| `cargo test-cloudflare` | 55 passed | +| `cargo test-spin` | 99 passed | +| `mise exec github:fastly/viceroy@0.21.0 -- cargo test-fastly` | 222 adapter tests, 2,984 shared WASM tests and integration/doctest checks passed | +| `mise exec github:fastly/viceroy@0.21.0 -- cargo test-fastly-reuse` | 222 passed | +| `cargo test -p trusted-server-cli --target x86_64-unknown-linux-gnu` | 890 passed; 29 remain ignored | +| `cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity` | 17 passed | +| `cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test integration --no-run` | Compiled; this is not an end-to-end runtime pass | +| `cargo test -p trusted-server-core --test template_build_digest` | 10 passed | +| `cargo clippy -p trusted-server-core --test template_build_digest -- -D warnings` | Passed | +| `cargo check-axum`, `check-fastly`, `check-cloudflare`, `check-spin` | Passed | +| `cargo clippy-fastly`, `clippy-axum`, `clippy-cloudflare`, `clippy-cloudflare-wasm`, `clippy-spin-native`, `clippy-spin-wasm`, `clippy-cli`, `clippy-codegen` | All aliases passed | +| `cargo fmt --all --check` | Passed | +| `cargo doc -p trusted-server-core --no-deps --all-features` | Passed with 30 existing rustdoc warnings | +| `cargo check -p trusted-server-adapter-axum --features aws-appconfig-agent` | Passed | +| `cargo check -p trusted-server-adapter-axum --features aws-secrets-manager` | Passed | +| `cargo build -p trusted-server-adapter-axum --features aws-appconfig-agent,aws-secrets-manager --bin trusted-server-axum` | Passed; this binary ran the provider acceptance below | +| `node --test scripts/tests/*.test.mjs` | Six Linux helper tests passed, including CLI-build HOME isolation | +| `mise exec shellcheck@0.11.0 -- shellcheck scripts/smoke-{common,axum,fastly,cloudflare,spin}.sh` | Passed | +| `ts dev lint domains --changed-vs 6ef07ae52bec3e695ec57fe090243b1a281c314f` | Passed on the committed changes | +| `bash scripts/lint-wasm-clock.sh` | Passed | +| In `crates/trusted-server-js/lib`: `npm run build`, `npm test`, `npm run lint`, `npm run format` | Passed; 1,211 JS tests. JS library inputs are unchanged since that run | +| In `docs`: `npm run format`, `npm run lint`, `npm run build`; then `bash scripts/docs-proposal/test.sh` from root | Passed; 20 proposal-script tests | + +The strict probe `RUSTDOCFLAGS="-D warnings" cargo doc -p trusted-server-core +--no-deps --all-features` failed on broken/private documentation links. The same +probe on the untouched TS base reproduced those diagnostics. No strict rustdoc +pass is claimed. Ordinary suite skips remain skips; only the real Next.js case +below was explicitly rerun with `--ignored`. + +## Actual local runtime checks + +### Prepared native startup, ingress and HTTP + +The native suites launched the real binary with owned synthetic configuration. +They checked valid envelopes, missing/tampered configuration, missing secrets, +invalid runtime settings, no legacy environment rescue, restart-only adoption, +request-local client/public-URL metadata and an absolute-target spoof attempt. +The 15 native HTTP tests exercised Host override, duplicate cookies, decoding, +byte limits, deadlines, pending-handle selection, late append and cancellation. +Buffered JSON endpoint probes also passed without making publisher bodies eager. + +The real Next.js 14.2.35 fixture was prewarmed on loopback, then run with: + +```sh +NATIVE_NEXTJS_ORIGIN="$LOCAL_NEXTJS_ORIGIN" cargo test \ + -p trusted-server-adapter-axum --test native_acceptance \ + real_nextjs_action_and_api_after_truncated_post -- --ignored --exact --nocapture +``` + +This passed a truncated POST, a subsequent real server action returning 303, +and a subsequent real API response. The fixture retained its explicit +`publisher.example.com` server-action origin allowance. An unknown-action 404 +was not accepted as success. + +### Independent native providers + +```sh +node scripts/test-native-providers.mjs target/debug/trusted-server-axum +``` + +AppConfig Agent local-development mode and Moto Secrets Manager ran separately +from TS. Agent-only, Secrets-Manager-only and combined selection passed real +TS health, authentication and embedded-TSJS checks. They also passed immutable +snapshot checks, restart adoption, whole secret-string handling, and selected +provider failures before listen without local/environment fallback. + +This fixture did not publish a CLI export into the Agent. CLI-generated envelope +acceptance was checked separately through the native first-success smoke. It did +not test DynamoDB, real AWS credentials, IAM or AWS account services. + +### Fastly, Spin, Cloudflare and native launchers + +```sh +mise exec github:fastly/cli@16.0.0 github:fastly/viceroy@0.21.0 -- bash scripts/smoke-fastly.sh +mise exec spin-framework@4.1.0 -- bash scripts/smoke-spin.sh +bash scripts/smoke-axum.sh +# With the exact worker-build/wasm-bindgen versions above on PATH: +(cd crates/trusted-server-adapter-cloudflare && worker-build --release -- --locked) +bash scripts/smoke-cloudflare.sh +``` + +All passed. Fastly and the real local Worker passed publisher GET rewriting, +nonempty POST echo and two separate Set-Cookie fields. The Worker also checked +four observable missing-config/secret failures. Its rebuilt bundle's 289 input +hashes were verified after the run. A further full smoke started without a CLI, +`TS_BIN`, `RUSTUP_HOME` or `CARGO_HOME`; it built the CLI using the caller's homes +before isolating Wrangler and passed every runtime assertion. + +Spin ran its SDK7/P3 component in Spin 4.1.0. Separate Fastly closed-delivery tests +covered Application versus fallback completion, source failure, refusal and +exactly-once observation. Raw Fastly health, JA4 and optional sandbox metrics +remain the documented SDK-owned exceptions. + +### Native image + +```sh +docker build \ + --build-arg TRUSTED_SERVER__GIT_VERSION=4a486a0d11525c2466e37b393634b1653ac2c802 \ + -t ts-edgezero431-local:4a486a0d . +node scripts/test-native-container.mjs ts-edgezero431-local:4a486a0d +``` + +Both passed. The local linux/amd64 image ID was +`sha256:57601dad86d181379b09a497767eb1b5925e4f859136351327637663f019d4c1`, +42,750,041 bytes, configured as UID/GID `10001:10001`. Runtime checks proved +non-loopback published-port access, invalid-input rejection before listen, page +rewriting, embedded TSJS, CA roots, no Node/Rust toolchain, a read-only root with +explicit writable mounts, and a normal in-flight SIGTERM drain in 1,346 ms. +No image was published. The new image workflow only builds and tests locally. + +## Failures found and resolved + +- The core environment test also failed on untouched main because the build's + `TRUSTED_SERVER__GIT_VERSION` is not a Settings field. The test-only environment + reader now excludes that build metadata key. +- Viceroy 0.17 lacked the SDK0.13 cache ABI. Viceroy 0.21 passed it. +- Spin's lazy empty upload reached a buffered-only SDK converter. Bounded owned + upload collection fixed it. Review then found the same rejection in Fastly + and Cloudflare. Both now use that shared collector, with source-error and + over-limit tests plus actual GET/POST checks. +- Fastly's closed store preparation opened the unused `trusted_server_kv` + declaration and failed. Removing that declaration fixed the real smoke; + metadata tests were updated together. Dedicated Fastly EC storage is unchanged. +- Worker errors were silent because `log` had no backend. The private Info-level + console backend made all four negative smoke diagnostics observable. +- Review found that Wrangler's isolated HOME also redirected the CLI-build + fallback's rustup/Cargo homes. CLI resolution now precedes isolation. A + regression test and an actual fresh-CLI full smoke passed with default homes. +- Iteration also caught compile, Clippy, formatting, stale metadata assertions + and fixture/setup failures. Final checks above passed after the relevant fixes. + +Initial correctness, architecture, code-smell and style reviews inspected the +first implementation. A source-only correctness follow-up inspected the full +runtime-fix delta. The coordinator verified and fixed C1, C2 and C3 and the Spin +wildcard import. The three nonblocking duplication suggestions remain deferred. +Reviews were not counted as executed runtime evidence. + +## Remaining limits + +No AWS provisioning/deployment, registry publication, hosted provider deployment, +remote CI certification, new EC graph or native signing backend was tested or +added. The minimal native profile rejects enabled signing/dynamic extra config +stores before listen. Provider acceptance used the host feature-enabled binary; +the tested image used default features. No arm64 image or external HTTPS-origin +round trip was tested. CA-root presence alone is not a TLS handshake test. +The full browser/vendor/container integration matrix was not run. + +Coordinator logs, input fingerprints and exit codes are in `/tmp/ts431-checks`. +The Cloudflare worker retained its exact tool, bundle and input hashes separately. +Those local logs are not hosted CI results and may disappear after cleanup.