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..fff9d078b8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +# 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 +crates/trusted-server-adapter-cloudflare/build +crates/trusted-server-adapter-cloudflare/pkg +**/.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..d7b4152c9f 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 @@ -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/.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/.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..94e660a617 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,7 +1,9 @@ -fastly 15.1.0 -rust 1.95.0 +fastly 16.0.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 +wrangler 4.151.3 awscli 2.36.45 terraform 1.16.2 diff --git a/AGENTS.md b/AGENTS.md index ec97980f0d..b612a6290a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,16 +31,18 @@ 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 | 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`) | --- @@ -62,7 +64,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 +90,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 +161,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 ``` --- @@ -352,7 +355,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..f426176c3c 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,11 +288,54 @@ 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 1.4.2", + "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" +version = "1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" dependencies = [ "aws-lc-sys", "zeroize", @@ -312,9 +343,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" dependencies = [ "cc", "cmake", @@ -323,6 +354,391 @@ 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 1.4.2", + "http-body", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-dynamodb" +version = "1.120.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f578638cafe182113da770e9798e7063d6de96cbbfe06f647d0026f197c9b97c" +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 0.2.12", + "http 1.4.2", + "regex-lite", + "tracing", + "url", +] + +[[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 1.4.2", + "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 1.4.2", + "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 1.4.2", + "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 1.4.2", + "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 1.4.2", + "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 1.4.2", + "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 1.4.2", + "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 1.4.2", + "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 1.4.2", + "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 1.4.2", +] + +[[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 1.4.2", +] + +[[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 1.4.2", + "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" @@ -333,7 +749,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", + "http 1.4.2", "http-body", "http-body-util", "hyper", @@ -364,7 +780,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", + "http 1.4.2", "http-body", "http-body-util", "mime", @@ -381,12 +797,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 +867,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" @@ -452,7 +893,7 @@ dependencies = [ "futures-util", "hex", "home", - "http", + "http 1.4.2", "http-body-util", "hyper", "hyper-named-pipe", @@ -525,9 +966,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 +1014,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 +1208,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 +1271,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 +1346,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 +1531,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 +1586,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 +1749,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", ] @@ -1371,11 +1852,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid", - "crypto-common", + "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 = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "directories" version = "5.0.1" @@ -1498,11 +1991,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=83d7a6296d285e135c4ea70021b19e6c8dca7e9f#83d7a6296d285e135c4ea70021b19e6c8dca7e9f" dependencies = [ + "base16ct 1.0.0", + "edgezero-core", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.1", "toml", "walkdir", ] @@ -1510,21 +2005,32 @@ 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=83d7a6296d285e135c4ea70021b19e6c8dca7e9f#83d7a6296d285e135c4ea70021b19e6c8dca7e9f" dependencies = [ "anyhow", + "async-stream", "async-trait", "axum", "bytes", "ctor", "edgezero-adapter", "edgezero-core", + "edgezero-store-aws", + "edgezero-store-celld", "futures", "futures-util", - "http", + "getrandom 0.4.2", + "http 1.4.2", + "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 +2044,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=83d7a6296d285e135c4ea70021b19e6c8dca7e9f#83d7a6296d285e135c4ea70021b19e6c8dca7e9f" dependencies = [ "anyhow", + "async-stream", "async-trait", "brotli", "bytes", @@ -1555,17 +2062,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=83d7a6296d285e135c4ea70021b19e6c8dca7e9f#83d7a6296d285e135c4ea70021b19e6c8dca7e9f" dependencies = [ "anyhow", "async-stream", "async-trait", + "base16ct 1.0.0", "brotli", "bytes", "chrono", @@ -1573,6 +2083,8 @@ dependencies = [ "edgezero-adapter", "edgezero-core", "fastly", + "fastly-shared", + "fastly-sys", "fern", "flate2", "futures", @@ -1581,7 +2093,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 +2102,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=83d7a6296d285e135c4ea70021b19e6c8dca7e9f#83d7a6296d285e135c4ea70021b19e6c8dca7e9f" dependencies = [ "anyhow", + "async-stream", "async-trait", - "brotli", "bytes", "ctor", "edgezero-adapter", "edgezero-core", - "flate2", "futures", "futures-util", "log", @@ -1617,7 +2128,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=83d7a6296d285e135c4ea70021b19e6c8dca7e9f#83d7a6296d285e135c4ea70021b19e6c8dca7e9f" dependencies = [ "chrono", "clap", @@ -1632,7 +2143,7 @@ dependencies = [ "log", "serde", "serde_json", - "similar", + "similar 3.2.0", "simple_logger", "thiserror 2.0.18", "toml", @@ -1642,30 +2153,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=83d7a6296d285e135c4ea70021b19e6c8dca7e9f#83d7a6296d285e135c4ea70021b19e6c8dca7e9f" 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 1.4.2", "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 +2197,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=83d7a6296d285e135c4ea70021b19e6c8dca7e9f#83d7a6296d285e135c4ea70021b19e6c8dca7e9f" dependencies = [ "log", "proc-macro2", @@ -1685,6 +2209,44 @@ dependencies = [ "validator", ] +[[package]] +name = "edgezero-store-aws" +version = "0.1.0" +source = "git+https://github.com/IABTechLab/edgezero.git?rev=83d7a6296d285e135c4ea70021b19e6c8dca7e9f#83d7a6296d285e135c4ea70021b19e6c8dca7e9f" +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 = "edgezero-store-celld" +version = "0.1.0" +source = "git+https://github.com/IABTechLab/edgezero.git?rev=83d7a6296d285e135c4ea70021b19e6c8dca7e9f#83d7a6296d285e135c4ea70021b19e6c8dca7e9f" +dependencies = [ + "async-trait", + "base64", + "bytes", + "edgezero-core", + "futures-util", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.11.1", + "thiserror 2.0.18", + "tokio", + "url", +] + [[package]] name = "ego-tree" version = "0.10.0" @@ -1703,7 +2265,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 +2359,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 +2411,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", @@ -1861,9 +2422,8 @@ dependencies = [ "fastly-macros", "fastly-shared", "fastly-sys", - "http", + "http 1.4.2", "itertools 0.13.0", - "lazy_static", "mime", "serde", "serde_json", @@ -1878,9 +2438,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,23 +2449,23 @@ 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", + "http 1.4.2", ] [[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", - "http", + "http 1.4.2", "wasip2", "wit-bindgen 0.51.0", ] @@ -1988,6 +2548,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 +2737,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]] @@ -2561,7 +3129,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c91d8cffac8849493a82233811bd02b2b183b8cf39bf704de0fa0841b737595" dependencies = [ "bstr", - "hashbrown 0.17.1", + "hashbrown 0.16.1", ] [[package]] @@ -2977,7 +3545,7 @@ checksum = "0da1c46491b49458a446cc76f0085860f8164c2290742e0aa8c653ce67240a97" dependencies = [ "bstr", "fastrand", - "getrandom 0.4.3", + "getrandom 0.4.2", "unicode-normalization", ] @@ -3072,7 +3640,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.2", "indexmap 2.14.0", "slab", "tokio", @@ -3127,8 +3695,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 +3713,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -3150,16 +3724,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 +3773,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" @@ -3234,6 +3808,17 @@ dependencies = [ "match_token", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.2" @@ -3251,7 +3836,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.2", ] [[package]] @@ -3262,7 +3847,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", + "http 1.4.2", "http-body", "pin-project-lite", ] @@ -3279,18 +3864,27 @@ 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", "futures-channel", "futures-core", "h2", - "http", + "http 1.4.2", "http-body", "httparse", "httpdate", @@ -3322,10 +3916,11 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http", + "http 1.4.2", "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -3355,7 +3950,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", + "http 1.4.2", "http-body", "hyper", "ipnet", @@ -3849,9 +4444,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 +4500,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 +4519,7 @@ dependencies = [ "cfg-if", "cssparser 0.36.0", "encoding_rs", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "memchr", "mime", @@ -4288,6 +4883,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" @@ -4939,7 +5540,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 +5604,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 +5674,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" @@ -5092,7 +5699,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", + "http 1.4.2", "http-body", "http-body-util", "hyper", @@ -5132,7 +5739,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", + "http 1.4.2", "http-body", "http-body-util", "hyper", @@ -5151,12 +5758,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 +5814,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 +5840,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", ] @@ -5292,9 +5900,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -5366,9 +5974,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -5457,7 +6065,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 +6348,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 +6419,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 +6476,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,19 +6487,19 @@ 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", "futures", - "http", + "http 1.4.2", "http-body", "http-body-util", "spin-macro", "thiserror 2.0.18", - "wasip3", + "wasip3 0.7.1+wasi-0.3.0", ] [[package]] @@ -6046,7 +6674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -6360,7 +6988,7 @@ dependencies = [ "base64", "bytes", "h2", - "http", + "http 1.4.2", "http-body", "http-body-util", "hyper", @@ -6434,7 +7062,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", + "http 1.4.2", "http-body", "http-body-util", "pin-project-lite", @@ -6495,17 +7123,24 @@ name = "trusted-server-adapter-axum" version = "0.1.0" dependencies = [ "async-trait", + "aws-config", + "aws-sdk-dynamodb", "axum", "base64", "edgezero-adapter-axum", "edgezero-core", + "edgezero-store-celld", "error-stack", "futures", "log", "reqwest 0.12.28", + "serde", + "serde_json", "simple_logger", "temp-env", + "tempfile", "tokio", + "toml", "tower 0.4.13", "trusted-server-core", ] @@ -6574,6 +7209,7 @@ dependencies = [ "futures", "http-body-util", "log", + "serde_json", "spin-sdk", "tokio", "trusted-server-core", @@ -6598,7 +7234,7 @@ dependencies = [ "futures", "gix", "glob", - "http", + "http 1.4.2", "http-body-util", "hyper", "hyper-util", @@ -6615,7 +7251,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", - "similar", + "similar 2.7.0", "temp-env", "tempfile", "time", @@ -6650,14 +7286,15 @@ dependencies = [ "derive_more", "ed25519-dalek", "edgezero-core", + "edgezero-store-celld", "error-stack", "flate2", "futures", "getrandom 0.2.17", "glob", "hex", - "hmac", - "http", + "hmac 0.12.1", + "http 1.4.2", "httpdate", "iab_gpp", "jose-jwk", @@ -6696,13 +7333,14 @@ dependencies = [ "edgezero-core", "env_logger", "error-stack", - "http", + "http 1.4.2", "http-body-util", "libc", "log", "reqwest 0.12.28", "scraper", "serde_json", + "tempfile", "testcontainers", "tokio", "toml", @@ -6754,7 +7392,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.2", "httparse", "log", "rand 0.9.4", @@ -6836,7 +7474,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", ] @@ -6874,7 +7512,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ "base64", - "http", + "http 1.4.2", "httparse", "log", ] @@ -6928,19 +7566,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 +7612,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 +7639,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,12 +7663,21 @@ 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", + "http 1.4.2", "http-body", "thiserror 2.0.18", "wit-bindgen 0.57.1", @@ -7086,6 +7738,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 +7755,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 +7778,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 +7808,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 +8174,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 +8185,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 +8207,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 +8237,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 +8268,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 +8304,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 +8344,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.247.0", ] [[package]] @@ -7579,7 +8358,7 @@ dependencies = [ "chrono", "futures-channel", "futures-util", - "http", + "http 1.4.2", "http-body", "js-sys", "matchit 0.7.3", @@ -7593,7 +8372,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.6.0", "web-sys", "worker-macros", "worker-sys", @@ -7661,6 +8440,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 +8454,7 @@ checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", - "hashlink 0.11.1", + "hashlink", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2d56af4bb2..a058c44120 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,16 +56,17 @@ 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 = "83d7a6296d285e135c4ea70021b19e6c8dca7e9f", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/IABTechLab/edgezero.git", rev = "83d7a6296d285e135c4ea70021b19e6c8dca7e9f", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/IABTechLab/edgezero.git", rev = "83d7a6296d285e135c4ea70021b19e6c8dca7e9f", default-features = false } +edgezero-adapter-spin = { git = "https://github.com/IABTechLab/edgezero.git", rev = "83d7a6296d285e135c4ea70021b19e6c8dca7e9f", default-features = false } +edgezero-cli = { git = "https://github.com/IABTechLab/edgezero.git", rev = "83d7a6296d285e135c4ea70021b19e6c8dca7e9f" } +edgezero-core = { git = "https://github.com/IABTechLab/edgezero.git", rev = "83d7a6296d285e135c4ea70021b19e6c8dca7e9f", default-features = false } +edgezero-store-celld = { git = "https://github.com/IABTechLab/edgezero.git", rev = "83d7a6296d285e135c4ea70021b19e6c8dca7e9f", 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 +88,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 +108,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 +126,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..12c0072388 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +# syntax=docker/dockerfile:1 +# Adapted from the pinned EdgeZero RC'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 and CloudFront Geo are default-off. 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 +feature_ifs=$IFS +IFS=, +for feature in $NATIVE_FEATURES; do + case "$feature" in + aws-appconfig-agent|aws-secrets-manager|aws-dynamodb-ec|celld-ec|geo-cloudfront) ;; + *) echo 'Only the declared native store, EC and Geo features are supported' >&2; exit 1 ;; + esac +done +IFS=$feature_ifs +unset feature feature_ifs +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..0b5ddb2e7e 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -18,19 +18,37 @@ 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"] +aws-dynamodb-ec = ["dep:aws-config", "dep:aws-sdk-dynamodb"] +geo-cloudfront = ["edgezero-adapter-axum/geo-cloudfront"] +celld-ec = ["dep:edgezero-store-celld"] + [dependencies] async-trait = { workspace = true } +aws-config = { version = "=1.12.0", default-features = false, features = ["default-https-client", "rt-tokio", "credentials-process", "sso"], optional = true } +aws-sdk-dynamodb = { version = "=1.120.0", default-features = false, features = ["default-https-client", "rt-tokio"], optional = true } edgezero-adapter-axum = { workspace = true, features = ["axum"] } edgezero-core = { workspace = true } error-stack = { workspace = true } futures = { workspace = true } log = { workspace = true } reqwest = { workspace = true } +serde = { workspace = true } +toml = { workspace = true } simple_logger = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] } trusted-server-core = { workspace = true } +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +edgezero-store-celld = { workspace = true, features = ["records"], optional = 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..244d29f520 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,13 +16,25 @@ use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan, }; use trusted_server_core::cache_policy::EdgeCacheHeader; -use trusted_server_core::ec::EcContext; +use trusted_server_core::config_payload::{DEFAULT_CONFIG_STORE_ID, DEFAULT_SECRET_STORE_ID}; +use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; +use trusted_server_core::ec::consent::ec_consent_withdrawn; +use trusted_server_core::ec::device::DeviceSignals; +use trusted_server_core::ec::finalize::ec_finalize_response; +use trusted_server_core::ec::identify::{cors_preflight_identify, handle_identify}; +use trusted_server_core::ec::kv::KvIdentityGraph; +use trusted_server_core::ec::kv_backend::EcKvStore; use trusted_server_core::ec::registry::PartnerRegistry; +use trusted_server_core::ec::{EcContext, EidSyncSource}; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; -use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; +use trusted_server_core::http_util::is_navigation_request; +use trusted_server_core::integrations::{ + IntegrationRegistry, ProxyDispatchInput, RequestFilterRegistryInput, + RequestFilterRegistryOutcome, +}; use trusted_server_core::proxy::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, @@ -33,14 +47,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,29 +59,12 @@ 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) + partners: PartnerRegistry, + ec_graph: Option KvIdentityGraph + Send + Sync>>, } /// Build the application state from explicit settings. @@ -79,7 +73,7 @@ fn build_state() -> Result, Report> { /// /// 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) @@ -89,24 +83,51 @@ fn build_state_with_services( settings: Settings, services: Option, ) -> Result, Report> { + settings + .ec + .resolve_storage(trusted_server_core::ec::storage::EcRuntime::Native)?; let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Axum)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; let registry = IntegrationRegistry::with_plan(&settings, plan)?; + let partners = PartnerRegistry::from_config(&settings.ec.partners)?; Ok(Arc::new(AppState { settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), services, + partners, + ec_graph: None, })) } 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") + }) + } + + pub(crate) fn set_ec_store(&mut self, store: S) + where + S: EcKvStore + Clone + Send + Sync + 'static, + { + self.ec_graph = Some(Arc::new(move || KvIdentityGraph::new(store.clone()))); + } + + fn identity_graph(&self) -> Option { + self.ec_graph.as_ref().map(|factory| factory()) + } + + 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 +171,42 @@ 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() { + trusted_server_core::ec::consent_update::CONSENT_UPDATE_PATH => { + Some(trusted_server_core::ec::consent_update::MAX_CONSENT_UPDATE_BODY_BYTES) + } + "/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 req.uri().path() == trusted_server_core::ec::consent_update::CONSENT_UPDATE_PATH { + req = match trusted_server_core::ec::consent_update::prepare_consent_update(&services, req) + .await + { + Ok(req) => req, + Err(response) => return Ok(*response), + }; + } if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &state.settings, &mut req, @@ -181,11 +227,15 @@ where /// /// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction /// Unknown, which fails the auction consent gate closed even for consented -/// users. Geo comes from the platform (a no-op on the local Axum dev server, so -/// jurisdiction stays Unknown there unless the request carries TCF consent). A -/// malformed consent string is logged and falls back to the default -/// (fail-closed) context rather than being silently swallowed. -fn build_ec_context(state: &AppState, services: &RuntimeServices, req: &Request) -> EcContext { +/// users. Geo comes from the immutable managed request snapshot. Off, untrusted +/// or unavailable results remain Unknown unless real consent supplies it. A +/// malformed EC cookie fails closed before dispatch rather than silently +/// substituting a context. Consent decoding remains owned by core. +fn build_ec_context( + state: &AppState, + services: &RuntimeServices, + req: &Request, +) -> Result> { let geo_info = services .geo() .lookup(services.client_info().client_ip) @@ -194,10 +244,90 @@ fn build_ec_context(state: &AppState, services: &RuntimeServices, req: &Request) None }); EcContext::read_from_request_with_geo(&state.settings, req, services, geo_info.as_ref()) - .unwrap_or_else(|e| { - log::warn!("EC context read failed: {e:?}"); - EcContext::default() +} + +/// Native has no trusted TLS/JA4 fingerprint from CloudFront/ALB. Classify only +/// obvious browser traffic from UA, then require checked client metadata and +/// shared navigation/consent gates before issuance. This is a heuristic, not +/// authentication, and forwarded fingerprint headers are deliberately ignored. +fn native_browser_signals(req: &Request) -> (DeviceSignals, bool) { + let ua = req + .headers() + .get(header::USER_AGENT) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + let signals = DeviceSignals::derive(ua, None, None); + let browser = signals.platform_class.is_some() + && ["Firefox/", "Chrome/", "Safari/"] + .iter() + .any(|token| ua.contains(token)) + && !ua.to_ascii_lowercase().contains("bot"); + (signals, browser) +} + +fn cookie_value(req: &Request, name: &str) -> Option { + req.headers() + .get_all(header::COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(';')) + .filter_map(|pair| pair.trim().split_once('=')) + .find(|(key, _)| key.trim() == name) + .map(|(_, value)| value.trim().to_owned()) +} + +struct NativeEcRequest { + context: EcContext, + graph: Option, + browser: bool, + eids_cookie: Option, + sharedid_cookie: Option, +} + +impl NativeEcRequest { + fn read( + state: &AppState, + services: &RuntimeServices, + req: &Request, + ) -> Result> { + let mut context = build_ec_context(state, services, req)?; + let (signals, browser) = native_browser_signals(req); + context.set_device_signals(signals); + Ok(Self { + context, + graph: state.identity_graph(), + browser, + eids_cookie: cookie_value(req, COOKIE_TS_EIDS), + sharedid_cookie: cookie_value(req, COOKIE_SHAREDID), }) + } + + async fn finalize(mut self, state: &AppState, mut response: Response) -> Response { + // Revocation must remain writable even for a hardened/non-browser UA. + let graph = self + .graph + .as_ref() + .filter(|_| self.browser || ec_consent_withdrawn(self.context.consent())); + ec_finalize_response( + &state.settings, + &mut self.context, + graph, + &state.partners, + self.eids_cookie.as_deref(), + self.sharedid_cookie.as_deref(), + &mut response, + ) + .await; + response + } +} + +#[derive(Clone, Copy)] +enum EcRoute { + Publisher, + Auction, + PageBids, + ConsentUpdate, } // --------------------------------------------------------------------------- @@ -221,16 +351,80 @@ async fn dispatch_fallback( return handle_tsjs_dynamic(&req, &state.registry, EdgeCacheHeader::SMaxageFallback); } - if state.registry.has_route(&method, &path) { - let mut ec_context = EcContext::default(); - return state + dispatch_ec_route(state, services, req, EcRoute::Publisher).await +} + +async fn dispatch_ec_route( + state: &AppState, + services: &RuntimeServices, + mut req: Request, + route: EcRoute, +) -> Result> { + let mut ec = NativeEcRequest::read(state, services, &req)?; + let method = req.method().clone(); + let path = req.uri().path().to_owned(); + let effects = match state + .registry + .filter_request(RequestFilterRegistryInput { + settings: &state.settings, + services, + req: &mut req, + geo_info: ec.context.geo_info(), + }) + .await + { + Ok(RequestFilterRegistryOutcome::Continue(effects)) => effects, + Ok(RequestFilterRegistryOutcome::Respond { response, effects }) => { + let mut response = ec.finalize(state, *response).await; + effects.apply_to_response(&mut response); + return Ok(response); + } + Err(error) => return Ok(ec.finalize(state, http_error(&error)).await), + }; + // Borrows of the graph and context remain local to this request. Neither + // the non-Send graph nor a response snapshot enters shared App extensions. + let graph = ec.graph.as_ref().filter(|_| ec.browser); + let auction = AuctionDispatch { + orchestrator: &state.orchestrator, + slots: state.settings.creative_opportunity_slots(), + registry: Some(&state.partners), + }; + let result = match route { + EcRoute::ConsentUpdate => Ok( + trusted_server_core::ec::consent_update::handle_consent_update(&req, ec.graph.as_ref()), + ), + EcRoute::Auction => { + handle_auction( + &state.settings, + &state.orchestrator, + graph, + Some(&state.partners), + &mut ec.context, + services, + req, + ) + .await + } + EcRoute::PageBids => { + handle_page_bids( + &state.settings, + services, + graph, + auction, + &mut ec.context, + req, + ) + .await + } + EcRoute::Publisher if state.registry.has_route(&method, &path) => state .registry .handle_proxy(ProxyDispatchInput { method: &method, path: &path, settings: &state.settings, - kv: None, - ec_context: &mut ec_context, + kv: graph, + allow_generation: false, + ec_context: &mut ec.context, services, req, }) @@ -239,50 +433,61 @@ async fn dispatch_fallback( Err(Report::new(TrustedServerError::BadRequest { message: format!("Unknown integration route: {path}"), })) - }); - } - - // Run the server-side auction with the configured creative-opportunity - // slots; `handle_publisher_request` matches them against the request path. - let mut ec_context = build_ec_context(state, services, &req); - let auction = AuctionDispatch { - orchestrator: &state.orchestrator, - slots: state.settings.creative_opportunity_slots(), - registry: None, + }), + EcRoute::Publisher => { + let navigation = method == Method::GET + && is_navigation_request(&req) + && state.settings.asset_route_for_path(&path).is_none(); + if navigation { + ec.context.set_eid_sync_source(EidSyncSource::Navigation); + } + let eligible = navigation && ec.browser && graph.is_some(); + if eligible + && let Err(error) = ec.context.generate_if_needed(&state.settings, graph).await + { + log::warn!("native EC generation failed: {error:?}"); + } + match handle_publisher_request( + &state.settings, + services, + graph, + &mut ec.context, + auction, + req, + EdgeCacheHeader::SMaxageFallback, + ) + .await + { + Ok(publisher_response) => { + ec.context.set_recovery_eligible(eligible); + buffer_publisher_response_async( + publisher_response, + &method, + &state.settings, + &state.registry, + &state.orchestrator, + services, + ) + .await + } + Err(error) => Err(error), + } + } }; - let publisher_response = handle_publisher_request( - &state.settings, - services, - None, - &mut ec_context, - auction, - req, - EdgeCacheHeader::SMaxageFallback, - ) - .await?; - // Async finalize so the dispatched auction is collected and its bids are - // injected before `` (the sync buffer path would drop them). - buffer_publisher_response_async( - publisher_response, - &method, - &state.settings, - &state.registry, - &state.orchestrator, - services, - ) - .await + // Convert handled route errors before finalization, so withdrawal is not + // lost when an origin/auction fails. There is exactly one finalize call. + let mut response = ec + .finalize(state, result.unwrap_or_else(|error| http_error(&error))) + .await; + effects.apply_to_response(&mut response); + Ok(response) } -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 + })) } } @@ -303,6 +508,8 @@ enum NamedRouteHandler { LegacyAdminDenied, Auction, PageBids, + ConsentUpdate, + Identify, FirstPartyProxy, FirstPartyClick, FirstPartySign, @@ -325,8 +532,28 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_routes() -> [NamedRoute; 17] { +fn named_routes() -> [NamedRoute; 19] { [ + NamedRoute { + path: trusted_server_core::ec::consent_update::CONSENT_UPDATE_PATH, + primary_methods: &[ + Method::GET, + Method::POST, + Method::HEAD, + Method::OPTIONS, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::TRACE, + Method::CONNECT, + ], + handler: NamedRouteHandler::ConsentUpdate, + }, + NamedRoute { + path: "/_ts/api/v1/identify", + primary_methods: &[Method::GET, Method::OPTIONS], + handler: NamedRouteHandler::Identify, + }, NamedRoute { path: "/.well-known/trusted-server.json", primary_methods: &[Method::GET], @@ -442,13 +669,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 +699,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", @@ -491,31 +714,42 @@ fn named_route_handler( Ok(resp) } NamedRouteHandler::AdminEcNotSupported => { - // The EC identity graph is Fastly KV backed; the Axum - // dev server has no store to read. + // Enabling native operator EC diagnostics is separate + // from the browser/server lifecycle. Ok(admin_ec_lookup_not_supported()) } NamedRouteHandler::AdminEidsLookup => { - let partner_registry = - PartnerRegistry::from_config(&state.settings.ec.partners)?; - handle_admin_eids_lookup(&partner_registry, &req) + handle_admin_eids_lookup(&state.partners, &req) } NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()), + NamedRouteHandler::ConsentUpdate => { + dispatch_ec_route(&state, &services, req, EcRoute::ConsentUpdate).await + } + NamedRouteHandler::Identify => { + // Read-only identity lookup never enters generation or + // response finalization, including cookie ingestion. + if req.method() == Method::OPTIONS { + cors_preflight_identify(&state.settings, &req) + } else { + let context = build_ec_context(&state, &services, &req)?; + let graph = state.identity_graph().ok_or_else(|| { + Report::new(TrustedServerError::KvStore { + store_name: "native EC".to_owned(), + message: "native EC provider is not selected".to_owned(), + }) + })?; + handle_identify( + &state.settings, + &graph, + &state.partners, + &req, + &context, + ) + .await + } + } NamedRouteHandler::Auction => { - // Build the geo-aware EC context so the auction consent - // gate sees the caller's jurisdiction — `EcContext::default()` - // fails it closed for consented users. - let mut ec_context = build_ec_context(&state, &services, &req); - handle_auction( - &state.settings, - &state.orchestrator, - None, - None, - &mut ec_context, - &services, - req, - ) - .await + dispatch_ec_route(&state, &services, req, EcRoute::Auction).await } NamedRouteHandler::PageBids => { // SPA re-auction endpoint. `OPTIONS` is a CORS preflight @@ -524,21 +758,7 @@ fn named_route_handler( if req.method() == Method::OPTIONS { Ok(page_bids_preflight_denied()) } else { - let mut ec_context = build_ec_context(&state, &services, &req); - let auction = AuctionDispatch { - orchestrator: &state.orchestrator, - slots: state.settings.creative_opportunity_slots(), - registry: None, - }; - handle_page_bids( - &state.settings, - &services, - None, - auction, - &mut ec_context, - req, - ) - .await + dispatch_ec_route(&state, &services, req, EcRoute::PageBids).await } } NamedRouteHandler::FirstPartyProxy => { @@ -575,32 +795,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 +820,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 +856,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 +884,47 @@ 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)); + /// Builds a router with an explicit live conditional EC store. + /// + /// This seam uses the same request-local graph and lifecycle as production. + /// It never reads provider selection from the environment. + /// + /// # Errors + /// Returns an error when settings or runtime plans cannot initialize. + /// + /// # Panics + /// Panics if a newly built application state unexpectedly has another owner. + pub fn routes_with_settings_services_and_ec_store( + settings: Settings, + services: RuntimeServices, + store: S, + ) -> Result> + where + S: EcKvStore + Clone + Send + Sync + 'static, + { + let mut state = build_state_with_services(settings, Some(services))?; + Arc::get_mut(&mut state) + .expect("should uniquely own new app state") + .set_ec_store(store); + Ok(build_router(Some(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 +941,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/celld_ec.rs b/crates/trusted-server-adapter-axum/src/celld_ec.rs new file mode 100644 index 0000000000..df677627cc --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/celld_ec.rs @@ -0,0 +1,120 @@ +//! Native EC persistence through celld's typed conditional-record client. + +use async_trait::async_trait; +use edgezero_core::EdgeError; +use edgezero_core::time::MonotonicClock; +use edgezero_store_celld::CelldPreparation; +use edgezero_store_celld::records::{ + CelldRecordStore, RecordWrite, RecordWriteMode, RecordWriteOutcome, +}; +use edgezero_store_celld::settings::{CelldAccessToken, CelldKvSettings, CelldPreparationLimits}; +use error_stack::Report; +use trusted_server_core::ec::kv_backend::{ + EcKvLookup, EcKvStore, EcKvWrite, EcKvWriteMode, EcKvWriteOutcome, +}; +use trusted_server_core::error::TrustedServerError; + +/// A live provider. Clones share transport, never cached identity rows. +#[derive(Clone, Debug)] +pub struct CelldEcKv { + records: CelldRecordStore, +} + +fn preparation_error() -> EdgeError { + EdgeError::internal(std::io::Error::other("native EC celld preparation failed")) +} + +fn store_error() -> Report { + Report::new(TrustedServerError::KvStore { + store_name: "native-ec-celld".to_owned(), + message: "EC celld operation failed".to_owned(), + }) +} + +impl CelldEcKv { + /// Prepare an explicitly selected provider using one captured bootstrap token. + /// + /// # Errors + /// Invalid settings, credentials, protocol or service failures prevent readiness. + pub async fn prepare( + settings: &CelldKvSettings, + token: Option<&CelldAccessToken>, + ) -> Result { + let mut preparation = + CelldPreparation::new(MonotonicClock::default(), CelldPreparationLimits::default()) + .map_err(|_| preparation_error())?; + let records = preparation + .prepare_records(settings, token) + .await + .map_err(|_| preparation_error())?; + Ok(Self { records }) + } +} + +#[async_trait(?Send)] +impl EcKvStore for CelldEcKv { + fn store_name(&self) -> &str { + "native-ec-celld" + } + async fn lookup(&self, key: &str) -> Result, Report> { + self.records + .lookup(key) + .await + .map(|row| { + row.map(|row| EcKvLookup { + body: row.body, + metadata: row.metadata, + generation: row.generation, + }) + }) + .map_err(|_| store_error()) + } + async fn key_exists(&self, key: &str) -> Result> { + self.records + .key_exists(key) + .await + .map_err(|_| store_error()) + } + async fn insert( + &self, + key: &str, + write: EcKvWrite<'_>, + ) -> Result> { + let mode = match write.mode { + EcKvWriteMode::Add => RecordWriteMode::Add, + EcKvWriteMode::Overwrite => RecordWriteMode::Overwrite, + EcKvWriteMode::IfGenerationMatch(generation) => { + RecordWriteMode::IfGenerationMatch(generation) + } + }; + self.records + .insert( + key, + RecordWrite { + body: write.body.as_bytes(), + metadata: Some(write.metadata.as_bytes()), + ttl: write.ttl, + mode, + }, + ) + .await + .map(|outcome| match outcome { + RecordWriteOutcome::Written => EcKvWriteOutcome::Written, + RecordWriteOutcome::PreconditionFailed => EcKvWriteOutcome::PreconditionFailed, + }) + .map_err(|_| store_error()) + } + async fn list_keys_with_prefix( + &self, + prefix: &str, + limit: u32, + ) -> Result, Report> { + self.records + .list_keys_with_prefix(prefix, limit) + .await + .map_err(|_| store_error()) + } + async fn delete(&self, key: &str) -> Result<(), Report> { + self.records.delete(key).await.map_err(|_| store_error()) + } +} diff --git a/crates/trusted-server-adapter-axum/src/ec_kv.rs b/crates/trusted-server-adapter-axum/src/ec_kv.rs new file mode 100644 index 0000000000..0d2a4412b0 --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/ec_kv.rs @@ -0,0 +1,672 @@ +//! Native single-region DynamoDB storage for the EC graph. +//! +//! Rows and the retained generation allocator use separate namespace partitions. +//! Never delete or expire the allocator while a namespace can be reused: its +//! monotonic counter prevents stale CAS after hard deletion and recreation. +//! Lookup/list apply logical TTL. Exact existence includes expired physical rows +//! so withdrawal can fence conditional writes already in flight before expiry. + +use std::sync::Arc; + +use async_trait::async_trait; +use edgezero_core::EdgeError; +use error_stack::Report; +use trusted_server_core::ec::kv_backend::{EcKvLookup, EcKvStore, EcKvWrite, EcKvWriteOutcome}; +use trusted_server_core::ec::storage::DynamoDbEcStorage; +use trusted_server_core::error::TrustedServerError; + +/// Cloneable live provider. Clones share the SDK connection pool, not a cache. +#[derive(Clone, Debug)] +pub struct DynamoDbEcKv { + #[cfg(feature = "aws-dynamodb-ec")] + client: aws_sdk_dynamodb::Client, + #[cfg(feature = "aws-dynamodb-ec")] + table: String, + #[cfg(feature = "aws-dynamodb-ec")] + partition: String, + #[cfg(feature = "aws-dynamodb-ec")] + allocator_partition: String, +} + +fn preparation_error() -> EdgeError { + EdgeError::internal(std::io::Error::other( + "native EC DynamoDB preparation failed", + )) +} + +fn store_error() -> Report { + Report::new(TrustedServerError::KvStore { + store_name: "native-ec-dynamodb".to_owned(), + message: "EC DynamoDB operation failed".to_owned(), + }) +} + +/// Reject obsolete native launch selection without preparing from environment. +/// +/// # Errors +/// Enabled or malformed legacy selection must be migrated to published config. +pub fn reject_legacy_configuration() -> Result<(), EdgeError> { + match std::env::var("TS_EC_DYNAMODB_ENABLED") { + Err(std::env::VarError::NotPresent) => Ok(()), + Ok(value) if value == "false" => Ok(()), + Ok(value) if value == "true" => Err(EdgeError::internal(std::io::Error::other( + "TS_EC_DYNAMODB_ENABLED is obsolete; publish ec.storage and remove legacy EC environment selection", + ))), + _ => Err(EdgeError::internal(std::io::Error::other( + "TS_EC_DYNAMODB_ENABLED is malformed; migrate to published ec.storage configuration", + ))), + } +} + +/// Prepare the explicitly selected typed `DynamoDB` provider before readiness. +/// +/// # Errors +/// Returns a sanitized error on invalid input, missing feature or failed preparation. +pub async fn prepare(config: &DynamoDbEcStorage) -> Result, EdgeError> { + config.validate().map_err(|_| preparation_error())?; + #[cfg(feature = "aws-dynamodb-ec")] + { + enabled::prepare(config).await + } + #[cfg(not(feature = "aws-dynamodb-ec"))] + Err(EdgeError::internal(std::io::Error::other( + "native EC DynamoDB selected but aws-dynamodb-ec build feature is unavailable", + ))) +} + +#[cfg(not(feature = "aws-dynamodb-ec"))] +#[async_trait(?Send)] +impl EcKvStore for DynamoDbEcKv { + fn store_name(&self) -> &str { + "native-ec-dynamodb" + } + async fn lookup(&self, _: &str) -> Result, Report> { + Err(store_error()) + } + async fn key_exists(&self, _: &str) -> Result> { + Err(store_error()) + } + async fn insert( + &self, + _: &str, + _: EcKvWrite<'_>, + ) -> Result> { + Err(store_error()) + } + async fn list_keys_with_prefix( + &self, + _: &str, + _: u32, + ) -> Result, Report> { + Err(store_error()) + } + async fn delete(&self, _: &str) -> Result<(), Report> { + Err(store_error()) + } +} + +#[cfg(feature = "aws-dynamodb-ec")] +mod enabled { + use std::collections::HashMap; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + use aws_config::BehaviorVersion; + use aws_sdk_dynamodb::config::{Region, retry::RetryConfig, timeout::TimeoutConfig}; + use aws_sdk_dynamodb::primitives::Blob; + use aws_sdk_dynamodb::types::{AttributeValue, ReturnValue, TableDescription}; + use trusted_server_core::ec::kv_backend::EcKvWriteMode; + + use super::{ + Arc, DynamoDbEcKv, EcKvLookup, EcKvStore, EcKvWrite, EcKvWriteOutcome, EdgeError, Report, + TrustedServerError, async_trait, preparation_error, store_error, + }; + + // Query can stop at 1 MB or an empty filtered page. These bounds limit work, + // not completeness: budget exhaustion is an error, never a short success. + const MAX_LIST_PAGES: usize = 64; + const PAGE_SIZE: usize = 1_000; + const OPERATION_TIMEOUT: Duration = Duration::from_secs(3); + const PREPARATION_TIMEOUT: Duration = Duration::from_secs(10); + type Item = HashMap; + type StoreResult = Result>; + + pub(super) async fn prepare( + config: &super::DynamoDbEcStorage, + ) -> Result, EdgeError> { + tokio::time::timeout(PREPARATION_TIMEOUT, async { + let builder = match &config.local_endpoint { + Some(endpoint) => { + // Explicit emulator mode never reads ambient credentials. + aws_sdk_dynamodb::Config::builder() + .behavior_version(BehaviorVersion::v2026_01_12()) + .region(Region::new(config.region.clone())) + .credentials_provider(aws_sdk_dynamodb::config::Credentials::new( + "local", + "local", + None, + None, + "ec-dynamodb-local", + )) + .endpoint_url(endpoint) + } + None => { + let shared = aws_config::defaults(BehaviorVersion::v2026_01_12()) + .region(Region::new(config.region.clone())) + .load() + .await; + let mut builder = aws_sdk_dynamodb::Config::from(&shared).to_builder(); + // Production ignores ambient endpoint overrides. + builder.set_endpoint_url(None); + builder + } + }; + let config_sdk = builder + .retry_config(RetryConfig::standard().with_max_attempts(1)) + .timeout_config(timeout_config()) + .build(); + DynamoDbEcKv::prepare_with_client( + aws_sdk_dynamodb::Client::from_conf(config_sdk), + config.table.clone(), + config.namespace.clone(), + ) + .await + }) + .await + .map_err(|_| preparation_error())? + } + + fn validate_binding(table: &str, namespace: &str) -> Result<(), EdgeError> { + if !(3..=255).contains(&table.len()) + || !table + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) + || namespace.is_empty() + || namespace.len() > 512 + || namespace.chars().any(char::is_control) + { + return Err(preparation_error()); + } + Ok(()) + } + + fn timeout_config() -> TimeoutConfig { + TimeoutConfig::builder() + .connect_timeout(Duration::from_secs(1)) + .operation_attempt_timeout(OPERATION_TIMEOUT) + .operation_timeout(OPERATION_TIMEOUT) + .build() + } + + impl DynamoDbEcKv { + /// Prepare a provider with an explicitly supplied SDK client. + /// + /// Useful for `DynamoDB` Local and deterministic loopback tests without + /// reading ambient credentials or changing process environment. The + /// caller owns endpoint/credential configuration; retries are disabled + /// for each operation even if the supplied client enables them. + /// + /// # Errors + /// Returns a sanitized error when bindings or the table are unsuitable. + pub async fn prepare_with_client( + client: aws_sdk_dynamodb::Client, + table: impl Into, + namespace: impl Into, + ) -> Result, EdgeError> { + let table = table.into(); + let namespace = namespace.into(); + validate_binding(&table, &namespace)?; + let provider = Self { + client, + table, + partition: format!("ec:{namespace}"), + allocator_partition: format!("ec-allocator:{namespace}"), + }; + let output = tokio::time::timeout( + OPERATION_TIMEOUT, + provider + .client + .describe_table() + .table_name(&provider.table) + .customize() + .config_override(Self::policy()) + .send(), + ) + .await + .map_err(|_| preparation_error())? + .map_err(|_| preparation_error())?; + validate_table( + output.table.as_ref().ok_or_else(preparation_error)?, + &provider.table, + )?; + Ok(Arc::new(provider)) + } + + fn policy() -> aws_sdk_dynamodb::config::Builder { + aws_sdk_dynamodb::Config::builder() + .retry_config(RetryConfig::standard().with_max_attempts(1)) + .timeout_config(timeout_config()) + } + + fn key(&self, key: &str) -> Item { + HashMap::from([ + ("pk".to_owned(), AttributeValue::S(self.partition.clone())), + ("sk".to_owned(), AttributeValue::S(key.to_owned())), + ]) + } + + async fn allocate_generation(&self) -> StoreResult { + let output = self + .client + .update_item() + .table_name(&self.table) + .key("pk", AttributeValue::S(self.allocator_partition.clone())) + .key("sk", AttributeValue::S("generation".to_owned())) + .update_expression("ADD #g :one") + .condition_expression("attribute_not_exists(#g) OR #g < :max") + .expression_attribute_names("#g", "generation") + .expression_attribute_values(":one", AttributeValue::N("1".to_owned())) + .expression_attribute_values(":max", AttributeValue::N(u64::MAX.to_string())) + .return_values(ReturnValue::UpdatedNew) + .customize() + .config_override(Self::policy()) + .send() + .await + .map_err(|_| store_error())?; + let generation = unsigned( + output.attributes.as_ref().ok_or_else(store_error)?, + "generation", + )?; + if generation == 0 { + return Err(store_error()); + } + Ok(generation) + } + + async fn get_physical(&self, key: &str) -> StoreResult> { + validate_key(key)?; + let output = self + .client + .get_item() + .table_name(&self.table) + .set_key(Some(self.key(key))) + .consistent_read(true) + .customize() + .config_override(Self::policy()) + .send() + .await + .map_err(|_| store_error())?; + match output.item { + None => Ok(None), + Some(item) => { + if row_key(&item, &self.partition)? != key { + return Err(store_error()); + } + // Malformed expiry is an error, not evidence of absence. + expiry(&item)?; + Ok(Some(item)) + } + } + } + } + + #[async_trait(?Send)] + impl EcKvStore for DynamoDbEcKv { + fn store_name(&self) -> &str { + "native-ec-dynamodb" + } + + async fn lookup(&self, key: &str) -> StoreResult> { + tokio::time::timeout(OPERATION_TIMEOUT, async { + let Some(item) = self.get_physical(key).await? else { + return Ok(None); + }; + if expiry(&item)? <= now_ms()? { + return Ok(None); + } + let binary = |name: &str| match item.get(name) { + Some(AttributeValue::B(value)) => Ok(value.as_ref().to_vec()), + _ => Err(store_error()), + }; + let generation = unsigned(&item, "generation")?; + if generation == 0 { + return Err(store_error()); + } + Ok(Some(EcKvLookup { + body: binary("body")?, + metadata: Some(binary("metadata")?), + generation, + })) + }) + .await + .map_err(|_| store_error())? + } + + async fn key_exists(&self, key: &str) -> StoreResult { + // Withdrawal must fence writes already in flight with an earlier + // client timestamp. An expired physical row can still satisfy such + // a CAS, so only physical absence proves there is nothing to revoke. + tokio::time::timeout(OPERATION_TIMEOUT, self.get_physical(key)) + .await + .map_err(|_| store_error())? + .map(|item| item.is_some()) + } + + async fn insert(&self, key: &str, write: EcKvWrite<'_>) -> StoreResult { + validate_key(key)?; + // DynamoDB caps complete items at 400 KiB. Include all names, + // partition/key bytes and conservative decimal numeric accounting. + let bytes = write + .body + .len() + .checked_add(write.metadata.len()) + .and_then(|n| n.checked_add(self.partition.len() + key.len() + 256)) + .ok_or_else(store_error)?; + if bytes > 409_600 || write.ttl.is_zero() { + return Err(store_error()); + } + tokio::time::timeout(OPERATION_TIMEOUT, async { + let generation = self.allocate_generation().await?; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| store_error())?; + let now = u64::try_from(timestamp.as_millis()).map_err(|_| store_error())?; + let expires = expiry_from_duration(timestamp, write.ttl)?; + let mut item = self.key(key); + item.insert( + "body".to_owned(), + AttributeValue::B(Blob::new(write.body.as_bytes())), + ); + item.insert( + "metadata".to_owned(), + AttributeValue::B(Blob::new(write.metadata.as_bytes())), + ); + item.insert( + "generation".to_owned(), + AttributeValue::N(generation.to_string()), + ); + item.insert( + "expires_at_ms".to_owned(), + AttributeValue::N(expires.to_string()), + ); + item.insert( + "ttl_epoch_s".to_owned(), + AttributeValue::N(expires.div_ceil(1_000).to_string()), + ); + let mut request = self + .client + .put_item() + .table_name(&self.table) + .set_item(Some(item)); + request = match write.mode { + EcKvWriteMode::Add => request + .condition_expression("attribute_not_exists(#pk) OR #e <= :now") + .expression_attribute_names("#pk", "pk") + .expression_attribute_names("#e", "expires_at_ms") + .expression_attribute_values(":now", AttributeValue::N(now.to_string())), + EcKvWriteMode::IfGenerationMatch(expected) => request + .condition_expression("#g = :expected AND #e > :now") + .expression_attribute_names("#g", "generation") + .expression_attribute_names("#e", "expires_at_ms") + .expression_attribute_values( + ":expected", + AttributeValue::N(expected.to_string()), + ) + .expression_attribute_values(":now", AttributeValue::N(now.to_string())), + EcKvWriteMode::Overwrite => request, + }; + match request + .customize() + .config_override(Self::policy()) + .send() + .await + { + Ok(_) => Ok(EcKvWriteOutcome::Written), + Err(error) + if error + .as_service_error() + .is_some_and(aws_sdk_dynamodb::operation::put_item::PutItemError::is_conditional_check_failed_exception) => + { + Ok(EcKvWriteOutcome::PreconditionFailed) + } + Err(_) => Err(store_error()), + } + }) + .await + .map_err(|_| store_error())? + } + + async fn list_keys_with_prefix( + &self, + prefix: &str, + limit: u32, + ) -> StoreResult> { + if prefix.len() > 1_024 || prefix.chars().any(char::is_control) { + return Err(store_error()); + } + if limit == 0 { + return Ok(Vec::new()); + } + tokio::time::timeout(OPERATION_TIMEOUT, async { + let mut keys = Vec::new(); + let mut cursor: Option = None; + for _ in 0..MAX_LIST_PAGES { + let remaining = (limit as usize).saturating_sub(keys.len()).min(PAGE_SIZE); + let mut request = self + .client + .query() + .table_name(&self.table) + .consistent_read(true) + .limit(i32::try_from(remaining).map_err(|_| store_error())?) + .key_condition_expression(if prefix.is_empty() { + "#pk = :pk" + } else { + "#pk = :pk AND begins_with(#sk, :prefix)" + }) + .expression_attribute_names("#pk", "pk") + .expression_attribute_names("#sk", "sk") + .expression_attribute_names("#e", "expires_at_ms") + .expression_attribute_names("#t", "ttl_epoch_s") + .expression_attribute_values( + ":pk", + AttributeValue::S(self.partition.clone()), + ) + .projection_expression("#pk, #sk, #e, #t") + .set_exclusive_start_key(cursor.clone()); + if !prefix.is_empty() { + request = request.expression_attribute_values( + ":prefix", + AttributeValue::S(prefix.to_owned()), + ); + } + let output = request + .customize() + .config_override(Self::policy()) + .send() + .await + .map_err(|_| store_error())?; + let items = output.items.unwrap_or_default(); + if items.len() > remaining { + return Err(store_error()); + } + let mut previous = cursor + .as_ref() + .map(|c| row_key(c, &self.partition)) + .transpose()? + .map(str::to_owned); + let now = now_ms()?; + for item in &items { + let key = row_key(item, &self.partition)?; + if !key.starts_with(prefix) + || previous.as_deref().is_some_and(|last| key <= last) + { + return Err(store_error()); + } + previous = Some(key.to_owned()); + if expiry(item)? > now { + keys.push(key.to_owned()); + } + } + let next = output.last_evaluated_key.filter(|c| !c.is_empty()); + if let Some(next) = &next { + let key = row_key(next, &self.partition)?; + if next.len() != 2 + || !key.starts_with(prefix) + || cursor.as_ref().is_some_and(|last| { + row_key(last, &self.partition).is_ok_and(|last| key <= last) + }) + || previous.as_deref().is_some_and(|last| key < last) + { + return Err(store_error()); + } + } + if keys.len() == limit as usize || next.is_none() { + return Ok(keys); + } + cursor = next; + } + Err(store_error()) + }) + .await + .map_err(|_| store_error())? + } + + async fn delete(&self, key: &str) -> StoreResult<()> { + validate_key(key)?; + tokio::time::timeout( + OPERATION_TIMEOUT, + self.client + .delete_item() + .table_name(&self.table) + .set_key(Some(self.key(key))) + .customize() + .config_override(Self::policy()) + .send(), + ) + .await + .map_err(|_| store_error())? + .map_err(|_| store_error())?; + Ok(()) + } + } + + fn validate_key(key: &str) -> StoreResult<()> { + if key.is_empty() || key.len() > 1_024 || key.chars().any(char::is_control) { + return Err(store_error()); + } + Ok(()) + } + + fn now_ms() -> StoreResult { + u64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| store_error())? + .as_millis(), + ) + .map_err(|_| store_error()) + } + + fn expiry_from_duration(now: Duration, ttl: Duration) -> StoreResult { + let expires = now.checked_add(ttl).ok_or_else(store_error)?; + let rounded = expires + .as_millis() + .checked_add(u128::from( + !expires.subsec_nanos().is_multiple_of(1_000_000), + )) + .ok_or_else(store_error)?; + u64::try_from(rounded).map_err(|_| store_error()) + } + + fn unsigned(item: &Item, name: &str) -> StoreResult { + let Some(AttributeValue::N(value)) = item.get(name) else { + return Err(store_error()); + }; + let number = value.parse::().map_err(|_| store_error())?; + if number.to_string() != *value { + return Err(store_error()); + } + Ok(number) + } + + fn expiry(item: &Item) -> StoreResult { + let expires = unsigned(item, "expires_at_ms")?; + if unsigned(item, "ttl_epoch_s")? != expires.div_ceil(1_000) { + return Err(store_error()); + } + Ok(expires) + } + + fn row_key<'a>(item: &'a Item, partition: &str) -> StoreResult<&'a str> { + match (item.get("pk"), item.get("sk")) { + (Some(AttributeValue::S(pk)), Some(AttributeValue::S(sk))) if pk == partition => { + validate_key(sk)?; + Ok(sk) + } + _ => Err(store_error()), + } + } + + fn validate_table(table: &TableDescription, expected: &str) -> Result<(), EdgeError> { + if table.table_name.as_deref() != Some(expected) + || table + .table_status + .as_ref() + .is_none_or(|status| status.as_str() != "ACTIVE") + || !table.replicas().is_empty() + || table.global_table_version.is_some() + || !table.global_secondary_indexes().is_empty() + || !table.local_secondary_indexes().is_empty() + || table + .vector_indexes + .as_ref() + .is_some_and(|indexes| !indexes.is_empty()) + || table.key_schema().len() != 2 + || table.attribute_definitions().len() != 2 + || ![("pk", "HASH"), ("sk", "RANGE")] + .into_iter() + .all(|(name, kind)| { + table + .key_schema() + .iter() + .any(|key| key.attribute_name() == name && key.key_type().as_str() == kind) + }) + || !["pk", "sk"].into_iter().all(|name| { + table.attribute_definitions().iter().any(|attr| { + attr.attribute_name() == name && attr.attribute_type().as_str() == "S" + }) + }) + { + return Err(preparation_error()); + } + Ok(()) + } + #[cfg(test)] + mod unit_tests { + use super::*; + + #[test] + fn absolute_expiry_rounding_never_shortens_ttl() { + assert_eq!( + expiry_from_duration(Duration::from_nanos(999_999), Duration::from_nanos(1)) + .expect("should round exact millisecond boundary"), + 1 + ); + assert_eq!( + expiry_from_duration(Duration::from_nanos(999_999), Duration::from_nanos(2)) + .expect("should round above millisecond boundary"), + 2 + ); + assert_eq!( + expiry_from_duration(Duration::from_nanos(1), Duration::from_secs(60)) + .expect("should retain fractional current timestamp"), + 60_001 + ); + assert!(expiry_from_duration(Duration::MAX, Duration::from_nanos(1)).is_err()); + assert!(expiry_from_duration(Duration::ZERO, Duration::MAX).is_err()); + } + } +} + +#[cfg(all(test, feature = "aws-dynamodb-ec"))] +#[path = "ec_kv_tests.rs"] +mod tests; diff --git a/crates/trusted-server-adapter-axum/src/ec_kv_tests.rs b/crates/trusted-server-adapter-axum/src/ec_kv_tests.rs new file mode 100644 index 0000000000..45defdda53 --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/ec_kv_tests.rs @@ -0,0 +1,869 @@ +//! Tests through the real SDK HTTP protocol, without ambient AWS credentials. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use aws_config::BehaviorVersion; +use aws_sdk_dynamodb::Client; +use aws_sdk_dynamodb::config::{Credentials, Region}; +use aws_sdk_dynamodb::types::{ + AttributeDefinition, AttributeValue, BillingMode, KeySchemaElement, KeyType, + ScalarAttributeType, +}; +use axum::body::Bytes; +use axum::extract::State; +use axum::http::{HeaderMap, Request, StatusCode}; +use axum::response::IntoResponse; +use edgezero_core::body::Body; +use edgezero_core::http::Response; +use futures::FutureExt as _; +use serde_json::{Value, json}; +use trusted_server_core::ec::EcContext; +use trusted_server_core::ec::finalize::ec_finalize_response; +use trusted_server_core::ec::kv::{KvIdentityGraph, UpsertResult}; +use trusted_server_core::ec::kv_backend::{EcKvStore, EcKvWrite, EcKvWriteMode, EcKvWriteOutcome}; +use trusted_server_core::ec::kv_types::KvEntry; +use trusted_server_core::ec::registry::PartnerRegistry; +use trusted_server_core::platform::GeoInfo; +use trusted_server_core::test_support::nextjs_auction::{self, NextJsAuctionOrigin}; + +use super::DynamoDbEcKv; + +const TABLE: &str = "ec-fixture"; +const NS: &str = "fixture"; + +type Requests = Arc>>; +type Responses = Arc>>; + +struct Fixture { + provider: Arc, + requests: Requests, + responses: Responses, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.task.abort(); + } +} + +fn client(endpoint: &str) -> Client { + Client::from_conf( + aws_sdk_dynamodb::Config::builder() + .behavior_version(BehaviorVersion::v2026_01_12()) + .region(Region::new("us-east-1")) + .credentials_provider(Credentials::new( + "fixture", "fixture", None, None, "fixture", + )) + .endpoint_url(endpoint) + .build(), + ) +} + +fn table_description() -> Value { + json!({"Table": { + "TableName": TABLE, "TableStatus": "ACTIVE", + "KeySchema": [{"AttributeName":"pk", "KeyType":"HASH"}, {"AttributeName":"sk", "KeyType":"RANGE"}], + "AttributeDefinitions": [{"AttributeName":"pk", "AttributeType":"S"}, {"AttributeName":"sk", "AttributeType":"S"}] + }}) +} + +#[tokio::test(flavor = "current_thread")] +async fn typed_local_preparation_uses_envelope_binding_and_dummy_credentials() { + let captured = Arc::new(Mutex::new(Vec::::new())); + let headers = Arc::clone(&captured); + let router = axum::Router::new().fallback(move |request: Request| { + headers + .lock() + .expect("should capture typed preparation request") + .push(request.headers().clone()); + async { axum::Json(table_description()) } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind typed preparation fixture"); + let endpoint = format!( + "http://{}", + listener + .local_addr() + .expect("should read local fixture address") + ); + let task = tokio::spawn(async move { + axum::serve(listener, router) + .await + .expect("should serve typed preparation fixture"); + }); + let config = trusted_server_core::ec::storage::DynamoDbEcStorage { + region: "us-east-1".to_owned(), + table: TABLE.to_owned(), + namespace: " exact ".to_owned(), + local_endpoint: Some(endpoint), + }; + let provider = super::prepare(&config) + .await + .expect("should prepare from typed envelope fields"); + assert_eq!( + provider.partition, "ec: exact ", + "should preserve exact namespace bytes" + ); + let headers = captured.lock().expect("should inspect preparation headers"); + assert_eq!(headers.len(), 1, "should not retry preparation"); + let authorization = headers[0] + .get("authorization") + .expect("should sign local request") + .to_str() + .expect("should decode local authorization"); + assert!( + authorization.contains("Credential=local/"), + "should bypass ambient credentials with explicit local dummy credentials" + ); + task.abort(); +} + +async fn fixture() -> Fixture { + let requests: Requests = Arc::new(Mutex::new(Vec::new())); + let responses: Responses = Arc::new(Mutex::new(VecDeque::from([( + StatusCode::OK, + table_description(), + )]))); + let router = axum::Router::new() + .fallback( + async |State((requests, responses)): State<(Requests, Responses)>, + headers: HeaderMap, + body: Bytes| { + let target = headers + .get("x-amz-target") + .expect("should receive SDK operation target") + .to_str() + .expect("should decode SDK operation target") + .to_owned(); + requests + .lock() + .expect("should lock recorded SDK requests") + .push(( + target, + serde_json::from_slice(&body).expect("should decode SDK JSON request"), + )); + let (status, body) = responses + .lock() + .expect("should lock queued SDK responses") + .pop_front() + .expect("unexpected SDK request"); + ( + status, + [("content-type", "application/x-amz-json-1.0")], + body.to_string(), + ) + .into_response() + }, + ) + .with_state((Arc::clone(&requests), Arc::clone(&responses))); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind loopback wire fixture"); + let endpoint = format!( + "http://{}", + listener + .local_addr() + .expect("should read wire fixture address") + ); + let task = tokio::spawn(async move { + axum::serve(listener, router) + .await + .expect("should serve SDK wire fixture"); + }); + let provider = DynamoDbEcKv::prepare_with_client(client(&endpoint), TABLE, NS) + .await + .expect("should prepare SDK wire provider"); + Fixture { + provider, + requests, + responses, + task, + } +} + +impl Fixture { + fn queue(&self, value: Value) { + self.responses + .lock() + .expect("should lock queued wire response") + .push_back((StatusCode::OK, value)); + } + fn conflict(&self) { + self.responses.lock().expect("should queue conditional conflict response").push_back((StatusCode::BAD_REQUEST, json!({"__type":"com.amazonaws.dynamodb.v20120810#ConditionalCheckFailedException", "message":"private key and body"}))); + } + fn fail(&self) { + self.responses + .lock() + .expect("should queue denied response") + .push_back(( + StatusCode::FORBIDDEN, + json!({"__type":"AccessDeniedException", "message":"private credential and row"}), + )); + } + fn allocate(&self, generation: u64) { + self.queue(json!({"Attributes":{"generation":{"N":generation.to_string()}}})); + } + fn requests(&self) -> Vec<(String, Value)> { + self.requests + .lock() + .expect("should lock recorded requests") + .clone() + } +} + +fn row(key: &str, expires: u64) -> Value { + json!({"pk":{"S":"ec:fixture"}, "sk":{"S":key}, "body":{"B":"Ym9keQ=="}, "metadata":{"B":"bWV0YQ=="}, "generation":{"N":"7"}, "expires_at_ms":{"N":expires.to_string()}, "ttl_epoch_s":{"N":expires.div_ceil(1_000).to_string()}}) +} + +fn write(mode: EcKvWriteMode) -> EcKvWrite<'static> { + EcKvWrite { + body: "body", + metadata: "meta", + ttl: Duration::from_secs(60), + mode, + } +} + +#[tokio::test(flavor = "current_thread")] +async fn conditional_writes_use_real_sdk_expressions_and_allocator_partition() { + let f = fixture().await; + f.allocate(1); + f.queue(json!({})); + assert_eq!( + f.provider + .insert("key", write(EcKvWriteMode::Add)) + .await + .expect("should perform Add wire write"), + EcKvWriteOutcome::Written + ); + f.allocate(2); + f.conflict(); + assert_eq!( + f.provider + .insert("key", write(EcKvWriteMode::Add)) + .await + .expect("should classify Add conflict"), + EcKvWriteOutcome::PreconditionFailed + ); + f.allocate(3); + f.conflict(); + assert_eq!( + f.provider + .insert("key", write(EcKvWriteMode::IfGenerationMatch(1))) + .await + .expect("should classify stale CAS conflict"), + EcKvWriteOutcome::PreconditionFailed + ); + f.allocate(4); + f.queue(json!({})); + assert_eq!( + f.provider + .insert("key", write(EcKvWriteMode::Overwrite)) + .await + .expect("should perform overwrite wire write"), + EcKvWriteOutcome::Written + ); + let requests = f.requests(); + for (_, req) in requests + .iter() + .filter(|(target, _)| target.ends_with("UpdateItem")) + { + assert_eq!( + req["Key"], + json!({"pk":{"S":"ec-allocator:fixture"}, "sk":{"S":"generation"}}) + ); + assert_eq!(req["UpdateExpression"], "ADD #g :one"); + assert_eq!( + req["ConditionExpression"], + "attribute_not_exists(#g) OR #g < :max" + ); + assert_eq!( + req["ExpressionAttributeValues"][":max"]["N"], + u64::MAX.to_string() + ); + assert!(req.get("ttl_epoch_s").is_none()); + } + let puts: Vec<_> = requests + .iter() + .filter(|(target, _)| target.ends_with("PutItem")) + .map(|(_, r)| r) + .collect(); + assert_eq!( + puts[0]["ConditionExpression"], + "attribute_not_exists(#pk) OR #e <= :now" + ); + assert_eq!( + puts[2]["ConditionExpression"], + "#g = :expected AND #e > :now" + ); + assert_eq!(puts[2]["ExpressionAttributeValues"][":expected"]["N"], "1"); + assert!(puts[3].get("ConditionExpression").is_none()); + for (i, put) in puts.into_iter().enumerate() { + assert_eq!(put["Item"]["generation"]["N"], (i + 1).to_string()); + assert_eq!(put["Item"]["body"]["B"], "Ym9keQ=="); + assert_eq!(put["Item"]["metadata"]["B"], "bWV0YQ=="); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn lookup_expires_logically_but_exact_physical_existence_can_fence_withdrawal() { + let f = fixture().await; + f.queue(json!({"Item":row("key", u64::MAX)})); + let found = f + .provider + .lookup("key") + .await + .expect("should read SDK wire row") + .expect("should retain SDK wire row"); + assert_eq!(found.body, b"body"); + assert_eq!( + found.metadata.expect("should retain wire metadata"), + b"meta" + ); + assert_eq!(found.generation, 7); + f.queue(json!({"Item":row("key", 1)})); + assert!( + f.provider + .lookup("key") + .await + .expect("should hide logically expired body") + .is_none() + ); + f.queue(json!({"Item":row("key", 1)})); + assert!( + f.provider + .key_exists("key") + .await + .expect("should confirm physical expired row for withdrawal fencing") + ); + f.queue(json!({})); + assert!( + !f.provider + .key_exists("absent") + .await + .expect("should check exact absent key") + ); + for (_, req) in f + .requests() + .iter() + .filter(|(target, _)| target.ends_with("GetItem")) + { + assert_eq!(req["ConsistentRead"], true); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn prefix_listing_drives_empty_expired_and_short_pages_to_completion() { + let f = fixture().await; + let cursor = |key: &str| json!({"pk":{"S":"ec:fixture"}, "sk":{"S":key}}); + f.queue(json!({"Items":[], "LastEvaluatedKey":cursor("marker:0")})); + f.queue(json!({"Items":[row("marker:1",1)], "LastEvaluatedKey":cursor("marker:1")})); + f.queue(json!({"Items":[row("marker:2",u64::MAX)], "LastEvaluatedKey":cursor("marker:2")})); + f.queue(json!({"Items":[row("marker:3",u64::MAX)]})); + assert_eq!( + f.provider + .list_keys_with_prefix("marker:", 3) + .await + .expect("should complete prefix pagination"), + ["marker:2", "marker:3"] + ); + let requests = f.requests(); + let queries: Vec<_> = requests + .iter() + .filter(|(target, _)| target.ends_with("Query")) + .map(|(_, r)| r) + .collect(); + assert_eq!(queries.len(), 4); + for req in queries { + assert_eq!(req["ConsistentRead"], true); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn incomplete_listing_and_infrastructure_failures_never_look_like_absence_or_conflict() { + let f = fixture().await; + for n in 0..64 { + f.queue(json!({"Items":[], "LastEvaluatedKey":{"pk":{"S":"ec:fixture"}, "sk":{"S":format!("p:{n:03}")}}})); + } + assert!(f.provider.list_keys_with_prefix("p:", 2).await.is_err()); + f.fail(); + let error = f + .provider + .key_exists("private-key") + .await + .expect_err("denied read should remain an infrastructure error"); + assert!(!format!("{error:?}").contains("private")); + f.allocate(99); + f.fail(); + assert!( + f.provider + .insert("key", write(EcKvWriteMode::Add)) + .await + .is_err() + ); + f.fail(); + assert!(f.provider.delete("key").await.is_err()); + assert_eq!(f.requests().len(), 69); // describe + 64 query + get + allocation + put + delete +} + +#[tokio::test(flavor = "current_thread")] +async fn retryable_write_failure_is_sanitized_and_never_automatically_retried() { + let f = fixture().await; + f.allocate(50); + f.responses + .lock() + .expect("should queue retryable failure") + .push_back(( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"__type":"InternalServerError", "message":"private row and credentials"}), + )); + let error = f + .provider + .insert("private-key", write(EcKvWriteMode::Add)) + .await + .expect_err("uncertain write should fail without an automatic retry"); + assert!(!format!("{error:?}").contains("private")); + assert_eq!( + f.requests().len(), + 3, + "should send DescribeTable, allocator and one PutItem only" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn malformed_cursor_expiry_or_allocator_fails_closed() { + let f = fixture().await; + f.queue(json!({"Items":[], "LastEvaluatedKey":{"pk":{"S":"other"}, "sk":{"S":"p:1"}}})); + assert!(f.provider.list_keys_with_prefix("p:", 2).await.is_err()); + let mut malformed = row("key", u64::MAX); + malformed["ttl_epoch_s"] = json!({"N":"0"}); + f.queue(json!({"Item":malformed})); + assert!(f.provider.lookup("key").await.is_err()); + f.allocate(0); + assert!( + f.provider + .insert("key", write(EcKvWriteMode::Add)) + .await + .is_err() + ); +} + +/// Run explicitly against disposable `DynamoDB` Local, never AWS: +/// `TS_TEST_EC_DYNAMODB_ENDPOINT=http://127.0.0.1:PORT cargo test ... -- --ignored` +#[tokio::test(flavor = "current_thread")] +#[ignore = "requires explicitly selected disposable DynamoDB Local loopback endpoint"] +async fn dynamodb_local_contract() { + let endpoint = std::env::var("TS_TEST_EC_DYNAMODB_ENDPOINT") + .expect("should explicitly select a test-only Local endpoint"); + trusted_server_core::ec::storage::DynamoDbEcStorage { + region: "us-east-1".to_owned(), + table: "example-identity".to_owned(), + namespace: "fixture".to_owned(), + local_endpoint: Some(endpoint.clone()), + } + .validate() + .expect("should restrict DynamoDB Local tests to a loopback root URL"); + let client = client(&endpoint); + let table = format!( + "ec-contract-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("should obtain table name timestamp") + .as_nanos() + ); + client + .create_table() + .table_name(&table) + .billing_mode(BillingMode::PayPerRequest) + .key_schema( + KeySchemaElement::builder() + .attribute_name("pk") + .key_type(KeyType::Hash) + .build() + .expect("should build partition schema"), + ) + .key_schema( + KeySchemaElement::builder() + .attribute_name("sk") + .key_type(KeyType::Range) + .build() + .expect("should build sort schema"), + ) + .attribute_definitions( + AttributeDefinition::builder() + .attribute_name("pk") + .attribute_type(ScalarAttributeType::S) + .build() + .expect("should define partition type"), + ) + .attribute_definitions( + AttributeDefinition::builder() + .attribute_name("sk") + .attribute_type(ScalarAttributeType::S) + .build() + .expect("should define sort type"), + ) + .send() + .await + .expect("should create disposable Local table"); + let result = std::panic::AssertUnwindSafe(async { + let p = DynamoDbEcKv::prepare_with_client(client.clone(), &table, NS) + .await + .expect("should prepare Local provider"); + local_generation_contract(&client, &table, &p).await; + local_expiry_and_listing_contract(&client, &table, &p).await; + local_graph_withdrawal_contract(&p).await; + local_allocator_exhaustion_contract(&client, &table, &p).await; + }) + .catch_unwind() + .await; + let cleanup = client.delete_table().table_name(&table).send().await; + if let Err(panic) = result { + std::panic::resume_unwind(panic); + } + cleanup.expect("should delete disposable Local table"); +} + +async fn local_generation_contract(client: &Client, table: &str, p: &DynamoDbEcKv) { + let writes = (0..12).map(|_| p.insert("key", write(EcKvWriteMode::Add))); + let outcomes = futures::future::join_all(writes).await; + assert!( + outcomes.iter().all(Result::is_ok), + "concurrent Add must not hide infrastructure failures" + ); + assert_eq!( + outcomes + .into_iter() + .filter(|r| matches!(r, Ok(EcKvWriteOutcome::Written))) + .count(), + 1 + ); + let initial = p + .lookup("key") + .await + .expect("should read created row") + .expect("should retain created row"); + assert_eq!(initial.body, b"body"); + assert_eq!(initial.metadata.expect("should retain metadata"), b"meta"); + assert_eq!( + p.insert( + "key", + write(EcKvWriteMode::IfGenerationMatch(initial.generation)) + ) + .await + .expect("should execute matching CAS"), + EcKvWriteOutcome::Written + ); + assert_eq!( + p.insert( + "key", + write(EcKvWriteMode::IfGenerationMatch(initial.generation)) + ) + .await + .expect("should execute stale CAS"), + EcKvWriteOutcome::PreconditionFailed + ); + let before_delete = p + .lookup("key") + .await + .expect("should read before delete") + .expect("should retain row before delete") + .generation; + p.delete("key").await.expect("should hard-delete row"); + let recreated = DynamoDbEcKv::prepare_with_client(client.clone(), table, NS) + .await + .expect("should recreate provider"); + assert_eq!( + recreated + .insert("key", write(EcKvWriteMode::Add)) + .await + .expect("should recreate deleted row"), + EcKvWriteOutcome::Written + ); + assert!( + recreated + .lookup("key") + .await + .expect("should read recreated row") + .expect("should retain recreated row") + .generation + > before_delete + ); + assert_eq!( + p.insert( + "key", + write(EcKvWriteMode::IfGenerationMatch(before_delete)) + ) + .await + .expect("should reject pre-delete generation"), + EcKvWriteOutcome::PreconditionFailed + ); + assert!(!p.key_exists("ke").await.expect("should check exact key")); + let isolated = DynamoDbEcKv::prepare_with_client(client.clone(), table, "other") + .await + .expect("should prepare isolated namespace"); + assert!( + !isolated + .key_exists("key") + .await + .expect("should isolate exact lookup") + ); + assert!( + isolated + .list_keys_with_prefix("", 100) + .await + .expect("should isolate prefix listing") + .is_empty() + ); +} + +async fn local_expiry_and_listing_contract(client: &Client, table: &str, p: &DynamoDbEcKv) { + let expiring = EcKvWrite { + ttl: Duration::from_millis(1), + ..write(EcKvWriteMode::Add) + }; + p.insert("expired", expiring) + .await + .expect("should insert expiring row"); + tokio::time::sleep(Duration::from_millis(20)).await; + let physical = client + .get_item() + .table_name(table) + .key("pk", AttributeValue::S("ec:fixture".into())) + .key("sk", AttributeValue::S("expired".into())) + .consistent_read(true) + .send() + .await + .expect("should read physical expired row") + .item + .expect("Local TTL disabled should retain physical expired row"); + let expired_generation = physical["generation"] + .as_n() + .expect("should store numeric generation") + .parse::() + .expect("should store u64 generation"); + assert!( + p.lookup("expired") + .await + .expect("should check logical expiry") + .is_none() + ); + assert!( + p.key_exists("expired") + .await + .expect("should retain physical presence for withdrawal fencing") + ); + assert_eq!( + p.insert( + "expired", + write(EcKvWriteMode::IfGenerationMatch(expired_generation)) + ) + .await + .expect("should reject expired CAS"), + EcKvWriteOutcome::PreconditionFailed + ); + assert_eq!( + p.insert("expired", write(EcKvWriteMode::Add)) + .await + .expect("should replace logically absent row"), + EcKvWriteOutcome::Written + ); + p.insert( + "__ts_ec_withdrawal_complete__:key:a", + write(EcKvWriteMode::Add), + ) + .await + .expect("should support completion marker key"); + assert_eq!( + p.count_keys_with_prefix("__ts_ec_withdrawal_complete__:", 100) + .await + .expect("should count completion markers"), + 1 + ); + // Query Limit=2 first evaluates only expired rows. A later short page also + // has a continuation. The adapter must drive both before returning live keys. + for key in ["page:000", "page:001", "page:002"] { + p.insert(key, expiring) + .await + .expect("should insert expiring prefix row"); + } + for key in ["page:003", "page:004"] { + p.insert(key, write(EcKvWriteMode::Add)) + .await + .expect("should insert live prefix row"); + } + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!( + p.list_keys_with_prefix("page:", 2) + .await + .expect("should traverse expired Local query pages"), + ["page:003", "page:004"] + ); + assert_eq!( + p.count_keys_with_prefix("page:", 3) + .await + .expect("should count complete live prefix"), + 2 + ); +} + +async fn local_allocator_exhaustion_contract(client: &Client, table: &str, p: &DynamoDbEcKv) { + client + .update_item() + .table_name(table) + .key("pk", AttributeValue::S("ec-allocator:fixture".to_owned())) + .key("sk", AttributeValue::S("generation".to_owned())) + .update_expression("SET #g = :near_max") + .expression_attribute_names("#g", "generation") + .expression_attribute_values(":near_max", AttributeValue::N((u64::MAX - 1).to_string())) + .send() + .await + .expect("should seed retained allocator boundary"); + assert_eq!( + p.insert("allocator:last", write(EcKvWriteMode::Add)) + .await + .expect("should allocate final u64 generation"), + EcKvWriteOutcome::Written + ); + assert_eq!( + p.lookup("allocator:last") + .await + .expect("should read final generation") + .expect("should retain final generation") + .generation, + u64::MAX + ); + assert!( + p.insert("allocator:overflow", write(EcKvWriteMode::Add)) + .await + .is_err() + ); + assert!( + !p.key_exists("allocator:overflow") + .await + .expect("failed allocation should not put a row") + ); + let recreated = DynamoDbEcKv::prepare_with_client(client.clone(), table, NS) + .await + .expect("should recreate provider with exhausted allocator"); + assert!( + recreated + .insert("allocator:recreated", write(EcKvWriteMode::Add)) + .await + .is_err() + ); +} + +async fn local_graph_withdrawal_contract(p: &DynamoDbEcKv) { + let ec = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.Ab1234"; + let settings = nextjs_auction::settings(); + let services = nextjs_auction::services(Arc::new(NextJsAuctionOrigin::default())); + let graph = KvIdentityGraph::new(p.clone()); + graph + .create(ec, &KvEntry::minimal("initial.example", "initial-uid", 1)) + .await + .expect("should create graph root"); + let (first, second) = futures::join!( + graph.upsert_partner_id_if_exists(ec, "first.example", "first-uid"), + graph.upsert_partner_id_if_exists(ec, "second.example", "second-uid"), + ); + assert_eq!( + first.expect("should merge first concurrent UID"), + UpsertResult::Written + ); + assert_eq!( + second.expect("should merge second concurrent UID"), + UpsertResult::Written + ); + let (merged, _) = graph + .get(ec) + .await + .expect("should read merged graph") + .expect("should retain merged graph"); + assert_eq!(merged.ids["first.example"].uid, "first-uid"); + assert_eq!(merged.ids["second.example"].uid, "second-uid"); + let before_withdrawal = p + .lookup(ec) + .await + .expect("should read pre-withdrawal row") + .expect("should retain pre-withdrawal row"); + let stale_body = std::str::from_utf8(&before_withdrawal.body).expect("should store JSON body"); + let stale_meta = std::str::from_utf8( + before_withdrawal + .metadata + .as_deref() + .expect("should store JSON metadata"), + ) + .expect("should encode metadata as UTF-8"); + let request = Request::builder() + .uri("https://test-publisher.example.com/article") + .header("cookie", format!("ts-ec={ec}")) + .header("sec-gpc", "1") + .body(Body::empty()) + .expect("should build synthetic withdrawal request"); + let geo = GeoInfo::country_region("US", Some("TN".to_owned())); + let mut context = + EcContext::read_from_request_with_geo(&settings, &request, &services, Some(&geo)) + .expect("should decode synthetic GPC withdrawal"); + let registry = PartnerRegistry::from_config(&[]).expect("should create empty fixture registry"); + let mut response = Response::new(Body::empty()); + let (_, raced) = futures::join!( + ec_finalize_response( + &settings, + &mut context, + Some(&graph), + ®istry, + None, + None, + &mut response + ), + graph.upsert_partner_id_if_exists(ec, "late.example", "late-uid"), + ); + assert!(matches!( + raced.expect("should resolve partner update racing withdrawal"), + UpsertResult::Written | UpsertResult::ConsentWithdrawn + )); + let (tombstone, _) = graph + .get(ec) + .await + .expect("should read authoritative tombstone") + .expect("should retain tombstone"); + assert!(!tombstone.consent.ok); + assert!(tombstone.ids.is_empty()); + assert_eq!( + graph + .upsert_partner_id_if_exists(ec, "late.example", "retry-uid") + .await + .expect("should reject post-withdrawal UID"), + UpsertResult::ConsentWithdrawn + ); + assert_eq!( + p.insert( + ec, + EcKvWrite { + body: stale_body, + metadata: stale_meta, + ttl: Duration::from_secs(60), + mode: EcKvWriteMode::IfGenerationMatch(before_withdrawal.generation) + } + ) + .await + .expect("should reject stale pre-withdrawal CAS"), + EcKvWriteOutcome::PreconditionFailed + ); + assert!( + response + .headers() + .get_all("set-cookie") + .iter() + .any(|value| value + .to_str() + .is_ok_and(|value| value.starts_with("ts-ec=;") && value.contains("Max-Age=0"))), + "withdrawal should expire browser EC cookie" + ); +} diff --git a/crates/trusted-server-adapter-axum/src/lib.rs b/crates/trusted-server-adapter-axum/src/lib.rs index 2f15e566d2..533093138e 100644 --- a/crates/trusted-server-adapter-axum/src/lib.rs +++ b/crates/trusted-server-adapter-axum/src/lib.rs @@ -1,12 +1,22 @@ -//! 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. Optional reader geography comes only from the +//! managed native host's protected, immutable request outcome. /// Application routing and handler registration for the Axum dev server. pub mod app; +#[cfg(all(feature = "celld-ec", not(target_arch = "wasm32")))] +pub mod celld_ec; +/// Native live DynamoDB conditional identity storage and provider selection. +pub mod ec_kv; /// Request middleware (auth, response finalisation). pub mod middleware; -/// Platform-trait implementations backed by env vars and `reqwest`. +/// Explicit native Geo boot profile and provider composition. +pub mod native_geo; +/// 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..ba25b2541a 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -1,7 +1,10 @@ -use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumDevServerConfig}; -use edgezero_core::app::Hooks as _; +use edgezero_adapter_axum::dev_server::{ + AxumRunOptions, run_production_app_with_options_and_initializer, +}; use log::LevelFilter; use trusted_server_adapter_axum::app::TrustedServerApp; +use trusted_server_adapter_axum::native_geo::load_selection; +use trusted_server_adapter_axum::startup::initialize_native; /// Environment variable that sets the maximum log level. const LOG_LEVEL_ENV: &str = "RUST_LOG"; @@ -26,25 +29,42 @@ 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(), + let options = match native_options() { + Ok(options) => options, + Err(error) => { + log::error!("Trusted Server native hosting configuration failed: {error}"); + std::process::exit(1); + } }; - - 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_options_and_initializer::( + options, + |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); } } +/// Capture hosting inputs and validate the final provider selection before startup. +fn native_options() -> Result { + let selection = load_selection().map_err(|error| error.to_string())?; + let options = AxumRunOptions::from_env() + .map_err(|_| "native hosting environment is invalid".to_owned())? + .with_geo(selection); + options + .geo_capability() + .map_err(|error| error.to_string())?; + Ok(options) +} + /// Parses an optional `RUST_LOG` value into the logger's maximum level. /// /// Absent and blank values resolve to [`DEFAULT_LOG_LEVEL`]. Any other value @@ -65,23 +85,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..73b479c57e 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -5,11 +5,28 @@ 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::constants::HEADER_X_GEO_INFO_AVAILABLE; -use trusted_server_core::http_util::sanitize_trusted_client_ip_headers; +use trusted_server_core::auth::enforce_basic_auth_parts; +use trusted_server_core::constants::{ + HEADER_X_GEO_CITY, HEADER_X_GEO_CONTINENT, HEADER_X_GEO_COORDINATES, HEADER_X_GEO_COUNTRY, + HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_GEO_METRO_CODE, HEADER_X_GEO_REGION, +}; +use trusted_server_core::http_util::sanitize_trusted_client_ip_header_map; +use trusted_server_core::platform::GeoInfo; use trusted_server_core::settings::Settings; +use crate::app::AppState; +use crate::platform::geo_from_outcome; + +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 +41,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 } @@ -52,8 +78,9 @@ impl Middleware for SanitizeRequestMiddleware { /// Response-finalization middleware: injects all standard TS response headers. /// -/// Geo lookup is unavailable in the Axum dev server — `X-Geo-Info-Available: false` -/// is always emitted. `X-TS-Version` carries the compiled-in git version. The +/// Geo headers reflect the same immutable managed outcome used by handlers, +/// without resolving the provider during finalization. Missing detail is omitted. +/// `X-TS-Version` carries the compiled-in git version. The /// Fastly-specific headers (`X-TS-Fastly-Version`, `X-TS-ENV`) are skipped /// because the corresponding env vars are not set in a local dev context. /// @@ -61,22 +88,32 @@ 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 geo = geo_from_outcome(ctx.geo()); let mut response = next.run(ctx).await?; - apply_finalize_headers(&self.settings, &mut response); + apply_finalize_headers(&settings, geo.as_ref(), &mut response); Ok(response) } } @@ -87,26 +124,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) => { @@ -125,16 +171,34 @@ impl Middleware for AuthMiddleware { /// Applies standard Trusted Server response headers to the given response. /// -/// Unlike the Fastly variant, geo is always unavailable so `X-Geo-Info-Available: false` -/// is unconditionally emitted, followed by the compiled-in `X-TS-Version`. -/// Fastly-specific headers are omitted. +/// Clear stale upstream geographic headers, then emit only detail present in +/// the request snapshot. Fastly-specific headers are omitted. /// Operator-configured `settings.response_headers` are applied last (with the /// shared cookie cache-privacy hardening) and can override any managed header. -pub(crate) fn apply_finalize_headers(settings: &Settings, response: &mut Response) { - response.headers_mut().insert( +pub(crate) fn apply_finalize_headers( + settings: &Settings, + geo: Option<&GeoInfo>, + response: &mut Response, +) { + for name in [ + HEADER_X_GEO_CITY, + HEADER_X_GEO_COUNTRY, + HEADER_X_GEO_CONTINENT, + HEADER_X_GEO_COORDINATES, + HEADER_X_GEO_METRO_CODE, + HEADER_X_GEO_REGION, HEADER_X_GEO_INFO_AVAILABLE, - HeaderValue::from_static("false"), - ); + ] { + response.headers_mut().remove(name); + } + if let Some(geo) = geo { + geo.set_response_headers(response); + } else { + response.headers_mut().insert( + HEADER_X_GEO_INFO_AVAILABLE, + HeaderValue::from_static("false"), + ); + } trusted_server_core::version_header::apply_git_version_header(response); @@ -213,7 +277,7 @@ mod tests { let settings = settings_with_response_headers(vec![]); let mut response = empty_response(); - apply_finalize_headers(&settings, &mut response); + apply_finalize_headers(&settings, None, &mut response); assert_eq!( response @@ -231,7 +295,7 @@ mod tests { settings_with_response_headers(vec![("X-Geo-Info-Available", "operator-override")]); let mut response = empty_response(); - apply_finalize_headers(&settings, &mut response); + apply_finalize_headers(&settings, None, &mut response); assert_eq!( response @@ -248,7 +312,7 @@ mod tests { let settings = settings_with_response_headers(vec![("X-Custom-Header", "custom-value")]); let mut response = empty_response(); - apply_finalize_headers(&settings, &mut response); + apply_finalize_headers(&settings, None, &mut response); assert_eq!( response @@ -260,6 +324,38 @@ mod tests { ); } + #[test] + fn partial_geo_emits_no_invented_or_stale_detail() { + let settings = settings_with_response_headers(vec![]); + let mut response = empty_response(); + response + .headers_mut() + .insert("x-geo-coordinates", HeaderValue::from_static("0,0")); + response + .headers_mut() + .insert("x-geo-city", HeaderValue::from_static("stale-origin-city")); + let geo = GeoInfo::country_region("US", Some("CA".to_owned())); + apply_finalize_headers(&settings, Some(&geo), &mut response); + assert_eq!(response.headers()["x-geo-info-available"], "true"); + assert_eq!(response.headers()["x-geo-country"], "US"); + assert_eq!(response.headers()["x-geo-region"], "CA"); + for name in [ + "x-geo-coordinates", + "x-geo-city", + "x-geo-continent", + "x-geo-metro-code", + ] { + assert!( + !response.headers().contains_key(name), + "should omit unavailable detail" + ); + } + apply_finalize_headers(&settings, None, &mut response); + assert_eq!(response.headers()["x-geo-info-available"], "false"); + assert!(!response.headers().contains_key("x-geo-country")); + assert!(!response.headers().contains_key("x-geo-region")); + } + #[test] fn sanitize_middleware_strips_configured_trust_headers_before_routing() { let mut settings = settings_with_response_headers(vec![]); @@ -275,8 +371,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()) } @@ -295,7 +391,7 @@ mod tests { #[test] fn emits_git_version_header() { let mut response = empty_response(); - apply_finalize_headers(&settings_with_response_headers(vec![]), &mut response); + apply_finalize_headers(&settings_with_response_headers(vec![]), None, &mut response); assert_eq!( response .headers() diff --git a/crates/trusted-server-adapter-axum/src/native_geo.rs b/crates/trusted-server-adapter-axum/src/native_geo.rs new file mode 100644 index 0000000000..1599b959cc --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/native_geo.rs @@ -0,0 +1,198 @@ +//! Explicit application-owned composition of the native Geo provider. +//! +//! EdgeZero does not load a Geo environment or store binding. This optional TS +//! boot profile selects its Rust options before store preparation and listening. +//! It contains a credential removal name, never the expected origin credential. + +use std::fmt; +use std::fs; + +use edgezero_adapter_axum::geo::{NativeGeoSelection, ProtectedCloudFrontAlb}; +use serde::Deserialize; + +/// Path to the separately delivered TS native Geo boot profile. +pub const PROFILE_FILE_ENV: &str = "TS_NATIVE_GEO_PROFILE_FILE"; + +/// Safe startup categories that never retain profile bytes or policy inputs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeGeoProfileError { + /// A selected profile file could not be read. + Unavailable, + /// The file is not the supported typed profile. + InvalidProfile, + /// `EdgeZero` rejected the protected source or credential removal policy. + InvalidPolicy, +} + +impl fmt::Display for NativeGeoProfileError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Unavailable => "native Geo profile could not be read", + Self::InvalidProfile => "native Geo profile is invalid", + Self::InvalidPolicy => "native Geo protected source policy is invalid", + }) + } +} + +impl core::error::Error for NativeGeoProfileError {} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NativeGeoProfile { + version: u32, + geo: GeoSelection, +} + +#[derive(Deserialize)] +#[serde(tag = "provider", deny_unknown_fields)] +enum GeoSelection { + #[serde(rename = "off")] + Off {}, + #[serde(rename = "cloudfront")] + CloudFront { + alb_peer_cidrs: Vec, + origin_credential_header_name: String, + }, +} + +/// Capture the application-owned selection once before starting the runner. +/// +/// An absent profile keeps existing local hosting Off. A supplied unreadable or +/// invalid file is an error, never a silent fallback. The selected built-in +/// implementation is validated by final `AxumRunOptions::geo_capability()`. +/// +/// # Errors +/// Returns a bounded category without the file path, input bytes, CIDRs or name. +pub fn load_selection() -> Result { + let Some(path) = std::env::var_os(PROFILE_FILE_ENV) else { + return Ok(NativeGeoSelection::Off); + }; + let bytes = fs::read_to_string(path).map_err(|_| NativeGeoProfileError::Unavailable)?; + parse_selection(&bytes) +} + +fn parse_selection(bytes: &str) -> Result { + let profile: NativeGeoProfile = + toml::from_str(bytes).map_err(|_| NativeGeoProfileError::InvalidProfile)?; + if profile.version != 1 { + return Err(NativeGeoProfileError::InvalidProfile); + } + match profile.geo { + GeoSelection::Off {} => Ok(NativeGeoSelection::Off), + GeoSelection::CloudFront { + alb_peer_cidrs, + origin_credential_header_name, + } => ProtectedCloudFrontAlb::new(alb_peer_cidrs, &origin_credential_header_name) + .map(|policy| NativeGeoSelection::CloudFront { policy }) + .map_err(|_| NativeGeoProfileError::InvalidPolicy), + } +} + +#[cfg(test)] +mod tests { + use edgezero_adapter_axum::dev_server::AxumRunOptions; + use edgezero_adapter_axum::geo::NativeGeoCapability; + + use super::*; + + const PROFILE: &str = r#"version = 1 +[geo] +provider = "cloudfront" +alb_peer_cidrs = ["127.0.0.1/32"] +origin_credential_header_name = "x-origin-fixture-private-name" +"#; + + #[test] + fn absent_profile_retains_off() { + temp_env::with_var_unset(PROFILE_FILE_ENV, || { + assert!(matches!( + load_selection().expect("should retain absent selection"), + NativeGeoSelection::Off + )); + }); + } + + #[test] + fn explicit_off_is_a_complete_selection() { + assert!(matches!( + parse_selection("version = 1\n[geo]\nprovider = 'off'") + .expect("should parse explicit off"), + NativeGeoSelection::Off + )); + assert!( + parse_selection("version = 1\n[geo]\nprovider = 'off'\nalb_peer_cidrs = []").is_err(), + "should reject source policy fields on an off selection" + ); + } + + #[test] + fn invalid_selected_file_does_not_fall_back() { + let directory = tempfile::tempdir().expect("should create profile directory"); + let path = directory.path().join("missing-profile.toml"); + temp_env::with_var(PROFILE_FILE_ENV, Some(path.as_os_str()), || { + assert!(matches!( + load_selection(), + Err(NativeGeoProfileError::Unavailable) + )); + fs::write(&path, "credential-sensitive malformed input") + .expect("should write invalid profile"); + assert!(matches!( + load_selection(), + Err(NativeGeoProfileError::InvalidProfile) + )); + }); + } + + #[test] + fn invalid_policy_diagnostics_do_not_disclose_inputs() { + for bytes in [ + PROFILE.replace("127.0.0.1/32", "0.0.0.0/0"), + PROFILE.replace("x-origin-fixture-private-name", "authorization"), + PROFILE.replace("version = 1", "version = 2"), + format!("{PROFILE}origin_credential_value = 'fixture-credential-never-supported'\n"), + ] { + let error = + parse_selection(&bytes).expect_err("should reject unsupported native Geo profile"); + let diagnostic = format!("{error:?}: {error}"); + for sensitive in [ + "127.0.0.1", + "0.0.0.0", + "x-origin-fixture-private-name", + "authorization", + "fixture-credential-never-supported", + ] { + assert!( + !diagnostic.contains(sensitive), + "should redact profile inputs" + ); + } + } + } + + #[test] + fn selected_implementation_is_explicitly_validated() { + let selection = parse_selection(PROFILE).expect("should compose protected source policy"); + let options = AxumRunOptions::new("127.0.0.1:8787".parse().expect("should parse address")) + .expect("should construct options") + .with_geo(selection); + #[cfg(feature = "geo-cloudfront")] + assert!(matches!( + options + .geo_capability() + .expect("should validate selected implementation"), + NativeGeoCapability::Selected { .. } + )); + #[cfg(not(feature = "geo-cloudfront"))] + { + assert!( + options.geo_capability().is_err(), + "should reject compiled-out Geo" + ); + let off = options.with_geo(NativeGeoSelection::Off); + assert_eq!( + off.geo_capability().expect("should permit off"), + NativeGeoCapability::Off + ); + } + } +} 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..1cc6e101c1 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -1,159 +1,21 @@ -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::geo::{GeoOutcome, GeoStatus}; +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,516 +35,157 @@ impl PlatformBackend for AxumPlatformBackend { } } -// --------------------------------------------------------------------------- -// PlatformGeo -// --------------------------------------------------------------------------- - -/// No-op geo implementation — geographic lookup is unavailable in local development. -pub struct AxumPlatformGeo; - -impl PlatformGeo for AxumPlatformGeo { - fn lookup(&self, _client_ip: Option) -> Result, Report> { - Ok(None) - } -} - -// --------------------------------------------------------------------------- -// 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. +/// An owned snapshot of the managed native request's geographic outcome. /// -/// 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, +/// Provider resolution belongs to `EdgeZero` ingress. Every downstream lookup +/// returns this same request's data, never an IP lookup or shared global state. +#[derive(Clone, Default)] +pub struct AxumPlatformGeo { + info: Option, } -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. -/// -/// `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 { +impl AxumPlatformGeo { + fn from_context(context: &RequestContext) -> 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()); + info: geo_from_outcome(context.geo()), } - 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() +impl PlatformGeo for AxumPlatformGeo { + fn lookup(&self, _client_ip: Option) -> Result, Report> { + Ok(self.info.clone()) } } -#[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, - }) +/// Project only the private managed outcome, without reading request headers. +/// Unavailable, untrusted, error and unselected outcomes supply no location. +pub(crate) fn geo_from_outcome(outcome: &GeoOutcome) -> Option { + match outcome { + GeoOutcome::Selected { + status: GeoStatus::Located { location, .. }, + .. + } => Some(GeoInfo::country_region( + location.country().as_str(), + location + .subdivision() + .map(|value| value.as_str().to_owned()), + )), + _ => None, } } -// --------------------------------------------------------------------------- -// build_runtime_services -// --------------------------------------------------------------------------- - -/// Construct [`RuntimeServices`] for an incoming Axum request. +/// Install checked public URL facts without consuming or replacing the request body. /// -/// # Degraded features in dev +/// 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. /// -/// 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, +/// # 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(()) +} - // 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 - }))) +/// Construct request-local runtime services from initialized immutable stores. +/// +/// The RC client and application clock come from the incoming request. Only +/// dependency handles are shared; checked visitor metadata is read afresh. +/// Generic KV remains unavailable. Geography is captured before converting the +/// managed context back into an ordinary request and never resolved again. +/// +/// # 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(); + + 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::new(AxumPlatformGeo::from_context(context))) .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" - ); - } + use edgezero_core::body::Body; + use edgezero_core::http::request_builder; - #[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" - ); - } - - #[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 +194,119 @@ 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"); + fn located(country: &str, region: Option<&str>) -> GeoOutcome { + use edgezero_core::geo::{ + CountryCode, GeoLocation, GeoProviderId, GeoSource, GeoTrustBasis, SubdivisionCode, + }; + GeoOutcome::Selected { + provider: GeoProviderId::cloudfront(), + status: GeoStatus::Located { + location: GeoLocation::new( + CountryCode::new(country).expect("should parse fixture country"), + region.map(|value| { + SubdivisionCode::new(value).expect("should parse fixture subdivision") + }), + ), + source: GeoSource::CloudFront, + trust_basis: GeoTrustBasis::DeclaredProtectedAlb, + }, + } } - #[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); + #[test] + fn geography_is_partial_and_request_local() { + let first = AxumPlatformGeo { + info: geo_from_outcome(&located("US", Some("CA"))), + }; + let second = AxumPlatformGeo { + info: geo_from_outcome(&located("GB", None)), + }; + let us = first + .lookup(None) + .expect("should read first snapshot") + .expect("should retain US"); + let gb = second + .lookup(None) + .expect("should read second snapshot") + .expect("should retain GB"); + assert_eq!(us.country, "US"); + assert_eq!(us.region.as_deref(), Some("CA")); + assert_eq!(gb.country, "GB"); + assert_eq!(gb.region, None); + assert_eq!(us.coordinates, None); + assert_eq!(us.city, None); + assert_eq!(us.continent, None); + assert_eq!(us.metro_code, None); assert_eq!( - response - .headers() - .get(edgezero_core::http::header::LOCATION) - .and_then(|value| value.to_str().ok()), - Some("https://redirect.example/next") + first + .lookup(Some( + "192.0.2.1".parse().expect("should parse different IP") + )) + .expect("should reuse same snapshot"), + Some(us), + "should not resolve geography from another lookup argument" ); } - #[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" - ); - assert!( - response.headers().get(header::CONNECTION).is_none(), - "should strip connection" - ); - assert!( - response.headers().get("keep-alive").is_none(), - "should strip keep-alive" - ); + #[test] + fn unavailable_outcomes_and_forged_extensions_cannot_supply_geography() { + use edgezero_core::geo::{GeoError, GeoProviderId, GeoUnavailableReason}; + for status in [ + GeoStatus::Unavailable(GeoUnavailableReason::MissingMetadata), + GeoStatus::Unavailable(GeoUnavailableReason::UntrustedIngress), + GeoStatus::Error(GeoError::InvalidAssertion), + GeoStatus::Error(GeoError::ProviderFailure), + ] { + assert!( + geo_from_outcome(&GeoOutcome::Selected { + provider: GeoProviderId::cloudfront(), + status, + }) + .is_none() + ); + } + let mut request = request_builder() + .header("cloudfront-viewer-country", "US") + .header("cloudfront-viewer-country-region", "CA") + .body(Body::empty()) + .expect("should build unmanaged request"); + request.extensions_mut().insert(located("US", Some("CA"))); + let context = RequestContext::new(request, edgezero_core::params::PathParams::default()); 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::from_context(&context) + .lookup(None) + .expect("should read unmanaged outcome") + .is_none(), + "should ignore raw headers and public extensions" ); } - #[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..d87f13962c --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/startup.rs @@ -0,0 +1,492 @@ +//! 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; +#[cfg(feature = "celld-ec")] +use edgezero_store_celld::settings::CelldAccessToken; +use error_stack::Report; +use trusted_server_core::config_payload::{ + DEFAULT_CONFIG_STORE_ID, DEFAULT_SECRET_STORE_ID, VerifiedConfigBlob, +}; +use trusted_server_core::ec::storage::{EcRuntime, EcStorageSelection}; +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::middleware::apply_finalize_headers; +use crate::platform::{AxumPlatformBackend, AxumPlatformGeo, geo_from_outcome}; + +/// 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::default())) + .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. +/// +/// # Panics +/// Panics if newly constructed application state unexpectedly has another owner. +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)?; + crate::ec_kv::reject_legacy_configuration()?; + let mut state = build_state_with_settings(settings).map_err(settings_error)?; + // EC records stay live. Only the validated provider handle is shared, never + // mutable identity rows in the immutable config/secret snapshot. + let provider_settings = Arc::clone(&state.settings); + match provider_settings + .ec + .resolve_storage(EcRuntime::Native) + .map_err(settings_error)? + { + None => {} + Some(EcStorageSelection::DynamoDb(config)) => { + let provider = crate::ec_kv::prepare(config).await?; + Arc::get_mut(&mut state) + .expect("should uniquely own initializing app state") + .set_ec_store(provider.as_ref().clone()); + } + Some(EcStorageSelection::Celld(config)) => { + #[cfg(feature = "celld-ec")] + { + // Capture only the selected bootstrap reference once. The live + // handle owns this credential; request paths never read env. + let token = config + .access + .token_env() + .map(|name| { + std::env::var(name) + .map_err(|_| { + configuration_error( + "selected EC celld bootstrap token is unavailable", + ) + }) + .and_then(|value| { + CelldAccessToken::new(value).map_err(|_| { + configuration_error( + "selected EC celld bootstrap token is invalid", + ) + }) + }) + }) + .transpose()?; + let provider = crate::celld_ec::CelldEcKv::prepare(config, token.as_ref()).await?; + Arc::get_mut(&mut state) + .expect("should uniquely own initializing app state") + .set_ec_store(provider); + } + #[cfg(not(feature = "celld-ec"))] + { + let _ = config; + return Err(configuration_error( + "native EC celld selected but celld-ec build feature is unavailable", + )); + } + } + Some(EcStorageSelection::Fastly(_)) => { + unreachable!("native resolution should not select Fastly") + } + } + 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, + }); + let finalize_settings = Arc::clone(&state.settings); + app.set_admitted_error_response_finalizer(move |outcome, response| { + let geo = geo_from_outcome(outcome); + apply_finalize_headers(&finalize_settings, geo.as_ref(), response); + }); + 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/ec_kv_configuration.rs b/crates/trusted-server-adapter-axum/tests/ec_kv_configuration.rs new file mode 100644 index 0000000000..52cce60459 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/ec_kv_configuration.rs @@ -0,0 +1,81 @@ +//! The obsolete launch contract is rejected without reading AWS binding values. + +use std::process::Command; + +#[cfg(not(feature = "aws-dynamodb-ec"))] +use trusted_server_adapter_axum::ec_kv::prepare; +use trusted_server_adapter_axum::ec_kv::reject_legacy_configuration; +#[cfg(not(feature = "aws-dynamodb-ec"))] +use trusted_server_core::ec::storage::DynamoDbEcStorage; + +#[test] +fn legacy_ec_selection_is_rejected_in_isolated_processes() { + for case in ["absent", "false", "true", "empty", "uppercase", "numeric"] { + let mut command = + Command::new(std::env::current_exe().expect("should locate config test executable")); + command + .args(["--exact", "legacy_ec_selection_subprocess", "--nocapture"]) + .env_clear() + .env("TS_EC_CONFIGURATION_CASE", case) + .env("TS_EC_DYNAMODB_TABLE", "orphaned-unused-table") + .env("TS_EC_DYNAMODB_NAMESPACE", "orphaned-unused-namespace") + .env("TS_EC_DYNAMODB_LOCAL_ENDPOINT", "invalid-secret-endpoint"); + if case != "absent" { + command.env( + "TS_EC_DYNAMODB_ENABLED", + match case { + "empty" => "", + "uppercase" => "TRUE", + "numeric" => "1", + value => value, + }, + ); + } + let output = command + .output() + .expect("should execute isolated migration check"); + assert!( + output.status.success(), + "should pass case {case}: {}", + String::from_utf8_lossy(&output.stderr) + ); + } +} + +#[test] +fn legacy_ec_selection_subprocess() { + let Ok(case) = std::env::var("TS_EC_CONFIGURATION_CASE") else { + return; + }; + let result = reject_legacy_configuration(); + if matches!(case.as_str(), "absent" | "false") { + result.expect("should ignore inactive legacy selection and orphaned bindings"); + } else { + let detail = format!( + "{:?}", + result.expect_err("should reject obsolete or malformed selection") + ); + assert!( + detail.contains("ec.storage"), + "should explain publication migration" + ); + assert!( + !detail.contains("invalid-secret-endpoint"), + "should not echo legacy input" + ); + } +} + +#[cfg(not(feature = "aws-dynamodb-ec"))] +#[tokio::test] +async fn typed_dynamodb_selection_fails_without_compiled_provider() { + let error = prepare(&DynamoDbEcStorage { + region: "us-east-1".to_owned(), + table: "example-identity".to_owned(), + namespace: "fixture".to_owned(), + local_endpoint: None, + }) + .await + .expect_err("should reject selected provider before network access"); + assert!(format!("{error:?}").contains("aws-dynamodb-ec build feature is unavailable")); +} 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..84a86f44c5 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/fixtures/native/README.md @@ -0,0 +1,84 @@ +# 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()})') +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 \ + 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..015980278e --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/native_acceptance.rs @@ -0,0 +1,908 @@ +//! 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; + +#[cfg(all(feature = "celld-ec", feature = "geo-cloudfront"))] +#[path = "support/celld.rs"] +mod celld_fixture; +#[cfg(all(feature = "celld-ec", feature = "geo-cloudfront"))] +use base64::{Engine as _, engine::general_purpose::STANDARD}; +#[cfg(all(feature = "celld-ec", feature = "geo-cloudfront"))] +use trusted_server_core::ec::kv_backend::{ + EcKvStore as _, EcKvWrite, EcKvWriteMode, EcKvWriteOutcome, +}; + +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"; + +#[cfg(all(feature = "celld-ec", feature = "geo-cloudfront"))] +fn ec_response_cookie(response: &Response) -> Option { + response + .headers() + .get_all("set-cookie") + .iter() + .filter_map(|value| value.to_str().ok()) + .find_map(|value| { + value.strip_prefix("ts-ec=").map(|value| { + value + .split(';') + .next() + .expect("should contain cookie value") + .to_owned() + }) + }) +} + +#[cfg(all(feature = "celld-ec", feature = "geo-cloudfront"))] +#[test] +#[ignore = "requires CELLD_EC_TEST_BINARY and celld-ec,geo-cloudfront features; real disposable servers only"] +fn real_celld_envelope_drives_native_http_issuance_withdrawal_restart_and_outage() { + let mut celld = celld_fixture::CelldFixture::new(); + let origin = origin::Origin::start("celld-ec-http"); + let mut prepared = Prepared::new(&origin.base_url); + let mut storage = + serde_json::to_value(celld.settings()).expect("should serialize pure selected binding"); + storage["provider"] = json!("celld"); + prepared.data["ec"]["storage"] = storage; + prepared.data["ec"]["partners"] = json!([{"name":"Fixture partner","source_domain":"partner.example.com","bidstream_enabled":true,"pull_sync_enabled":false}]); + prepared.publish(); + let profile = prepared.workspace.path().join("native-geo.toml"); + fs::write(&profile,"version = 1\n[geo]\nprovider = \"cloudfront\"\nalb_peer_cidrs = [\"127.0.0.1/32\"]\norigin_credential_header_name = \"x-origin-synthetic-fixture\"\n").expect("should write synthetic local protected-ingress profile"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("should drive typed record inspection"); + let store = runtime.block_on(celld.store()); + let client = client(None); + let document = |server: &Running, country: &str, cookie: Option<&str>, withdrawal: bool| { + let mut request = client + .get(format!("{}/article", server.base_url())) + .header("host", "publisher.example.com") + .header( + "forwarded", + "for=198.51.100.24;proto=https;host=publisher.example.com", + ) + .header("accept", "text/html") + .header("sec-fetch-dest", "document") + .header("sec-fetch-mode", "navigate") + .header( + "user-agent", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/146.0.0.0 Safari/537.36", + ) + .header("cloudfront-viewer-country", country) + .header("cloudfront-viewer-country-region", "TN") + .header("x-origin-synthetic-fixture", "synthetic-fixture-only"); + if let Some(cookie) = cookie { + request = request.header("cookie", cookie); + } + if withdrawal { + request = request.header("sec-gpc", "1"); + } + request + .send() + .expect("should complete actual native publisher HTTP request") + }; + let mut app = prepared.spawn_with_geo(true, Some(&profile)); + app.ready(); + let issued = document(&app, "AU", None, false); + assert_eq!(issued.status().as_u16(), 200); + assert_eq!( + issued + .headers() + .get("x-geo-country") + .expect("should admit checked synthetic geography"), + "AU" + ); + let ec = ec_response_cookie(&issued) + .expect("should issue only with confirmed real record persistence"); + let created = runtime + .block_on(store.lookup(&ec)) + .expect("should read actual persisted identity") + .expect("should retain issued identity"); + let eids = STANDARD.encode( + json!([{"source":"partner.example.com","uids":[{"id":"fixture-uid","atype":1}]}]) + .to_string(), + ); + let cookie = format!("ts-ec={ec}; ts-eids={eids}"); + let updated = document(&app, "AU", Some(&cookie), false); + assert_eq!(updated.status().as_u16(), 200); + let updated = runtime + .block_on(store.lookup(&ec)) + .expect("should read real native EID update") + .expect("should retain updated identity"); + let row: Value = + serde_json::from_slice(&updated.body).expect("should decode persisted graph schema"); + assert_eq!(row["ids"]["partner.example.com"]["uid"], "fixture-uid"); + assert!(updated.generation > created.generation); + drop(app); + app = prepared.spawn_with_geo(true, Some(&profile)); + app.ready(); + assert!( + ec_response_cookie(&document(&app, "AU", Some(&cookie), false)).is_none(), + "should reuse identity across application recreation" + ); + let withdrawn = document(&app, "US", Some(&cookie), true); + assert_eq!(ec_response_cookie(&withdrawn).as_deref(), Some("")); + let tombstone = runtime + .block_on(store.lookup(&ec)) + .expect("should read acknowledged native withdrawal") + .expect("should retain tombstone"); + let row: Value = + serde_json::from_slice(&tombstone.body).expect("should decode actual tombstone"); + assert_eq!(row["consent"]["ok"], false); + celld.stop(); + assert!( + ec_response_cookie(&document(&app, "AU", None, false)).is_none(), + "should not issue unsupported cookie during real storage outage" + ); + drop(app); + celld.restart(); + prepared.data["ec"]["storage"]["endpoint"] = json!(celld.endpoint); + prepared.publish(); + app = prepared.spawn_with_geo(true, Some(&profile)); + app.ready(); + assert!( + ec_response_cookie(&document(&app, "AU", Some(&cookie), false)).is_none(), + "should not revive withdrawal after server and application restart" + ); + let store = runtime.block_on(celld.store()); + let restored = runtime + .block_on(store.lookup(&ec)) + .expect("should read recovered tombstone") + .expect("should restore withdrawal"); + assert_eq!(restored.body, tombstone.body); + assert_eq!( + runtime + .block_on(store.insert( + &ec, + EcKvWrite { + body: std::str::from_utf8(&updated.body).expect("should decode old entry"), + metadata: "{}", + ttl: Duration::from_secs(60), + mode: EcKvWriteMode::IfGenerationMatch(updated.generation) + } + )) + .expect("should reject stale writer"), + EcKvWriteOutcome::PreconditionFailed + ); +} + +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 { + self.spawn_with_geo(trusted_proxy, None) + } + + fn spawn_with_geo( + &self, + trusted_proxy: bool, + geo_profile: Option<&std::path::Path>, + ) -> 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"); + } + if let Some(profile) = geo_profile { + command.env("TS_NATIVE_GEO_PROFILE_FILE", profile); + } + 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_ec.rs b/crates/trusted-server-adapter-axum/tests/native_ec.rs new file mode 100644 index 0000000000..fff640dad9 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/native_ec.rs @@ -0,0 +1,646 @@ +//! Native lifecycle regressions with isolated conditional storage and origins. + +use std::collections::BTreeMap; +use std::net::IpAddr; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use edgezero_core::body::Body; +use edgezero_core::http::{Method, Request, Response, header}; +use edgezero_core::router::RouterService; +use error_stack::Report; +use serde_json::json; +use tokio::sync::Notify; +use trusted_server_adapter_axum::app::TrustedServerApp; +use trusted_server_adapter_axum::platform::AxumPlatformBackend; +use trusted_server_core::ec::kv_backend::{ + EcKvLookup, EcKvStore, EcKvWrite, EcKvWriteMode, EcKvWriteOutcome, +}; +use trusted_server_core::error::TrustedServerError; +use trusted_server_core::platform::{ + ClientInfo, GeoInfo, PlatformConfigStore, PlatformError, PlatformGeo, PlatformSecretStore, + RuntimeServices, StoreId, StoreName, UnavailableKvStore, +}; +use trusted_server_core::settings::Settings; +use trusted_server_core::test_support::nextjs_auction::{self, NextJsAuctionOrigin}; + +#[cfg(feature = "celld-ec")] +#[path = "support/celld.rs"] +mod celld_fixture; + +#[cfg(feature = "celld-ec")] +#[tokio::test(flavor = "current_thread")] +#[ignore = "requires CELLD_EC_TEST_BINARY pointing to the built real conditional-record server"] +async fn real_celld_persists_routes_withdrawal_restart_and_outage_without_fallback() { + let mut celld = celld_fixture::CelldFixture::new(); + let store = celld.store().await; + let app = allowed_router(store.clone()); + let issued = call(&app, request(Method::GET, "/article", None)).await; + let ec = + ec_cookie(&issued).expect("should issue cookie only after real record acknowledgement"); + let original = store + .lookup(&ec) + .await + .expect("should read real record") + .expect("should retain issued identity"); + assert!( + store + .key_exists(&ec) + .await + .expect("should strongly confirm exact identity") + ); + let eids = BASE64.encode( + json!([{"source":"partner.example.com","uids":[{"id":"celld-fixture-uid","atype":1}]}]) + .to_string(), + ); + let cookie = format!("ts-ec={ec}; ts-eids={eids}"); + let returned = call(&app, request(Method::GET, "/article", Some(&cookie))).await; + assert_eq!(returned.status(), 200); + let updated = store + .lookup(&ec) + .await + .expect("should read updated record") + .expect("should retain updated identity"); + assert!(updated.generation > original.generation); + let data: serde_json::Value = + serde_json::from_slice(&updated.body).expect("should decode unchanged graph schema"); + assert_eq!( + data["ids"]["partner.example.com"]["uid"], + "celld-fixture-uid" + ); + celld.restart(); + let store = celld.store().await; + let restored = store + .lookup(&ec) + .await + .expect("should read after actual celld restart") + .expect("should restore identity"); + assert_eq!(restored.body, updated.body); + assert_eq!(restored.metadata, updated.metadata); + assert_eq!(restored.generation, updated.generation); + let regulated = router( + store.clone(), + Some(GeoInfo::country_region("US", Some("TN".to_owned()))), + ); + let mut withdrawal = request(Method::GET, "/article", Some(&cookie)); + withdrawal.headers_mut().insert( + "sec-gpc", + "1".parse().expect("should set synthetic withdrawal signal"), + ); + let withdrawn = call(®ulated, withdrawal).await; + assert_eq!(ec_cookie(&withdrawn).as_deref(), Some("")); + let tombstone = store + .lookup(&ec) + .await + .expect("should read actual tombstone") + .expect("should retain actual tombstone"); + let data: serde_json::Value = + serde_json::from_slice(&tombstone.body).expect("should decode tombstone"); + assert_eq!(data["consent"]["ok"], false); + assert_eq!( + store + .insert( + &ec, + EcKvWrite { + body: std::str::from_utf8(&updated.body).expect("should decode old entry"), + metadata: "{}", + ttl: Duration::from_secs(60), + mode: EcKvWriteMode::IfGenerationMatch(updated.generation) + } + ) + .await + .expect("should classify stale CAS"), + EcKvWriteOutcome::PreconditionFailed + ); + celld.restart(); + let store = celld.store().await; + assert_eq!( + store + .lookup(&ec) + .await + .expect("should read recovered withdrawal") + .expect("should retain withdrawal") + .body, + tombstone.body + ); + let app = allowed_router(store.clone()); + assert!( + ec_cookie(&call(&app, request(Method::GET, "/article", Some(&cookie))).await).is_none(), + "should not resurrect withdrawal" + ); + celld.stop(); + let failed = call(&app, request(Method::GET, "/article", None)).await; + assert!( + ec_cookie(&failed).is_none(), + "should not mint unsupported cookie on real storage outage" + ); + assert!( + store.key_exists(&ec).await.is_err(), + "should not turn outage into false absence" + ); + trusted_server_adapter_axum::celld_ec::CelldEcKv::prepare(&celld.settings(), None) + .await + .expect_err("should fail selected-provider preparation without fallback"); +} + +#[derive(Clone, Default)] +struct Store { + rows: Arc>>, + generation: Arc, + fail_writes: Arc, + pause_writes: Arc, + started: Arc, + resume: Arc, +} + +#[async_trait(?Send)] +impl EcKvStore for Store { + fn store_name(&self) -> &str { + "native-ec-fixture" + } + async fn lookup(&self, key: &str) -> Result, Report> { + Ok(self + .rows + .lock() + .expect("should lock fixture rows") + .get(key) + .cloned()) + } + async fn key_exists(&self, key: &str) -> Result> { + Ok(self.lookup(key).await?.is_some()) + } + async fn insert( + &self, + key: &str, + write: EcKvWrite<'_>, + ) -> Result> { + if self.pause_writes.load(Ordering::SeqCst) { + self.started.notify_one(); + self.resume.notified().await; + } + if self.fail_writes.load(Ordering::SeqCst) { + return Err(Report::new(TrustedServerError::KvStore { + store_name: self.store_name().to_owned(), + message: "isolated uncertain write".to_owned(), + })); + } + let mut rows = self.rows.lock().expect("should lock fixture rows"); + match write.mode { + EcKvWriteMode::Add if rows.contains_key(key) => { + return Ok(EcKvWriteOutcome::PreconditionFailed); + } + EcKvWriteMode::IfGenerationMatch(expected) + if rows.get(key).map(|row| row.generation) != Some(expected) => + { + return Ok(EcKvWriteOutcome::PreconditionFailed); + } + _ => {} + } + rows.insert( + key.to_owned(), + EcKvLookup { + body: write.body.as_bytes().to_vec(), + metadata: Some(write.metadata.as_bytes().to_vec()), + generation: self.generation.fetch_add(1, Ordering::SeqCst) + 1, + }, + ); + Ok(EcKvWriteOutcome::Written) + } + async fn list_keys_with_prefix( + &self, + prefix: &str, + limit: u32, + ) -> Result, Report> { + Ok(self + .rows + .lock() + .expect("should lock fixture rows") + .keys() + .filter(|key| key.starts_with(prefix)) + .take(limit as usize) + .cloned() + .collect()) + } + async fn delete(&self, key: &str) -> Result<(), Report> { + self.rows + .lock() + .expect("should lock fixture rows") + .remove(key); + Ok(()) + } +} + +struct UnusedStores; +impl PlatformConfigStore for UnusedStores { + fn get(&self, _: &StoreName, _: &str) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } +} +impl PlatformSecretStore for UnusedStores { + fn get_bytes(&self, _: &StoreName, _: &str) -> Result, Report> { + Err(Report::new(PlatformError::Unsupported)) + } + fn create(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } +} +struct Geo(Option); +impl PlatformGeo for Geo { + fn lookup(&self, _: Option) -> Result, Report> { + Ok(self.0.clone()) + } +} + +fn settings() -> Settings { + let mut settings = nextjs_auction::settings(); + settings.auction.enabled = false; + settings.auction.providers.clear(); + settings.creative_opportunities = None; + settings.ec.partners = serde_json::from_value(json!([{ + "name": "Fixture partner", "source_domain": "partner.example.com", + "bidstream_enabled": true, "api_token": "native-fixture-partner-token-32-bytes-minimum" + }])) + .expect("should configure isolated partner"); + settings +} + +fn router( + store: S, + geo: Option, +) -> RouterService { + let services = RuntimeServices::builder() + .config_store(Arc::new(UnusedStores)) + .secret_store(Arc::new(UnusedStores)) + .kv_store(Arc::new(UnavailableKvStore)) + .backend(Arc::new(AxumPlatformBackend)) + .http_client(Arc::new(NextJsAuctionOrigin::default())) + .geo(Arc::new(Geo(geo))) + .client_info(ClientInfo { + client_ip: Some("198.51.100.42".parse().expect("should parse test IP")), + ..ClientInfo::default() + }) + .build(); + TrustedServerApp::routes_with_settings_services_and_ec_store(settings(), services, store) + .expect("should construct native EC fixture router") +} +fn allowed_router(store: S) -> RouterService { + router(store, Some(GeoInfo::country_region("AU", None))) +} +fn request(method: Method, path: &str, cookie: Option<&str>) -> Request { + let mut req = axum::http::Request::builder() + .method(method) + .uri(format!("https://test-publisher.example.com{path}")) + .header( + header::USER_AGENT, + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/146.0.0.0 Safari/537.36", + ) + .header("sec-fetch-dest", "document") + .header(header::ACCEPT, "text/html"); + if let Some(cookie) = cookie { + req = req.header(header::COOKIE, cookie); + } + req.body(Body::empty()) + .expect("should construct fixture request") +} +fn ec_cookie(response: &Response) -> Option { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .find_map(|value| { + value.strip_prefix("ts-ec=").map(|value| { + value + .split(';') + .next() + .expect("should contain cookie value") + .to_owned() + }) + }) +} +async fn call(router: &RouterService, request: Request) -> Response { + router + .oneshot(request) + .await + .expect("should dispatch native fixture request") +} + +#[tokio::test(flavor = "current_thread")] +async fn persists_before_cookie_reuses_across_app_recreation_and_withdraws() { + let store = Store::default(); + let app = allowed_router(store.clone()); + let response = call(&app, request(Method::GET, "/article", None)).await; + assert_eq!(response.status(), 200); + let ec = ec_cookie(&response).expect("should issue EC after confirmed persistence"); + assert!( + store + .key_exists(&ec) + .await + .expect("should confirm authoritative row") + ); + assert_eq!( + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .filter(|value| value + .to_str() + .is_ok_and(|value| value.starts_with("ts-ec="))) + .count(), + 1, + "should finalize exactly once" + ); + assert!( + response.headers()[header::CACHE_CONTROL] + .to_str() + .expect("should read cache policy") + .contains("private") + ); + let eids = BASE64.encode( + json!([{"source":"partner.example.com","uids":[{"id":"fixture-uid","atype":1}]}]) + .to_string(), + ); + let cookie = format!("ts-ec={ec}; ts-eids={eids}"); + let recreated = allowed_router(store.clone()); + let returning = call(&recreated, request(Method::GET, "/article", Some(&cookie))).await; + assert_eq!(returning.status(), 200); + assert!( + ec_cookie(&returning).is_none(), + "should not refresh an unchanged returning cookie" + ); + let row: serde_json::Value = serde_json::from_slice( + &store + .lookup(&ec) + .await + .expect("should read row") + .expect("should retain row") + .body, + ) + .expect("should decode persisted entry"); + assert_eq!(row["ids"]["partner.example.com"]["uid"], "fixture-uid"); + let mut identify = request(Method::GET, "/_ts/api/v1/identify", Some(&cookie)); + identify.headers_mut().insert( + header::AUTHORIZATION, + "Bearer native-fixture-partner-token-32-bytes-minimum" + .parse() + .expect("should set fixture partner token"), + ); + let identified = call(&recreated, identify).await; + assert_eq!(identified.status(), 200); + assert!( + ec_cookie(&identified).is_none(), + "identify must not emit an identity cookie" + ); + let data: serde_json::Value = serde_json::from_slice( + &identified + .into_body() + .into_bytes_bounded(64 * 1024) + .await + .expect("should read identify JSON"), + ) + .expect("should decode identify JSON"); + assert_eq!( + data["uid"], "fixture-uid", + "native identify must use the live graph and partner registry" + ); + let mut withdrawal = request(Method::GET, "/article", Some(&cookie)); + withdrawal.headers_mut().insert( + "sec-gpc", + "1".parse().expect("should set genuine withdrawal input"), + ); + // Revocation remains writable even when the user agent no longer passes + // the ordinary browser heuristic. + withdrawal.headers_mut().insert( + header::USER_AGENT, + "privacy-client".parse().expect("should set hardened UA"), + ); + let regulated = router( + store.clone(), + Some(GeoInfo::country_region("US", Some("TN".to_owned()))), + ); + let withdrawn = call(®ulated, withdrawal).await; + assert_eq!(ec_cookie(&withdrawn).as_deref(), Some("")); + let row: serde_json::Value = serde_json::from_slice( + &store + .lookup(&ec) + .await + .expect("should read tombstone") + .expect("should retain tombstone") + .body, + ) + .expect("should decode tombstone"); + assert_eq!(row["consent"]["ok"], false); + assert!( + row.get("ids") + .is_none_or(|ids| ids.as_object().is_some_and(serde_json::Map::is_empty)), + "should clear all withdrawn partner IDs" + ); + let retry = call(&recreated, request(Method::GET, "/article", Some(&cookie))).await; + assert!( + ec_cookie(&retry).is_none(), + "should not resurrect a revoked identity" + ); + let row: serde_json::Value = serde_json::from_slice( + &store + .lookup(&ec) + .await + .expect("should read tombstone after retry") + .expect("should retain tombstone") + .body, + ) + .expect("should decode retained tombstone"); + assert_eq!(row["consent"]["ok"], false); +} + +#[tokio::test(flavor = "current_thread")] +async fn missing_consent_unknown_geo_storage_failure_and_non_navigation_do_not_mint() { + let store = Store::default(); + for geo in [ + None, + Some(GeoInfo::country_region("US", Some("TN".to_owned()))), + ] { + let response = call( + &router(store.clone(), geo), + request(Method::GET, "/article", None), + ) + .await; + assert!( + ec_cookie(&response).is_none(), + "should preserve missing-consent policy" + ); + } + let app = allowed_router(store.clone()); + for (method, path) in [ + (Method::GET, "/health"), + (Method::GET, "/static/tsjs=fixture"), + (Method::OPTIONS, "/article"), + (Method::OPTIONS, "/_ts/page-bids"), + (Method::GET, "/_ts/admin/ec"), + (Method::GET, "/_ts/api/v1/identify"), + (Method::HEAD, "/article"), + (Method::POST, "/article"), + ] { + let response = call(&app, request(method, path, None)).await; + assert!(ec_cookie(&response).is_none(), "should not mint on {path}"); + } + let mut asset = request(Method::GET, "/article", None); + asset.headers_mut().insert( + "sec-fetch-dest", + "image".parse().expect("should set subresource destination"), + ); + assert!(ec_cookie(&call(&app, asset).await).is_none()); + let mut bot = request(Method::GET, "/article", None); + bot.headers_mut().insert( + header::USER_AGENT, + "curl/8.0".parse().expect("should set bot UA"), + ); + assert!(ec_cookie(&call(&app, bot).await).is_none()); + let mut identify = request(Method::GET, "/_ts/api/v1/identify", None); + identify.headers_mut().insert( + header::AUTHORIZATION, + "Bearer native-fixture-partner-token-32-bytes-minimum" + .parse() + .expect("should set fixture partner token"), + ); + let identified = call(&app, identify).await; + assert_eq!( + identified.status(), + 204, + "authenticated identify without an identity stays read-only" + ); + assert!(ec_cookie(&identified).is_none()); + assert!( + store + .rows + .lock() + .expect("should read fixture rows") + .is_empty() + ); + store.fail_writes.store(true, Ordering::SeqCst); + assert!( + ec_cookie(&call(&app, request(Method::GET, "/article", None)).await).is_none(), + "uncertain writes must not issue phantom identities" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn regulated_fixture_uses_decoded_signal_and_gpc_keeps_precedence() { + let store = Store::default(); + let app = router( + store.clone(), + Some(GeoInfo::country_region("US", Some("TN".to_owned()))), + ); + // Encoded, synthetic US Privacy input exercises the real core decoder. + // This is not evidence of consent capture from a real publisher CMP. + let accepted = call( + &app, + request(Method::GET, "/article", Some("us_privacy=1YNN")), + ) + .await; + let ec = ec_cookie(&accepted).expect("should honor the decoded qualifying fixture signal"); + assert!( + store + .key_exists(&ec) + .await + .expect("should confirm regulated fixture row") + ); + let mut denied = request(Method::GET, "/article", Some("us_privacy=1YNN")); + denied + .headers_mut() + .insert("sec-gpc", "1".parse().expect("should set GPC input")); + assert!( + ec_cookie(&call(&app, denied).await).is_none(), + "GPC must override the non-opt-out fixture signal" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn delayed_storage_yields_to_health_and_cancels_without_cookie() { + let store = Store::default(); + store.pause_writes.store(true, Ordering::SeqCst); + let app = allowed_router(store.clone()); + let article = call(&app, request(Method::GET, "/article", None)); + tokio::pin!(article); + tokio::select! { + _ = store.started.notified() => {}, + _ = &mut article => panic!("storage should remain pending"), + } + let health = tokio::time::timeout( + Duration::from_millis(250), + call(&app, request(Method::GET, "/health", None)), + ) + .await + .expect("health should proceed on the same current thread while storage waits"); + assert_eq!(health.status(), 200); + assert!(ec_cookie(&health).is_none()); + assert!( + store + .rows + .lock() + .expect("should inspect paused rows") + .is_empty() + ); + // Dropping the pending request models cancellation before acknowledgement. + // No synchronous executor/worker must be joined before this runtime exits. +} + +#[tokio::test(flavor = "current_thread")] +async fn orphan_recovery_rotates_only_after_an_eligible_publisher_start() { + let store = Store::default(); + let app = allowed_router(store.clone()); + let created = call(&app, request(Method::GET, "/article", None)).await; + let original = ec_cookie(&created).expect("should issue initial identity"); + store + .delete(&original) + .await + .expect("should simulate expired identity row"); + let eids = BASE64.encode( + json!([{"source":"partner.example.com","uids":[{"id":"fixture-uid","atype":1}]}]) + .to_string(), + ); + let cookie = format!("ts-ec={original}; ts-eids={eids}"); + let named = call( + &app, + request(Method::GET, "/_ts/api/v1/identify", Some(&cookie)), + ) + .await; + assert!( + ec_cookie(&named).is_none(), + "read-only identify must not recover an orphan" + ); + assert!( + store + .rows + .lock() + .expect("should read fixture rows") + .is_empty() + ); + let recovered = call(&app, request(Method::GET, "/article", Some(&cookie))).await; + let replacement = + ec_cookie(&recovered).expect("should recover on eligible publisher navigation"); + assert_ne!(replacement, original); + assert!( + store + .key_exists(&replacement) + .await + .expect("should confirm recovered row before cookie") + ); + assert!( + !store + .key_exists(&original) + .await + .expect("should leave orphan absent") + ); +} 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..971e4ee96a 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)> { @@ -64,12 +98,84 @@ fn assert_route_registered(method: &str, path: &str) { ); } +#[tokio::test] +async fn consent_route_rejects_other_methods_and_foreign_origins_locally() { + for method in [ + "GET", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE", "TRACE", "CONNECT", + ] { + assert_route_registered(method, "/_ts/consent"); + let mut service = make_service(); + let response = service + .ready() + .await + .expect("ready") + .call( + Request::builder() + .method(method) + .uri("/_ts/consent") + .header("host", "test-publisher.example.com") + .body(AxumBody::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status().as_u16(), 405); + assert_eq!(response.headers()["allow"], "POST"); + let cache = response.headers()["cache-control"] + .to_str() + .expect("cache policy"); + assert!(cache.contains("no-store") && cache.contains("private")); + } + for origin in [None, Some("https://foreign.example")] { + let mut builder = Request::builder() + .method("POST") + .uri("/_ts/consent") + .header("host", "test-publisher.example.com"); + if let Some(origin) = origin { + builder = builder.header("origin", origin); + } + let response = make_service() + .ready() + .await + .expect("ready") + .call(builder.body(AxumBody::empty()).expect("request")) + .await + .expect("response"); + assert_eq!(response.status().as_u16(), 403); + } +} + +#[tokio::test] +async fn consent_route_does_not_falsely_acknowledge_an_unconfigured_store() { + let response = make_service() + .ready() + .await + .expect("ready") + .call( + Request::builder() + .method("POST") + .uri("/_ts/consent") + .header("host", "test-publisher.example.com") + .header("origin", "http://test-publisher.example.com") + .header("content-type", "application/json") + .body(AxumBody::from( + r#"{"gppString":"DBABLA~BVQqAAAAAgA.QA","applicableSections":[7]}"#, + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status().as_u16(), 503); + assert!(!response.headers().contains_key("set-cookie")); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn aps_profile_serves_renderer_through_adapter_fallback() { let mut settings = test_settings(); settings.auction.providers.insert( "aps-main".parse().expect("should parse APS provider ID"), trusted_server_core::auction::ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "aps".to_string(), endpoint: "https://aps.example/e/pb/bid".to_string(), @@ -83,7 +189,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") @@ -118,6 +224,7 @@ fn all_explicit_routes_are_registered() { ("POST", "/admin/keys/rotate"), ("POST", "/admin/keys/deactivate"), ("POST", "/auction"), + ("POST", "/_ts/consent"), // SPA re-auction endpoint, plus its deprecated `/__ts/` alias. Both // paths are spelled out as literals rather than referencing // `PAGE_BIDS_PATH` / `PAGE_BIDS_LEGACY_PATH` so this test pins the 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..f5666bbda5 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/startup.rs @@ -0,0 +1,576 @@ +//! 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 edgezero_core::blob_envelope::BlobEnvelope; +use serde_json::json; +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); + +#[cfg(feature = "celld-ec")] +#[path = "support/celld.rs"] +mod celld_fixture; + +#[cfg(feature = "celld-ec")] +#[tokio::test(flavor = "current_thread")] +#[ignore = "requires CELLD_EC_TEST_BINARY pointing to the built real conditional-record server"] +async fn real_celld_provider_prepares_from_published_envelope_and_fails_closed_on_outage() { + let mut celld = celld_fixture::CelldFixture::new(); + for _ in 0..2 { + let storage = + serde_json::to_value(celld.settings()).expect("should serialize pure celld settings"); + let mut storage = storage; + storage["provider"] = json!("celld"); + let mut server = ServerFixture::new(Some(&storage_envelope(storage))); + server.spawn(false, false); + server.wait_ready(); + let store = celld.store().await; + assert!( + !trusted_server_core::ec::kv_backend::EcKvStore::key_exists(&store, "never-issued") + .await + .expect("should read strongly through prepared typed backend") + ); + drop(server); + celld.restart(); + } + let mut storage = + serde_json::to_value(celld.settings()).expect("should serialize final binding"); + storage["provider"] = json!("celld"); + celld.stop(); + let mut server = ServerFixture::new(Some(&storage_envelope(storage))); + server.spawn(false, false); + server.assert_failed_before_listen("native EC celld preparation failed"); +} + +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) { + self.spawn_configured(missing_secret, short_passphrase, None); + } + + fn spawn_configured( + &mut self, + missing_secret: bool, + short_passphrase: bool, + profile: Option<&str>, + ) { + self.spawn_inputs(missing_secret, short_passphrase, profile, None); + } + + fn spawn_inputs( + &mut self, + missing_secret: bool, + short_passphrase: bool, + profile: Option<&str>, + legacy: Option<&str>, + ) { + 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 let Some(profile) = profile { + let path = self.root.join("native-geo.toml"); + fs::write(&path, profile).expect("should write synthetic Geo profile"); + command.env("TS_NATIVE_GEO_PROFILE_FILE", path); + } + if !missing_secret { + command.env( + "EDGEZERO__SECRETS__EC_KEY", + if short_passphrase { + "short" + } else { + "native-fixture-passphrase-32-bytes-minimum" + }, + ); + } + if let Some(value) = legacy { + command.env("TS_EC_DYNAMODB_ENABLED", value); + } + 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 { + self.get_with_headers(path, "") + } + + fn get_with_headers(&self, path: &str, headers: &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{headers}\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); + } +} + +fn storage_envelope(storage: serde_json::Value) -> String { + let mut envelope: serde_json::Value = + serde_json::from_str(ENVELOPE).expect("should decode fixture envelope"); + let mut data = envelope["data"].take(); + data["ec"]["storage"] = storage; + serde_json::to_string(&BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned())) + .expect("should encode existing envelope format") +} + +#[test] +fn active_legacy_ec_environment_cannot_disable_or_override_published_storage() { + let configured = storage_envelope( + json!({"provider":"celld","endpoint":"http://127.0.0.1:7780","store":"identity","access":{"mode":"trusted-network"}}), + ); + for envelope in [ENVELOPE, configured.as_str()] { + let mut server = ServerFixture::new(Some(envelope)); + server.spawn_inputs(false, false, None, Some("true")); + server.assert_failed_before_listen( + "publish ec.storage and remove legacy EC environment selection", + ); + } +} + +#[test] +fn explicit_unsupported_and_uncompiled_ec_providers_fail_before_listen() { + let cases=[ + Some((json!({"provider":"fastly","store":"exact-binding"}),"unsupported by the selected serving runtime")), + (!cfg!(feature="aws-dynamodb-ec")).then(||(json!({"provider":"dynamodb","region":"us-east-1","table":"example-identity","namespace":"fixture"}),"aws-dynamodb-ec build feature is unavailable")), + (!cfg!(feature="celld-ec")).then(||(json!({"provider":"celld","endpoint":"http://127.0.0.1:7780","store":"identity","access":{"mode":"trusted-network"}}),"celld-ec build feature is unavailable")), + ]; + for (storage, expected) in cases.into_iter().flatten() { + let mut server = ServerFixture::new(Some(&storage_envelope(storage))); + server.spawn(false, false); + server.assert_failed_before_listen(expected); + } +} + +#[cfg(feature = "celld-ec")] +#[test] +fn selected_celld_captures_only_its_explicit_bootstrap_reference_before_network() { + let storage = json!({"provider":"celld","endpoint":"https://127.0.0.1:7780","store":"identity","access":{"mode":"authenticated","token_env":"FIXTURE_MISSING_CELLD_TOKEN"}}); + let mut server = ServerFixture::new(Some(&storage_envelope(storage))); + server.spawn(false, false); + server.assert_failed_before_listen("selected EC celld bootstrap token is unavailable"); +} + +#[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") + ); +} + +const GEO_PROFILE: &str = r#"version = 1 +[geo] +provider = "cloudfront" +alb_peer_cidrs = ["127.0.0.1/32"] +origin_credential_header_name = "x-origin-test-removal" +"#; + +#[test] +fn invalid_geo_profile_fails_before_store_preparation_and_listen() { + let mut server = ServerFixture::new(None); + server.spawn_configured(false, false, Some("version = 999\n[geo]\nprovider = 'off'")); + server.assert_failed_before_listen("native Geo profile is invalid"); +} + +#[test] +#[cfg(not(feature = "geo-cloudfront"))] +fn compiled_out_geo_selection_fails_before_store_preparation_and_listen() { + let mut server = ServerFixture::new(None); + server.spawn_configured(false, false, Some(GEO_PROFILE)); + server.assert_failed_before_listen("cloudfront implementation unavailable"); +} + +#[test] +#[cfg(feature = "geo-cloudfront")] +fn managed_geo_is_partial_immutable_per_request_and_finalized_on_auth_errors() { + let mut server = ServerFixture::new(Some(ENVELOPE)); + server.spawn_configured(false, false, Some(GEO_PROFILE)); + let health = server.wait_ready(); + assert!( + health + .to_ascii_lowercase() + .contains("x-geo-info-available: false") + ); + let headers = "CloudFront-Viewer-Country: us\r\nCloudFront-Viewer-Country-Region: ca\r\nx-origin-test-removal: fixture-origin-credential\r\n"; + for (path, status) in [ + ("/health", "200"), + ("/_ts/admin/ec", "401"), + ("/static/tsjs=0000000000000000", "404"), + ] { + let response = server + .get_with_headers(path, headers) + .expect("should reach managed native route"); + let lower = response.to_ascii_lowercase(); + assert!( + response.starts_with(&format!("HTTP/1.1 {status}")), + "should preserve route status: {response}" + ); + assert!(lower.contains("x-geo-info-available: true")); + assert!(lower.contains("x-geo-country: us")); + assert!(lower.contains("x-geo-region: ca")); + for absent in [ + "x-geo-coordinates:", + "x-geo-city:", + "x-geo-continent:", + "x-geo-metro-code:", + "x-origin-test-removal", + "fixture-origin-credential", + ] { + assert!( + !lower.contains(absent), + "should not export unknown detail or credential" + ); + } + } + let gb = server + .get_with_headers("/health", "CloudFront-Viewer-Country: GB\r\n") + .expect("should process another country"); + assert!(gb.to_ascii_lowercase().contains("x-geo-country: gb")); + assert!(!gb.to_ascii_lowercase().contains("x-geo-region:")); + for headers in [ + "", + "CloudFront-Viewer-Country: UK\r\n", + "CloudFront-Viewer-Country: US\r\nCloudFront-Viewer-Country: US\r\n", + "CloudFront-Viewer-Country-Region: CA\r\n", + ] { + let response = server + .get_with_headers("/health", headers) + .expect("should preserve health with unavailable geography"); + assert!( + response + .to_ascii_lowercase() + .contains("x-geo-info-available: false") + ); + assert!(!response.to_ascii_lowercase().contains("x-geo-country:")); + } +} + +#[test] +#[cfg(feature = "geo-cloudfront")] +fn admitted_body_error_keeps_managed_geo_without_losing_framework_status() { + let mut server = ServerFixture::new(Some(ENVELOPE)); + server.spawn_configured(false, false, Some(GEO_PROFILE)); + server.wait_ready(); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .expect("should create local fixture client"); + let response = client + .post(format!("http://{}/auction", server.addr)) + .header("host", "publisher.example.com") + .header("content-type", "application/json") + .header("cloudfront-viewer-country", "US") + .header("cloudfront-viewer-country-region", "CA") + .header("x-origin-test-removal", "fixture-error-credential") + .body(vec![ + b' '; + trusted_server_core::auction::endpoints::MAX_AUCTION_BODY_SIZE + + 1 + ]) + .send() + .expect("should receive managed oversized-body response"); + assert_eq!( + response.status(), + reqwest::StatusCode::PAYLOAD_TOO_LARGE, + "should retain framework payload limit status" + ); + assert_eq!(response.headers()["x-geo-info-available"], "true"); + assert_eq!(response.headers()["x-geo-country"], "US"); + assert_eq!(response.headers()["x-geo-region"], "CA"); + assert!(!response.headers().contains_key("x-origin-test-removal")); + let body = response.text().expect("should read framework error"); + assert!(!body.contains("fixture-error-credential")); + assert!(!body.contains("x-origin-test-removal")); +} + +#[test] +#[cfg(feature = "geo-cloudfront")] +fn nonmatching_real_socket_peer_cannot_supply_geo() { + let mut server = ServerFixture::new(Some(ENVELOPE)); + server.spawn_configured( + false, + false, + Some(&GEO_PROFILE.replace("127.0.0.1/32", "192.0.2.1/32")), + ); + server.wait_ready(); + let response = server.get_with_headers("/health", "CloudFront-Viewer-Country: US\r\nCloudFront-Viewer-Country-Region: CA\r\nX-Forwarded-For: 192.0.2.1\r\n") + .expect("should reach health without trusting forged peer"); + assert!( + response + .to_ascii_lowercase() + .contains("x-geo-info-available: false") + ); + assert!(!response.to_ascii_lowercase().contains("x-geo-country:")); +} + +#[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"); + assert!( + !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-axum/tests/support/celld.rs b/crates/trusted-server-adapter-axum/tests/support/celld.rs new file mode 100644 index 0000000000..9deab739d7 --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/support/celld.rs @@ -0,0 +1,105 @@ +//! Disposable real celld, selected explicitly by an external-test prerequisite. + +use std::io::{BufRead as _, BufReader}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +use edgezero_store_celld::settings::{CelldAccess, CelldKvSettings}; +use serde_json::json; +use trusted_server_adapter_axum::celld_ec::CelldEcKv; + +pub struct CelldFixture { + root: tempfile::TempDir, + binary: PathBuf, + child: Option, + pub endpoint: String, +} + +impl CelldFixture { + pub fn new() -> Self { + let binary = PathBuf::from(std::env::var_os("CELLD_EC_TEST_BINARY").expect( + "should set CELLD_EC_TEST_BINARY to the built conditional-record celld executable", + )); + assert!( + binary.is_absolute() && binary.is_file(), + "should supply an existing absolute celld executable path" + ); + let root = tempfile::tempdir().expect("should create disposable celld directory"); + let policy = json!({"version":1,"listen":"127.0.0.1:0", + "stores":[{"alias":"identity","namespace":"fixture-ec"}], + "access":{"mode":"trusted-network","grants":[{"store":"identity","operations":["read","write","delete","list"]}]}}); + std::fs::write( + root.path().join("policy.json"), + serde_json::to_vec(&policy).expect("should encode fixture policy"), + ) + .expect("should write disposable fixture policy"); + let mut fixture = Self { + root, + binary, + child: None, + endpoint: String::new(), + }; + fixture.start(); + fixture + } + fn start(&mut self) { + let mut child = Command::new(&self.binary) + .env_clear() + .env("CELLD_TEST_DATA_DIR", self.root.path().join("node-cache")) + .args([ + "--listen", + "127.0.0.1:0", + "--internal-listen", + "127.0.0.1:0", + "--store-api-config", + ]) + .arg(self.root.path().join("policy.json")) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("should start disposable real celld"); + let stdout = child.stdout.take().expect("should capture celld readiness"); + let (send, receive) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + if let Some((_, address)) = line.split_once("celld scoped stores listening on ") { + let _ = send.send(format!("http://{}", address.trim())); + } + } + }); + self.child = Some(child); + self.endpoint = receive + .recv_timeout(Duration::from_secs(30)) + .expect("should observe real celld readiness"); + } + pub fn stop(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } + pub fn restart(&mut self) { + self.stop(); + self.start(); + } + pub fn settings(&self) -> CelldKvSettings { + CelldKvSettings { + endpoint: self.endpoint.clone(), + store: "identity".to_owned(), + access: CelldAccess::TrustedNetwork { + allow_remote_http: false, + }, + } + } + pub async fn store(&self) -> CelldEcKv { + CelldEcKv::prepare(&self.settings(), None) + .await + .expect("should prepare typed records against real celld") + } +} +impl Drop for CelldFixture { + fn drop(&mut self) { + self.stop(); + } +} diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 87b9567e7f..185defea55 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -203,6 +203,9 @@ fn build_state_with_services( settings: Settings, services: Option, ) -> Result, Report> { + settings + .ec + .resolve_storage(trusted_server_core::ec::storage::EcRuntime::Cloudflare)?; let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Cloudflare)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; @@ -276,7 +279,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 +533,23 @@ 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 req.uri().path() == trusted_server_core::ec::consent_update::CONSENT_UPDATE_PATH { + // Reserve this path without an identity graph; never forward + // consent snapshots or acknowledge an unpersisted revocation. + return Ok( + match trusted_server_core::ec::consent_update::prepare_consent_update( + &services, req, + ) + .await + { + Ok(req) => trusted_server_core::ec::consent_update::handle_consent_update( + &req, None, + ), + Err(response) => *response, + }, + ); + } if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); } @@ -542,6 +579,7 @@ fn build_router(state: &Arc) -> RouterService { path: &path, settings: &state.settings, kv: None, + allow_generation: true, ec_context: &mut ec_context, services: &services, req, @@ -777,6 +815,32 @@ fn build_router(state: &Arc) -> RouterService { mod tests { use super::*; + #[test] + fn explicit_ec_storage_is_rejected_before_route_or_provider_work() { + for storage in [ + serde_json::json!({"provider":"fastly","store":"exact-fixture-binding"}), + serde_json::json!({"provider":"dynamodb","region":"us-east-1","table":"example-identity","namespace":"fixture"}), + serde_json::json!({"provider":"celld","endpoint":"http://127.0.0.1:7780","store":"identity","access":{"mode":"trusted-network"}}), + ] { + let mut settings = Settings::default(); + settings.ec.storage = + Some(serde_json::from_value(storage).expect("should parse pure provider settings")); + let error = build_state_with_settings(settings) + .err() + .expect("should reject unsupported EC provider before route work"); + assert!(format!("{error:?}").contains("ec.storage.provider is unsupported")); + } + let mut settings = Settings::default(); + settings.ec.ec_store = Some("legacy-inactive-binding".to_owned()); + assert!( + settings + .ec + .resolve_storage(trusted_server_core::ec::storage::EcRuntime::Cloudflare) + .expect("should accept inactive legacy field") + .is_none() + ); + } + fn aps_profile_settings() -> Settings { let mut settings = Settings::from_toml( r#" @@ -799,6 +863,7 @@ mod tests { settings.auction.providers.insert( "aps-main".parse().expect("should parse APS provider ID"), trusted_server_core::auction::ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "aps".to_string(), endpoint: "https://aps.example/e/pb/bid".to_string(), @@ -865,6 +930,7 @@ mod tests { ( id.parse().expect("should parse provider ID"), trusted_server_core::auction::ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "standard".to_string(), endpoint: format!("https://{id}.example/openrtb"), @@ -909,6 +975,7 @@ mod tests { ( id.parse().expect("should parse provider ID"), trusted_server_core::auction::ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "standard".to_string(), endpoint: format!("https://{id}.example/openrtb"), 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/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..90b913de4e 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}")) }) @@ -309,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(); @@ -331,9 +333,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 +475,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 +618,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); @@ -655,14 +658,13 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R /// /// `cf-ipcountry` is available on all plans. `cf-ipcity`, `cf-ipcontinent`, /// `cf-iplatitude`, and `cf-iplongitude` require an Enterprise plan. Absent or -/// unparseable values default to empty strings or `0.0`. Country code `XX` -/// (Cloudflare's "unknown" sentinel) is treated as absent. +/// unparseable values are absent. Coordinates are available only when both +/// values parse. Country code `XX` is treated as absent. struct CloudflareGeo { country: String, - city: String, - continent: String, - latitude: f64, - longitude: f64, + city: Option, + continent: Option, + coordinates: Option, } impl PlatformGeo for CloudflareGeo { @@ -674,9 +676,8 @@ impl PlatformGeo for CloudflareGeo { city: self.city.clone(), country: self.country.clone(), continent: self.continent.clone(), - latitude: self.latitude, - longitude: self.longitude, - metro_code: 0, + coordinates: self.coordinates, + metro_code: None, region: None, asn: None, })) @@ -684,7 +685,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()) @@ -694,35 +695,37 @@ fn build_geo(ctx: &edgezero_core::context::RequestContext) -> CloudflareGeo { let city = headers .get("cf-ipcity") .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); + .filter(|s| !s.is_empty()) + .map(str::to_string); let continent = headers .get("cf-ipcontinent") .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); + .filter(|s| !s.is_empty()) + .map(str::to_string); let latitude = headers .get("cf-iplatitude") .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse::().ok()) - .unwrap_or(0.0); + .and_then(|s| s.parse::().ok()); let longitude = headers .get("cf-iplongitude") .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse::().ok()) - .unwrap_or(0.0); + .and_then(|s| s.parse::().ok()); + let coordinates = latitude.zip(longitude).map(|(latitude, longitude)| { + trusted_server_core::platform::GeoCoordinates { + latitude, + longitude, + } + }); CloudflareGeo { country, city, continent, - latitude, - longitude, + coordinates, } } 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()) @@ -876,16 +879,63 @@ mod tests { .expect("should perform geo lookup") .expect("should return GeoInfo when country is set"); assert_eq!(info.country, "US", "should populate country"); - assert_eq!(info.city, "New York", "should populate city"); - assert_eq!(info.continent, "NA", "should populate continent"); - assert!( - (info.latitude - 40.71).abs() < 0.01, - "should populate latitude" + assert_eq!( + info.city.as_deref(), + Some("New York"), + "should populate city" ); - assert!( - (info.longitude - (-74.01)).abs() < 0.01, - "should populate longitude" + assert_eq!( + info.continent.as_deref(), + Some("NA"), + "should populate continent" ); + let coordinates = info.coordinates.expect("should populate coordinate pair"); + assert_eq!(coordinates.latitude, 40.71); + assert_eq!(coordinates.longitude, -74.01); + } + + #[test] + fn build_geo_country_only_does_not_invent_detail() { + let ctx = make_ctx_with_header("cf-ipcountry", "US"); + let info = build_geo(&ctx) + .lookup(None) + .expect("should look up geo") + .expect("should have country"); + assert_eq!(info, GeoInfo::country_region("US", None)); + } + + #[test] + fn build_geo_requires_both_parseable_coordinates_and_preserves_zero() { + for (latitude, longitude, has_coordinates) in [ + (None, None, false), + (Some("0"), None, false), + (None, Some("0"), false), + (Some("bad"), Some("0"), false), + (Some("0"), Some("bad"), false), + (Some("0"), Some("0"), true), + ] { + let mut builder = request_builder() + .uri("https://example.com/") + .header("cf-ipcountry", "US"); + if let Some(value) = latitude { + builder = builder.header("cf-iplatitude", value); + } + if let Some(value) = longitude { + builder = builder.header("cf-iplongitude", value); + } + let request = builder + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let ctx = RequestContext::new(request, PathParams::default()); + let info = build_geo(&ctx) + .lookup(None) + .expect("should look up geo") + .expect("should have country"); + assert_eq!(info.coordinates.is_some(), has_coordinates); + if has_coordinates { + assert_eq!(info.coordinates_string().as_deref(), Some("0,0")); + } + } } // --------------------------------------------------------------------------- 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..38d48e86af 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, @@ -220,6 +221,9 @@ pub(crate) fn build_state_from_settings( ) -> Result, Report> { warn_if_certificate_check_disabled(&settings); + settings + .ec + .resolve_storage(trusted_server_core::ec::storage::EcRuntime::Fastly)?; let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Fastly)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; @@ -229,6 +233,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 +267,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() }); @@ -447,7 +454,11 @@ fn build_ec_request_state( // Bot gate: suppress KV-backed EC writes for unrecognized clients, except // consent withdrawals. Revocations keep the write path so tombstones stay // authoritative even for privacy-extension-heavy clients. - let kv_graph = crate::maybe_identity_graph(settings); + let (kv_graph, storage_error) = match crate::maybe_identity_graph(settings) { + Ok(graph) => (graph, None), + Err(error) => (None, Some(error)), + }; + let setup_error = setup_error.or(storage_error); let finalize_kv_graph = if setup_error.is_none() && (is_real_browser || ec_consent_withdrawn(ec_context.consent())) { @@ -545,14 +556,54 @@ 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) + } + NamedRouteHandler::ConsentUpdate => { + Some(trusted_server_core::ec::consent_update::MAX_CONSENT_UPDATE_BODY_BYTES) + } + _ => None, + }; + if let Some(limit) = limit { + ctx.body_bytes(limit).await?; + } + } + let mut req = ctx.into_request()?; + if matches!(handler, NamedRouteHandler::ConsentUpdate) { + req = match trusted_server_core::ec::consent_update::prepare_consent_update(&services, req) + .await + { + Ok(req) => req, + Err(response) => return Ok(*response), + }; + } // S2S batch sync uses Bearer auth (not EC cookies), so it skips EC // context creation entirely — mirroring the dedicated early arm in the // legacy route_request. Batch-sync also skips request filters, matching // legacy, which returns before the filter step for this route. if matches!(handler, NamedRouteHandler::BatchSync) { - return Ok(run_batch_sync(&state, &services, req)); + return Ok(run_batch_sync(&state, &services, req).await); } // An operator cache purge is not a reader request: running the EC lifecycle would @@ -576,19 +627,22 @@ async fn execute_named( handler, NamedRouteHandler::AdminEcLookup | NamedRouteHandler::AdminEidsLookup ) { - let response = PartnerRegistry::from_config(&state.settings.ec.partners) - .and_then(|registry| match handler { + let response = async { + let registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; + match handler { NamedRouteHandler::AdminEcLookup => { // Deliberately do not use an EC request-state graph: that // copy is bot-gated, while operators use curl for this // authenticated diagnostic. - let kv = crate::maybe_identity_graph(&state.settings); - handle_admin_ec_lookup(kv.as_ref(), ®istry, &req) + let kv = crate::maybe_identity_graph(&state.settings)?; + handle_admin_ec_lookup(kv.as_ref(), ®istry, &req).await } NamedRouteHandler::AdminEidsLookup => handle_admin_eids_lookup(®istry, &req), _ => unreachable!("admin diagnostics should use early dispatch"), - }) - .unwrap_or_else(|error| http_error(&error)); + } + } + .await + .unwrap_or_else(|error| http_error(&error)); return Ok(response); } @@ -665,8 +719,15 @@ async fn run_named_route( &req, &ec.ec_context, ) + .await } } + NamedRouteHandler::ConsentUpdate => Ok( + trusted_server_core::ec::consent_update::handle_consent_update( + &req, + ec.finalize_kv_graph.as_ref(), + ), + ), NamedRouteHandler::SetTester => handle_set_tester(&state.settings), NamedRouteHandler::ClearTester => handle_clear_tester(&state.settings), NamedRouteHandler::Auction => { @@ -734,17 +795,19 @@ async fn run_named_route( /// Handles `POST /_ts/api/v1/batch-sync`, mirroring the legacy arm: identity /// graph + partner registry + rate limiter, with a default EC context for /// response finalization. -fn run_batch_sync(state: &AppState, services: &RuntimeServices, req: Request) -> Response { +async fn run_batch_sync(state: &AppState, services: &RuntimeServices, req: Request) -> Response { let device_signals = device_signals_for(&req); let is_real_browser = device_signals.looks_like_browser(); let eids_cookie = crate::extract_cookie_value(&req, COOKIE_TS_EIDS); let sharedid_cookie = crate::extract_cookie_value(&req, COOKIE_SHAREDID); - let result = crate::require_identity_graph(&state.settings).and_then(|kv| { + let result = async { + let kv = crate::require_identity_graph(&state.settings)?; let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; let limiter = FastlyRateLimiter::new(RATE_COUNTER_NAME); - handle_batch_sync(&kv, &partner_registry, &limiter, req) - }); + handle_batch_sync(&kv, &partner_registry, &limiter, req).await + } + .await; let mut response = result.unwrap_or_else(|e| http_error(&e)); // Legacy parity: batch-sync responses still pass through @@ -765,7 +828,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) } @@ -774,6 +837,14 @@ async fn dispatch_fallback( services: &RuntimeServices, mut req: Request, ) -> Response { + if req.uri().path() == trusted_server_core::ec::consent_update::CONSENT_UPDATE_PATH { + return match trusted_server_core::ec::consent_update::prepare_consent_update(services, req) + .await + { + Ok(req) => trusted_server_core::ec::consent_update::handle_consent_update(&req, None), + Err(response) => *response, + }; + } if let Some(response) = deny_admin_diagnostic_fallback(&req) { return response; } @@ -818,6 +889,7 @@ async fn dispatch_fallback( settings: &state.settings, kv: ec.kv_graph.as_ref(), ec_context: &mut ec.ec_context, + allow_generation: true, services, req, }) @@ -852,6 +924,7 @@ async fn dispatch_fallback( && let Err(err) = ec .ec_context .generate_if_needed(&state.settings, ec.kv_graph.as_ref()) + .await { log::warn!("EC generation failed for publisher proxy: {err:?}"); } @@ -1082,6 +1155,7 @@ enum NamedRouteHandler { LegacyAdminDenied, BatchSync, Identify, + ConsentUpdate, SetTester, ClearTester, Auction, @@ -1114,6 +1188,21 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ ]; const NAMED_ROUTES: &[NamedRoute] = &[ + NamedRoute { + path: trusted_server_core::ec::consent_update::CONSENT_UPDATE_PATH, + primary_methods: &[ + Method::GET, + Method::POST, + Method::HEAD, + Method::OPTIONS, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::TRACE, + Method::CONNECT, + ], + handler: NamedRouteHandler::ConsentUpdate, + }, NamedRoute { path: "/.well-known/trusted-server.json", primary_methods: &[Method::GET], @@ -1274,11 +1363,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>) { @@ -1350,10 +1451,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], @@ -1416,13 +1516,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"), @@ -1577,6 +1684,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, ), @@ -1665,6 +1773,7 @@ mod tests { settings.auction.providers.insert( "aps-main".parse().expect("should parse APS provider ID"), trusted_server_core::auction::ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "aps".to_string(), endpoint: "https://aps.example/e/pb/bid".to_string(), @@ -2997,7 +3106,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/ec_kv.rs b/crates/trusted-server-adapter-fastly/src/ec_kv.rs index c427ede96b..4914d323a8 100644 --- a/crates/trusted-server-adapter-fastly/src/ec_kv.rs +++ b/crates/trusted-server-adapter-fastly/src/ec_kv.rs @@ -2,7 +2,9 @@ //! //! Maps the platform-neutral identity-graph store operations onto the //! Fastly KV Store API, including generation markers for compare-and-swap -//! writes (`if_generation_match`). +//! writes (`if_generation_match`). The SDK exposes only blocking completion +//! hostcalls for KV operations, so these local async methods retain that Fastly +//! behavior. Native backends must yield during network I/O instead. use error_stack::{Report, ResultExt}; use fastly::kv_store::{InsertMode, KVStore, KVStoreError, ListPage}; @@ -86,12 +88,13 @@ impl FastlyEcKvStore { } } +#[async_trait::async_trait(?Send)] impl EcKvStore for FastlyEcKvStore { fn store_name(&self) -> &str { &self.store_name } - fn lookup(&self, key: &str) -> Result, Report> { + async fn lookup(&self, key: &str) -> Result, Report> { let store = self.open_store()?; let mut response = match store.lookup(key) { Ok(resp) => resp, @@ -117,7 +120,7 @@ impl EcKvStore for FastlyEcKvStore { })) } - fn key_exists(&self, key: &str) -> Result> { + async fn key_exists(&self, key: &str) -> Result> { let store = self.open_store()?; contains_exact_key(&self.store_name, key, |cursor| { // build_list defaults to ListMode::Strong, reading primary state. @@ -129,7 +132,7 @@ impl EcKvStore for FastlyEcKvStore { }) } - fn insert( + async fn insert( &self, key: &str, write: EcKvWrite<'_>, @@ -160,7 +163,7 @@ impl EcKvStore for FastlyEcKvStore { } } - fn list_keys_with_prefix( + async fn list_keys_with_prefix( &self, prefix: &str, limit: u32, @@ -178,7 +181,7 @@ impl EcKvStore for FastlyEcKvStore { }) } - fn delete(&self, key: &str) -> Result<(), Report> { + async fn delete(&self, key: &str) -> Result<(), Report> { let store = self.open_store()?; store .delete(key) @@ -205,7 +208,7 @@ mod tests { FastlyEcKvStore::new(TEST_STORE) } - fn write(key: &str, body: &str, mode: EcKvWriteMode) -> EcKvWriteOutcome { + async fn write(key: &str, body: &str, mode: EcKvWriteMode) -> EcKvWriteOutcome { store() .insert( key, @@ -216,6 +219,7 @@ mod tests { mode, }, ) + .await .expect("should reach the store") } @@ -334,166 +338,201 @@ mod tests { #[test] fn strong_existence_checks_real_store_keys_exactly() { - let backend = store(); - let key = "existence-test-key"; - let longer = "existence-test-key-longer"; - write(longer, "body", EcKvWriteMode::Overwrite); - assert!( - !backend.key_exists(key).expect("should list the prefix"), - "should not confuse a prefix with an exact key" - ); - write(key, "body", EcKvWriteMode::Overwrite); - assert!( - backend.key_exists(key).expect("should list the issued key"), - "should see a completed issuance" - ); - backend.delete(key).expect("should delete exact key"); - backend.delete(longer).expect("should delete longer key"); - assert!( - !backend.key_exists(key).expect("should list after deletion"), - "should report absence after deletion" - ); + futures::executor::block_on(async { + let backend = store(); + let key = "existence-test-key"; + let longer = "existence-test-key-longer"; + write(longer, "body", EcKvWriteMode::Overwrite).await; + assert!( + !backend + .key_exists(key) + .await + .expect("should list the prefix"), + "should not confuse a prefix with an exact key" + ); + write(key, "body", EcKvWriteMode::Overwrite).await; + assert!( + backend + .key_exists(key) + .await + .expect("should list the issued key"), + "should see a completed issuance" + ); + backend.delete(key).await.expect("should delete exact key"); + backend + .delete(longer) + .await + .expect("should delete longer key"); + assert!( + !backend + .key_exists(key) + .await + .expect("should list after deletion"), + "should report absence after deletion" + ); + }); } #[test] fn opening_a_store_this_service_does_not_have_is_an_error() { - let error = FastlyEcKvStore::new("no_such_store") - .lookup("any-key") - .expect_err("should not resolve against a store that is not linked"); - - assert!( - matches!( - error.current_context(), - TrustedServerError::KvStore { store_name, .. } if store_name == "no_such_store" - ), - "should name the store it could not open: {error:?}" - ); + futures::executor::block_on(async { + let error = FastlyEcKvStore::new("no_such_store") + .lookup("any-key") + .await + .expect_err("should not resolve against a store that is not linked"); + + assert!( + matches!( + error.current_context(), + TrustedServerError::KvStore { store_name, .. } if store_name == "no_such_store" + ), + "should name the store it could not open: {error:?}" + ); + }); } #[test] fn a_missing_key_is_absent_rather_than_an_error() { - let absent = format!("{}.ABC123", "1".repeat(64)); - - assert!( - store() - .lookup(&absent) - .expect("should reach the store") - .is_none(), - "a key the store does not hold is absent, not a failure" - ); + futures::executor::block_on(async { + let absent = format!("{}.ABC123", "1".repeat(64)); + + assert!( + store() + .lookup(&absent) + .await + .expect("should reach the store") + .is_none(), + "a key the store does not hold is absent, not a failure" + ); + }); } #[test] fn an_entry_round_trips_through_insert_lookup_and_delete() { - let key = format!("{}.ABC123", "2".repeat(64)); - let backend = store(); + futures::executor::block_on(async { + let key = format!("{}.ABC123", "2".repeat(64)); + let backend = store(); - assert_eq!( - write(&key, "entry-body-1", EcKvWriteMode::Overwrite), - EcKvWriteOutcome::Written, - "should write the entry" - ); + assert_eq!( + write(&key, "entry-body-1", EcKvWriteMode::Overwrite).await, + EcKvWriteOutcome::Written, + "should write the entry" + ); - let found = backend - .lookup(&key) - .expect("should reach the store") - .expect("should hold the entry just written"); - assert_eq!(found.body, b"entry-body-1", "should read back the body"); - assert_eq!( - found.metadata.as_deref(), - Some(METADATA.as_bytes()), - "should read back the metadata" - ); - - backend.delete(&key).expect("should delete the entry"); - assert!( - backend + let found = backend .lookup(&key) + .await .expect("should reach the store") - .is_none(), - "a deleted key is absent" - ); + .expect("should hold the entry just written"); + assert_eq!(found.body, b"entry-body-1", "should read back the body"); + assert_eq!( + found.metadata.as_deref(), + Some(METADATA.as_bytes()), + "should read back the metadata" + ); + + backend.delete(&key).await.expect("should delete the entry"); + assert!( + backend + .lookup(&key) + .await + .expect("should reach the store") + .is_none(), + "a deleted key is absent" + ); + }); } #[test] fn add_mode_refuses_a_key_that_already_exists() { - let key = format!("{}.ABC123", "3".repeat(64)); - let backend = store(); - - assert_eq!( - write(&key, "entry-body-1", EcKvWriteMode::Add), - EcKvWriteOutcome::Written, - "should create a key nothing holds" - ); - assert_eq!( - write(&key, "entry-body-2", EcKvWriteMode::Add), - EcKvWriteOutcome::PreconditionFailed, - "a precondition failure is control flow, not an error" - ); - - backend.delete(&key).expect("should delete the entry"); + futures::executor::block_on(async { + let key = format!("{}.ABC123", "3".repeat(64)); + let backend = store(); + + assert_eq!( + write(&key, "entry-body-1", EcKvWriteMode::Add).await, + EcKvWriteOutcome::Written, + "should create a key nothing holds" + ); + assert_eq!( + write(&key, "entry-body-2", EcKvWriteMode::Add).await, + EcKvWriteOutcome::PreconditionFailed, + "a precondition failure is control flow, not an error" + ); + + backend.delete(&key).await.expect("should delete the entry"); + }); } #[test] fn a_generation_mismatch_is_reported_as_a_precondition_failure() { - let key = format!("{}.ABC123", "4".repeat(64)); - let backend = store(); - - write(&key, "entry-body-1", EcKvWriteMode::Overwrite); - let generation = backend - .lookup(&key) - .expect("should reach the store") - .expect("should hold the entry just written") - .generation; - - assert_eq!( - write( - &key, - "entry-body-2", - EcKvWriteMode::IfGenerationMatch(generation) - ), - EcKvWriteOutcome::Written, - "should write when the generation still matches" - ); - assert_eq!( - write( - &key, - "entry-body-3", - EcKvWriteMode::IfGenerationMatch(generation) - ), - EcKvWriteOutcome::PreconditionFailed, - "the generation moved on with the previous write" - ); + futures::executor::block_on(async { + let key = format!("{}.ABC123", "4".repeat(64)); + let backend = store(); - backend.delete(&key).expect("should delete the entry"); + write(&key, "entry-body-1", EcKvWriteMode::Overwrite).await; + let generation = backend + .lookup(&key) + .await + .expect("should reach the store") + .expect("should hold the entry just written") + .generation; + + assert_eq!( + write( + &key, + "entry-body-2", + EcKvWriteMode::IfGenerationMatch(generation) + ) + .await, + EcKvWriteOutcome::Written, + "should write when the generation still matches" + ); + assert_eq!( + write( + &key, + "entry-body-3", + EcKvWriteMode::IfGenerationMatch(generation) + ) + .await, + EcKvWriteOutcome::PreconditionFailed, + "the generation moved on with the previous write" + ); + + backend.delete(&key).await.expect("should delete the entry"); + }); } #[test] fn counting_a_prefix_counts_only_the_keys_under_it() { - let hash = "5".repeat(64); - let backend = store(); - let keys = [format!("{hash}.AAA111"), format!("{hash}.BBB222")]; - for key in &keys { - write(key, "entry-body-1", EcKvWriteMode::Overwrite); - } - - assert_eq!( - backend - .count_keys_with_prefix(&hash, 100) - .expect("should list the prefix"), - 2, - "should count both keys issued under this hash" - ); - assert_eq!( - backend - .count_keys_with_prefix(&"6".repeat(64), 100) - .expect("should list the prefix"), - 0, - "should count nothing under a hash nothing was issued for" - ); + futures::executor::block_on(async { + let hash = "5".repeat(64); + let backend = store(); + let keys = [format!("{hash}.AAA111"), format!("{hash}.BBB222")]; + for key in &keys { + write(key, "entry-body-1", EcKvWriteMode::Overwrite).await; + } - for key in &keys { - backend.delete(key).expect("should delete the entry"); - } + assert_eq!( + backend + .count_keys_with_prefix(&hash, 100) + .await + .expect("should list the prefix"), + 2, + "should count both keys issued under this hash" + ); + assert_eq!( + backend + .count_keys_with_prefix(&"6".repeat(64), 100) + .await + .expect("should list the prefix"), + 0, + "should count nothing under a hash nothing was issued for" + ); + + for key in &keys { + backend.delete(key).await.expect("should delete the entry"); + } + }); } } diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index b8f814a01c..02439b51a6 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; @@ -51,6 +52,7 @@ use crate::middleware::{HEADER_X_TS_FINALIZED, apply_finalize_headers, resolve_g use crate::platform::{FastlyPlatformGeo, client_info_from_request}; use crate::rate_limiter::{FastlyRateLimiter, RATE_COUNTER_NAME}; use crate::sandbox::{RetainedApp, Sandbox, SandboxCounters, ServeMode, StartupDiagnostics}; +use trusted_server_core::ec::storage::{EcRuntime, EcStorageSelection}; // Only the reuse path builds a serving loop, so the retirement snapshots have // no consumer in the default build. #[cfg(feature = "reusable-sandbox")] @@ -107,10 +109,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 +140,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 +151,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 +163,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 +183,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 +210,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 +333,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 +360,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 +381,7 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re .send_to_client(); } } - return; + return Ok(()); } let config_store = @@ -376,7 +392,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 +406,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 +482,110 @@ 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)) => { + // EdgeZero's Fastly response hook is synchronous, and the + // Fastly KV SDK still completes I/O through hostcall waits. + // Keep this bridge in Fastly, never in the native runtime. + match futures::executor::block_on(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()) => + { + futures::executor::block_on(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, + ); } } @@ -592,14 +610,14 @@ fn apply_entry_point_finalize_headers( apply_finalize_headers(settings, geo_info.as_ref(), response); } -fn apply_edgezero_ec_finalize( +async fn apply_edgezero_ec_finalize( settings: &Settings, ec_state: &mut EcFinalizeState, response: &mut HttpResponse, ) -> Result> { let partner_registry = PartnerRegistry::from_config(&settings.ec.partners)?; let finalize_kv_graph = if ec_state.use_finalize_kv { - maybe_identity_graph(settings) + maybe_identity_graph(settings)? } else { None }; @@ -611,11 +629,12 @@ fn apply_edgezero_ec_finalize( ec_state.eids_cookie.as_deref(), ec_state.sharedid_cookie.as_deref(), response, - ); + ) + .await; Ok(partner_registry) } -fn run_edgezero_pull_sync_after_send( +async fn run_edgezero_pull_sync_after_send( settings: &Settings, partner_registry: &PartnerRegistry, ec_state: &EcFinalizeState, @@ -639,7 +658,8 @@ fn run_edgezero_pull_sync_after_send( &limiter, &context, &ec_state.services, - ); + ) + .await; } fn prepare_pull_sync_after_send( @@ -660,69 +680,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, @@ -794,12 +751,16 @@ fn build_ja4_debug_response(req: &FastlyRequest) -> FastlyResponse { .with_body(body) } -pub(crate) fn maybe_identity_graph(settings: &Settings) -> Option { - settings - .ec - .ec_store - .as_ref() - .map(|store_name| KvIdentityGraph::new(FastlyEcKvStore::new(store_name))) +pub(crate) fn maybe_identity_graph( + settings: &Settings, +) -> Result, Report> { + match settings.ec.resolve_storage(EcRuntime::Fastly)? { + Some(EcStorageSelection::Fastly(store_name)) => { + Ok(Some(KvIdentityGraph::new(FastlyEcKvStore::new(store_name)))) + } + None => Ok(None), + _ => unreachable!("Fastly resolution should select only Fastly storage"), + } } /// Constructs a `KvIdentityGraph` from settings, or returns an error if the @@ -807,13 +768,12 @@ pub(crate) fn maybe_identity_graph(settings: &Settings) -> Option Result> { - let store_name = settings.ec.ec_store.as_deref().ok_or_else(|| { + maybe_identity_graph(settings)?.ok_or_else(|| { Report::new(TrustedServerError::KvStore { - store_name: "ec.ec_store".to_owned(), - message: "ec.ec_store is not configured".to_owned(), + store_name: "ec.storage".to_owned(), + message: "EC identity storage is not configured".to_owned(), }) - })?; - Ok(KvIdentityGraph::new(FastlyEcKvStore::new(store_name))) + }) } /// Extracts a named cookie value from the request's `Cookie` header. @@ -1079,6 +1039,36 @@ mod tests { assert!(response.headers().get("cdn-cache-control").is_none()); } + #[test] + fn fastly_graph_selection_preserves_legacy_and_new_binding_input() { + for storage in [ + None, + Some(serde_json::json!({"store":"exact-fixture-binding"})), + Some(serde_json::json!({"provider":"fastly","store":"exact-fixture-binding"})), + ] { + let mut settings = diagnostics_settings(); + settings.ec.storage = storage + .map(|value| serde_json::from_value(value).expect("should parse provider config")); + if settings.ec.storage.is_none() { + settings.ec.ec_store = Some("exact-fixture-binding".to_owned()); + } + assert!(matches!( + settings + .ec + .resolve_storage(EcRuntime::Fastly) + .expect("should resolve Fastly input"), + Some(EcStorageSelection::Fastly("exact-fixture-binding")) + )); + assert!( + maybe_identity_graph(&settings) + .expect("should construct graph lazily") + .is_some() + ); + require_identity_graph(&settings) + .expect("should require the configured binding without opening it early"); + } + } + fn diagnostics_settings() -> Settings { Settings::from_toml( r#" 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..ad7a1cf221 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. @@ -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(); @@ -660,12 +662,14 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { /// that field mapping is never duplicated. fn geo_from_fastly(geo: &Geo) -> GeoInfo { GeoInfo { - city: geo.city().to_string(), + city: Some(geo.city().to_string()), country: geo.country_code().to_string(), - continent: format!("{:?}", geo.continent()), - latitude: geo.latitude(), - longitude: geo.longitude(), - metro_code: geo.metro_code(), + continent: Some(format!("{:?}", geo.continent())), + coordinates: Some(trusted_server_core::platform::GeoCoordinates { + latitude: geo.latitude(), + longitude: geo.longitude(), + }), + metro_code: Some(geo.metro_code()), region: geo.region().map(str::to_string), asn: None, } @@ -1441,12 +1445,12 @@ 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") .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"); @@ -1454,7 +1458,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 +1466,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,12 +1520,12 @@ 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") .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"); @@ -1529,7 +1533,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 +1541,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/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..25864ba62a 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) @@ -131,6 +70,9 @@ fn build_state_with_services( settings: Settings, services: Option, ) -> Result, Report> { + settings + .ec + .resolve_storage(trusted_server_core::ec::storage::EcRuntime::Spin)?; let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Spin)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; @@ -145,6 +87,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 +241,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 +254,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 +264,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 +289,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 +299,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 +312,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 +346,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 +448,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 +461,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 +480,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 +504,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 +543,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 +591,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 +621,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 +681,23 @@ 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 req.uri().path() == trusted_server_core::ec::consent_update::CONSENT_UPDATE_PATH { + // Reserve this path without an identity graph; never forward + // consent snapshots or acknowledge an unpersisted revocation. + return Ok( + match trusted_server_core::ec::consent_update::prepare_consent_update( + &services, req, + ) + .await + { + Ok(req) => trusted_server_core::ec::consent_update::handle_consent_update( + &req, None, + ), + Err(response) => *response, + }, + ); + } if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); } @@ -823,6 +724,7 @@ fn build_router(state: &Arc) -> RouterService { path: &path, settings: &state.settings, kv: None, + allow_generation: true, ec_context: &mut ec_context, services: &services, req, @@ -872,11 +774,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 +784,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 +820,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 +859,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() } } @@ -977,6 +879,32 @@ fn build_router(state: &Arc) -> RouterService { mod tests { use super::*; + #[test] + fn explicit_ec_storage_is_rejected_before_route_or_provider_work() { + for storage in [ + serde_json::json!({"provider":"fastly","store":"exact-fixture-binding"}), + serde_json::json!({"provider":"dynamodb","region":"us-east-1","table":"example-identity","namespace":"fixture"}), + serde_json::json!({"provider":"celld","endpoint":"http://127.0.0.1:7780","store":"identity","access":{"mode":"trusted-network"}}), + ] { + let mut settings = Settings::default(); + settings.ec.storage = + Some(serde_json::from_value(storage).expect("should parse pure provider settings")); + let error = build_state_with_settings(settings) + .err() + .expect("should reject unsupported EC provider before route work"); + assert!(format!("{error:?}").contains("ec.storage.provider is unsupported")); + } + let mut settings = Settings::default(); + settings.ec.ec_store = Some("legacy-inactive-binding".to_owned()); + assert!( + settings + .ec + .resolve_storage(trusted_server_core::ec::storage::EcRuntime::Spin) + .expect("should accept inactive legacy field") + .is_none() + ); + } + fn multi_provider_settings() -> Settings { let mut settings = Settings::from_toml( r#" @@ -1003,6 +931,7 @@ mod tests { ( id.parse().expect("should parse provider ID"), trusted_server_core::auction::ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "standard".to_string(), endpoint: format!("https://{id}.example/openrtb"), @@ -1027,6 +956,7 @@ mod tests { settings.auction.providers.insert( "aps-main".parse().expect("should parse APS provider ID"), trusted_server_core::auction::ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "aps".to_string(), endpoint: "https://aps.example/e/pb/bid".to_string(), @@ -1123,7 +1053,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 +1061,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 +1114,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 +1167,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 +1226,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 +1248,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 +1278,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..7ae48e589a 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> { @@ -468,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(); @@ -496,18 +462,14 @@ 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 = 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(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 +543,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 +609,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 +627,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 +639,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 +690,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,7 +700,10 @@ 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] @@ -806,11 +726,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 +909,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..34910b4f85 --- /dev/null +++ b/crates/trusted-server-adapter-spin/src/startup.rs @@ -0,0 +1,424 @@ +//! 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 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; + 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..e3baa4f5bb --- /dev/null +++ b/crates/trusted-server-adapter-spin/src/startup/loading/tests.rs @@ -0,0 +1,543 @@ +use std::cell::{Cell, RefCell}; +use std::collections::BTreeMap; +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::platform::{PlatformSecretStore, StoreId}; +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..31c078bce9 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(); } @@ -66,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/Cargo.toml b/crates/trusted-server-core/Cargo.toml index 0a8d052805..bed1dbac54 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -23,6 +23,7 @@ cssparser = { workspace = true } derive_more = { workspace = true } ed25519-dalek = { workspace = true } edgezero-core = { workspace = true } +edgezero-store-celld = { workspace = true } error-stack = { workspace = true } flate2 = { workspace = true } futures = { workspace = true } diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index f364464561..c89d3a6efb 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) @@ -310,7 +316,7 @@ pub async fn handle_auction( // present registry: without one, `resolve_auction_eids` yields no // server-side EIDs, so the snapshot would be an unused billable KV read. let auction_kv_snapshot = match (kv, ec_id.as_deref(), registry) { - (Some(graph), Some(ec_id), Some(_)) => graph.load_snapshot(ec_id), + (Some(graph), Some(ec_id), Some(_)) => graph.load_snapshot(ec_id).await, _ => EcKvSnapshot::NotRead, }; // Hand the loaded row to the request context so response finalization — @@ -732,7 +738,10 @@ mod tests { let ec_id = format!("{}.ABC123", "a".repeat(64)); let mut live = crate::ec::kv_types::KvEntry::tombstone(1000); live.consent.ok = true; - graph.create(&ec_id, &live).expect("should seed live row"); + graph + .create(&ec_id, &live) + .await + .expect("should seed live row"); lookups.store(0, std::sync::atomic::Ordering::Relaxed); let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); @@ -785,7 +794,8 @@ mod tests { None, Some("shared-cookie-id"), &mut response, - ); + ) + .await; assert_eq!( lookups.load(std::sync::atomic::Ordering::Relaxed), @@ -794,6 +804,7 @@ mod tests { ); let (stored, _) = graph .get(&ec_id) + .await .expect("should read store") .expect("row should exist"); assert_eq!( @@ -829,7 +840,10 @@ mod tests { let ec_id = format!("{}.eidbdy", "a".repeat(64)); let mut live = crate::ec::kv_types::KvEntry::tombstone(1000); live.consent.ok = true; - graph.create(&ec_id, &live).expect("should seed live row"); + graph + .create(&ec_id, &live) + .await + .expect("should seed live row"); let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); ec_context.set_eid_sync_source(crate::ec::EidSyncSource::Auction); @@ -883,10 +897,12 @@ mod tests { None, // No `ts-eids` cookie on this request at all. None, &mut response, - ); + ) + .await; let (stored, _) = graph .get(&ec_id) + .await .expect("should read store") .expect("row should exist"); assert_eq!( diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index a0df63d94a..3a38082645 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -588,6 +588,7 @@ mod tests { providers: BTreeMap::from([( ProviderId::from_str("pbs-primary").expect("should parse provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "prebid-server".to_string(), endpoint: "https://pbs.example.test/openrtb".to_string(), diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index 4323039284..7855e9d46e 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -215,6 +215,7 @@ mod plan_sharing_tests { settings.auction.providers = std::collections::BTreeMap::from([( "aps-main".parse().expect("should parse APS provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "aps".to_string(), endpoint: "https://aps.example/e/pb/bid".to_string(), diff --git a/crates/trusted-server-core/src/auction/openrtb.rs b/crates/trusted-server-core/src/auction/openrtb.rs index 74553c9bce..5285b07f78 100644 --- a/crates/trusted-server-core/src/auction/openrtb.rs +++ b/crates/trusted-server-core/src/auction/openrtb.rs @@ -384,10 +384,19 @@ fn build_common_request( geo: device.geo.as_ref().map(|geo| Geo { country: Some(geo.country.clone()), region: geo.region.clone(), - city: Some(geo.city.clone()), - lat: matches!(policy, ProfilePolicy::Prebid(_)).then_some(geo.latitude), - lon: matches!(policy, ProfilePolicy::Prebid(_)).then_some(geo.longitude), - metro: (geo.metro_code > 0).then(|| geo.metro_code.to_string()), + city: geo.city.clone(), + lat: geo + .coordinates + .filter(|_| matches!(policy, ProfilePolicy::Prebid(_))) + .map(|coordinates| coordinates.latitude), + lon: geo + .coordinates + .filter(|_| matches!(policy, ProfilePolicy::Prebid(_))) + .map(|coordinates| coordinates.longitude), + metro: geo + .metro_code + .filter(|code| *code > 0) + .map(|code| code.to_string()), r#type: Some(2), ..Default::default() }), @@ -960,6 +969,7 @@ mod routing_metadata_tests { providers: BTreeMap::from([( provider_id.clone(), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: profile.to_string(), endpoint: if profile == "aps" { diff --git a/crates/trusted-server-core/src/auction/openrtb/tests.rs b/crates/trusted-server-core/src/auction/openrtb/tests.rs index f47efa475e..605d56afbe 100644 --- a/crates/trusted-server-core/src/auction/openrtb/tests.rs +++ b/crates/trusted-server-core/src/auction/openrtb/tests.rs @@ -42,6 +42,7 @@ fn config_with_endpoint(profile: &str, profile_config: Value, endpoint: &str) -> providers: BTreeMap::from([( provider_id.clone(), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: profile.to_string(), endpoint: endpoint.to_string(), @@ -171,6 +172,70 @@ fn deterministic_signer() -> RequestSigner { RequestSigner::from_services(&services).expect("should load deterministic signer") } +#[test] +fn geo_exports_omit_unavailable_partial_fields_across_profiles() { + for (profile, config) in [ + ("prebid-server", json!({})), + ("standard", json!({})), + ("aps", json!({"account_id": "example-account"})), + ] { + for region in [None, Some("CA".to_owned())] { + let mut request = canonical_parity_auction_request(); + request.device.as_mut().expect("should have device").geo = Some( + crate::platform::GeoInfo::country_region("US", region.clone()), + ); + let serialized = + serde_json::to_value(build_with_request(profile, config.clone(), request, None)) + .expect("should serialize partial geography"); + let mut expected = json!({"country": "US", "type": 2}); + if let Some(region) = region { + expected["region"] = json!(region); + } + assert_eq!(serialized["device"]["geo"], expected, "{profile}"); + } + } +} + +#[test] +fn geo_exports_preserve_populated_fields_and_real_zero_coordinates() { + for (profile, config) in [ + ("prebid-server", json!({})), + ("standard", json!({})), + ("aps", json!({"account_id": "example-account"})), + ] { + for coordinates in [ + crate::platform::GeoCoordinates { + latitude: 12.34, + longitude: 56.78, + }, + crate::platform::GeoCoordinates { + latitude: 0.0, + longitude: 0.0, + }, + ] { + let mut request = canonical_parity_auction_request(); + request + .device + .as_mut() + .expect("should have device") + .geo + .as_mut() + .expect("should have geo") + .coordinates = Some(coordinates); + let serialized = + serde_json::to_value(build_with_request(profile, config.clone(), request, None)) + .expect("should serialize populated geography"); + let mut expected = json!({"country": "US", "region": "CA", "city": "Example City", + "metro": "501", "type": 2}); + if profile == "prebid-server" { + expected["lat"] = json!(coordinates.latitude); + expected["lon"] = json!(coordinates.longitude); + } + assert_eq!(serialized["device"]["geo"], expected, "{profile}"); + } + } +} + #[test] fn consent_matrix_preserves_pbs_standard_and_aps_policies() { let cases = [ diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 204201e605..93902a9239 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -2618,6 +2618,7 @@ mod tests { ( ProviderId::from_str(id).expect("should parse fictional provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "standard".to_string(), endpoint: "https://example.test/openrtb".to_string(), @@ -2650,6 +2651,7 @@ mod tests { ( ProviderId::from_str(id).expect("should parse fictional provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "prebid-server".to_string(), endpoint: format!("https://{id}.example.test/openrtb"), @@ -2686,6 +2688,7 @@ mod tests { ( ProviderId::from_str(id).expect("should parse fictional provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "aps".to_string(), endpoint: "https://aps.example/e/pb/bid".to_string(), diff --git a/crates/trusted-server-core/src/auction/plan.rs b/crates/trusted-server-core/src/auction/plan.rs index df36fc09b7..367930ccc7 100644 --- a/crates/trusted-server-core/src/auction/plan.rs +++ b/crates/trusted-server-core/src/auction/plan.rs @@ -135,6 +135,10 @@ pub struct ProviderConfig { pub profile: String, /// Fixed provider endpoint. pub endpoint: String, + /// Permit unencrypted HTTP for this provider only. Defaults to false. + /// Use only for explicitly approved demo/test endpoints. + #[serde(default, skip_serializing_if = "is_false")] + pub allow_insecure_http: bool, /// Optional profile-default timeout override. #[serde(default)] pub timeout_ms: Option, @@ -314,7 +318,12 @@ impl AuctionPlan { "provider `{id}` profile_config must be an object" ))); } - let endpoint = canonicalize_endpoint(&id, registration.id, &raw.endpoint)?; + let endpoint = canonicalize_endpoint( + &id, + registration.id, + &raw.endpoint, + raw.allow_insecure_http, + )?; let timeout_ms = raw .timeout_ms .unwrap_or(match registration.default_timeout { @@ -566,13 +575,14 @@ fn canonicalize_endpoint( provider_id: &ProviderId, profile_id: &str, value: &str, + allow_insecure_http: bool, ) -> Result> { let mut endpoint = Url::parse(value).map_err(|error| { configuration_error(format!( "provider `{provider_id}` endpoint must be an absolute HTTPS URL: {error}" )) })?; - if endpoint.scheme() != "https" + if !(endpoint.scheme() == "https" || (allow_insecure_http && endpoint.scheme() == "http")) || endpoint.host_str().is_none() || !endpoint.username().is_empty() || endpoint.password().is_some() @@ -598,6 +608,10 @@ fn canonicalize_endpoint( Ok(CanonicalProviderEndpoint(endpoint)) } +fn is_false(value: &bool) -> bool { + !*value +} + fn normalize_prebid_server_endpoint(endpoint: &mut Url) { match endpoint.path() { "" | "/" => endpoint.set_path("/openrtb2/auction"), @@ -644,6 +658,7 @@ mod tests { fn provider(profile: &str) -> ProviderConfig { ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: profile.to_string(), endpoint: "https://bid.example/openrtb2/auction".to_string(), @@ -1123,6 +1138,69 @@ mod tests { assert!(AuctionPlan::compile(config(BTreeMap::from([(id("aps"), aps)]))).is_err()); } + #[test] + fn insecure_http_requires_an_explicit_per_provider_opt_in() { + let mut demo = provider("prebid-server"); + demo.endpoint = "http://192.0.2.1:8000".to_string(); + assert!( + AuctionPlan::compile(config(BTreeMap::from([(id("demo"), demo.clone())]))).is_err() + ); + demo.allow_insecure_http = true; + let plan = AuctionPlan::compile(config(BTreeMap::from([(id("demo"), demo.clone())]))) + .expect("explicit demo HTTP endpoint should compile"); + assert_eq!( + plan.providers()[0].endpoint.as_str(), + "http://192.0.2.1:8000/openrtb2/auction" + ); + let mut other = provider("standard"); + other.endpoint = "http://other.example/bid".to_string(); + assert!( + AuctionPlan::compile(config(BTreeMap::from([ + (id("demo"), demo), + (id("other"), other) + ]))) + .is_err() + ); + } + + #[test] + fn insecure_http_opt_in_does_not_allow_credentials_fragments_or_other_schemes() { + for endpoint in [ + "http://user:pass@pbs.example/bid", + "http://pbs.example/bid#fragment", + "ftp://pbs.example/bid", + "/relative", + ] { + let mut raw = provider("prebid-server"); + raw.allow_insecure_http = true; + raw.endpoint = endpoint.to_string(); + assert!( + AuctionPlan::compile(config(BTreeMap::from([(id("demo"), raw)]))).is_err(), + "should reject {endpoint}" + ); + } + } + + #[test] + fn insecure_http_default_preserves_existing_provider_wire_shape() { + let legacy = serde_json::to_value(provider("prebid-server")).unwrap(); + assert!(legacy.get("allow_insecure_http").is_none()); + assert!( + !serde_json::from_value::(legacy) + .unwrap() + .allow_insecure_http + ); + let mut opted_in = provider("prebid-server"); + opted_in.allow_insecure_http = true; + let encoded = serde_json::to_value(opted_in).unwrap(); + assert_eq!(encoded["allow_insecure_http"], true); + assert!( + serde_json::from_value::(encoded) + .unwrap() + .allow_insecure_http + ); + } + #[test] fn compiler_normalizes_only_prebid_server_origin_and_canonical_paths() { for (configured, expected) in [ 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/auction/routing.rs b/crates/trusted-server-core/src/auction/routing.rs index 2b8135da3c..f87b5dbd3d 100644 --- a/crates/trusted-server-core/src/auction/routing.rs +++ b/crates/trusted-server-core/src/auction/routing.rs @@ -574,6 +574,7 @@ mod tests { fn provider(profile: &str, routing: RoutingMode) -> ProviderConfig { ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: profile.to_string(), endpoint: format!("https://{profile}.example.test/openrtb"), diff --git a/crates/trusted-server-core/src/auction/test_support.rs b/crates/trusted-server-core/src/auction/test_support.rs index a83f8899a8..a2ba93467f 100644 --- a/crates/trusted-server-core/src/auction/test_support.rs +++ b/crates/trusted-server-core/src/auction/test_support.rs @@ -105,12 +105,14 @@ pub(crate) fn canonical_parity_auction_request() -> AuctionRequest { user_agent: Some("Fictional Browser".to_string()), ip: Some("192.0.2.10".to_string()), geo: Some(GeoInfo { - city: "Example City".to_string(), + city: Some("Example City".to_string()), country: "US".to_string(), - continent: "NA".to_string(), - latitude: 12.34, - longitude: 56.78, - metro_code: 501, + continent: Some("NA".to_string()), + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 12.34, + longitude: 56.78, + }), + metro_code: Some(501), region: Some("CA".to_string()), asn: None, }), diff --git a/crates/trusted-server-core/src/auction_config_types.rs b/crates/trusted-server-core/src/auction_config_types.rs index 56171e1ea5..36f7e789d6 100644 --- a/crates/trusted-server-core/src/auction_config_types.rs +++ b/crates/trusted-server-core/src/auction_config_types.rs @@ -169,6 +169,7 @@ impl AuctionConfig { ( id, ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "standard".to_string(), endpoint: format!("https://{name}.example/openrtb2/auction"), 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.rs b/crates/trusted-server-core/src/config.rs index e48f5c2f03..16245bc387 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -647,6 +647,7 @@ formats = [{ width = 300, height = 250 }] settings.auction.providers.insert( "aps-main".parse().expect("should parse APS provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "aps".to_string(), endpoint: "https://aps.example.com/e/pb/bid".to_string(), 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/consent/jurisdiction.rs b/crates/trusted-server-core/src/consent/jurisdiction.rs index 9073730731..539e219381 100644 --- a/crates/trusted-server-core/src/consent/jurisdiction.rs +++ b/crates/trusted-server-core/src/consent/jurisdiction.rs @@ -1,19 +1,20 @@ -//! Jurisdiction detection for consent observability. +//! Jurisdiction recognition for consent policy and observability. //! -//! Determines the applicable privacy regime based on geolocation data and -//! publisher configuration. Used for **logging and monitoring only** — the -//! detected jurisdiction never causes consent to be synthesized (see proposal -//! Key Decision #3). +//! Geography selects the applicable policy, never synthesizes consent. Partial +//! or unrecognized assertions remain unknown when policy requires subdivision +//! recognition. use core::fmt; +use edgezero_core::geo::CountryCode; + use crate::consent_config::ConsentConfig; use crate::geo::GeoInfo; /// The privacy jurisdiction applicable to a request. /// /// Derived from the user's geolocation and the publisher's configured -/// country/state lists. Used for observability — not for consent synthesis. +/// country/state lists. Used by consent policy, never for consent synthesis. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub enum Jurisdiction { /// GDPR applies (EU/EEA/UK per `consent.gdpr.applies_in`). @@ -43,12 +44,16 @@ impl fmt::Display for Jurisdiction { /// Checks the user's country against `config.gdpr.applies_in`, and for US /// users checks the region against `config.us_states.privacy_states`. /// -/// Returns [`Jurisdiction::Unknown`] when no geo data is available. +/// Returns [`Jurisdiction::Unknown`] for missing/unrecognized country data or +/// US geography without a recognized subdivision. #[must_use] pub fn detect_jurisdiction(geo: Option<&GeoInfo>, config: &ConsentConfig) -> Jurisdiction { let Some(geo) = geo else { return Jurisdiction::Unknown; }; + let Ok(country) = CountryCode::new(&geo.country) else { + return Jurisdiction::Unknown; + }; // Check GDPR countries first (EU/EEA/UK). This ordering also resolves // the `DE` code collision: ISO 3166-1 `DE` is Germany (GDPR), while @@ -58,26 +63,43 @@ pub fn detect_jurisdiction(geo: Option<&GeoInfo>, config: &ConsentConfig) -> Jur .gdpr .applies_in .iter() - .any(|code| code.eq_ignore_ascii_case(&geo.country)) + .any(|code| code.eq_ignore_ascii_case(country.as_str())) { return Jurisdiction::Gdpr; } - // For US users, check if the region is a state with a privacy law. - if geo.country.eq_ignore_ascii_case("US") - && let Some(region) = &geo.region - && config + if country.as_str() == "US" { + let Some(region) = geo.region.as_deref() else { + return Jurisdiction::Unknown; + }; + let region = region.to_ascii_uppercase(); + // Recognition is not the configurable list of states with privacy laws. + // CloudFront supplies syntax-checked assertions, not a state catalogue. + if !US_SUBDIVISIONS.contains(®ion.as_str()) { + return Jurisdiction::Unknown; + } + if config .us_states .privacy_states .iter() - .any(|state| state.eq_ignore_ascii_case(region)) - { - return Jurisdiction::UsState(region.to_uppercase()); + .any(|state| state.eq_ignore_ascii_case(®ion)) + { + return Jurisdiction::UsState(region); + } } Jurisdiction::NonRegulated } +// Assigned US state, district and outlying-area subdivision components. This +// recognition list is separate from the publisher's privacy-policy settings. +const US_SUBDIVISIONS: &[&str] = &[ + "AK", "AL", "AR", "AS", "AZ", "CA", "CO", "CT", "DC", "DE", "FL", "GA", "GU", "HI", "IA", "ID", + "IL", "IN", "KS", "KY", "LA", "MA", "MD", "ME", "MI", "MN", "MO", "MP", "MS", "MT", "NC", "ND", + "NE", "NH", "NJ", "NM", "NV", "NY", "OH", "OK", "OR", "PA", "PR", "RI", "SC", "SD", "TN", "TX", + "UM", "UT", "VA", "VI", "VT", "WA", "WI", "WV", "WY", +]; + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -89,16 +111,7 @@ mod tests { use crate::geo::GeoInfo; fn make_geo(country: &str, region: Option<&str>) -> GeoInfo { - GeoInfo { - city: "Test".to_owned(), - country: country.to_owned(), - continent: "Test".to_owned(), - latitude: 0.0, - longitude: 0.0, - metro_code: 0, - region: region.map(str::to_owned), - asn: None, - } + GeoInfo::country_region(country, region.map(str::to_owned)) } #[test] @@ -157,13 +170,13 @@ mod tests { } #[test] - fn us_no_region_is_non_regulated() { + fn us_no_region_remains_unknown() { let config = ConsentConfig::default(); let geo = make_geo("US", None); assert_eq!( detect_jurisdiction(Some(&geo), &config), - Jurisdiction::NonRegulated, - "US without region should be non-regulated" + Jurisdiction::Unknown, + "country-only US geography must not imply a non-regulated state" ); } @@ -199,6 +212,55 @@ mod tests { ); } + #[test] + fn unrecognized_subdivisions_remain_unknown_even_if_configured() { + let mut config = ConsentConfig::default(); + config.us_states.privacy_states.push("ZZ".to_owned()); + for region in ["ZZ", "01", "US-CA", "", " CA"] { + let geo = make_geo("US", Some(region)); + assert_eq!( + detect_jurisdiction(Some(&geo), &config), + Jurisdiction::Unknown + ); + } + for country in ["", "XX", "ZZ", "UK", "USA"] { + assert_eq!( + detect_jurisdiction(Some(&make_geo(country, None)), &config), + Jurisdiction::Unknown + ); + } + } + + #[test] + fn partial_geo_never_synthesizes_tcf_or_auction_permission() { + use crate::consent::{ConsentContext, consent_allows_server_side_auction}; + let config = ConsentConfig::default(); + for geo in [ + make_geo("US", None), + make_geo("US", Some("ZZ")), + make_geo("GB", None), + ] { + let context = ConsentContext { + jurisdiction: detect_jurisdiction(Some(&geo), &config), + ..ConsentContext::default() + }; + assert!(!consent_allows_server_side_auction(&context)); + assert!(context.tcf.is_none()); + } + let context = ConsentContext { + jurisdiction: detect_jurisdiction(Some(&make_geo("US", Some("CA"))), &config), + ..ConsentContext::default() + }; + assert!( + consent_allows_server_side_auction(&context), + "should retain existing non-GDPR auction admission policy" + ); + assert!( + context.tcf.is_none(), + "should not manufacture identity consent" + ); + } + #[test] fn display_formatting() { assert_eq!(Jurisdiction::Gdpr.to_string(), "GDPR"); diff --git a/crates/trusted-server-core/src/creative_opportunities.rs b/crates/trusted-server-core/src/creative_opportunities.rs index 8a20c72965..75c893d4a9 100644 --- a/crates/trusted-server-core/src/creative_opportunities.rs +++ b/crates/trusted-server-core/src/creative_opportunities.rs @@ -2383,6 +2383,7 @@ mod tests { providers: BTreeMap::from([( ProviderId::from_str("pbs-primary").expect("should parse provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "prebid-server".to_string(), endpoint: "https://pbs.example.test/openrtb".to_string(), diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs index e21fab702c..2bd6f3a7b2 100644 --- a/crates/trusted-server-core/src/ec/admin.rs +++ b/crates/trusted-server-core/src/ec/admin.rs @@ -273,7 +273,7 @@ struct SkippedPartnerId { /// /// Returns [`TrustedServerError::KvStore`] when the store open or read /// fails. -pub fn handle_admin_ec_lookup( +pub async fn handle_admin_ec_lookup( kv: Option<&KvIdentityGraph>, registry: &PartnerRegistry, req: &Request, @@ -287,7 +287,7 @@ pub fn handle_admin_ec_lookup( Err(response) => return Ok(*response), }; - let Some(lookup) = kv.lookup_raw(&ec_id)? else { + let Some(lookup) = kv.lookup_raw(&ec_id).await? else { log::info!("Admin EC lookup: no entry for '{}'", log_id(&ec_id)); return Ok(json_error( StatusCode::NOT_FOUND, @@ -742,18 +742,22 @@ mod tests { .expect("should build test request") } - fn kv_with_entry(ec_id: &str, entry: &KvEntry) -> KvIdentityGraph { + async fn kv_with_entry(ec_id: &str, entry: &KvEntry) -> KvIdentityGraph { let kv = KvIdentityGraph::in_memory("test-store"); - kv.create(ec_id, entry).expect("should seed KV entry"); + kv.create(ec_id, entry).await.expect("should seed KV entry"); kv } - fn kv_with_raw_body(ec_id: &str, body: &str) -> KvIdentityGraph { + async fn kv_with_raw_body(ec_id: &str, body: &str) -> KvIdentityGraph { let metadata = serde_json::json!({ "ok": true, "country": "US", "v": 1 }).to_string(); - kv_with_raw_body_and_metadata(ec_id, body, &metadata) + kv_with_raw_body_and_metadata(ec_id, body, &metadata).await } - fn kv_with_raw_body_and_metadata(ec_id: &str, body: &str, metadata: &str) -> KvIdentityGraph { + async fn kv_with_raw_body_and_metadata( + ec_id: &str, + body: &str, + metadata: &str, + ) -> KvIdentityGraph { let store = InMemoryEcKv::new("test-store"); store .insert( @@ -765,6 +769,7 @@ mod tests { mode: EcKvWriteMode::Add, }, ) + .await .expect("should seed raw KV body"); KvIdentityGraph::new(store) } @@ -971,13 +976,14 @@ mod tests { } } - #[test] - fn returns_entry_with_auction_view() { + #[tokio::test(flavor = "current_thread")] + async fn returns_entry_with_auction_view() { let ec_id = test_ec_id(); - let kv = kv_with_entry(&ec_id, &sample_entry()); + let kv = kv_with_entry(&ec_id, &sample_entry()).await; let req = get_request(&format!("/_ts/admin/ec/{ec_id}")); let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req) + .await .expect("should handle lookup"); assert_eq!(response.status(), StatusCode::OK); @@ -1037,8 +1043,8 @@ mod tests { ); } - #[test] - fn preserves_raw_entry_and_metadata_shapes() { + #[tokio::test(flavor = "current_thread")] + async fn preserves_raw_entry_and_metadata_shapes() { let ec_id = test_ec_id(); let body = serde_json::json!({ "v": 1, @@ -1070,10 +1076,11 @@ mod tests { "future_metadata": { "source": "edge" } }) .to_string(); - let kv = kv_with_raw_body_and_metadata(&ec_id, &body, &metadata); + let kv = kv_with_raw_body_and_metadata(&ec_id, &body, &metadata).await; let req = get_request(&format!("/_ts/admin/ec/{ec_id}")); let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req) + .await .expect("should handle lookup"); let json = response_json(response); @@ -1093,14 +1100,15 @@ mod tests { assert_eq!(json["auction"]["eids"][0]["source"], "bidstream.example"); } - #[test] - fn valid_json_with_invalid_entry_schema_remains_visible() { + #[tokio::test(flavor = "current_thread")] + async fn valid_json_with_invalid_entry_schema_remains_visible() { let ec_id = test_ec_id(); let body = serde_json::json!({ "future": "value" }).to_string(); - let kv = kv_with_raw_body(&ec_id, &body); + let kv = kv_with_raw_body(&ec_id, &body).await; let req = get_request(&format!("/_ts/admin/ec/{ec_id}")); let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req) + .await .expect("should handle lookup"); let json = response_json(response); @@ -1115,17 +1123,19 @@ mod tests { assert!(json.get("auction").is_none()); } - #[test] - fn reports_tombstone_entries() { + #[tokio::test(flavor = "current_thread")] + async fn reports_tombstone_entries() { let ec_id = test_ec_id(); // Only an identity the store already holds can be tombstoned, so seed // the live entry the withdrawal replaces. let kv = kv_with_entry( &ec_id, &KvEntry::minimal("bidstream.example", "uid-live", 1_741_824_000), - ); + ) + .await; assert_eq!( kv.write_withdrawal_tombstone(&ec_id, drop) + .await .expect("should write tombstone"), TombstoneOutcome::Written, "should tombstone the seeded identity" @@ -1133,6 +1143,7 @@ mod tests { let req = get_request(&format!("/_ts/admin/ec/{ec_id}")); let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req) + .await .expect("should handle lookup"); assert_eq!(response.status(), StatusCode::OK); @@ -1147,35 +1158,38 @@ mod tests { ); } - #[test] - fn missing_entry_returns_404() { + #[tokio::test(flavor = "current_thread")] + async fn missing_entry_returns_404() { let kv = KvIdentityGraph::in_memory("test-store"); let req = get_request(&format!("/_ts/admin/ec/{}", test_ec_id())); let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req) + .await .expect("should handle lookup"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } - #[test] - fn invalid_id_returns_400() { + #[tokio::test(flavor = "current_thread")] + async fn invalid_id_returns_400() { let kv = KvIdentityGraph::in_memory("test-store"); let req = get_request("/_ts/admin/ec/not-a-valid-id"); let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req) + .await .expect("should handle lookup"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } - #[test] - fn corrupt_entry_returns_parse_error_and_raw_body() { + #[tokio::test(flavor = "current_thread")] + async fn corrupt_entry_returns_parse_error_and_raw_body() { let ec_id = test_ec_id(); - let kv = kv_with_raw_body(&ec_id, "not json at all"); + let kv = kv_with_raw_body(&ec_id, "not json at all").await; let req = get_request(&format!("/_ts/admin/ec/{ec_id}")); let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req) + .await .expect("should handle lookup"); assert_eq!( @@ -1203,8 +1217,8 @@ mod tests { ); } - #[test] - fn invalid_schema_version_reports_validation_error() { + #[tokio::test(flavor = "current_thread")] + async fn invalid_schema_version_reports_validation_error() { let ec_id = test_ec_id(); let body = serde_json::json!({ "v": 99, @@ -1213,10 +1227,11 @@ mod tests { "geo": { "country": "US" } }) .to_string(); - let kv = kv_with_raw_body(&ec_id, &body); + let kv = kv_with_raw_body(&ec_id, &body).await; let req = get_request(&format!("/_ts/admin/ec/{ec_id}")); let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req) + .await .expect("should handle lookup"); assert_eq!(response.status(), StatusCode::OK); @@ -1235,13 +1250,14 @@ mod tests { ); } - #[test] - fn bare_route_uses_ts_ec_cookie() { + #[tokio::test(flavor = "current_thread")] + async fn bare_route_uses_ts_ec_cookie() { let ec_id = test_ec_id(); - let kv = kv_with_entry(&ec_id, &sample_entry()); + let kv = kv_with_entry(&ec_id, &sample_entry()).await; let req = get_request_with_cookie("/_ts/admin/ec", &format!("other=1; ts-ec={ec_id}; x=2")); let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req) + .await .expect("should handle lookup"); assert_eq!(response.status(), StatusCode::OK); @@ -1253,20 +1269,21 @@ mod tests { ); } - #[test] - fn bare_route_uses_last_duplicate_ts_ec_cookie() { + #[tokio::test(flavor = "current_thread")] + async fn bare_route_uses_last_duplicate_ts_ec_cookie() { // `CookieJar` — the parser the live EC lifecycle uses — keeps the last // duplicate pair. The diagnostic must inspect that same record, not the // first one a header scan would find. let stale_ec_id = format!("{}.stale1", "b".repeat(64)); let live_ec_id = test_ec_id(); - let kv = kv_with_entry(&live_ec_id, &sample_entry()); + let kv = kv_with_entry(&live_ec_id, &sample_entry()).await; let req = get_request_with_cookie( "/_ts/admin/ec", &format!("ts-ec={stale_ec_id}; ts-ec={live_ec_id}"), ); let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req) + .await .expect("should handle lookup"); assert_eq!(response.status(), StatusCode::OK); @@ -1278,12 +1295,13 @@ mod tests { ); } - #[test] - fn bare_route_without_cookie_returns_404() { + #[tokio::test(flavor = "current_thread")] + async fn bare_route_without_cookie_returns_404() { let kv = KvIdentityGraph::in_memory("test-store"); let req = get_request("/_ts/admin/ec"); let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req) + .await .expect("should handle lookup"); assert_eq!(response.status(), StatusCode::NOT_FOUND); @@ -1297,12 +1315,13 @@ mod tests { ); } - #[test] - fn missing_identity_graph_returns_501() { + #[tokio::test(flavor = "current_thread")] + async fn missing_identity_graph_returns_501() { let req = get_request(&format!("/_ts/admin/ec/{}", test_ec_id())); - let response = - handle_admin_ec_lookup(None, &test_registry(), &req).expect("should handle lookup"); + let response = handle_admin_ec_lookup(None, &test_registry(), &req) + .await + .expect("should handle lookup"); assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); } @@ -1331,12 +1350,12 @@ mod tests { ); } - #[test] - fn kv_read_failure_propagates() { + #[tokio::test(flavor = "current_thread")] + async fn kv_read_failure_propagates() { let kv = KvIdentityGraph::failing("broken-store"); let req = get_request(&format!("/_ts/admin/ec/{}", test_ec_id())); - let result = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req); + let result = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req).await; assert!(result.is_err(), "should propagate KV read failures"); } diff --git a/crates/trusted-server-core/src/ec/batch_sync.rs b/crates/trusted-server-core/src/ec/batch_sync.rs index 4903589b29..e0d0707a88 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"; @@ -39,8 +42,9 @@ const MAX_BATCH_SIZE: usize = 1000; use super::kv_types::MAX_UID_LENGTH; +#[async_trait::async_trait(?Send)] trait BatchSyncWriter { - fn upsert_partner_id_if_exists( + async fn upsert_partner_id_if_exists( &self, ec_id: &str, partner_id: &str, @@ -48,14 +52,15 @@ trait BatchSyncWriter { ) -> Result>; } +#[async_trait::async_trait(?Send)] impl BatchSyncWriter for KvIdentityGraph { - fn upsert_partner_id_if_exists( + async fn upsert_partner_id_if_exists( &self, ec_id: &str, partner_id: &str, uid: &str, ) -> Result> { - KvIdentityGraph::upsert_partner_id_if_exists(self, ec_id, partner_id, uid) + KvIdentityGraph::upsert_partner_id_if_exists(self, ec_id, partner_id, uid).await } } @@ -111,16 +116,16 @@ struct MappingGroup { /// # Errors /// /// Returns [`TrustedServerError`] on serialization or KV store failures. -pub fn handle_batch_sync( +pub async fn handle_batch_sync( kv: &KvIdentityGraph, registry: &PartnerRegistry, rate_limiter: &dyn RateLimiter, req: Request, ) -> Result, Report> { - handle_batch_sync_with_writer(kv, registry, rate_limiter, req) + handle_batch_sync_with_writer(kv, registry, rate_limiter, req).await } -fn handle_batch_sync_with_writer( +async fn handle_batch_sync_with_writer( writer: &dyn BatchSyncWriter, registry: &PartnerRegistry, rate_limiter: &dyn RateLimiter, @@ -141,7 +146,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 +153,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, @@ -167,7 +176,7 @@ fn handle_batch_sync_with_writer( } // 4. Process mappings with per-item validation and rejection reasons. - let (accepted, errors) = process_mappings(writer, &partner.source_domain, &body.mappings); + let (accepted, errors) = process_mappings(writer, &partner.source_domain, &body.mappings).await; let rejected = errors.len(); let status = if rejected > 0 { @@ -199,7 +208,7 @@ fn content_length_exceeds_limit(req: &Request, max_body_size: usize) - /// group. On infrastructure failure, the failing group and all unprocessed /// valid groups are rejected as unavailable; already validated invalid inputs /// keep their specific errors. Errors are sorted by original input index. -fn process_mappings( +async fn process_mappings( writer: &dyn BatchSyncWriter, partner_id: &str, mappings: &[SyncMapping], @@ -244,7 +253,10 @@ fn process_mappings( let mut accepted = 0; for (group_index, group) in groups.iter().enumerate() { - match writer.upsert_partner_id_if_exists(&group.ec_id, partner_id, &group.partner_uid) { + match writer + .upsert_partner_id_if_exists(&group.ec_id, partner_id, &group.partner_uid) + .await + { Ok(UpsertResult::Written | UpsertResult::Unchanged) => { accepted += group.indexes.len(); } @@ -368,8 +380,9 @@ mod tests { } } + #[async_trait::async_trait(?Send)] impl BatchSyncWriter for MockWriter { - fn upsert_partner_id_if_exists( + async fn upsert_partner_id_if_exists( &self, ec_id: &str, partner_id: &str, @@ -474,8 +487,8 @@ mod tests { ); } - #[test] - fn handle_batch_sync_rejects_oversized_content_length_before_body_parse() { + #[tokio::test(flavor = "current_thread")] + async fn handle_batch_sync_rejects_oversized_content_length_before_body_parse() { let writer = MockWriter::new(vec![]); let registry = test_registry(); let limiter = MockRateLimiter { @@ -490,6 +503,7 @@ mod tests { .expect("should build test request"); let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, req) + .await .expect("should return oversized response"); assert_eq!( @@ -499,8 +513,8 @@ mod tests { ); } - #[test] - fn handle_batch_sync_uses_post_read_limit_for_malformed_content_length() { + #[tokio::test(flavor = "current_thread")] + async fn handle_batch_sync_uses_post_read_limit_for_malformed_content_length() { let writer = MockWriter::new(vec![]); let registry = test_registry(); let limiter = MockRateLimiter { @@ -516,6 +530,7 @@ mod tests { .expect("should build test request"); let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, req) + .await .expect("should return oversized response"); assert_eq!( @@ -525,8 +540,8 @@ mod tests { ); } - #[test] - fn process_mappings_returns_multistatus_errors_per_mapping() { + #[tokio::test(flavor = "current_thread")] + async fn process_mappings_returns_multistatus_errors_per_mapping() { let writer = MockWriter::new(vec![Ok(UpsertResult::Written)]); let mappings = vec![ mapping("x", "u1", 1), @@ -534,7 +549,7 @@ mod tests { mapping(&format!("{}.ABC123", "a".repeat(64)), "u3", 1), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let (accepted, errors) = process_mappings(&writer, "partner", &mappings).await; assert_eq!(accepted, 1, "should count successful writes as accepted"); assert_eq!(errors.len(), 2, "should reject invalid mappings only"); @@ -544,8 +559,8 @@ mod tests { assert_eq!(errors[1].reason, REASON_INVALID_PARTNER_UID); } - #[test] - fn process_mappings_aborts_on_kv_unavailable() { + #[tokio::test(flavor = "current_thread")] + async fn process_mappings_aborts_on_kv_unavailable() { let writer = MockWriter::new(vec![ Ok(UpsertResult::Written), Err(Report::new(TrustedServerError::KvStore { @@ -560,7 +575,7 @@ mod tests { mapping(&format!("{}.ABC123", "c".repeat(64)), "u3", 1), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let (accepted, errors) = process_mappings(&writer, "partner", &mappings).await; assert_eq!(accepted, 1, "should keep accepted count before failure"); assert_eq!( @@ -579,8 +594,8 @@ mod tests { ); } - #[test] - fn handle_batch_sync_rejects_missing_auth() { + #[tokio::test(flavor = "current_thread")] + async fn handle_batch_sync_rejects_missing_auth() { let kv = KvIdentityGraph::failing("test_store"); let registry = PartnerRegistry::empty(); let limiter = MockRateLimiter { @@ -592,8 +607,9 @@ mod tests { .body(EdgeBody::empty()) .expect("should build test request"); - let response = - handle_batch_sync(&kv, ®istry, &limiter, req).expect("should return response"); + let response = handle_batch_sync(&kv, ®istry, &limiter, req) + .await + .expect("should return response"); assert_eq!( response.status(), StatusCode::UNAUTHORIZED, @@ -644,8 +660,8 @@ mod tests { assert_eq!(json["errors"][0]["reason"], REASON_INELIGIBLE); } - #[test] - fn process_mappings_collapses_missing_and_withdrawn_to_ineligible() { + #[tokio::test(flavor = "current_thread")] + async fn process_mappings_collapses_missing_and_withdrawn_to_ineligible() { let writer = MockWriter::new(vec![ Ok(UpsertResult::NotFound), Ok(UpsertResult::ConsentWithdrawn), @@ -657,7 +673,7 @@ mod tests { mapping(&withdrawn_ec_id, "uid-2", 101), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let (accepted, errors) = process_mappings(&writer, "partner", &mappings).await; assert_eq!(accepted, 0, "should not accept ineligible mappings"); assert_eq!(errors.len(), 2, "should report both errors"); @@ -668,13 +684,13 @@ mod tests { assert_eq!(writer.calls().len(), 2, "should exercise both outcomes"); } - #[test] - fn process_mappings_fans_out_unchanged_to_group_members() { + #[tokio::test(flavor = "current_thread")] + async fn process_mappings_fans_out_unchanged_to_group_members() { let writer = MockWriter::new(vec![Ok(UpsertResult::Unchanged)]); let ec_id = format!("{}.ABC123", "a".repeat(64)); let mappings = vec![mapping(&ec_id, "uid-1", 100), mapping(&ec_id, "uid-1", 101)]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let (accepted, errors) = process_mappings(&writer, "partner", &mappings).await; assert_eq!(accepted, 2, "should accept every unchanged group member"); assert!( @@ -684,8 +700,8 @@ mod tests { assert_eq!(writer.calls().len(), 1, "should call once for the group"); } - #[test] - fn process_mappings_does_not_order_by_timestamp() { + #[tokio::test(flavor = "current_thread")] + async fn process_mappings_does_not_order_by_timestamp() { let writer = MockWriter::new(vec![Ok(UpsertResult::Written)]); let ec_id = format!("{}.ABC123", "a".repeat(64)); let mappings = vec![ @@ -693,7 +709,7 @@ mod tests { mapping(&ec_id, "uid-old", 100), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let (accepted, errors) = process_mappings(&writer, "partner", &mappings).await; assert_eq!( accepted, 2, @@ -711,8 +727,8 @@ mod tests { ); } - #[test] - fn process_mappings_groups_normalized_ids_in_first_occurrence_order() { + #[tokio::test(flavor = "current_thread")] + async fn process_mappings_groups_normalized_ids_in_first_occurrence_order() { let writer = MockWriter::new(vec![Ok(UpsertResult::Written), Ok(UpsertResult::Unchanged)]); let ec_id_a = format!("{}.ABC123", "a".repeat(64)); let ec_id_a_upper = format!("{}.ABC123", "A".repeat(64)); @@ -723,7 +739,7 @@ mod tests { mapping(&ec_id_a_upper, "a-last", 3), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let (accepted, errors) = process_mappings(&writer, "partner", &mappings).await; assert_eq!(accepted, 3, "should accept every valid group member"); assert!(errors.is_empty(), "should report no errors"); @@ -745,15 +761,15 @@ mod tests { ); } - #[test] - fn process_mappings_bounds_writer_calls_at_max_batch_size() { + #[tokio::test(flavor = "current_thread")] + async fn process_mappings_bounds_writer_calls_at_max_batch_size() { let writer = MockWriter::new(vec![Ok(UpsertResult::Written)]); let ec_id = format!("{}.ABC123", "a".repeat(64)); let mappings = (0..MAX_BATCH_SIZE) .map(|index| mapping(&ec_id, &format!("uid-{index}"), index as u64)) .collect::>(); - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let (accepted, errors) = process_mappings(&writer, "partner", &mappings).await; assert_eq!( accepted, MAX_BATCH_SIZE, @@ -771,8 +787,8 @@ mod tests { ); } - #[test] - fn process_mappings_keeps_suffix_case_distinct() { + #[tokio::test(flavor = "current_thread")] + async fn process_mappings_keeps_suffix_case_distinct() { let writer = MockWriter::new(vec![Ok(UpsertResult::Written), Ok(UpsertResult::Written)]); let upper_suffix = format!("{}.ABC123", "a".repeat(64)); let mixed_suffix = format!("{}.AbC123", "a".repeat(64)); @@ -781,7 +797,7 @@ mod tests { mapping(&mixed_suffix, "mixed", 2), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let (accepted, errors) = process_mappings(&writer, "partner", &mappings).await; assert_eq!(accepted, 2, "should accept both distinct EC IDs"); assert!(errors.is_empty(), "should report no errors"); @@ -803,8 +819,8 @@ mod tests { ); } - #[test] - fn process_mappings_invalid_duplicate_does_not_replace_last_valid_uid() { + #[tokio::test(flavor = "current_thread")] + async fn process_mappings_invalid_duplicate_does_not_replace_last_valid_uid() { let writer = MockWriter::new(vec![Ok(UpsertResult::Written)]); let ec_id = format!("{}.ABC123", "a".repeat(64)); let mappings = vec![ @@ -813,7 +829,7 @@ mod tests { mapping(&ec_id, " ", 3), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let (accepted, errors) = process_mappings(&writer, "partner", &mappings).await; assert_eq!(accepted, 2, "should accept valid group members"); assert_eq!(errors.len(), 1, "should retain the invalid UID error"); @@ -826,8 +842,8 @@ mod tests { ); } - #[test] - fn process_mappings_fans_out_ineligible_outcomes_to_group_members() { + #[tokio::test(flavor = "current_thread")] + async fn process_mappings_fans_out_ineligible_outcomes_to_group_members() { let writer = MockWriter::new(vec![ Ok(UpsertResult::NotFound), Ok(UpsertResult::ConsentWithdrawn), @@ -841,7 +857,7 @@ mod tests { mapping(&ec_id_b, "b-2", 4), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let (accepted, errors) = process_mappings(&writer, "partner", &mappings).await; assert_eq!(accepted, 0, "should reject all ineligible group members"); assert_eq!( @@ -861,8 +877,8 @@ mod tests { assert_eq!(writer.calls().len(), 2, "should call once per group"); } - #[test] - fn process_mappings_aborts_by_group_and_preserves_sorted_accounting() { + #[tokio::test(flavor = "current_thread")] + async fn process_mappings_aborts_by_group_and_preserves_sorted_accounting() { let writer = MockWriter::new(vec![ Ok(UpsertResult::Written), Err(Report::new(TrustedServerError::KvStore { @@ -882,7 +898,7 @@ mod tests { mapping(&ec_id_c, "", 6), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let (accepted, errors) = process_mappings(&writer, "partner", &mappings).await; assert_eq!( accepted, 2, @@ -913,8 +929,8 @@ mod tests { ); } - #[test] - fn handle_batch_sync_accepts_every_member_of_a_written_group() { + #[tokio::test(flavor = "current_thread")] + async fn handle_batch_sync_accepts_every_member_of_a_written_group() { let registry = test_registry(); let limiter = MockRateLimiter { should_exceed: false, @@ -931,6 +947,7 @@ mod tests { &limiter, authorized_batch_request(&body), ) + .await .expect("should return success response"); assert_eq!( @@ -943,8 +960,8 @@ mod tests { assert_eq!(json["rejected"], 0, "should reject no group members"); } - #[test] - fn handle_batch_sync_rejects_every_member_of_an_ineligible_group() { + #[tokio::test(flavor = "current_thread")] + async fn handle_batch_sync_rejects_every_member_of_an_ineligible_group() { let registry = test_registry(); let limiter = MockRateLimiter { should_exceed: false, @@ -961,6 +978,7 @@ mod tests { &limiter, authorized_batch_request(&body), ) + .await .expect("should return multi-status response"); assert_eq!( @@ -981,8 +999,8 @@ mod tests { ); } - #[test] - fn handle_batch_sync_reports_validation_errors_without_writer_calls() { + #[tokio::test(flavor = "current_thread")] + async fn handle_batch_sync_reports_validation_errors_without_writer_calls() { let writer = MockWriter::new(vec![]); let registry = test_registry(); let limiter = MockRateLimiter { @@ -999,6 +1017,7 @@ mod tests { &limiter, authorized_batch_request(&body), ) + .await .expect("should return validation response"); assert_eq!(response.status(), StatusCode::MULTI_STATUS); @@ -1018,8 +1037,8 @@ mod tests { ); } - #[test] - fn handle_batch_sync_reports_grouped_infrastructure_failure() { + #[tokio::test(flavor = "current_thread")] + async fn handle_batch_sync_reports_grouped_infrastructure_failure() { let writer = MockWriter::new(vec![ Ok(UpsertResult::Written), Err(Report::new(TrustedServerError::KvStore { @@ -1044,6 +1063,7 @@ mod tests { &limiter, authorized_batch_request(&body), ) + .await .expect("should return infrastructure failure response"); assert_eq!(response.status(), StatusCode::MULTI_STATUS); diff --git a/crates/trusted-server-core/src/ec/consent_update.rs b/crates/trusted-server-core/src/ec/consent_update.rs new file mode 100644 index 0000000000..1ffdb942dc --- /dev/null +++ b/crates/trusted-server-core/src/ec/consent_update.rs @@ -0,0 +1,558 @@ +//! First-party reconciliation after the publisher CMP changes its real consent signal. +//! +//! The browser transports consent through the ordinary consent cookies. This endpoint +//! accepts no client-supplied identity, vendor token, or inferred permission grant. + +use edgezero_core::body::Body as EdgeBody; +use http::{HeaderValue, Method, Request, Response, StatusCode, header}; +use serde::Deserialize; +use url::Url; + +use super::kv::KvIdentityGraph; +use crate::http_util::RequestInfo; +use crate::platform::RuntimeServices; +use crate::response_privacy::enforce_terminal_private_cache_privacy; + +pub const CONSENT_UPDATE_PATH: &str = "/_ts/consent"; +// Two browser-cookie-sized signals plus their JSON representation. This is a +// consent-only transport, not another general auction or identity payload. +pub const MAX_CONSENT_UPDATE_BODY_BYTES: usize = 16 * 1024; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ConsentSnapshot { + #[serde(deserialize_with = "required_nullable_gpp")] + gpp_string: Option, + applicable_sections: Vec, +} + +fn required_nullable_gpp<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + Option::deserialize(deserializer) +} + +#[derive(Clone, Copy, Debug)] +struct ConsentUpdateResponse; + +#[derive(Clone, Copy, Debug)] +struct PreparedConsentUpdate; + +fn response(status: StatusCode) -> Response { + let mut response = Response::new(EdgeBody::empty()); + *response.status_mut() = status; + enforce_terminal_private_cache_privacy(&mut response); + response +} + +/// Marks an accepted consent update whose finalization could not confirm revocation. +/// Ordinary publisher/error responses retain their existing status semantics. +pub(crate) fn mark_reconciliation_failed(response: &mut Response) { + if response + .extensions() + .get::() + .is_some() + { + *response.status_mut() = StatusCode::SERVICE_UNAVAILABLE; + } +} + +fn same_origin(services: &RuntimeServices, req: &Request) -> bool { + let Some(origin) = req + .headers() + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + else { + return false; + }; + let Ok(origin) = Url::parse(origin) else { + return false; + }; + if !origin.username().is_empty() + || origin.password().is_some() + || origin.path() != "/" + || origin.query().is_some() + || origin.fragment().is_some() + { + return false; + } + let info = RequestInfo::from_request(req, services.client_info()); + let Ok(expected) = Url::parse(&format!("{}://{}", info.scheme, info.host)) else { + return false; + }; + // The serving origin may be a reviewed demo origin; use normalized trusted + // ingress, not a hard-coded publisher origin or a viewer-selected header. + origin.origin() == expected.origin() +} + +/// Reads one bounded CMP snapshot before adapters construct the EC context. +/// +/// Each request carries its own snapshot: a withdrawal must not accidentally +/// read a later grant from cookies by the time network dispatch occurs. +/// Only GPP consent headers are replaced; identity and other privacy signals remain. +/// +/// # Errors +/// +/// Returns a private HTTP error for unsupported methods, invalid origins, +/// oversized bodies, or malformed snapshots. Error responses are boxed so the +/// successful request path does not carry a large error variant. +pub async fn prepare_consent_update( + services: &RuntimeServices, + req: Request, +) -> Result, Box>> { + if req.method() != Method::POST { + let mut response = response(StatusCode::METHOD_NOT_ALLOWED); + response + .headers_mut() + .insert(header::ALLOW, HeaderValue::from_static("POST")); + return Err(Box::new(response)); + } + if !same_origin(services, &req) { + return Err(Box::new(response(StatusCode::FORBIDDEN))); + } + let (mut parts, body) = req.into_parts(); + let bytes = body + .into_bytes_bounded(MAX_CONSENT_UPDATE_BODY_BYTES) + .await + .map_err(|_| response(StatusCode::PAYLOAD_TOO_LARGE))?; + let snapshot: ConsentSnapshot = + serde_json::from_slice(&bytes).map_err(|_| response(StatusCode::BAD_REQUEST))?; + if let Some(gpp) = &snapshot.gpp_string { + if gpp.is_empty() + || !gpp + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._~-=".contains(&byte)) + || snapshot + .applicable_sections + .iter() + .any(|id| !(1..=65535).contains(id)) + || crate::consent::gpp::decode_gpp_string(gpp).is_err() + { + return Err(Box::new(response(StatusCode::BAD_REQUEST))); + } + } else if !snapshot.applicable_sections.is_empty() { + return Err(Box::new(response(StatusCode::BAD_REQUEST))); + } + let mut cookies = Vec::new(); + for header in parts.headers.get_all(header::COOKIE) { + let value = header + .to_str() + .map_err(|_| response(StatusCode::BAD_REQUEST))?; + for pair in value + .split(';') + .map(str::trim) + .filter(|pair| !pair.is_empty()) + { + let name = pair.split_once('=').map_or(pair, |(name, _)| name.trim()); + if name != "__gpp" && name != "__gpp_sid" { + cookies.push(pair.to_owned()); + } + } + } + if let Some(gpp) = snapshot.gpp_string { + cookies.push(format!("__gpp={gpp}")); + if !snapshot.applicable_sections.is_empty() { + cookies.push(format!( + "__gpp_sid={}", + snapshot + .applicable_sections + .iter() + .map(i32::to_string) + .collect::>() + .join(",") + )); + } + } + parts.headers.remove(header::COOKIE); + if !cookies.is_empty() { + let value = HeaderValue::from_str(&cookies.join("; ")) + .map_err(|_| response(StatusCode::BAD_REQUEST))?; + parts.headers.insert(header::COOKIE, value); + } + let mut req = Request::from_parts(parts, EdgeBody::empty()); + req.extensions_mut().insert(PreparedConsentUpdate); + Ok(req) +} + +/// Acknowledges a prepared snapshot through the ordinary EC finalization path. +/// +/// Adapters must prepare the snapshot before extracting EC/consent context, run +/// their usual request filters, and finalize exactly once before headers commit. +/// This endpoint never mints an identity or restores EIDs. Thus an older grant +/// response cannot recreate an identity after a concurrent withdrawal. Creation +/// remains on eligible publisher navigation; partner identify remains read-only. +pub fn handle_consent_update( + req: &Request, + graph: Option<&KvIdentityGraph>, +) -> Response { + if req.extensions().get::().is_none() { + return response(StatusCode::BAD_REQUEST); + } + let mut response = response(if graph.is_some() { + StatusCode::NO_CONTENT + } else { + StatusCode::SERVICE_UNAVAILABLE + }); + response.extensions_mut().insert(ConsentUpdateResponse); + response +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::consent::{ConsentContext, jurisdiction::Jurisdiction}; + use crate::ec::{EcContext, finalize::ec_finalize_response, registry::PartnerRegistry}; + use crate::platform::test_support::noop_services; + use crate::test_support::tests::create_test_settings; + + const GPP: &str = "DBABLA~BVQqAAAAAgA.QA"; + const EC: &str = "aaaaaaaabbbbbbbbccccccccddddddddeeeeeeeeffffffff0000000011111111.abc123"; + + fn request( + method: Method, + origin: Option<&str>, + body: impl Into, + ) -> Request { + let mut builder = Request::builder() + .method(method) + .uri("https://publisher.example/_ts/consent") + .header(header::HOST, "publisher.example") + .header("x-forwarded-proto", "https") + .header(header::CONTENT_TYPE, "application/json"); + if let Some(origin) = origin { + builder = builder.header(header::ORIGIN, origin); + } + builder + .body(EdgeBody::from(body.into().to_string())) + .expect("fixture request") + } + + fn snapshot() -> serde_json::Value { + serde_json::json!({ "gppString": GPP, "applicableSections": [7] }) + } + + #[tokio::test] + async fn snapshot_replaces_only_gpp_headers_and_preserves_identity_and_gpc() { + let mut req = request(Method::POST, Some("https://publisher.example"), snapshot()); + req.headers_mut().insert( + header::COOKIE, + HeaderValue::from_str(&format!( + "__gpp=synthetic-later-state; __gpp_sid=2; us_privacy=1YNN; ts-ec={EC}" + )) + .expect("fixture cookie"), + ); + req.headers_mut() + .insert("sec-gpc", HeaderValue::from_static("1")); + let req = prepare_consent_update(&noop_services(), req) + .await + .expect("valid snapshot"); + let cookies = crate::cookies::handle_request_cookies(&req) + .expect("cookies") + .expect("jar"); + assert_eq!(cookies.get("__gpp").expect("GPP").value(), GPP); + assert_eq!(cookies.get("__gpp_sid").expect("sections").value(), "7"); + assert_eq!(cookies.get("ts-ec").expect("identity").value(), EC); + assert_eq!(cookies.get("us_privacy").expect("legacy").value(), "1YNN"); + assert_eq!(req.headers()["sec-gpc"], "1"); + } + + #[tokio::test] + async fn missing_signal_is_not_synthesized_as_an_opt_out_or_grant() { + let mut req = request( + Method::POST, + Some("https://publisher.example"), + serde_json::json!({ + "gppString": null, "applicableSections": [] + }), + ); + req.headers_mut().insert( + header::COOKIE, + HeaderValue::from_static("__gpp=stale; __gpp_sid=7; ordinary=retained"), + ); + let req = prepare_consent_update(&noop_services(), req) + .await + .expect("missing snapshot"); + let jar = crate::cookies::handle_request_cookies(&req) + .expect("cookies") + .expect("jar"); + assert!(jar.get("__gpp").is_none()); + assert!(jar.get("__gpp_sid").is_none()); + assert!(jar.get("ordinary").is_some()); + } + + #[tokio::test] + async fn rejects_missing_or_foreign_origins_and_non_origin_urls() { + for origin in [ + None, + Some("null"), + Some("https://attacker.invalid"), + Some("https://publisher.example/path"), + Some("https://user@publisher.example"), + ] { + let response = + prepare_consent_update(&noop_services(), request(Method::POST, origin, snapshot())) + .await + .expect_err("must reject origin"); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + } + + #[tokio::test] + async fn rejects_unclaimed_methods_locally() { + for method in [Method::GET, Method::OPTIONS, Method::HEAD] { + let response = prepare_consent_update( + &noop_services(), + request(method, Some("https://publisher.example"), snapshot()), + ) + .await + .expect_err("must reject method"); + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(response.headers()[header::ALLOW], "POST"); + } + } + + #[tokio::test] + async fn rejects_malformed_or_inconsistent_snapshots() { + for body in [ + serde_json::json!({}), + serde_json::json!({"gppString": "invalid", "applicableSections": [7]}), + serde_json::json!({"gppString": null, "applicableSections": [7]}), + serde_json::json!({"gppString": GPP, "applicableSections": [-1]}), + serde_json::json!({"gppString": GPP, "applicableSections": [70000]}), + serde_json::json!({"gppString": GPP, "applicableSections": [7], "identity": "not-accepted"}), + ] { + let response = prepare_consent_update( + &noop_services(), + request(Method::POST, Some("https://publisher.example"), body), + ) + .await + .expect_err("must reject snapshot"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + } + + #[tokio::test] + async fn reconciliation_does_not_mint_identity_even_when_creation_is_permitted() { + let settings = create_test_settings(); + let req = prepare_consent_update( + &noop_services(), + request(Method::POST, Some("https://publisher.example"), snapshot()), + ) + .await + .expect("valid snapshot"); + let graph = KvIdentityGraph::in_memory("consent-test"); + let mut ec = EcContext::new_for_test_with_ip( + None, + ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..Default::default() + }, + Some("198.51.100.24".to_owned()), + ); + let mut response = handle_consent_update(&req, Some(&graph)); + ec_finalize_response( + &settings, + &mut ec, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ) + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert!(ec.ec_value().is_none()); + assert!(!response.headers().contains_key(header::SET_COOKIE)); + } + + #[tokio::test] + async fn real_opt_out_snapshot_tombstones_the_existing_row_despite_later_grant_cookies() { + // Synthetic protocol fixtures only, never visitor consent or identities. + const OPT_OUT: &str = "DBABRg~BVVVVVVVVWA.AA"; + let settings = create_test_settings(); + let services = noop_services(); + let graph = KvIdentityGraph::in_memory("consent-revoke-test"); + let mut original = EcContext::new_for_test_with_ip( + None, + ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..Default::default() + }, + Some("198.51.100.24".to_owned()), + ); + original + .generate_if_needed(&settings, Some(&graph)) + .await + .expect("fixture identity"); + let id = original.ec_value().expect("fixture ID").to_owned(); + let mut req = request( + Method::POST, + Some("https://publisher.example"), + serde_json::json!({ + "gppString": OPT_OUT, "applicableSections": [9] + }), + ); + req.headers_mut().insert( + header::COOKIE, + HeaderValue::from_str(&format!("ts-ec={id}; __gpp={GPP}; __gpp_sid=7")) + .expect("fixture cookie"), + ); + let req = prepare_consent_update(&services, req) + .await + .expect("valid opt-out"); + let geo = crate::geo::GeoInfo::country_region("US", Some("TN".into())); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("consent context"); + assert_eq!( + ec.consent() + .gpp + .as_ref() + .and_then(|gpp| gpp.us_sale_opt_out), + Some(true) + ); + let mut response = handle_consent_update(&req, Some(&graph)); + ec_finalize_response( + &settings, + &mut ec, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ) + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert!( + !graph + .get(&id) + .await + .expect("read") + .expect("tombstone") + .0 + .consent + .ok + ); + + // A concurrent older grant may arrive after withdrawal. This endpoint + // does not generate/recover identities or ingest EIDs, so it cannot + // restore the tombstoned row or issue a live EC cookie. + let grant_req = prepare_consent_update( + &services, + request(Method::POST, Some("https://publisher.example"), snapshot()), + ) + .await + .expect("grant snapshot"); + let mut late = EcContext::new_for_test( + Some(id.clone()), + ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + ..Default::default() + }, + ); + let mut response = handle_consent_update(&grant_req, Some(&graph)); + ec_finalize_response( + &settings, + &mut late, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ) + .await; + assert!( + !graph + .get(&id) + .await + .expect("read") + .expect("tombstone") + .0 + .consent + .ok + ); + assert!( + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .all(|value| { + value.to_str().is_ok_and(|value| { + !value.starts_with("ts-ec=") || value.contains("Max-Age=0") + }) + }) + ); + } + + #[tokio::test] + async fn bounds_snapshot_body_before_parsing() { + let response = prepare_consent_update(&noop_services(), request(Method::POST, Some("https://publisher.example"), serde_json::json!({ + "gppString": "x".repeat(MAX_CONSENT_UPDATE_BODY_BYTES + 1), "applicableSections": [7] + }))).await.expect_err("oversized snapshot"); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } + + #[tokio::test] + async fn unavailable_store_is_not_acknowledged_as_success() { + let req = prepare_consent_update( + &noop_services(), + request(Method::POST, Some("https://publisher.example"), snapshot()), + ) + .await + .expect("valid snapshot"); + let mut response = handle_consent_update(&req, None); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let mut settings = create_test_settings(); + settings + .response_headers + .insert("Cache-Control".into(), "public, max-age=300".into()); + crate::response_privacy::apply_response_headers_with_cache_privacy( + &settings, + &mut response, + ); + let cache = response.headers()[header::CACHE_CONTROL] + .to_str() + .expect("cache policy"); + assert!(cache.contains("private") && cache.contains("no-store")); + } + + #[tokio::test] + async fn failed_revocation_returns_failure_while_expiring_the_browser_cookie() { + let settings = create_test_settings(); + let req = prepare_consent_update( + &noop_services(), + request(Method::POST, Some("https://publisher.example"), snapshot()), + ) + .await + .expect("valid snapshot"); + let graph = KvIdentityGraph::failing("unavailable-test-store"); + let mut ec = EcContext::new_for_test( + Some(EC.to_owned()), + ConsentContext { + jurisdiction: Jurisdiction::UsState("TN".to_owned()), + gpc: true, + ..Default::default() + }, + ); + let mut response = handle_consent_update(&req, Some(&graph)); + ec_finalize_response( + &settings, + &mut ec, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ) + .await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!( + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .any(|value| value + .to_str() + .is_ok_and(|value| value.starts_with("ts-ec=") && value.contains("Max-Age=0"))) + ); + } +} diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 019ef2aa40..712110e7df 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -44,7 +44,7 @@ const EC_RESPONSE_HEADERS: &[&str] = &[ /// /// `eids_cookie` should be the raw value of the `ts-eids` cookie extracted /// from the request *before* routing consumes it. -pub fn ec_finalize_response( +pub async fn ec_finalize_response( settings: &Settings, ec_context: &mut EcContext, kv: Option<&KvIdentityGraph>, @@ -71,7 +71,8 @@ pub fn ec_finalize_response( registry, consent_withdrawn, response, - ); + ) + .await; return; } @@ -107,17 +108,18 @@ pub fn ec_finalize_response( && ec_context.recovery_eligible() && matches!(ec_context.kv_snapshot(), EcKvSnapshot::NotRead) { - let snapshot = graph.load_snapshot(&ec_id); + let snapshot = graph.load_snapshot(&ec_id).await; ec_context.set_kv_snapshot(snapshot); } else if let Some(source) = source { - sync_eid_cookie_updates(graph, ec_context, &ec_id, &updates, source); + sync_eid_cookie_updates(graph, ec_context, &ec_id, &updates, source).await; } if matches!(ec_context.kv_snapshot(), EcKvSnapshot::Missing { .. }) && ec_context.recovery_eligible() { confirm_then_recover_orphaned_ec( settings, ec_context, graph, &ec_id, &updates, response, - ); + ) + .await; } } @@ -147,7 +149,7 @@ pub fn ec_finalize_response( ), ec_context.consent(), ); - sync_eid_cookie_updates(graph, ec_context, &ec_id, &updates, EidSyncSource::NewEc); + sync_eid_cookie_updates(graph, ec_context, &ec_id, &updates, EidSyncSource::NewEc).await; if ec_context.kv_snapshot().entry_for(&ec_id).is_some() { set_ec_cookie_on_response(settings, ec_context, response); } else { @@ -158,7 +160,7 @@ pub fn ec_finalize_response( reconcile_pull_sync_marker(settings, registry, ec_context, response); } -fn sync_eid_cookie_updates( +async fn sync_eid_cookie_updates( graph: &KvIdentityGraph, ec_context: &mut EcContext, ec_id: &str, @@ -169,11 +171,9 @@ fn sync_eid_cookie_updates( return; } - let (snapshot, outcome) = graph.sync_eid_cookie_updates_from_snapshot( - ec_id, - updates, - ec_context.kv_snapshot().clone(), - ); + let (snapshot, outcome) = graph + .sync_eid_cookie_updates_from_snapshot(ec_id, updates, ec_context.kv_snapshot().clone()) + .await; ec_context.set_kv_snapshot(snapshot); record_eid_sync_terminal(source, outcome); } @@ -268,7 +268,7 @@ fn gate_eid_updates_by_consent( Vec::new() } -fn recover_orphaned_ec( +async fn recover_orphaned_ec( settings: &Settings, ec_context: &mut EcContext, graph: &KvIdentityGraph, @@ -311,7 +311,7 @@ fn recover_orphaned_ec( .map(super::device::DeviceSignals::to_kv_device); apply_partner_id_updates(&mut entry, updates); - match graph.create_if_absent(&ec_id, &entry) { + match graph.create_if_absent(&ec_id, &entry).await { Ok(CreateIfAbsentOutcome::Written) => { let snapshot = EcKvSnapshot::Present { ec_id: ec_id.clone(), @@ -357,7 +357,7 @@ fn recover_orphaned_ec( /// the identity stays intact and recovery is retried on a later navigation; /// - neither a read failure nor a failed existence check is a miss, and neither /// rotates. -fn confirm_then_recover_orphaned_ec( +async fn confirm_then_recover_orphaned_ec( settings: &Settings, ec_context: &mut EcContext, graph: &KvIdentityGraph, @@ -365,16 +365,17 @@ fn confirm_then_recover_orphaned_ec( updates: &[super::kv::PartnerIdUpdate], response: &mut Response, ) { - let confirmed = graph.load_snapshot(ec_id); + let confirmed = graph.load_snapshot(ec_id).await; match confirmed { EcKvSnapshot::Present { .. } => { // The row became visible after the origin round trip: adopt it and // merge any pending updates rather than rotating a valid identity. ec_context.set_kv_snapshot(confirmed); - sync_eid_cookie_updates(graph, ec_context, ec_id, updates, EidSyncSource::Navigation); + sync_eid_cookie_updates(graph, ec_context, ec_id, updates, EidSyncSource::Navigation) + .await; } - EcKvSnapshot::Missing { .. } => match graph.key_exists_confirmed(ec_id) { - Ok(false) => recover_orphaned_ec(settings, ec_context, graph, updates, response), + EcKvSnapshot::Missing { .. } => match graph.key_exists_confirmed(ec_id).await { + Ok(false) => recover_orphaned_ec(settings, ec_context, graph, updates, response).await, Ok(true) => { log::warn!( "Orphan EC recovery skipped: both point reads missed a row the store still \ @@ -443,7 +444,7 @@ pub fn clear_ec_on_response(settings: &Settings, response: &mut Response, @@ -462,10 +463,11 @@ fn finalize_unusable_consent( // Compute once for the authoritative identity-graph tombstones. let ids_to_withdraw = withdrawal_ec_ids(ec_context); - // The identity-graph tombstone is the authoritative withdrawal marker - // for subsequent EC behavior. + // The identity-graph tombstone is the authoritative withdrawal marker. + // A consent-update acknowledgment must reflect failed/unavailable writes. + let mut withdrawal_failed = !ids_to_withdraw.is_empty() && kv.is_none(); if let Some(graph) = kv { - apply_withdrawal_tombstones(&ids_to_withdraw, |ec_id| { + for ec_id in &ids_to_withdraw { // The graph hands back the post-withdrawal snapshot rather than // leaving the caller to rebuild it, so post-send work that reads // the context — pull sync discloses the raw EC ID to partners — @@ -476,12 +478,13 @@ fn finalize_unusable_consent( } else { EcKvSnapshot::NotRead }; - let outcome = graph.tombstone_existing_from_snapshot(ec_id, initial); + let outcome = graph.tombstone_existing_from_snapshot(ec_id, initial).await; // The browser cookie is already cleared, so a failed tombstone // leaves a live row that server-side consumers still read as // consented. Report every failure, including the non-active cookie // ID whose outcome is not retained on the request context. if matches!(outcome, EcKvSnapshot::Failed { .. }) { + withdrawal_failed = true; log::warn!( "EC withdrawal tombstone failed for '{}': the identity-graph row may \ still be live with consent granted", @@ -491,7 +494,10 @@ fn finalize_unusable_consent( if ec_context.ec_value() == Some(ec_id) { ec_context.set_kv_snapshot(outcome); } - }); + } + } + if withdrawal_failed { + super::consent_update::mark_reconciliation_failed(response); } } @@ -513,15 +519,6 @@ fn withdrawal_ec_ids(ec_context: &EcContext) -> HashSet { hashes } -fn apply_withdrawal_tombstones(ec_ids: &HashSet, mut write_tombstone: F) -where - F: FnMut(&str), -{ - for ec_id in ec_ids { - write_tombstone(ec_id); - } -} - #[cfg(test)] mod tests { use http::HeaderValue; @@ -687,23 +684,6 @@ mod tests { assert!(ids.contains(&valid_ec), "should keep the valid EC ID"); } - #[test] - fn apply_withdrawal_tombstones_invokes_writer_for_each_ec_id() { - let first = sample_ec_id("first1"); - let second = sample_ec_id("second"); - let mut ids = HashSet::new(); - ids.insert(first.clone()); - ids.insert(second.clone()); - - let mut written = Vec::new(); - apply_withdrawal_tombstones(&ids, |ec_id| written.push(ec_id.to_owned())); - written.sort(); - - let mut expected = vec![first, second]; - expected.sort(); - assert_eq!(written, expected, "should write a tombstone for each EC ID"); - } - #[test] fn clear_ec_on_response_removes_headers_and_expires_cookie() { let settings = create_test_settings(); @@ -739,8 +719,8 @@ mod tests { ); } - #[test] - fn finalize_withdrawal_does_not_create_a_row_for_an_unheld_identity() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_withdrawal_does_not_create_a_row_for_an_unheld_identity() { let settings = create_test_settings(); // The cookie value is chosen by the client, so a withdrawal naming an // identity this deployment never issued must not put a row in the @@ -766,10 +746,11 @@ mod tests { None, None, &mut response, - ); + ) + .await; assert!( - kv.get(&ec_id).expect("should read back").is_none(), + kv.get(&ec_id).await.expect("should read back").is_none(), "should not write a tombstone for an identity that was never issued" ); assert!( @@ -783,8 +764,8 @@ mod tests { ); } - #[test] - fn finalize_withdrawal_tombstones_a_held_identity() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_withdrawal_tombstones_a_held_identity() { let settings = create_test_settings(); let ec_id = sample_ec_id("held01"); let consent = ConsentContext { @@ -800,8 +781,9 @@ mod tests { &ec_id, &crate::ec::kv_types::KvEntry::minimal("p.example", "uid", 1), ) + .await .expect("should seed the identity"); - ec_context.set_kv_snapshot(kv.load_snapshot(&ec_id)); + ec_context.set_kv_snapshot(kv.load_snapshot(&ec_id).await); ec_context.set_eid_sync_source(EidSyncSource::Auction); assert!(matches!( ec_context.kv_snapshot(), @@ -818,10 +800,12 @@ mod tests { None, None, &mut response, - ); + ) + .await; let (entry, _) = kv .get(&ec_id) + .await .expect("should read back") .expect("should still hold the identity"); assert!( @@ -836,8 +820,8 @@ mod tests { assert_eq!(ec_context.kv_snapshot().generation_for(&ec_id), None); } - #[test] - fn withdrawal_still_expires_the_cookie_when_the_store_is_unavailable() { + #[tokio::test(flavor = "current_thread")] + async fn withdrawal_still_expires_the_cookie_when_the_store_is_unavailable() { // Cookie expiry is the primary enforcement, so it has to survive a // store that cannot answer at all — the case where the identity-graph // marker is exactly what goes missing. @@ -869,7 +853,8 @@ mod tests { None, None, &mut response, - ); + ) + .await; let set_cookie = get_header_str(&response, "set-cookie").unwrap_or_default(); assert!( @@ -886,8 +871,8 @@ mod tests { ); } - #[test] - fn finalize_withdrawal_clears_cookie_and_headers() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_withdrawal_clears_cookie_and_headers() { let settings = create_test_settings(); let ec_id = sample_ec_id("aBc123"); let consent = ConsentContext { @@ -914,7 +899,8 @@ mod tests { None, None, &mut response, - ); + ) + .await; assert!( get_header(&response, "x-ts-ec").is_none(), @@ -943,8 +929,8 @@ mod tests { ); } - #[test] - fn finalize_returning_user_with_cookie_mismatch_sets_no_header_or_cookie() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_returning_user_with_cookie_mismatch_sets_no_header_or_cookie() { let settings = create_test_settings(); let active_ec = sample_ec_id("activ1"); let cookie_ec = sample_ec_id("cook1e"); @@ -966,7 +952,8 @@ mod tests { None, None, &mut response, - ); + ) + .await; assert!( get_header(&response, "x-ts-ec").is_none(), @@ -978,8 +965,8 @@ mod tests { ); } - #[test] - fn finalize_returning_user_sets_no_header_or_cookie() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_returning_user_sets_no_header_or_cookie() { let settings = create_test_settings(); let ec_id = sample_ec_id("mtch01"); let mut ec_context = make_context( @@ -1000,7 +987,8 @@ mod tests { None, None, &mut response, - ); + ) + .await; assert!( get_header(&response, "x-ts-ec").is_none(), @@ -1012,8 +1000,8 @@ mod tests { ); } - #[test] - fn finalize_generated_ec_without_kv_skips_cookie_and_header() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_generated_ec_without_kv_skips_cookie_and_header() { let settings = create_test_settings(); let generated_ec = sample_ec_id("gen123"); let mut ec_context = make_context( @@ -1034,7 +1022,8 @@ mod tests { None, None, &mut response, - ); + ) + .await; assert!( get_header(&response, "x-ts-ec").is_none(), @@ -1046,8 +1035,8 @@ mod tests { ); } - #[test] - fn finalize_rotates_orphaned_cookie_to_new_backed_ec() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_rotates_orphaned_cookie_to_new_backed_ec() { let settings = create_test_settings(); let orphaned_ec = sample_ec_id("orphn1"); let consent = ConsentContext { @@ -1075,13 +1064,15 @@ mod tests { None, None, &mut response, - ); + ) + .await; let replacement = ec_context.ec_value().expect("should rotate orphan"); assert_ne!(replacement, orphaned_ec); assert!( graph .get(replacement) + .await .expect("should read replacement") .is_some(), "replacement cookie should have a backing row" @@ -1092,8 +1083,8 @@ mod tests { ); } - #[test] - fn finalize_recovers_orphaned_ec_when_purpose_four_denial_empties_updates() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_recovers_orphaned_ec_when_purpose_four_denial_empties_updates() { // Regression test: this request has real EID data (a configured // partner and a captured client EID), but TCF Purpose 4 denial gates // it away, emptying the update list. That must not also suppress the @@ -1136,7 +1127,8 @@ mod tests { None, None, &mut response, - ); + ) + .await; let replacement = ec_context.ec_value().expect( "should rotate the orphan even though Purpose 4 denial emptied the gated update list", @@ -1144,6 +1136,7 @@ mod tests { assert_ne!(replacement, &orphaned_ec); let (stored, _) = graph .get(replacement) + .await .expect("should read replacement") .expect("replacement cookie should have a backing row"); assert!( @@ -1156,8 +1149,8 @@ mod tests { ); } - #[test] - fn valid_marker_with_unread_snapshot_defers_orphan_recovery() { + #[tokio::test(flavor = "current_thread")] + async fn valid_marker_with_unread_snapshot_defers_orphan_recovery() { let settings = create_test_settings(); let orphaned_ec = sample_ec_id("orphn2"); let consent = ConsentContext { @@ -1191,7 +1184,8 @@ mod tests { None, None, &mut response, - ); + ) + .await; assert_eq!( ec_context.ec_value(), @@ -1208,8 +1202,8 @@ mod tests { ); } - #[test] - fn finalize_returning_user_subresource_does_not_persist_eid_updates() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_returning_user_subresource_does_not_persist_eid_updates() { let settings = create_test_settings(); let ec_id = sample_ec_id("subeid"); let graph = KvIdentityGraph::in_memory("test_store"); @@ -1221,8 +1215,10 @@ mod tests { ); graph .create(&ec_id, &live) + .await .expect("should seed the live row"); - let mut ec_context = returning_user_context(&ec_id, graph.load_snapshot(&ec_id), false); + let mut ec_context = + returning_user_context(&ec_id, graph.load_snapshot(&ec_id).await, false); let partners = vec![make_partner("sharedid.org")]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); let mut response = empty_response(); @@ -1235,10 +1231,12 @@ mod tests { None, Some("shared-cookie-id"), &mut response, - ); + ) + .await; let (stored, _) = graph .get(&ec_id) + .await .expect("should read store") .expect("row should remain"); assert!( @@ -1247,8 +1245,8 @@ mod tests { ); } - #[test] - fn finalize_navigation_routes_persist_returning_user_eid_updates() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_navigation_routes_persist_returning_user_eid_updates() { for (source, suffix, cookie_id) in [ (EidSyncSource::Navigation, "naveid", "navigation-cookie-id"), (EidSyncSource::PageBids, "spaeid", "page-bids-cookie-id"), @@ -1264,8 +1262,10 @@ mod tests { ); graph .create(&ec_id, &live) + .await .expect("should seed the live row"); - let mut ec_context = returning_user_context(&ec_id, graph.load_snapshot(&ec_id), true); + let mut ec_context = + returning_user_context(&ec_id, graph.load_snapshot(&ec_id).await, true); ec_context.set_eid_sync_source(source); let partners = vec![make_partner("sharedid.org")]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); @@ -1279,10 +1279,12 @@ mod tests { None, Some(cookie_id), &mut response, - ); + ) + .await; let (stored, _) = graph .get(&ec_id) + .await .expect("should read store") .expect("row should remain"); assert_eq!( @@ -1293,8 +1295,8 @@ mod tests { } } - #[test] - fn finalize_generated_ec_persists_eid_updates() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_generated_ec_persists_eid_updates() { let settings = create_test_settings(); let ec_id = sample_ec_id("geneid"); let graph = KvIdentityGraph::in_memory("test_store"); @@ -1306,10 +1308,11 @@ mod tests { ); graph .create(&ec_id, &live) + .await .expect("should seed generated row"); let mut ec_context = make_context(Some(&ec_id), None, false, true, Jurisdiction::NonRegulated); - ec_context.set_kv_snapshot(graph.load_snapshot(&ec_id)); + ec_context.set_kv_snapshot(graph.load_snapshot(&ec_id).await); let partners = vec![make_partner("sharedid.org")]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); let mut response = empty_response(); @@ -1322,10 +1325,12 @@ mod tests { None, Some("generated-cookie-id"), &mut response, - ); + ) + .await; let (stored, _) = graph .get(&ec_id) + .await .expect("should read store") .expect("row should remain"); assert_eq!( @@ -1430,8 +1435,8 @@ mod tests { ); } - #[test] - fn finalize_auction_transient_miss_still_persists_eid_updates() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_auction_transient_miss_still_persists_eid_updates() { // `/auction` saves its first lookup into the context and is never // recovery eligible, so a stale miss there has no later chance to // retry. Finalization must revalidate before dropping collected IDs. @@ -1446,6 +1451,7 @@ mod tests { ); graph .create(&ec_id, &live) + .await .expect("should seed the live row the endpoint lookup missed"); let mut ec_context = returning_user_context( &ec_id, @@ -1467,11 +1473,13 @@ mod tests { None, Some("shared-cookie-id"), &mut response, - ); + ) + .await; assert_did_not_rotate(&ec_context, &ec_id, &response); let (stored, _) = graph .get(&ec_id) + .await .expect("should read store") .expect("row should remain"); assert_eq!( @@ -1481,8 +1489,8 @@ mod tests { ); } - #[test] - fn finalize_persists_every_configured_partner_from_client_eids_over_a_trimmed_cookie() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_persists_every_configured_partner_from_client_eids_over_a_trimmed_cookie() { // Regression test for #1184: the `ts-eids` cookie only carries what // the browser could fit under its size cap, but `/auction` also // hands finalization the full EID set from the request body via @@ -1499,6 +1507,7 @@ mod tests { ); graph .create(&ec_id, &live) + .await .expect("should seed the live row this request updates"); let mut ec_context = returning_user_context( &ec_id, @@ -1546,10 +1555,12 @@ mod tests { Some(&eids_cookie), None, &mut response, - ); + ) + .await; let (stored, _) = graph .get(&ec_id) + .await .expect("should read store") .expect("row should exist after ingestion"); assert_eq!( @@ -1564,8 +1575,8 @@ mod tests { ); } - #[test] - fn finalize_withholds_eid_kv_writes_when_purpose_four_is_denied() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_withholds_eid_kv_writes_when_purpose_four_is_denied() { // A GDPR user can grant TCF Purpose 1 (storage/EC) while denying // Purpose 4 (personalized ads). `gate_eids_by_consent` already strips // EIDs from the outbound /auction bid request in that case; the KV @@ -1583,6 +1594,7 @@ mod tests { ); graph .create(&ec_id, &live) + .await .expect("should seed the live row this request updates"); let mut ec_context = EcContext::new_for_test_with_cookie( Some(ec_id.clone()), @@ -1615,10 +1627,12 @@ mod tests { None, None, &mut response, - ); + ) + .await; let (stored, _) = graph .get(&ec_id) + .await .expect("should read store") .expect("row should remain"); assert!( @@ -1628,8 +1642,8 @@ mod tests { ); } - #[test] - fn finalize_auction_confirmed_miss_does_not_create_a_row() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_auction_confirmed_miss_does_not_create_a_row() { // The same path with a genuinely absent row must stay a no-op: a route // without orphan recovery must never mint an identity-graph entry. let settings = create_test_settings(); @@ -1655,17 +1669,22 @@ mod tests { None, Some("shared-cookie-id"), &mut response, - ); + ) + .await; assert_did_not_rotate(&ec_context, &ec_id, &response); assert!( - graph.get(&ec_id).expect("should read store").is_none(), + graph + .get(&ec_id) + .await + .expect("should read store") + .is_none(), "a confirmed miss must not create a root entry" ); } - #[test] - fn finalize_transient_missing_row_confirms_present_and_does_not_rotate() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_transient_missing_row_confirms_present_and_does_not_rotate() { // The origin-overlapped preload transiently read `Missing` on an // eventually-consistent store, but the row actually exists. The // confirming re-read at finalize must adopt the live row instead of @@ -1681,6 +1700,7 @@ mod tests { ); graph .create(&orphan, &live) + .await .expect("should seed the live row the preload missed"); let mut ec_context = returning_user_context( &orphan, @@ -1699,7 +1719,8 @@ mod tests { None, None, &mut response, - ); + ) + .await; assert_did_not_rotate(&ec_context, &orphan, &response); assert!( @@ -1708,8 +1729,8 @@ mod tests { ); } - #[test] - fn finalize_two_missing_reads_do_not_rotate_a_row_the_store_still_lists() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_two_missing_reads_do_not_rotate_a_row_the_store_still_lists() { // Both the origin-overlapped preload and the confirming re-read missed, // but the row is live — the point reads were stale. Two stale reads are // not an absence proof, so the identity graph must be left intact and @@ -1728,6 +1749,7 @@ mod tests { ); graph .create(&orphan, &live) + .await .expect("should seed the live row both point reads miss"); let mut ec_context = returning_user_context( &orphan, @@ -1746,12 +1768,14 @@ mod tests { None, None, &mut response, - ); + ) + .await; assert_did_not_rotate(&ec_context, &orphan, &response); assert_eq!( graph .get(&orphan) + .await .expect("should read store") .map(|(entry, _)| entry.consent.ok), Some(true), @@ -1759,8 +1783,8 @@ mod tests { ); } - #[test] - fn finalize_rotates_when_only_a_longer_key_shares_the_orphan_prefix() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_rotates_when_only_a_longer_key_shares_the_orphan_prefix() { // `key_exists_confirmed` gates orphan recovery as well as withdrawal. // It matches whole keys, so a neighbouring key that merely starts with // the orphaned ID cannot answer for it. Under the prefix count this @@ -1779,10 +1803,12 @@ mod tests { ); graph .create(&neighbour, &live) + .await .expect("should seed the neighbouring row"); assert!( !graph .key_exists_confirmed(&orphan) + .await .expect("should confirm against the store"), "a longer key sharing the prefix must not prove the orphan exists" ); @@ -1803,7 +1829,8 @@ mod tests { None, None, &mut response, - ); + ) + .await; let replacement = ec_context.ec_value().expect("should rotate the orphan"); assert_ne!( @@ -1813,6 +1840,7 @@ mod tests { assert!( graph .get(replacement) + .await .expect("should read the replacement") .is_some(), "the replacement cookie should have a backing row" @@ -1820,14 +1848,15 @@ mod tests { assert!( graph .get(&neighbour) + .await .expect("should read the neighbour") .is_some(), "the neighbouring identity must be left untouched" ); } - #[test] - fn finalize_does_not_rotate_when_the_existence_check_fails() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_does_not_rotate_when_the_existence_check_fails() { // Absence is unprovable when the list itself errors. Rotation abandons a // year-lived identity, so it must not run on an unproven miss. let settings = create_test_settings(); @@ -1850,13 +1879,14 @@ mod tests { None, None, &mut response, - ); + ) + .await; assert_did_not_rotate(&ec_context, &orphan, &response); } - #[test] - fn finalize_generated_ec_does_not_emit_cookie_for_authoritative_missing_row() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_generated_ec_does_not_emit_cookie_for_authoritative_missing_row() { let settings = create_test_settings(); let generated_ec = sample_ec_id("genmis"); let mut ec_context = make_context( @@ -1880,7 +1910,8 @@ mod tests { None, None, &mut response, - ); + ) + .await; assert!( get_header(&response, "set-cookie").is_none(), @@ -1888,8 +1919,8 @@ mod tests { ); } - #[test] - fn finalize_denied_without_cookie_is_noop() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_denied_without_cookie_is_noop() { let settings = create_test_settings(); let mut ec_context = make_context(None, None, false, false, Jurisdiction::Unknown); let mut response = empty_response(); @@ -1903,7 +1934,8 @@ mod tests { None, None, &mut response, - ); + ) + .await; assert!( get_header(&response, "x-ts-ec").is_none(), @@ -1915,8 +1947,8 @@ mod tests { ); } - #[test] - fn finalize_unknown_jurisdiction_strips_headers_without_expiring_cookie() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_unknown_jurisdiction_strips_headers_without_expiring_cookie() { let settings = create_test_settings(); let ec_id = sample_ec_id("unk001"); let mut ec_context = make_context( @@ -1939,7 +1971,8 @@ mod tests { None, None, &mut response, - ); + ) + .await; assert!( get_header(&response, "x-ts-ec").is_none(), @@ -1995,8 +2028,8 @@ mod tests { ); } - #[test] - fn finalize_not_read_snapshot_does_not_rotate() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_not_read_snapshot_does_not_rotate() { let settings = create_test_settings(); let orphan = sample_ec_id("notrd1"); let mut ec_context = returning_user_context(&orphan, EcKvSnapshot::NotRead, true); @@ -2011,13 +2044,14 @@ mod tests { None, None, &mut response, - ); + ) + .await; assert_did_not_rotate(&ec_context, &orphan, &response); } - #[test] - fn finalize_failed_snapshot_does_not_rotate() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_failed_snapshot_does_not_rotate() { let settings = create_test_settings(); let orphan = sample_ec_id("faild1"); let mut ec_context = returning_user_context( @@ -2038,13 +2072,14 @@ mod tests { None, None, &mut response, - ); + ) + .await; assert_did_not_rotate(&ec_context, &orphan, &response); } - #[test] - fn finalize_tombstone_snapshot_does_not_rotate() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_tombstone_snapshot_does_not_rotate() { let settings = create_test_settings(); let orphan = sample_ec_id("tomb01"); let tombstone = EcKvSnapshot::Present { @@ -2064,13 +2099,14 @@ mod tests { None, None, &mut response, - ); + ) + .await; assert_did_not_rotate(&ec_context, &orphan, &response); } - #[test] - fn finalize_subresource_missing_row_does_not_rotate() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_subresource_missing_row_does_not_rotate() { let settings = create_test_settings(); let orphan = sample_ec_id("subrs1"); // Missing row, but the request is not a recovery-eligible browser navigation. @@ -2092,17 +2128,22 @@ mod tests { None, None, &mut response, - ); + ) + .await; assert_did_not_rotate(&ec_context, &orphan, &response); assert!( - graph.get(&orphan).expect("should read store").is_none(), + graph + .get(&orphan) + .await + .expect("should read store") + .is_none(), "a non-eligible request must not create the missing root" ); } - #[test] - fn finalize_withdrawal_tombstones_present_id_and_skips_missing_other() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_withdrawal_tombstones_present_id_and_skips_missing_other() { let settings = create_test_settings(); let active_ec = sample_ec_id("activ2"); let cookie_ec = sample_ec_id("cook2e"); @@ -2119,8 +2160,9 @@ mod tests { let graph = KvIdentityGraph::in_memory("test_store"); graph .create(&active_ec, &live_entry()) + .await .expect("should seed active row"); - ec_context.set_kv_snapshot(graph.load_snapshot(&active_ec)); + ec_context.set_kv_snapshot(graph.load_snapshot(&active_ec).await); let mut response = empty_response(); ec_finalize_response( @@ -2131,10 +2173,12 @@ mod tests { None, None, &mut response, - ); + ) + .await; let (active_stored, _) = graph .get(&active_ec) + .await .expect("should read active row") .expect("active row should remain as a tombstone"); assert!( @@ -2142,13 +2186,17 @@ mod tests { "the present active ID should be tombstoned after strong confirmation" ); assert!( - graph.get(&cookie_ec).expect("should read store").is_none(), + graph + .get(&cookie_ec) + .await + .expect("should read store") + .is_none(), "a missing second ID must never be created by withdrawal" ); } - #[test] - fn finalize_withdrawal_tombstones_both_present_ids_once() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_withdrawal_tombstones_both_present_ids_once() { let settings = create_test_settings(); let active_ec = sample_ec_id("activ3"); let cookie_ec = sample_ec_id("cook3e"); @@ -2166,14 +2214,16 @@ mod tests { &active_ec, &KvEntry::minimal("active.example.com", "active-uid", 1_000), ) + .await .expect("should seed active row"); graph .create( &cookie_ec, &KvEntry::minimal("cookie.example.com", "cookie-uid", 1_000), ) + .await .expect("should seed cookie row"); - ec_context.set_kv_snapshot(graph.load_snapshot(&active_ec)); + ec_context.set_kv_snapshot(graph.load_snapshot(&active_ec).await); let mut response = empty_response(); ec_finalize_response( @@ -2184,14 +2234,17 @@ mod tests { None, None, &mut response, - ); + ) + .await; let (active_tombstone, active_generation) = graph .get(&active_ec) + .await .expect("should read active row") .expect("should retain active tombstone"); let (cookie_tombstone, cookie_generation) = graph .get(&cookie_ec) + .await .expect("should read cookie row") .expect("should retain cookie tombstone"); assert!( @@ -2220,11 +2273,13 @@ mod tests { None, None, &mut repeated_response, - ); + ) + .await; assert_eq!( graph .get(&active_ec) + .await .expect("should read active row") .expect("should retain active tombstone") .1, @@ -2234,6 +2289,7 @@ mod tests { assert_eq!( graph .get(&cookie_ec) + .await .expect("should read cookie row") .expect("should retain cookie tombstone") .1, @@ -2242,8 +2298,8 @@ mod tests { ); } - #[test] - fn finalize_withdrawal_keeps_cookie_deletion_on_kv_failure() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_withdrawal_keeps_cookie_deletion_on_kv_failure() { let settings = create_test_settings(); let ec_id = sample_ec_id("failw1"); let consent = ConsentContext { @@ -2268,7 +2324,8 @@ mod tests { None, None, &mut response, - ); + ) + .await; let cookies = response .headers() @@ -2295,8 +2352,8 @@ mod tests { ); } - #[test] - fn finalize_sets_marker_for_complete_pull_partner_snapshot() { + #[tokio::test(flavor = "current_thread")] + async fn finalize_sets_marker_for_complete_pull_partner_snapshot() { let settings = create_test_settings(); let ec_id = sample_ec_id("compl1"); let mut partner = make_partner("ssp.example.com"); @@ -2334,7 +2391,8 @@ mod tests { None, None, &mut response, - ); + ) + .await; let cookies = response .headers() @@ -2350,8 +2408,8 @@ mod tests { ); } - #[test] - fn explicit_withdrawal_without_marker_or_ec_cookie_does_not_set_cookie() { + #[tokio::test(flavor = "current_thread")] + async fn explicit_withdrawal_without_marker_or_ec_cookie_does_not_set_cookie() { let settings = create_test_settings(); let consent = ConsentContext { jurisdiction: Jurisdiction::UsState("CA".to_owned()), @@ -2370,7 +2428,8 @@ mod tests { None, None, &mut response, - ); + ) + .await; assert!( response.headers().get(http::header::SET_COOKIE).is_none(), @@ -2378,8 +2437,8 @@ mod tests { ); } - #[test] - fn explicit_withdrawal_expires_marker_without_ec_cookie() { + #[tokio::test(flavor = "current_thread")] + async fn explicit_withdrawal_expires_marker_without_ec_cookie() { let settings = create_test_settings(); let consent = ConsentContext { jurisdiction: Jurisdiction::UsState("CA".to_owned()), @@ -2401,7 +2460,8 @@ mod tests { None, None, &mut response, - ); + ) + .await; let cookies = response .headers() diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index 94464d1a57..5c755b7a32 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -33,7 +33,7 @@ use super::registry::PartnerRegistry; /// /// Panics if response builder produces an invalid status or body, which cannot /// happen with the hardcoded values used here. -pub fn handle_identify( +pub async fn handle_identify( settings: &Settings, kv: &KvIdentityGraph, registry: &PartnerRegistry, @@ -87,7 +87,7 @@ pub fn handle_identify( let mut uid: Option = None; let mut cluster_size: Option = None; - match kv.get(ec_id) { + match kv.get(ec_id).await { Ok(Some((entry, generation))) => { if !entry.consent.ok { // Tombstone entries preserve the withdrawal signal for 24 hours. @@ -104,7 +104,7 @@ pub fn handle_identify( // Evaluate cluster size lazily for identify responses. Existing // stored cluster_size values are reused without a prefix-list call. - match kv.evaluate_cluster(ec_id, &entry, generation) { + match kv.evaluate_cluster(ec_id, &entry, generation).await { Ok(size) => { cluster_size = size; } @@ -462,8 +462,8 @@ mod tests { ); } - #[test] - fn handle_identify_rejects_missing_bearer_token() { + #[tokio::test(flavor = "current_thread")] + async fn handle_identify_rejects_missing_bearer_token() { let settings = create_test_settings(); let kv = KvIdentityGraph::failing("missing_store"); let registry = PartnerRegistry::empty(); @@ -475,6 +475,7 @@ mod tests { let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) + .await .expect("should construct unauthorized response"); assert_eq!( @@ -502,8 +503,8 @@ mod tests { ); } - #[test] - fn handle_identify_rejects_invalid_bearer_token() { + #[tokio::test(flavor = "current_thread")] + async fn handle_identify_rejects_invalid_bearer_token() { let settings = create_test_settings(); let kv = KvIdentityGraph::failing("missing_store"); let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; @@ -517,6 +518,7 @@ mod tests { let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) + .await .expect("should construct unauthorized response"); assert_eq!( @@ -527,8 +529,8 @@ mod tests { assert_no_store(&response); } - #[test] - fn handle_identify_denied_consent_returns_403() { + #[tokio::test(flavor = "current_thread")] + async fn handle_identify_denied_consent_returns_403() { let settings = create_test_settings(); let kv = KvIdentityGraph::failing("missing_store"); let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; @@ -542,6 +544,7 @@ mod tests { let ec_context = make_ec_context(Jurisdiction::Unknown, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) + .await .expect("should construct denied response"); assert_eq!( @@ -561,8 +564,8 @@ mod tests { ); } - #[test] - fn handle_identify_without_ec_returns_204() { + #[tokio::test(flavor = "current_thread")] + async fn handle_identify_without_ec_returns_204() { let settings = create_test_settings(); let kv = KvIdentityGraph::failing("missing_store"); let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; @@ -576,6 +579,7 @@ mod tests { let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) + .await .expect("should construct no-content response"); assert_eq!( @@ -586,8 +590,8 @@ mod tests { assert_no_store(&response); } - #[test] - fn handle_identify_kv_failure_sets_degraded_true() { + #[tokio::test(flavor = "current_thread")] + async fn handle_identify_kv_failure_sets_degraded_true() { let settings = create_test_settings(); let kv = KvIdentityGraph::failing("missing_store"); let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; @@ -602,6 +606,7 @@ mod tests { let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) + .await .expect("should construct degraded identify response"); assert_eq!( @@ -639,8 +644,8 @@ mod tests { ); } - #[test] - fn handle_identify_denies_mismatched_browser_origin() { + #[tokio::test(flavor = "current_thread")] + async fn handle_identify_denies_mismatched_browser_origin() { let settings = create_test_settings(); let kv = KvIdentityGraph::failing("missing_store"); let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; @@ -655,6 +660,7 @@ mod tests { let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) + .await .expect("should construct forbidden response"); assert_eq!( @@ -665,8 +671,8 @@ mod tests { assert_no_store(&response); } - #[test] - fn handle_identify_allows_browser_origin_and_reflects_cors_headers() { + #[tokio::test(flavor = "current_thread")] + async fn handle_identify_allows_browser_origin_and_reflects_cors_headers() { let settings = create_test_settings(); let kv = KvIdentityGraph::failing("missing_store"); let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; @@ -681,6 +687,7 @@ mod tests { let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) + .await .expect("should construct no-content response with CORS headers"); assert_eq!( diff --git a/crates/trusted-server-core/src/ec/kv.rs b/crates/trusted-server-core/src/ec/kv.rs index b0eb83d3fa..529d5b03b5 100644 --- a/crates/trusted-server-core/src/ec/kv.rs +++ b/crates/trusted-server-core/src/ec/kv.rs @@ -305,11 +305,11 @@ impl KvIdentityGraph { /// # Errors /// /// Returns [`TrustedServerError::KvStore`] on store open or read failure. - pub fn lookup_raw( + pub async fn lookup_raw( &self, ec_id: &str, ) -> Result, Report> { - self.store.lookup(ec_id) + self.store.lookup(ec_id).await } /// Reads the full entry and its generation marker for CAS writes. @@ -319,8 +319,11 @@ impl KvIdentityGraph { /// # Errors /// /// Returns [`TrustedServerError::KvStore`] on store open or read failure. - pub fn get(&self, ec_id: &str) -> Result, Report> { - let Some(lookup) = self.store.lookup(ec_id)? else { + pub async fn get( + &self, + ec_id: &str, + ) -> Result, Report> { + let Some(lookup) = self.store.lookup(ec_id).await? else { return Ok(None); }; @@ -330,8 +333,8 @@ impl KvIdentityGraph { /// Loads one request-scoped snapshot, preserving miss versus failure at the caller boundary. #[must_use] - pub fn load_snapshot(&self, ec_id: &str) -> EcKvSnapshot { - match self.get(ec_id) { + pub async fn load_snapshot(&self, ec_id: &str) -> EcKvSnapshot { + match self.get(ec_id).await { Ok(Some((entry, generation))) => EcKvSnapshot::Present { ec_id: ec_id.to_owned(), entry: Box::new(entry), @@ -383,11 +386,11 @@ impl KvIdentityGraph { /// # Errors /// /// Returns [`TrustedServerError::KvStore`] on store open or read failure. - pub fn get_metadata( + pub async fn get_metadata( &self, ec_id: &str, ) -> Result, Report> { - let Some(lookup) = self.store.lookup(ec_id)? else { + let Some(lookup) = self.store.lookup(ec_id).await? else { return Ok(None); }; @@ -413,9 +416,16 @@ impl KvIdentityGraph { /// /// Returns [`TrustedServerError::KvStore`] on store error or if the /// key already exists. - pub fn create(&self, ec_id: &str, entry: &KvEntry) -> Result<(), Report> { + pub async fn create( + &self, + ec_id: &str, + entry: &KvEntry, + ) -> Result<(), Report> { let (body, meta_str) = Self::serialize_entry(entry, self.store_name())?; - match self.write_entry(ec_id, &body, &meta_str, ENTRY_TTL, EcKvWriteMode::Add)? { + match self + .write_entry(ec_id, &body, &meta_str, ENTRY_TTL, EcKvWriteMode::Add) + .await? + { EcKvWriteOutcome::Written => Ok(()), EcKvWriteOutcome::PreconditionFailed => { Err(self.kv_error(format!("Key '{}' already exists", log_id(ec_id)))) @@ -428,20 +438,23 @@ impl KvIdentityGraph { /// # Errors /// /// Returns [`TrustedServerError::KvStore`] when serialization or store I/O fails. - pub fn create_if_absent( + pub async fn create_if_absent( &self, ec_id: &str, entry: &KvEntry, ) -> Result> { let (body, meta_str) = Self::serialize_entry(entry, self.store_name())?; - match self.write_entry(ec_id, &body, &meta_str, ENTRY_TTL, EcKvWriteMode::Add)? { + match self + .write_entry(ec_id, &body, &meta_str, ENTRY_TTL, EcKvWriteMode::Add) + .await? + { EcKvWriteOutcome::Written => Ok(CreateIfAbsentOutcome::Written), EcKvWriteOutcome::PreconditionFailed => Ok(CreateIfAbsentOutcome::AlreadyExists), } } /// Low-level write with shared error context. - fn write_entry( + async fn write_entry( &self, ec_id: &str, body: &str, @@ -449,15 +462,17 @@ impl KvIdentityGraph { ttl: Duration, mode: EcKvWriteMode, ) -> Result> { - self.store.insert( - ec_id, - EcKvWrite { - body, - metadata: meta_str, - ttl, - mode, - }, - ) + self.store + .insert( + ec_id, + EcKvWrite { + body, + metadata: meta_str, + ttl, + mode, + }, + ) + .await } /// Creates a new entry, or overwrites an existing tombstone on re-consent. @@ -475,7 +490,7 @@ impl KvIdentityGraph { /// /// Returns [`TrustedServerError::KvStore`] on store error or CAS /// exhaustion. - pub fn create_or_revive( + pub async fn create_or_revive( &self, ec_id: &str, entry: &KvEntry, @@ -484,17 +499,19 @@ impl KvIdentityGraph { let (body, meta_str) = Self::serialize_entry(entry, self.store_name())?; // Try create first — fast path for new entries. - if self.write_entry(ec_id, &body, &meta_str, ENTRY_TTL, EcKvWriteMode::Add)? + if self + .write_entry(ec_id, &body, &meta_str, ENTRY_TTL, EcKvWriteMode::Add) + .await? == EcKvWriteOutcome::Written { return Ok(()); } // Key exists — read it to determine if it's live or a tombstone. - let (existing, generation) = match self.get(ec_id)? { + let (existing, generation) = match self.get(ec_id).await? { Some(pair) => pair, // Raced with a delete — try create again. - None => return self.create(ec_id, entry), + None => return self.create(ec_id, entry).await, }; // Live entry — nothing to do. @@ -517,15 +534,18 @@ impl KvIdentityGraph { // A completion marker belongs to the tombstone generation. Remove // it before making this key live so a later withdrawal cannot be // suppressed by stale fallback state. - self.clear_withdrawal_marker(ec_id)?; + self.clear_withdrawal_marker(ec_id).await?; - match self.write_entry( - ec_id, - &body, - &meta_str, - ENTRY_TTL, - EcKvWriteMode::IfGenerationMatch(current_gen), - )? { + match self + .write_entry( + ec_id, + &body, + &meta_str, + ENTRY_TTL, + EcKvWriteMode::IfGenerationMatch(current_gen), + ) + .await? + { EcKvWriteOutcome::Written => return Ok(()), EcKvWriteOutcome::PreconditionFailed => { log::debug!( @@ -535,7 +555,7 @@ impl KvIdentityGraph { ); // Re-read immediately to get a fresh generation. Sleeping in // the CAS loop would block the edge compute request worker. - match self.get(ec_id)? { + match self.get(ec_id).await? { Some((refreshed, generation)) => { if refreshed.consent.ok { // Someone else revived it — done. @@ -543,7 +563,7 @@ impl KvIdentityGraph { } current_gen = generation; } - None => return self.create(ec_id, entry), + None => return self.create(ec_id, entry).await, } } } @@ -562,7 +582,7 @@ impl KvIdentityGraph { /// conflict this method never writes again. A live follow-up becomes the /// authoritative snapshot; a missing or failed follow-up retains the live /// pre-write snapshot as proof and defers the update. - pub(crate) fn sync_eid_cookie_updates_from_snapshot( + pub(crate) async fn sync_eid_cookie_updates_from_snapshot( &self, ec_id: &str, updates: &[PartnerIdUpdate], @@ -589,7 +609,7 @@ impl KvIdentityGraph { EcKvSnapshot::Failed { ec_id: ref snapshot_id, } if snapshot_id == ec_id => return (snapshot, EidCookieSyncOutcome::Failed), - _ => self.load_snapshot(ec_id), + _ => self.load_snapshot(ec_id).await, }; let (mut entry, generation) = match current { @@ -642,13 +662,16 @@ impl KvIdentityGraph { EidCookieSyncOutcome::Failed, ); }; - match self.write_entry( - ec_id, - &body, - &meta_str, - ENTRY_TTL, - EcKvWriteMode::IfGenerationMatch(generation), - ) { + match self + .write_entry( + ec_id, + &body, + &meta_str, + ENTRY_TTL, + EcKvWriteMode::IfGenerationMatch(generation), + ) + .await + { Ok(EcKvWriteOutcome::Written) => ( EcKvSnapshot::Present { ec_id: ec_id.to_owned(), @@ -662,7 +685,7 @@ impl KvIdentityGraph { }, ), Ok(EcKvWriteOutcome::PreconditionFailed) => { - let refreshed = self.load_snapshot(ec_id); + let refreshed = self.load_snapshot(ec_id).await; let Some(refreshed_entry) = refreshed.entry_for(ec_id) else { // The failed CAS proved `current`'s generation stale. Keep // only its existence proof so later writes must reread. @@ -720,7 +743,7 @@ impl KvIdentityGraph { /// later chance to retry. /// /// [`generate_if_needed`]: super::generate_if_needed - pub(crate) fn upsert_partner_ids_from_snapshot( + pub(crate) async fn upsert_partner_ids_from_snapshot( &self, ec_id: &str, updates: &[PartnerIdUpdate], @@ -760,7 +783,7 @@ impl KvIdentityGraph { EcKvSnapshot::Failed { ec_id: ref snapshot_id, } if snapshot_id == ec_id => return snapshot, - _ => self.load_snapshot(ec_id), + _ => self.load_snapshot(ec_id).await, }; for _attempt in 0..MAX_CAS_RETRIES { @@ -797,13 +820,16 @@ impl KvIdentityGraph { ec_id: ec_id.to_owned(), }; }; - match self.write_entry( - ec_id, - &body, - &meta_str, - ENTRY_TTL, - EcKvWriteMode::IfGenerationMatch(generation), - ) { + match self + .write_entry( + ec_id, + &body, + &meta_str, + ENTRY_TTL, + EcKvWriteMode::IfGenerationMatch(generation), + ) + .await + { Ok(EcKvWriteOutcome::Written) => { return EcKvSnapshot::Present { ec_id: ec_id.to_owned(), @@ -812,7 +838,7 @@ impl KvIdentityGraph { }; } Ok(EcKvWriteOutcome::PreconditionFailed) => { - current = self.load_snapshot(ec_id); + current = self.load_snapshot(ec_id).await; } Err(err) => { log::warn!( @@ -876,14 +902,14 @@ impl KvIdentityGraph { /// /// Returns [`TrustedServerError::KvStore`] on store error or CAS /// exhaustion after [`MAX_CAS_RETRIES`] attempts. - pub fn upsert_partner_id( + pub async fn upsert_partner_id( &self, ec_id: &str, partner_id: &str, uid: &str, ) -> Result<(), Report> { for attempt in 0..MAX_CAS_RETRIES { - let (mut entry, generation) = match self.get(ec_id)? { + let (mut entry, generation) = match self.get(ec_id).await? { Some(pair) => pair, None => { log::info!( @@ -927,13 +953,16 @@ impl KvIdentityGraph { let (body, meta_str) = Self::serialize_entry(&entry, self.store_name())?; - match self.write_entry( - ec_id, - &body, - &meta_str, - ENTRY_TTL, - EcKvWriteMode::IfGenerationMatch(generation), - )? { + match self + .write_entry( + ec_id, + &body, + &meta_str, + ENTRY_TTL, + EcKvWriteMode::IfGenerationMatch(generation), + ) + .await? + { EcKvWriteOutcome::Written => return Ok(()), EcKvWriteOutcome::PreconditionFailed => { log::debug!( @@ -966,14 +995,14 @@ impl KvIdentityGraph { /// /// Returns [`TrustedServerError::KvStore`] on store I/O or CAS /// exhaustion errors. - pub fn upsert_partner_id_if_exists( + pub async fn upsert_partner_id_if_exists( &self, ec_id: &str, partner_id: &str, uid: &str, ) -> Result> { for attempt in 0..MAX_CAS_RETRIES { - let (mut entry, generation) = match self.get(ec_id)? { + let (mut entry, generation) = match self.get(ec_id).await? { Some(pair) => pair, None => return Ok(UpsertResult::NotFound), }; @@ -999,13 +1028,16 @@ impl KvIdentityGraph { let (body, meta_str) = Self::serialize_entry(&entry, self.store_name())?; - match self.write_entry( - ec_id, - &body, - &meta_str, - ENTRY_TTL, - EcKvWriteMode::IfGenerationMatch(generation), - )? { + match self + .write_entry( + ec_id, + &body, + &meta_str, + ENTRY_TTL, + EcKvWriteMode::IfGenerationMatch(generation), + ) + .await? + { EcKvWriteOutcome::Written => return Ok(UpsertResult::Written), EcKvWriteOutcome::PreconditionFailed => { log::debug!( @@ -1029,8 +1061,11 @@ impl KvIdentityGraph { /// # Errors /// /// Returns [`TrustedServerError::KvStore`] if existence cannot be confirmed. - pub fn key_exists_confirmed(&self, ec_id: &str) -> Result> { - self.store.key_exists(ec_id) + pub async fn key_exists_confirmed( + &self, + ec_id: &str, + ) -> Result> { + self.store.key_exists(ec_id).await } fn withdrawal_marker_prefix(ec_id: &str) -> String { @@ -1041,17 +1076,19 @@ impl KvIdentityGraph { format!("{}{valid_until}", Self::withdrawal_marker_prefix(ec_id)) } - fn withdrawal_marker_keys( + async fn withdrawal_marker_keys( &self, ec_id: &str, ) -> Result, Report> { - self.store.list_keys_with_prefix( - &Self::withdrawal_marker_prefix(ec_id), - WITHDRAWAL_MARKER_LIST_LIMIT, - ) + self.store + .list_keys_with_prefix( + &Self::withdrawal_marker_prefix(ec_id), + WITHDRAWAL_MARKER_LIST_LIMIT, + ) + .await } - fn withdrawal_marker_exists( + async fn withdrawal_marker_exists( &self, ec_id: &str, now: Option, @@ -1064,35 +1101,40 @@ impl KvIdentityGraph { let marker_prefix = Self::withdrawal_marker_prefix(ec_id); Ok(self .store - .list_keys_with_prefix(&marker_prefix, WITHDRAWAL_MARKER_LIST_LIMIT)? + .list_keys_with_prefix(&marker_prefix, WITHDRAWAL_MARKER_LIST_LIMIT) + .await? .iter() .filter_map(|key| key.strip_prefix(&marker_prefix)) .filter_map(|valid_until| valid_until.parse::().ok()) .any(|valid_until| valid_until > now)) } - fn write_withdrawal_marker( + async fn write_withdrawal_marker( &self, ec_id: &str, tombstone_updated: u64, ) -> Result<(), Report> { let valid_until = tombstone_updated.saturating_add(TOMBSTONE_TTL.as_secs()); let marker_key = Self::withdrawal_marker_key(ec_id, valid_until); - match self.store.insert( - &marker_key, - EcKvWrite { - body: "1", - metadata: "{}", - ttl: TOMBSTONE_TTL, - mode: EcKvWriteMode::Add, - }, - )? { + match self + .store + .insert( + &marker_key, + EcKvWrite { + body: "1", + metadata: "{}", + ttl: TOMBSTONE_TTL, + mode: EcKvWriteMode::Add, + }, + ) + .await? + { EcKvWriteOutcome::Written | EcKvWriteOutcome::PreconditionFailed => Ok(()), } } - fn clear_withdrawal_marker(&self, ec_id: &str) -> Result<(), Report> { - let marker_keys = self.withdrawal_marker_keys(ec_id)?; + async fn clear_withdrawal_marker(&self, ec_id: &str) -> Result<(), Report> { + let marker_keys = self.withdrawal_marker_keys(ec_id).await?; if marker_keys.len() >= WITHDRAWAL_MARKER_LIST_LIMIT as usize { return Err(self.kv_error(format!( "Withdrawal marker cleanup exceeds list budget for '{}'", @@ -1101,8 +1143,8 @@ impl KvIdentityGraph { } for marker_key in marker_keys { - if let Err(delete_err) = self.store.delete(&marker_key) { - match self.store.key_exists(&marker_key) { + if let Err(delete_err) = self.store.delete(&marker_key).await { + match self.store.key_exists(&marker_key).await { // Another request removed the marker first. Ok(false) => {} Ok(true) | Err(_) => return Err(delete_err), @@ -1112,8 +1154,8 @@ impl KvIdentityGraph { Ok(()) } - fn record_withdrawal_completion(&self, ec_id: &str, tombstone_updated: u64) { - if let Err(err) = self.write_withdrawal_marker(ec_id, tombstone_updated) { + async fn record_withdrawal_completion(&self, ec_id: &str, tombstone_updated: u64) { + if let Err(err) = self.write_withdrawal_marker(ec_id, tombstone_updated).await { // The root is already tombstoned. Preserve that successful privacy // write even if the cost-control marker cannot be recorded. log::warn!( @@ -1177,12 +1219,12 @@ impl KvIdentityGraph { /// `error` level and continue: cookie deletion is the primary enforcement /// mechanism. #[cfg(test)] - pub(crate) fn write_withdrawal_tombstone( + pub(crate) async fn write_withdrawal_tombstone( &self, ec_id: &str, record_snapshot: impl FnOnce(EcKvSnapshot), ) -> Result> { - let written = self.tombstone_held_identity(ec_id); + let written = self.tombstone_held_identity(ec_id).await; record_snapshot(match &written { Ok(Some(entry)) => EcKvSnapshot::Present { @@ -1199,7 +1241,8 @@ impl KvIdentityGraph { }); if let Ok(Some(entry)) = &written { - self.record_withdrawal_completion(ec_id, entry.consent.updated); + self.record_withdrawal_completion(ec_id, entry.consent.updated) + .await; } written.map(|entry| { if entry.is_some() { @@ -1215,7 +1258,7 @@ impl KvIdentityGraph { /// `Ok(None)` means the store does not hold the identity, so nothing was /// written. #[cfg(test)] - fn tombstone_held_identity( + async fn tombstone_held_identity( &self, ec_id: &str, ) -> Result, Report> { @@ -1223,14 +1266,14 @@ impl KvIdentityGraph { // would restore the unconditional write whenever the store can be made // to fail, and an extra `Ok` variant would be discarded in silence by a // caller that only inspects the error case. - if !self.key_exists_confirmed(ec_id)? { + if !self.key_exists_confirmed(ec_id).await? { return Ok(None); } - self.overwrite_withdrawal_tombstone(ec_id).map(Some) + self.overwrite_withdrawal_tombstone(ec_id).await.map(Some) } - fn overwrite_withdrawal_tombstone( + async fn overwrite_withdrawal_tombstone( &self, ec_id: &str, ) -> Result> { @@ -1244,6 +1287,7 @@ impl KvIdentityGraph { TOMBSTONE_TTL, EcKvWriteMode::Overwrite, ) + .await .map(|_| entry) .map_err(|report| { report.change_context(TrustedServerError::KvStore { @@ -1262,13 +1306,13 @@ impl KvIdentityGraph { /// generation is available after a missed read. Marker-check failure falls /// back to that privacy write, as does an unusable clock; root-existence /// failure leaves withdrawal unresolved rather than silently dropped. - fn tombstone_unproven_missing( + async fn tombstone_unproven_missing( &self, ec_id: &str, missing: EcKvSnapshot, now: Option, ) -> EcKvSnapshot { - match self.withdrawal_marker_exists(ec_id, now) { + match self.withdrawal_marker_exists(ec_id, now).await { Ok(true) => { log::debug!( "withdrawal tombstone for '{}': completion marker already exists", @@ -1287,7 +1331,7 @@ impl KvIdentityGraph { } } - match self.key_exists_confirmed(ec_id) { + match self.key_exists_confirmed(ec_id).await { Ok(false) => missing, Ok(true) => { log::warn!( @@ -1295,9 +1339,10 @@ impl KvIdentityGraph { lists; writing an unconditional tombstone", log_id(ec_id) ); - match self.overwrite_withdrawal_tombstone(ec_id) { + match self.overwrite_withdrawal_tombstone(ec_id).await { Ok(tombstone) => { - self.record_withdrawal_completion(ec_id, tombstone.consent.updated); + self.record_withdrawal_completion(ec_id, tombstone.consent.updated) + .await; EcKvSnapshot::Present { ec_id: ec_id.to_owned(), entry: Box::new(tombstone), @@ -1346,7 +1391,7 @@ impl KvIdentityGraph { /// /// Resolving the initial snapshot happens outside the retry counter, so all /// [`MAX_CAS_RETRIES`] iterations stay available for the tombstone write. - pub(crate) fn tombstone_existing_from_snapshot( + pub(crate) async fn tombstone_existing_from_snapshot( &self, ec_id: &str, snapshot: EcKvSnapshot, @@ -1362,7 +1407,7 @@ impl KvIdentityGraph { generation: Some(_), .. } if snapshot_id == ec_id => snapshot, - _ => self.load_snapshot(ec_id), + _ => self.load_snapshot(ec_id).await, }; for _attempt in 0..MAX_CAS_RETRIES { @@ -1383,11 +1428,9 @@ impl KvIdentityGraph { EcKvSnapshot::Missing { ec_id: ref snapshot_id, } if snapshot_id == ec_id => { - return self.tombstone_unproven_missing( - ec_id, - current, - checked_current_timestamp(), - ); + return self + .tombstone_unproven_missing(ec_id, current, checked_current_timestamp()) + .await; } // A refreshed read that failed (or any other unusable state) // fails closed rather than silently dropping the withdrawal. @@ -1404,15 +1447,19 @@ impl KvIdentityGraph { ec_id: ec_id.to_owned(), }; }; - match self.write_entry( - ec_id, - &body, - &meta_str, - TOMBSTONE_TTL, - EcKvWriteMode::IfGenerationMatch(generation), - ) { + match self + .write_entry( + ec_id, + &body, + &meta_str, + TOMBSTONE_TTL, + EcKvWriteMode::IfGenerationMatch(generation), + ) + .await + { Ok(EcKvWriteOutcome::Written) => { - self.record_withdrawal_completion(ec_id, tombstone.consent.updated); + self.record_withdrawal_completion(ec_id, tombstone.consent.updated) + .await; return EcKvSnapshot::Present { ec_id: ec_id.to_owned(), entry: Box::new(tombstone), @@ -1420,7 +1467,7 @@ impl KvIdentityGraph { }; } Ok(EcKvWriteOutcome::PreconditionFailed) => { - current = self.load_snapshot(ec_id); + current = self.load_snapshot(ec_id).await; } Err(err) => { log::warn!( @@ -1457,7 +1504,7 @@ impl KvIdentityGraph { /// # Errors /// /// Returns [`TrustedServerError::KvStore`] on store error. - pub fn count_hash_prefix_keys( + pub async fn count_hash_prefix_keys( &self, hash_prefix: &str, ) -> Result> { @@ -1466,6 +1513,7 @@ impl KvIdentityGraph { // store context to list failures, so propagate without re-wrapping. self.store .count_keys_with_prefix(hash_prefix, CLUSTER_LIST_LIMIT) + .await } /// Evaluates the cluster size for an EC entry. @@ -1481,7 +1529,7 @@ impl KvIdentityGraph { /// # Errors /// /// Returns [`TrustedServerError::KvStore`] on store or list failure. - pub fn evaluate_cluster( + pub async fn evaluate_cluster( &self, ec_id: &str, entry: &KvEntry, @@ -1503,7 +1551,7 @@ impl KvIdentityGraph { // Compute cluster size via prefix list. let hash_prefix = ec_hash(ec_id); - let cluster_size = self.count_hash_prefix_keys(hash_prefix)?; + let cluster_size = self.count_hash_prefix_keys(hash_prefix).await?; log::debug!( "evaluate_cluster: computed cluster_size={cluster_size} for '{}'", @@ -1521,13 +1569,16 @@ impl KvIdentityGraph { let (body, meta_str) = Self::serialize_entry(&updated_entry, self.store_name())?; - match self.write_entry( - ec_id, - &body, - &meta_str, - ENTRY_TTL, - EcKvWriteMode::IfGenerationMatch(generation), - ) { + match self + .write_entry( + ec_id, + &body, + &meta_str, + ENTRY_TTL, + EcKvWriteMode::IfGenerationMatch(generation), + ) + .await + { Ok(EcKvWriteOutcome::Written) => {} Ok(EcKvWriteOutcome::PreconditionFailed) => { log::debug!( @@ -1556,11 +1607,11 @@ impl KvIdentityGraph { /// # Errors /// /// Returns [`TrustedServerError::KvStore`] on store error. - pub fn delete(&self, ec_id: &str) -> Result<(), Report> { + pub async fn delete(&self, ec_id: &str) -> Result<(), Report> { // The backend's delete already attaches store context, so propagate // without re-wrapping the same message. - self.clear_withdrawal_marker(ec_id)?; - self.store.delete(ec_id) + self.clear_withdrawal_marker(ec_id).await?; + self.store.delete(ec_id).await } } @@ -1636,7 +1687,7 @@ mod tests { } } - fn seed_live(&self, ec_id: &str) { + async fn seed_live(&self, ec_id: &str) { let (body, meta) = KvIdentityGraph::serialize_entry(&live_entry(), self.inner.store_name()) .expect("should serialize seeded entry"); @@ -1650,55 +1701,141 @@ mod tests { mode: EcKvWriteMode::Add, }, ) + .await .expect("should seed live entry"); } } + #[async_trait::async_trait(?Send)] impl EcKvStore for DisappearOnConflictEcKv { fn store_name(&self) -> &str { self.inner.store_name() } - fn lookup(&self, key: &str) -> Result, Report> { - self.inner.lookup(key) + async fn lookup( + &self, + key: &str, + ) -> Result, Report> { + self.inner.lookup(key).await } - fn key_exists(&self, key: &str) -> Result> { - self.inner.key_exists(key) + async fn key_exists(&self, key: &str) -> Result> { + self.inner.key_exists(key).await } - fn insert( + async fn insert( &self, key: &str, write: EcKvWrite<'_>, ) -> Result> { if matches!(write.mode, EcKvWriteMode::IfGenerationMatch(_)) { - let mut remaining = self - .conflicts_remaining - .lock() - .expect("should lock conflict counter"); - if *remaining > 0 { - *remaining -= 1; - self.inner.delete(key).expect("should delete on conflict"); + let inject_conflict = { + let mut remaining = self + .conflicts_remaining + .lock() + .expect("should lock conflict counter"); + let inject_conflict = *remaining > 0; + if inject_conflict { + *remaining -= 1; + } + inject_conflict + }; + if inject_conflict { + self.inner + .delete(key) + .await + .expect("should delete on conflict"); return Ok(EcKvWriteOutcome::PreconditionFailed); } } - self.inner.insert(key, write) + self.inner.insert(key, write).await } - fn list_keys_with_prefix( + async fn list_keys_with_prefix( &self, prefix: &str, limit: u32, ) -> Result, Report> { - self.inner.list_keys_with_prefix(prefix, limit) + self.inner.list_keys_with_prefix(prefix, limit).await } - fn delete(&self, key: &str) -> Result<(), Report> { - self.inner.delete(key) + async fn delete(&self, key: &str) -> Result<(), Report> { + self.inner.delete(key).await } } + struct DelayedLocalEcKv { + inner: InMemoryEcKv, + lookup_started: std::rc::Rc>, + release: std::cell::RefCell>>, + } + + #[async_trait::async_trait(?Send)] + impl EcKvStore for DelayedLocalEcKv { + fn store_name(&self) -> &str { + self.inner.store_name() + } + + async fn lookup( + &self, + key: &str, + ) -> Result, Report> { + self.lookup_started.set(true); + let release = self.release.borrow_mut().take().expect("should read once"); + release.await.expect("other work should release the lookup"); + self.inner.lookup(key).await + } + + async fn key_exists(&self, key: &str) -> Result> { + self.inner.key_exists(key).await + } + + async fn insert( + &self, + key: &str, + write: EcKvWrite<'_>, + ) -> Result> { + self.inner.insert(key, write).await + } + + async fn list_keys_with_prefix( + &self, + prefix: &str, + limit: u32, + ) -> Result, Report> { + self.inner.list_keys_with_prefix(prefix, limit).await + } + + async fn delete(&self, key: &str) -> Result<(), Report> { + self.inner.delete(key).await + } + } + + #[tokio::test(flavor = "current_thread")] + async fn graph_lookup_yields_to_other_work_with_a_non_send_store() { + let lookup_started = std::rc::Rc::new(std::cell::Cell::new(false)); + let (release, waiting) = tokio::sync::oneshot::channel(); + let graph = KvIdentityGraph::new(DelayedLocalEcKv { + inner: InMemoryEcKv::new("local-store"), + lookup_started: lookup_started.clone(), + release: std::cell::RefCell::new(Some(waiting)), + }); + let ec_id = snapshot_ec_id(); + graph + .create(&ec_id, &live_entry()) + .await + .expect("should seed row"); + + let (entry, ()) = tokio::join!(biased; graph.get(&ec_id), async { + assert!( + lookup_started.get(), + "lookup should suspend before other work" + ); + release.send(()).expect("lookup should still be pending"); + }); + assert!(entry.expect("should read row").is_some()); + } + fn snapshot_ec_id() -> String { format!("{}.ABC123", "a".repeat(64)) } @@ -1837,7 +1974,7 @@ mod tests { } } - fn seed_tombstone(&self, ec_id: &str) { + async fn seed_tombstone(&self, ec_id: &str) { let (body, meta) = KvIdentityGraph::serialize_entry( &KvEntry::tombstone(1000), self.inner.store_name(), @@ -1853,10 +1990,11 @@ mod tests { mode: EcKvWriteMode::Add, }, ) + .await .expect("should seed tombstone"); } - fn seed_live(&self, ec_id: &str) { + async fn seed_live(&self, ec_id: &str) { let (body, meta) = KvIdentityGraph::serialize_entry(&live_entry(), self.inner.store_name()) .expect("should serialize live entry"); @@ -1870,35 +2008,46 @@ mod tests { mode: EcKvWriteMode::Add, }, ) + .await .expect("should seed live entry"); } } + #[async_trait::async_trait(?Send)] impl EcKvStore for ConflictInjectingEcKv { fn store_name(&self) -> &str { self.inner.store_name() } - fn lookup(&self, key: &str) -> Result, Report> { - self.inner.lookup(key) + async fn lookup( + &self, + key: &str, + ) -> Result, Report> { + self.inner.lookup(key).await } - fn key_exists(&self, key: &str) -> Result> { - self.inner.key_exists(key) + async fn key_exists(&self, key: &str) -> Result> { + self.inner.key_exists(key).await } - fn insert( + async fn insert( &self, key: &str, write: EcKvWrite<'_>, ) -> Result> { if matches!(write.mode, EcKvWriteMode::IfGenerationMatch(_)) { - let mut remaining = self - .conflicts_remaining - .lock() - .expect("should lock conflict counter"); - if *remaining > 0 { - *remaining -= 1; + let inject_conflict = { + let mut remaining = self + .conflicts_remaining + .lock() + .expect("should lock conflict counter"); + let inject_conflict = *remaining > 0; + if inject_conflict { + *remaining -= 1; + } + inject_conflict + }; + if inject_conflict { if self.revive_on_conflict { // Simulate a concurrent writer reviving the entry // between this writer's read and its CAS write. @@ -1922,24 +2071,25 @@ mod tests { mode: EcKvWriteMode::Overwrite, }, ) + .await .expect("should apply concurrent revive"); } return Ok(EcKvWriteOutcome::PreconditionFailed); } } - self.inner.insert(key, write) + self.inner.insert(key, write).await } - fn list_keys_with_prefix( + async fn list_keys_with_prefix( &self, prefix: &str, limit: u32, ) -> Result, Report> { - self.inner.list_keys_with_prefix(prefix, limit) + self.inner.list_keys_with_prefix(prefix, limit).await } - fn delete(&self, key: &str) -> Result<(), Report> { - self.inner.delete(key) + async fn delete(&self, key: &str) -> Result<(), Report> { + self.inner.delete(key).await } } @@ -1975,7 +2125,7 @@ mod tests { self } - fn seed_live(&self, ec_id: &str) { + async fn seed_live(&self, ec_id: &str) { let (body, meta) = KvIdentityGraph::serialize_entry(&live_entry(), self.inner.store_name()) .expect("should serialize initial entry"); @@ -1989,16 +2139,21 @@ mod tests { mode: EcKvWriteMode::Add, }, ) + .await .expect("should seed initial entry"); } } + #[async_trait::async_trait(?Send)] impl EcKvStore for EidConflictEcKv { fn store_name(&self) -> &str { self.inner.store_name() } - fn lookup(&self, key: &str) -> Result, Report> { + async fn lookup( + &self, + key: &str, + ) -> Result, Report> { self.lookups .fetch_add(1, std::sync::atomic::Ordering::Relaxed); if self @@ -2007,14 +2162,14 @@ mod tests { { return Ok(None); } - self.inner.lookup(key) + self.inner.lookup(key).await } - fn key_exists(&self, key: &str) -> Result> { - self.inner.key_exists(key) + async fn key_exists(&self, key: &str) -> Result> { + self.inner.key_exists(key).await } - fn insert( + async fn insert( &self, key: &str, write: EcKvWrite<'_>, @@ -2037,6 +2192,7 @@ mod tests { mode: EcKvWriteMode::Overwrite, }, ) + .await .expect("should write concurrent entry"); if self.follow_up_miss { self.miss_next_lookup @@ -2044,42 +2200,44 @@ mod tests { } return Ok(EcKvWriteOutcome::PreconditionFailed); } - self.inner.insert(key, write) + self.inner.insert(key, write).await } - fn list_keys_with_prefix( + async fn list_keys_with_prefix( &self, prefix: &str, limit: u32, ) -> Result, Report> { - self.inner.list_keys_with_prefix(prefix, limit) + self.inner.list_keys_with_prefix(prefix, limit).await } - fn count_keys_with_prefix( + async fn count_keys_with_prefix( &self, prefix: &str, limit: u32, ) -> Result> { - self.inner.count_keys_with_prefix(prefix, limit) + self.inner.count_keys_with_prefix(prefix, limit).await } - fn delete(&self, key: &str) -> Result<(), Report> { - self.inner.delete(key) + async fn delete(&self, key: &str) -> Result<(), Report> { + self.inner.delete(key).await } } - #[test] - fn create_or_revive_retries_cas_conflict_and_succeeds() { + #[tokio::test(flavor = "current_thread")] + async fn create_or_revive_retries_cas_conflict_and_succeeds() { let store = ConflictInjectingEcKv::new(2, false); - store.seed_tombstone("ec-1"); + store.seed_tombstone("ec-1").await; let graph = KvIdentityGraph::new(store); graph .create_or_revive("ec-1", &live_entry()) + .await .expect("should revive after re-reading a fresh generation"); let (entry, _) = graph .get("ec-1") + .await .expect("should read entry") .expect("entry should exist"); assert!( @@ -2088,27 +2246,29 @@ mod tests { ); } - #[test] - fn create_or_revive_short_circuits_on_concurrent_revive() { + #[tokio::test(flavor = "current_thread")] + async fn create_or_revive_short_circuits_on_concurrent_revive() { // Inject more conflicts than MAX_CAS_RETRIES so the only way the call // can succeed is the concurrent-revive short-circuit on re-read. let store = ConflictInjectingEcKv::new(MAX_CAS_RETRIES + 1, true); - store.seed_tombstone("ec-2"); + store.seed_tombstone("ec-2").await; let graph = KvIdentityGraph::new(store); graph .create_or_revive("ec-2", &live_entry()) + .await .expect("should return Ok when a concurrent writer already revived the entry"); } - #[test] - fn create_or_revive_errors_after_cas_exhaustion() { + #[tokio::test(flavor = "current_thread")] + async fn create_or_revive_errors_after_cas_exhaustion() { let store = ConflictInjectingEcKv::new(MAX_CAS_RETRIES + 1, false); - store.seed_tombstone("ec-3"); + store.seed_tombstone("ec-3").await; let graph = KvIdentityGraph::new(store); let err = graph .create_or_revive("ec-3", &live_entry()) + .await .expect_err("should fail after exhausting CAS retries"); assert!( format!("{err}").contains("CAS conflict after"), @@ -2209,8 +2369,8 @@ mod tests { assert_eq!(entry.ids["ssp_x"].uid, "original"); } - #[test] - fn eid_cookie_sync_conflict_matching_value_stops_after_one_write() { + #[tokio::test(flavor = "current_thread")] + async fn eid_cookie_sync_conflict_matching_value_stops_after_one_write() { let ec_id = snapshot_ec_id(); let mut concurrent = live_entry(); concurrent.ids.insert( @@ -2226,15 +2386,17 @@ mod tests { std::sync::Arc::clone(&lookups), std::sync::Arc::clone(&writes), ); - store.seed_live(&ec_id); + store.seed_live(&ec_id).await; let graph = KvIdentityGraph::new(store); - let snapshot = graph.load_snapshot(&ec_id); + let snapshot = graph.load_snapshot(&ec_id).await; - let (snapshot, outcome) = graph.sync_eid_cookie_updates_from_snapshot( - &ec_id, - &[PartnerIdUpdate::new("ssp_x", "desired-uid")], - snapshot, - ); + let (snapshot, outcome) = graph + .sync_eid_cookie_updates_from_snapshot( + &ec_id, + &[PartnerIdUpdate::new("ssp_x", "desired-uid")], + snapshot, + ) + .await; assert_eq!(outcome, EidCookieSyncOutcome::ConflictMatched); assert_eq!( @@ -2257,8 +2419,8 @@ mod tests { ); } - #[test] - fn eid_cookie_sync_keeps_live_proof_when_conflict_follow_up_misses() { + #[tokio::test(flavor = "current_thread")] + async fn eid_cookie_sync_keeps_live_proof_when_conflict_follow_up_misses() { let ec_id = snapshot_ec_id(); let lookups = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let writes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -2268,16 +2430,18 @@ mod tests { std::sync::Arc::clone(&writes), ) .with_follow_up_miss(); - store.seed_live(&ec_id); + store.seed_live(&ec_id).await; let graph = KvIdentityGraph::new(store); - let (snapshot, outcome) = graph.sync_eid_cookie_updates_from_snapshot( - &ec_id, - &[PartnerIdUpdate::new("ssp_x", "desired-uid")], - EcKvSnapshot::Missing { - ec_id: ec_id.clone(), - }, - ); + let (snapshot, outcome) = graph + .sync_eid_cookie_updates_from_snapshot( + &ec_id, + &[PartnerIdUpdate::new("ssp_x", "desired-uid")], + EcKvSnapshot::Missing { + ec_id: ec_id.clone(), + }, + ) + .await; assert_eq!(outcome, EidCookieSyncOutcome::DeferredConflict); assert!( @@ -2301,8 +2465,8 @@ mod tests { ); } - #[test] - fn eid_cookie_sync_reports_proven_refresh_miss_as_deferred() { + #[tokio::test(flavor = "current_thread")] + async fn eid_cookie_sync_reports_proven_refresh_miss_as_deferred() { let ec_id = snapshot_ec_id(); let graph = KvIdentityGraph::in_memory("empty-store"); let proven = EcKvSnapshot::Present { @@ -2311,11 +2475,13 @@ mod tests { generation: None, }; - let (snapshot, outcome) = graph.sync_eid_cookie_updates_from_snapshot( - &ec_id, - &[PartnerIdUpdate::new("ssp_x", "desired-uid")], - proven, - ); + let (snapshot, outcome) = graph + .sync_eid_cookie_updates_from_snapshot( + &ec_id, + &[PartnerIdUpdate::new("ssp_x", "desired-uid")], + proven, + ) + .await; assert!( snapshot.entry_for(&ec_id).is_some(), @@ -2348,8 +2514,8 @@ mod tests { ); } - #[test] - fn eid_cookie_sync_conflicting_value_defers_after_one_write() { + #[tokio::test(flavor = "current_thread")] + async fn eid_cookie_sync_conflicting_value_defers_after_one_write() { let ec_id = snapshot_ec_id(); let mut concurrent = live_entry(); concurrent.ids.insert( @@ -2365,15 +2531,17 @@ mod tests { std::sync::Arc::clone(&lookups), std::sync::Arc::clone(&writes), ); - store.seed_live(&ec_id); + store.seed_live(&ec_id).await; let graph = KvIdentityGraph::new(store); - let snapshot = graph.load_snapshot(&ec_id); + let snapshot = graph.load_snapshot(&ec_id).await; - let (_, outcome) = graph.sync_eid_cookie_updates_from_snapshot( - &ec_id, - &[PartnerIdUpdate::new("ssp_x", "stale-uid")], - snapshot, - ); + let (_, outcome) = graph + .sync_eid_cookie_updates_from_snapshot( + &ec_id, + &[PartnerIdUpdate::new("ssp_x", "stale-uid")], + snapshot, + ) + .await; assert_eq!(outcome, EidCookieSyncOutcome::DeferredConflict); assert_eq!( @@ -2388,13 +2556,14 @@ mod tests { ); let (stored, _) = graph .get(&ec_id) + .await .expect("should read concurrent row") .expect("row should remain"); assert_eq!(stored.ids["ssp_x"].uid, "concurrent-uid"); } - #[test] - fn eid_cookie_sync_preserves_concurrent_withdrawal_after_conflict() { + #[tokio::test(flavor = "current_thread")] + async fn eid_cookie_sync_preserves_concurrent_withdrawal_after_conflict() { let ec_id = snapshot_ec_id(); let lookups = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let writes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -2403,15 +2572,17 @@ mod tests { std::sync::Arc::clone(&lookups), std::sync::Arc::clone(&writes), ); - store.seed_live(&ec_id); + store.seed_live(&ec_id).await; let graph = KvIdentityGraph::new(store); - let snapshot = graph.load_snapshot(&ec_id); + let snapshot = graph.load_snapshot(&ec_id).await; - let (snapshot, outcome) = graph.sync_eid_cookie_updates_from_snapshot( - &ec_id, - &[PartnerIdUpdate::new("ssp_x", "desired-uid")], - snapshot, - ); + let (snapshot, outcome) = graph + .sync_eid_cookie_updates_from_snapshot( + &ec_id, + &[PartnerIdUpdate::new("ssp_x", "desired-uid")], + snapshot, + ) + .await; assert_eq!(outcome, EidCookieSyncOutcome::ConsentWithdrawn); assert!( @@ -2432,8 +2603,8 @@ mod tests { ); } - #[test] - fn eid_cookie_sync_defers_different_values_without_value_owned_freshness() { + #[tokio::test(flavor = "current_thread")] + async fn eid_cookie_sync_defers_different_values_without_value_owned_freshness() { let ec_id = snapshot_ec_id(); let graph = KvIdentityGraph::in_memory("freshness-store"); let mut entry = live_entry(); @@ -2443,24 +2614,30 @@ mod tests { uid: "protected-uid".to_owned(), }, ); - graph.create(&ec_id, &entry).expect("should seed entry"); + graph + .create(&ec_id, &entry) + .await + .expect("should seed entry"); - let (_, outcome) = graph.sync_eid_cookie_updates_from_snapshot( - &ec_id, - &[PartnerIdUpdate::new("ssp_x", "incoming-uid")], - graph.load_snapshot(&ec_id), - ); + let (_, outcome) = graph + .sync_eid_cookie_updates_from_snapshot( + &ec_id, + &[PartnerIdUpdate::new("ssp_x", "incoming-uid")], + graph.load_snapshot(&ec_id).await, + ) + .await; assert_eq!(outcome, EidCookieSyncOutcome::DeferredFreshness); let (stored, _) = graph .get(&ec_id) + .await .expect("should read protected row") .expect("row should remain"); assert_eq!(stored.ids["ssp_x"].uid, "protected-uid"); } - #[test] - fn eid_cookie_sync_adds_missing_ids_while_deferring_different_values() { + #[tokio::test(flavor = "current_thread")] + async fn eid_cookie_sync_adds_missing_ids_while_deferring_different_values() { let ec_id = snapshot_ec_id(); let graph = KvIdentityGraph::in_memory("mixed-freshness-store"); let mut entry = live_entry(); @@ -2470,28 +2647,34 @@ mod tests { uid: "protected-uid".to_owned(), }, ); - graph.create(&ec_id, &entry).expect("should seed entry"); + graph + .create(&ec_id, &entry) + .await + .expect("should seed entry"); - let (_, outcome) = graph.sync_eid_cookie_updates_from_snapshot( - &ec_id, - &[ - PartnerIdUpdate::new("ssp_x", "different-uid"), - PartnerIdUpdate::new("ssp_y", "new-uid"), - ], - graph.load_snapshot(&ec_id), - ); + let (_, outcome) = graph + .sync_eid_cookie_updates_from_snapshot( + &ec_id, + &[ + PartnerIdUpdate::new("ssp_x", "different-uid"), + PartnerIdUpdate::new("ssp_y", "new-uid"), + ], + graph.load_snapshot(&ec_id).await, + ) + .await; assert_eq!(outcome, EidCookieSyncOutcome::WrittenWithDeferredFreshness); let (stored, _) = graph .get(&ec_id) + .await .expect("should read updated row") .expect("row should remain"); assert_eq!(stored.ids["ssp_x"].uid, "protected-uid"); assert_eq!(stored.ids["ssp_y"].uid, "new-uid"); } - #[test] - fn evaluate_cluster_returns_stored_value_without_store_io() { + #[tokio::test(flavor = "current_thread")] + async fn evaluate_cluster_returns_stored_value_without_store_io() { let kv = KvIdentityGraph::failing("nonexistent_store_for_cluster_cache_test"); let ec_id = format!("{}.ABC123", "a".repeat(64)); let mut entry = live_entry(); @@ -2501,6 +2684,7 @@ mod tests { let cluster_size = kv .evaluate_cluster(&ec_id, &entry, 0) + .await .expect("should not touch store when cluster_size is already known"); assert_eq!( @@ -2510,14 +2694,15 @@ mod tests { ); } - #[test] - fn evaluate_cluster_skips_tombstone_without_store_io() { + #[tokio::test(flavor = "current_thread")] + async fn evaluate_cluster_skips_tombstone_without_store_io() { let kv = KvIdentityGraph::failing("nonexistent_store_for_tombstone_cluster_test"); let ec_id = format!("{}.ABC123", "a".repeat(64)); let entry = KvEntry::tombstone(1000); let cluster_size = kv .evaluate_cluster(&ec_id, &entry, 0) + .await .expect("should not touch store for tombstone entries"); assert_eq!( @@ -2526,15 +2711,18 @@ mod tests { ); } - #[test] - fn create_then_get_roundtrips_entry() { + #[tokio::test(flavor = "current_thread")] + async fn create_then_get_roundtrips_entry() { let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); let entry = live_entry(); - kv.create(&ec_id, &entry).expect("should create new entry"); + kv.create(&ec_id, &entry) + .await + .expect("should create new entry"); let (loaded, generation) = kv .get(&ec_id) + .await .expect("should read entry back") .expect("should find created entry"); @@ -2542,15 +2730,18 @@ mod tests { assert!(generation > 0, "should expose a generation marker"); } - #[test] - fn create_rejects_existing_key() { + #[tokio::test(flavor = "current_thread")] + async fn create_rejects_existing_key() { let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); let entry = live_entry(); - kv.create(&ec_id, &entry).expect("should create new entry"); + kv.create(&ec_id, &entry) + .await + .expect("should create new entry"); let err = kv .create(&ec_id, &entry) + .await .expect_err("should reject duplicate create"); assert!( format!("{err}").contains("already exists"), @@ -2558,53 +2749,58 @@ mod tests { ); } - #[test] - fn create_if_absent_reports_written_and_collision() { + #[tokio::test(flavor = "current_thread")] + async fn create_if_absent_reports_written_and_collision() { let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); assert_eq!( kv.create_if_absent(&ec_id, &live_entry()) + .await .expect("should create absent entry"), CreateIfAbsentOutcome::Written ); assert_eq!( kv.create_if_absent(&ec_id, &live_entry()) + .await .expect("should report collision"), CreateIfAbsentOutcome::AlreadyExists ); } - #[test] - fn create_if_absent_propagates_store_error() { + #[tokio::test(flavor = "current_thread")] + async fn create_if_absent_propagates_store_error() { let kv = KvIdentityGraph::failing("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); assert!( - kv.create_if_absent(&ec_id, &live_entry()).is_err(), + kv.create_if_absent(&ec_id, &live_entry()).await.is_err(), "should preserve store failures instead of reporting a collision" ); } - #[test] - fn create_or_revive_revives_tombstone() { + #[tokio::test(flavor = "current_thread")] + async fn create_or_revive_revives_tombstone() { let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); kv.create(&ec_id, &KvEntry::tombstone(1000)) + .await .expect("should create tombstone"); kv.create_or_revive(&ec_id, &live_entry()) + .await .expect("should revive tombstone"); let (loaded, _) = kv .get(&ec_id) + .await .expect("should read entry back") .expect("should find revived entry"); assert!(loaded.consent.ok, "should be live after revive"); } - #[test] - fn create_or_revive_fresh_entry_does_not_access_marker_store() { + #[tokio::test(flavor = "current_thread")] + async fn create_or_revive_fresh_entry_does_not_access_marker_store() { let operations = Arc::new(RecordedEcKvOperations::default()); let kv = KvIdentityGraph::new(RecordingEcKv::with_marker_failures( Arc::clone(&operations), @@ -2613,6 +2809,7 @@ mod tests { let ec_id = format!("{}.ABC123", "a".repeat(64)); kv.create_or_revive(&ec_id, &live_entry()) + .await .expect("should create without reading withdrawal markers"); assert_eq!( @@ -2627,43 +2824,49 @@ mod tests { ); assert!( kv.get(&ec_id) + .await .expect("should read entry") .is_some_and(|(entry, _)| entry.consent.ok), "fresh create should persist a live entry" ); } - #[test] - fn create_or_revive_clears_withdrawal_marker() { + #[tokio::test(flavor = "current_thread")] + async fn create_or_revive_clears_withdrawal_marker() { let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); kv.create(&ec_id, &live_entry()) + .await .expect("should create live entry"); - let snapshot = kv.load_snapshot(&ec_id); - kv.tombstone_existing_from_snapshot(&ec_id, snapshot); + let snapshot = kv.load_snapshot(&ec_id).await; + kv.tombstone_existing_from_snapshot(&ec_id, snapshot).await; assert!( kv.withdrawal_marker_exists(&ec_id, checked_current_timestamp()) + .await .expect("should read withdrawal marker"), "withdrawal should record completion" ); kv.create_or_revive(&ec_id, &live_entry()) + .await .expect("should revive tombstone"); let (loaded, _) = kv .get(&ec_id) + .await .expect("should read revived entry") .expect("should find revived entry"); assert!(loaded.consent.ok, "should be live after revive"); assert!( !kv.withdrawal_marker_exists(&ec_id, checked_current_timestamp()) + .await .expect("should read withdrawal marker"), "revival should clear stale withdrawal completion" ); } - #[test] - fn stale_completion_marker_does_not_suppress_withdrawal_after_root_recreation() { + #[tokio::test(flavor = "current_thread")] + async fn stale_completion_marker_does_not_suppress_withdrawal_after_root_recreation() { let operations = Arc::new(RecordedEcKvOperations::default()); let graph = KvIdentityGraph::new(RecordingEcKv::with_stale_lookups( Arc::clone(&operations), @@ -2674,28 +2877,37 @@ mod tests { let expired_tombstone = KvEntry::tombstone(expired_updated); graph .create(&ec_id, &expired_tombstone) + .await .expect("should seed old tombstone"); graph .write_withdrawal_marker(&ec_id, expired_updated) + .await .expect("should seed old completion marker"); // Model the root expiring just before its later-written completion // marker, followed by recreation of the same key. - graph.store.delete(&ec_id).expect("should expire root row"); + graph + .store + .delete(&ec_id) + .await + .expect("should expire root row"); assert_eq!( graph .create_if_absent(&ec_id, &live_entry()) + .await .expect("should recreate expired root"), CreateIfAbsentOutcome::Written, "should make the same key live again after root expiry" ); - let outcome = graph.tombstone_existing_from_snapshot( - &ec_id, - EcKvSnapshot::Missing { - ec_id: ec_id.clone(), - }, - ); + let outcome = graph + .tombstone_existing_from_snapshot( + &ec_id, + EcKvSnapshot::Missing { + ec_id: ec_id.clone(), + }, + ) + .await; assert!( outcome @@ -2705,13 +2917,14 @@ mod tests { ); let (stored, _) = graph .get(&ec_id) + .await .expect("should read recreated row") .expect("should retain recreated row"); assert!(!stored.consent.ok, "recreated row should be tombstoned"); } - #[test] - fn withdrawal_failed_clock_stale_miss_tombstones_live_root() { + #[tokio::test(flavor = "current_thread")] + async fn withdrawal_failed_clock_stale_miss_tombstones_live_root() { let operations = Arc::new(RecordedEcKvOperations::default()); let graph = KvIdentityGraph::new(RecordingEcKv::with_stale_lookups( Arc::clone(&operations), @@ -2720,22 +2933,27 @@ mod tests { let ec_id = snapshot_ec_id(); graph .create(&ec_id, &concurrent_live_entry()) + .await .expect("should seed a live row with partner IDs"); graph .write_withdrawal_marker(&ec_id, 1000) + .await .expect("should seed a completion marker left after root recreation"); operations.reset(); - let missing = graph.load_snapshot(&ec_id); + let missing = graph.load_snapshot(&ec_id).await; assert!( matches!(missing, EcKvSnapshot::Missing { .. }), "should reproduce a stale point-read miss for the live row" ); // None is the checked clock's failure result, not Unix epoch zero. - let outcome = graph.tombstone_unproven_missing(&ec_id, missing, None); + let outcome = graph + .tombstone_unproven_missing(&ec_id, missing, None) + .await; let (stored, generation) = graph .get(&ec_id) + .await .expect("should read back persisted withdrawal") .expect("should retain the root"); assert!( @@ -2777,18 +2995,21 @@ mod tests { ); } - #[test] - fn withdrawal_failed_clock_does_not_create_absent_root() { + #[tokio::test(flavor = "current_thread")] + async fn withdrawal_failed_clock_does_not_create_absent_root() { let operations = Arc::new(RecordedEcKvOperations::default()); let graph = KvIdentityGraph::new(RecordingEcKv::new(Arc::clone(&operations))); let ec_id = snapshot_ec_id(); graph .write_withdrawal_marker(&ec_id, 1000) + .await .expect("should seed a marker without a root"); operations.reset(); - let missing = graph.load_snapshot(&ec_id); + let missing = graph.load_snapshot(&ec_id).await; - let outcome = graph.tombstone_unproven_missing(&ec_id, missing, None); + let outcome = graph + .tombstone_unproven_missing(&ec_id, missing, None) + .await; assert!( matches!(outcome, EcKvSnapshot::Missing { .. }), @@ -2797,6 +3018,7 @@ mod tests { assert!( graph .get(&ec_id) + .await .expect("should read absent root") .is_none(), "clock failure must not mint an unknown root" @@ -2812,12 +3034,13 @@ mod tests { ); } - #[test] - fn withdrawal_marker_validity_requires_a_usable_clock_before_expiry() { + #[tokio::test(flavor = "current_thread")] + async fn withdrawal_marker_validity_requires_a_usable_clock_before_expiry() { let graph = KvIdentityGraph::in_memory("test_store"); let ec_id = snapshot_ec_id(); graph .write_withdrawal_marker(&ec_id, 1000) + .await .expect("should seed marker"); let valid_until = 1000 + TOMBSTONE_TTL.as_secs(); @@ -2830,6 +3053,7 @@ mod tests { assert_eq!( graph .withdrawal_marker_exists(&ec_id, now) + .await .expect("should check marker validity"), expected, "marker validity should honor clock availability and exclusive expiry at {now:?}" @@ -2837,8 +3061,8 @@ mod tests { } } - #[test] - fn withdrawal_marker_existence_rejects_malformed_expiry_suffix() { + #[tokio::test(flavor = "current_thread")] + async fn withdrawal_marker_existence_rejects_malformed_expiry_suffix() { let ec_id = format!("{}.ABC123", "a".repeat(64)); let marker_key = KvIdentityGraph::withdrawal_marker_key( &ec_id, @@ -2856,23 +3080,26 @@ mod tests { mode: EcKvWriteMode::Add, }, ) + .await .expect("should seed longer marker key"); let kv = KvIdentityGraph::new(store); assert!( !kv.withdrawal_marker_exists(&ec_id, checked_current_timestamp()) + .await .expect("should validate withdrawal marker expiry"), "a malformed expiry suffix must not prove withdrawal completion" ); } - #[test] - fn clear_absent_withdrawal_marker_lists_once_without_delete() { + #[tokio::test(flavor = "current_thread")] + async fn clear_absent_withdrawal_marker_lists_once_without_delete() { let operations = Arc::new(RecordedEcKvOperations::default()); let kv = KvIdentityGraph::new(RecordingEcKv::new(Arc::clone(&operations))); let ec_id = snapshot_ec_id(); kv.clear_withdrawal_marker(&ec_id) + .await .expect("should accept an absent marker"); assert_eq!( @@ -2887,16 +3114,18 @@ mod tests { ); } - #[test] - fn writing_an_existing_withdrawal_marker_is_idempotent() { + #[tokio::test(flavor = "current_thread")] + async fn writing_an_existing_withdrawal_marker_is_idempotent() { let operations = Arc::new(RecordedEcKvOperations::default()); let kv = KvIdentityGraph::new(RecordingEcKv::new(Arc::clone(&operations))); let ec_id = snapshot_ec_id(); let updated = current_timestamp(); kv.write_withdrawal_marker(&ec_id, updated) + .await .expect("should write first completion marker"); kv.write_withdrawal_marker(&ec_id, updated) + .await .expect("should accept completion marker add collision"); assert_eq!( @@ -2915,18 +3144,20 @@ mod tests { ); assert!( kv.withdrawal_marker_exists(&ec_id, checked_current_timestamp()) + .await .expect("should read completion marker"), "completion marker should remain valid" ); } - #[test] - fn marker_list_saturation_blocks_revival_and_hard_delete_before_mutation() { + #[tokio::test(flavor = "current_thread")] + async fn marker_list_saturation_blocks_revival_and_hard_delete_before_mutation() { let operations = Arc::new(RecordedEcKvOperations::default()); let graph = KvIdentityGraph::new(RecordingEcKv::new(Arc::clone(&operations))); let ec_id = snapshot_ec_id(); graph .create(&ec_id, &KvEntry::tombstone(current_timestamp())) + .await .expect("should seed tombstone"); let first_valid_until = current_timestamp() + TOMBSTONE_TTL.as_secs(); for offset in 0..WITHDRAWAL_MARKER_LIST_LIMIT { @@ -2945,11 +3176,12 @@ mod tests { mode: EcKvWriteMode::Add, }, ) + .await .expect("should seed completion marker"); } operations.reset(); - let result = graph.create_or_revive(&ec_id, &live_entry()); + let result = graph.create_or_revive(&ec_id, &live_entry()).await; assert!( result.is_err(), @@ -2957,6 +3189,7 @@ mod tests { ); let (stored, _) = graph .get(&ec_id) + .await .expect("should read root") .expect("should retain root"); assert!(!stored.consent.ok, "root should remain tombstoned"); @@ -2967,7 +3200,7 @@ mod tests { ); operations.reset(); - let delete_result = graph.delete(&ec_id); + let delete_result = graph.delete(&ec_id).await; assert!( delete_result.is_err(), @@ -2975,6 +3208,7 @@ mod tests { ); let (stored, _) = graph .get(&ec_id) + .await .expect("should read root after rejected hard delete") .expect("rejected hard delete should retain root"); assert!( @@ -2988,106 +3222,120 @@ mod tests { ); } - #[test] - fn clear_withdrawal_marker_accepts_concurrent_removal_after_delete_error() { + #[tokio::test(flavor = "current_thread")] + async fn clear_withdrawal_marker_accepts_concurrent_removal_after_delete_error() { let ec_id = format!("{}.ABC123", "a".repeat(64)); let operations = Arc::new(RecordedEcKvOperations::default()); let kv = KvIdentityGraph::new(RecordingEcKv::with_marker_delete_failure(operations, true)); kv.create(&ec_id, &live_entry()) + .await .expect("should create live entry"); - let snapshot = kv.load_snapshot(&ec_id); - kv.tombstone_existing_from_snapshot(&ec_id, snapshot); + let snapshot = kv.load_snapshot(&ec_id).await; + kv.tombstone_existing_from_snapshot(&ec_id, snapshot).await; kv.clear_withdrawal_marker(&ec_id) + .await .expect("should accept a marker removed by another request"); assert!( !kv.withdrawal_marker_exists(&ec_id, checked_current_timestamp()) + .await .expect("should confirm marker removal"), "completion marker should remain absent" ); } - #[test] - fn clear_withdrawal_marker_preserves_delete_error_when_marker_remains() { + #[tokio::test(flavor = "current_thread")] + async fn clear_withdrawal_marker_preserves_delete_error_when_marker_remains() { let ec_id = format!("{}.ABC123", "a".repeat(64)); let operations = Arc::new(RecordedEcKvOperations::default()); let kv = KvIdentityGraph::new(RecordingEcKv::with_marker_delete_failure(operations, false)); kv.create(&ec_id, &live_entry()) + .await .expect("should create live entry"); - let snapshot = kv.load_snapshot(&ec_id); - kv.tombstone_existing_from_snapshot(&ec_id, snapshot); + let snapshot = kv.load_snapshot(&ec_id).await; + kv.tombstone_existing_from_snapshot(&ec_id, snapshot).await; assert!( - kv.clear_withdrawal_marker(&ec_id).is_err(), + kv.clear_withdrawal_marker(&ec_id).await.is_err(), "a marker that remains after delete failure should block revival" ); assert!( kv.withdrawal_marker_exists(&ec_id, checked_current_timestamp()) + .await .expect("should confirm marker remains"), "completion marker should remain present" ); } - #[test] - fn withdrawal_marker_is_excluded_from_hash_prefix_count() { + #[tokio::test(flavor = "current_thread")] + async fn withdrawal_marker_is_excluded_from_hash_prefix_count() { let hash = "a".repeat(64); let ec_id = format!("{hash}.ABC123"); let kv = KvIdentityGraph::in_memory("test_store"); kv.create(&ec_id, &live_entry()) + .await .expect("should create live entry"); assert_eq!( kv.count_hash_prefix_keys(&hash) + .await .expect("should count live root"), 1, "the root should be the only hash-prefix key" ); - let snapshot = kv.load_snapshot(&ec_id); - kv.tombstone_existing_from_snapshot(&ec_id, snapshot); + let snapshot = kv.load_snapshot(&ec_id).await; + kv.tombstone_existing_from_snapshot(&ec_id, snapshot).await; assert!( kv.withdrawal_marker_exists(&ec_id, checked_current_timestamp()) + .await .expect("should read withdrawal marker"), "withdrawal should record completion" ); assert_eq!( kv.count_hash_prefix_keys(&hash) + .await .expect("should count tombstoned root"), 1, "the marker namespace must not affect cluster counts" ); } - #[test] - fn delete_removes_withdrawal_marker() { + #[tokio::test(flavor = "current_thread")] + async fn delete_removes_withdrawal_marker() { let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); kv.create(&ec_id, &live_entry()) + .await .expect("should create live entry"); - let snapshot = kv.load_snapshot(&ec_id); - kv.tombstone_existing_from_snapshot(&ec_id, snapshot); + let snapshot = kv.load_snapshot(&ec_id).await; + kv.tombstone_existing_from_snapshot(&ec_id, snapshot).await; - kv.delete(&ec_id).expect("should delete entry and marker"); + kv.delete(&ec_id) + .await + .expect("should delete entry and marker"); assert!( - kv.get(&ec_id).expect("should read store").is_none(), + kv.get(&ec_id).await.expect("should read store").is_none(), "hard delete should remove the root" ); assert!( !kv.withdrawal_marker_exists(&ec_id, checked_current_timestamp()) + .await .expect("should read withdrawal marker"), "hard delete should remove withdrawal completion" ); } - #[test] - fn upsert_partner_id_if_exists_reports_missing_key() { + #[tokio::test(flavor = "current_thread")] + async fn upsert_partner_id_if_exists_reports_missing_key() { let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); let result = kv .upsert_partner_id_if_exists(&ec_id, "ssp_x", "uid-1") + .await .expect("should not error on missing key"); assert_eq!( result, @@ -3096,14 +3344,17 @@ mod tests { ); } - #[test] - fn upsert_partner_id_if_exists_writes_and_detects_unchanged() { + #[tokio::test(flavor = "current_thread")] + async fn upsert_partner_id_if_exists_writes_and_detects_unchanged() { let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); - kv.create(&ec_id, &live_entry()).expect("should create"); + kv.create(&ec_id, &live_entry()) + .await + .expect("should create"); let first = kv .upsert_partner_id_if_exists(&ec_id, "ssp_x", "uid-1") + .await .expect("should write partner id"); assert_eq!( first, @@ -3113,6 +3364,7 @@ mod tests { let second = kv .upsert_partner_id_if_exists(&ec_id, "ssp_x", "uid-1") + .await .expect("should detect unchanged uid"); assert_eq!( second, @@ -3121,21 +3373,24 @@ mod tests { ); } - #[test] - fn upsert_partner_id_if_exists_retries_cas_conflict() { + #[tokio::test(flavor = "current_thread")] + async fn upsert_partner_id_if_exists_retries_cas_conflict() { let graph = KvIdentityGraph::new(ConflictInjectingEcKv::new(1, false)); let ec_id = format!("{}.ABC123", "a".repeat(64)); graph .create(&ec_id, &live_entry()) + .await .expect("should seed live entry"); let result = graph .upsert_partner_id_if_exists(&ec_id, "ssp_x", "uid-1") + .await .expect("should retry and write after one generation conflict"); assert_eq!(result, UpsertResult::Written); let (entry, _) = graph .get(&ec_id) + .await .expect("should read persisted entry") .expect("should retain entry after conflict"); assert_eq!( @@ -3145,15 +3400,17 @@ mod tests { ); } - #[test] - fn upsert_partner_id_if_exists_rejects_tombstone() { + #[tokio::test(flavor = "current_thread")] + async fn upsert_partner_id_if_exists_rejects_tombstone() { let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); kv.create(&ec_id, &KvEntry::tombstone(1000)) + .await .expect("should create tombstone"); let result = kv .upsert_partner_id_if_exists(&ec_id, "ssp_x", "uid-1") + .await .expect("should not error on tombstone"); assert_eq!( result, @@ -3162,15 +3419,19 @@ mod tests { ); } - #[test] - fn snapshot_bulk_upsert_returns_persisted_entry_without_stale_generation() { + #[tokio::test(flavor = "current_thread")] + async fn snapshot_bulk_upsert_returns_persisted_entry_without_stale_generation() { let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); - kv.create(&ec_id, &live_entry()).expect("should create"); - let snapshot = kv.load_snapshot(&ec_id); + kv.create(&ec_id, &live_entry()) + .await + .expect("should create"); + let snapshot = kv.load_snapshot(&ec_id).await; let updates = [PartnerIdUpdate::new("ssp_x", "uid-1")]; - let outcome = kv.upsert_partner_ids_from_snapshot(&ec_id, &updates, snapshot); + let outcome = kv + .upsert_partner_ids_from_snapshot(&ec_id, &updates, snapshot) + .await; let entry = outcome .entry_for(&ec_id) @@ -3186,55 +3447,59 @@ mod tests { ); } - #[test] - fn snapshot_bulk_upsert_does_not_create_missing_root() { + #[tokio::test(flavor = "current_thread")] + async fn snapshot_bulk_upsert_does_not_create_missing_root() { let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); let updates = [PartnerIdUpdate::new("ssp_x", "uid-1")]; - let outcome = kv.upsert_partner_ids_from_snapshot( - &ec_id, - &updates, - EcKvSnapshot::Missing { - ec_id: ec_id.clone(), - }, - ); + let outcome = kv + .upsert_partner_ids_from_snapshot( + &ec_id, + &updates, + EcKvSnapshot::Missing { + ec_id: ec_id.clone(), + }, + ) + .await; assert!( matches!(outcome, EcKvSnapshot::Missing { .. }), "missing snapshot should remain missing" ); - assert!(kv.get(&ec_id).expect("should read store").is_none()); + assert!(kv.get(&ec_id).await.expect("should read store").is_none()); } - #[test] - fn tombstone_existing_from_snapshot_never_creates_missing_key() { + #[tokio::test(flavor = "current_thread")] + async fn tombstone_existing_from_snapshot_never_creates_missing_key() { let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); let snapshot = EcKvSnapshot::Missing { ec_id: ec_id.clone(), }; - let outcome = kv.tombstone_existing_from_snapshot(&ec_id, snapshot); + let outcome = kv.tombstone_existing_from_snapshot(&ec_id, snapshot).await; assert!( matches!(outcome, EcKvSnapshot::Missing { .. }), "withdrawal should preserve the missing snapshot for an absent identity" ); assert!( - kv.get(&ec_id).expect("should read store").is_none(), + kv.get(&ec_id).await.expect("should read store").is_none(), "withdrawal must not create a tombstone for an absent key" ); } - #[test] - fn tombstone_existing_from_snapshot_uses_existing_generation() { + #[tokio::test(flavor = "current_thread")] + async fn tombstone_existing_from_snapshot_uses_existing_generation() { let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); - kv.create(&ec_id, &live_entry()).expect("should create"); - let snapshot = kv.load_snapshot(&ec_id); + kv.create(&ec_id, &live_entry()) + .await + .expect("should create"); + let snapshot = kv.load_snapshot(&ec_id).await; - let outcome = kv.tombstone_existing_from_snapshot(&ec_id, snapshot); + let outcome = kv.tombstone_existing_from_snapshot(&ec_id, snapshot).await; assert!( outcome @@ -3244,19 +3509,23 @@ mod tests { ); let (stored, _) = kv .get(&ec_id) + .await .expect("should read store") .expect("should preserve existing key"); assert!(!stored.consent.ok, "should persist withdrawal state"); } - #[test] - fn write_withdrawal_tombstone_overwrites_live_entry() { + #[tokio::test(flavor = "current_thread")] + async fn write_withdrawal_tombstone_overwrites_live_entry() { let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); - kv.create(&ec_id, &live_entry()).expect("should create"); + kv.create(&ec_id, &live_entry()) + .await + .expect("should create"); assert_eq!( kv.write_withdrawal_tombstone(&ec_id, drop) + .await .expect("should write tombstone"), TombstoneOutcome::Written, "should tombstone an identity the store holds" @@ -3264,6 +3533,7 @@ mod tests { let (loaded, _) = kv .get(&ec_id) + .await .expect("should read entry back") .expect("should find tombstone entry"); assert!(!loaded.consent.ok, "should be withdrawn after tombstone"); @@ -3378,7 +3648,7 @@ mod tests { } } - fn completed_with_root_check_failure( + async fn completed_with_root_check_failure( operations: Arc, ec_id: &str, ) -> Self { @@ -3402,6 +3672,7 @@ mod tests { mode: EcKvWriteMode::Add, }, ) + .await .expect("should seed tombstone"); store .inner @@ -3417,6 +3688,7 @@ mod tests { mode: EcKvWriteMode::Add, }, ) + .await .expect("should seed completion marker"); store } @@ -3446,24 +3718,30 @@ mod tests { } } + #[async_trait::async_trait(?Send)] impl EcKvStore for RecordingEcKv { fn store_name(&self) -> &str { self.inner.store_name() } - fn lookup(&self, key: &str) -> Result, Report> { + async fn lookup( + &self, + key: &str, + ) -> Result, Report> { self.operations .lookups .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let mut stale_lookups = self - .stale_lookups_remaining - .lock() - .expect("should lock stale lookup counter"); - if *stale_lookups > 0 { - *stale_lookups -= 1; - return Ok(None); + { + let mut stale_lookups = self + .stale_lookups_remaining + .lock() + .expect("should lock stale lookup counter"); + if *stale_lookups > 0 { + *stale_lookups -= 1; + return Ok(None); + } } - let found = self.inner.lookup(key)?; + let found = self.inner.lookup(key).await?; if self.lag_live_entries && found.as_ref().is_some_and(|entry| { serde_json::from_slice::(&entry.body) @@ -3477,17 +3755,17 @@ mod tests { Ok(found) } - fn key_exists(&self, key: &str) -> Result> { + async fn key_exists(&self, key: &str) -> Result> { self.operations .exact_checks .fetch_add(1, std::sync::atomic::Ordering::Relaxed); if !key.starts_with(WITHDRAWAL_MARKER_PREFIX) && self.root_check_fails { return Err(self.root_check_error()); } - self.inner.key_exists(key) + self.inner.key_exists(key).await } - fn insert( + async fn insert( &self, key: &str, write: EcKvWrite<'_>, @@ -3503,10 +3781,10 @@ mod tests { if key.starts_with(WITHDRAWAL_MARKER_PREFIX) && self.marker_operations_fail { return Err(self.marker_error("write")); } - self.inner.insert(key, write) + self.inner.insert(key, write).await } - fn list_keys_with_prefix( + async fn list_keys_with_prefix( &self, prefix: &str, limit: u32, @@ -3517,10 +3795,10 @@ mod tests { if prefix.starts_with(WITHDRAWAL_MARKER_PREFIX) && self.marker_operations_fail { return Err(self.marker_error("lookup")); } - self.inner.list_keys_with_prefix(prefix, limit) + self.inner.list_keys_with_prefix(prefix, limit).await } - fn delete(&self, key: &str) -> Result<(), Report> { + async fn delete(&self, key: &str) -> Result<(), Report> { self.operations .deletes .fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -3528,11 +3806,11 @@ mod tests { && let Some(remove_before_error) = self.marker_delete_failure_removes_key { if remove_before_error { - self.inner.delete(key)?; + self.inner.delete(key).await?; } return Err(self.marker_error("delete")); } - self.inner.delete(key) + self.inner.delete(key).await } } @@ -3550,18 +3828,22 @@ mod tests { } } + #[async_trait::async_trait(?Send)] impl EcKvStore for WriteFailingEcKv { fn store_name(&self) -> &str { self.inner.store_name() } - fn lookup(&self, key: &str) -> Result, Report> { - self.inner.lookup(key) + async fn lookup( + &self, + key: &str, + ) -> Result, Report> { + self.inner.lookup(key).await } - fn key_exists(&self, key: &str) -> Result> { - self.inner.key_exists(key) + async fn key_exists(&self, key: &str) -> Result> { + self.inner.key_exists(key).await } - fn insert( + async fn insert( &self, _key: &str, _write: EcKvWrite<'_>, @@ -3571,24 +3853,27 @@ mod tests { message: "write failing test store".to_owned(), })) } - fn list_keys_with_prefix( + async fn list_keys_with_prefix( &self, prefix: &str, limit: u32, ) -> Result, Report> { - self.inner.list_keys_with_prefix(prefix, limit) + self.inner.list_keys_with_prefix(prefix, limit).await } - fn delete(&self, key: &str) -> Result<(), Report> { - self.inner.delete(key) + async fn delete(&self, key: &str) -> Result<(), Report> { + self.inner.delete(key).await } } - #[test] - fn snapshot_upsert_with_generation_writes_without_reading() { + #[tokio::test(flavor = "current_thread")] + async fn snapshot_upsert_with_generation_writes_without_reading() { let lookups = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let graph = KvIdentityGraph::counting("counting-store", lookups.clone()); let ec_id = snapshot_ec_id(); - graph.create(&ec_id, &live_entry()).expect("should seed"); + graph + .create(&ec_id, &live_entry()) + .await + .expect("should seed"); let snapshot = EcKvSnapshot::Present { ec_id: ec_id.clone(), entry: Box::new(live_entry()), @@ -3596,7 +3881,9 @@ mod tests { }; let updates = [PartnerIdUpdate::new("ssp_x", "uid-1")]; - let outcome = graph.upsert_partner_ids_from_snapshot(&ec_id, &updates, snapshot); + let outcome = graph + .upsert_partner_ids_from_snapshot(&ec_id, &updates, snapshot) + .await; assert_eq!( lookups.load(std::sync::atomic::Ordering::Relaxed), @@ -3617,25 +3904,27 @@ mod tests { ); } - #[test] - fn snapshot_upsert_unchanged_updates_preserve_generation() { + #[tokio::test(flavor = "current_thread")] + async fn snapshot_upsert_unchanged_updates_preserve_generation() { let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = snapshot_ec_id(); let mut seeded = live_entry(); apply_partner_id_updates(&mut seeded, &[PartnerIdUpdate::new("ssp_x", "uid-1")]); - kv.create(&ec_id, &seeded).expect("should seed"); - let snapshot = kv.load_snapshot(&ec_id); + kv.create(&ec_id, &seeded).await.expect("should seed"); + let snapshot = kv.load_snapshot(&ec_id).await; assert_eq!( snapshot.generation_for(&ec_id), Some(1), "seeded snapshot should carry its stored generation" ); - let outcome = kv.upsert_partner_ids_from_snapshot( - &ec_id, - &[PartnerIdUpdate::new("ssp_x", "uid-1")], - snapshot, - ); + let outcome = kv + .upsert_partner_ids_from_snapshot( + &ec_id, + &[PartnerIdUpdate::new("ssp_x", "uid-1")], + snapshot, + ) + .await; assert_eq!( outcome.generation_for(&ec_id), @@ -3644,12 +3933,15 @@ mod tests { ); } - #[test] - fn snapshot_upsert_refreshes_unavailable_generation_exactly_once() { + #[tokio::test(flavor = "current_thread")] + async fn snapshot_upsert_refreshes_unavailable_generation_exactly_once() { let lookups = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let graph = KvIdentityGraph::counting("counting-store", lookups.clone()); let ec_id = snapshot_ec_id(); - graph.create(&ec_id, &live_entry()).expect("should seed"); + graph + .create(&ec_id, &live_entry()) + .await + .expect("should seed"); // Finalize-written style snapshot: entry known, generation unavailable. let snapshot = EcKvSnapshot::Present { ec_id: ec_id.clone(), @@ -3658,7 +3950,9 @@ mod tests { }; let updates = [PartnerIdUpdate::new("ssp_x", "uid-1")]; - let outcome = graph.upsert_partner_ids_from_snapshot(&ec_id, &updates, snapshot); + let outcome = graph + .upsert_partner_ids_from_snapshot(&ec_id, &updates, snapshot) + .await; assert_eq!( lookups.load(std::sync::atomic::Ordering::Relaxed), @@ -3673,15 +3967,18 @@ mod tests { ); } - #[test] - fn snapshot_upsert_gen_unavailable_survives_four_conflicts_then_writes() { + #[tokio::test(flavor = "current_thread")] + async fn snapshot_upsert_gen_unavailable_survives_four_conflicts_then_writes() { // A generation-unavailable snapshot (finalize-written style) refreshes // once to obtain a usable generation. That refresh must not consume a // CAS attempt, so all five write attempts remain: four conflicts // followed by a successful fifth write still persist the update. let graph = KvIdentityGraph::new(ConflictInjectingEcKv::new(4, false)); let ec_id = snapshot_ec_id(); - graph.create(&ec_id, &live_entry()).expect("should seed"); + graph + .create(&ec_id, &live_entry()) + .await + .expect("should seed"); let snapshot = EcKvSnapshot::Present { ec_id: ec_id.clone(), entry: Box::new(live_entry()), @@ -3689,7 +3986,9 @@ mod tests { }; let updates = [PartnerIdUpdate::new("ssp_x", "uid-1")]; - let outcome = graph.upsert_partner_ids_from_snapshot(&ec_id, &updates, snapshot); + let outcome = graph + .upsert_partner_ids_from_snapshot(&ec_id, &updates, snapshot) + .await; assert_eq!( outcome @@ -3701,15 +4000,20 @@ mod tests { ); } - #[test] - fn snapshot_upsert_cas_conflict_remerges_concurrent_data() { + #[tokio::test(flavor = "current_thread")] + async fn snapshot_upsert_cas_conflict_remerges_concurrent_data() { let graph = KvIdentityGraph::new(ConflictInjectingEcKv::new(1, true)); let ec_id = snapshot_ec_id(); - graph.create(&ec_id, &live_entry()).expect("should seed"); - let snapshot = graph.load_snapshot(&ec_id); + graph + .create(&ec_id, &live_entry()) + .await + .expect("should seed"); + let snapshot = graph.load_snapshot(&ec_id).await; let updates = [PartnerIdUpdate::new("ssp_x", "uid-1")]; - let outcome = graph.upsert_partner_ids_from_snapshot(&ec_id, &updates, snapshot); + let outcome = graph + .upsert_partner_ids_from_snapshot(&ec_id, &updates, snapshot) + .await; let entry = outcome .entry_for(&ec_id) @@ -3722,24 +4026,28 @@ mod tests { assert!(entry.consent.ok, "concurrent revive keeps the row live"); } - #[test] - fn snapshot_upsert_revalidates_transient_missing_and_persists() { + #[tokio::test(flavor = "current_thread")] + async fn snapshot_upsert_revalidates_transient_missing_and_persists() { // An eventually-consistent point read earlier in the request missed a // row that exists. Named routes such as `/auction` never run orphan // recovery, so this refresh is the request's only chance to persist the // collected partner IDs. let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = snapshot_ec_id(); - kv.create(&ec_id, &live_entry()).expect("should seed live"); + kv.create(&ec_id, &live_entry()) + .await + .expect("should seed live"); let updates = [PartnerIdUpdate::new("ssp_x", "uid-1")]; - let outcome = kv.upsert_partner_ids_from_snapshot( - &ec_id, - &updates, - EcKvSnapshot::Missing { - ec_id: ec_id.clone(), - }, - ); + let outcome = kv + .upsert_partner_ids_from_snapshot( + &ec_id, + &updates, + EcKvSnapshot::Missing { + ec_id: ec_id.clone(), + }, + ) + .await; assert_eq!( outcome @@ -3750,6 +4058,7 @@ mod tests { ); let (stored, _) = kv .get(&ec_id) + .await .expect("should read store") .expect("row should remain"); assert_eq!( @@ -3759,48 +4068,54 @@ mod tests { ); } - #[test] - fn snapshot_upsert_confirmed_missing_still_never_creates() { + #[tokio::test(flavor = "current_thread")] + async fn snapshot_upsert_confirmed_missing_still_never_creates() { // Revalidation only changes what a *stale* miss does. A row that is // genuinely absent on the refresh must stay absent. let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = snapshot_ec_id(); let updates = [PartnerIdUpdate::new("ssp_x", "uid-1")]; - let outcome = kv.upsert_partner_ids_from_snapshot( - &ec_id, - &updates, - EcKvSnapshot::Missing { - ec_id: ec_id.clone(), - }, - ); + let outcome = kv + .upsert_partner_ids_from_snapshot( + &ec_id, + &updates, + EcKvSnapshot::Missing { + ec_id: ec_id.clone(), + }, + ) + .await; assert!( matches!(outcome, EcKvSnapshot::Missing { .. }), "a confirmed miss must stay missing" ); assert!( - kv.get(&ec_id).expect("should read store").is_none(), + kv.get(&ec_id).await.expect("should read store").is_none(), "must not create a root entry for a missing key" ); } - #[test] - fn snapshot_upsert_failed_snapshot_is_not_revalidated() { + #[tokio::test(flavor = "current_thread")] + async fn snapshot_upsert_failed_snapshot_is_not_revalidated() { // A lookup that already errored is not retried on the hot path, even // though the row exists. let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = snapshot_ec_id(); - kv.create(&ec_id, &live_entry()).expect("should seed live"); + kv.create(&ec_id, &live_entry()) + .await + .expect("should seed live"); let updates = [PartnerIdUpdate::new("ssp_x", "uid-1")]; - let outcome = kv.upsert_partner_ids_from_snapshot( - &ec_id, - &updates, - EcKvSnapshot::Failed { - ec_id: ec_id.clone(), - }, - ); + let outcome = kv + .upsert_partner_ids_from_snapshot( + &ec_id, + &updates, + EcKvSnapshot::Failed { + ec_id: ec_id.clone(), + }, + ) + .await; assert!( matches!(outcome, EcKvSnapshot::Failed { .. }), @@ -3808,16 +4123,19 @@ mod tests { ); } - #[test] - fn snapshot_upsert_rejects_tombstone() { + #[tokio::test(flavor = "current_thread")] + async fn snapshot_upsert_rejects_tombstone() { let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = snapshot_ec_id(); kv.create(&ec_id, &KvEntry::tombstone(1000)) + .await .expect("should seed tombstone"); - let snapshot = kv.load_snapshot(&ec_id); + let snapshot = kv.load_snapshot(&ec_id).await; let updates = [PartnerIdUpdate::new("ssp_x", "uid-1")]; - let outcome = kv.upsert_partner_ids_from_snapshot(&ec_id, &updates, snapshot); + let outcome = kv + .upsert_partner_ids_from_snapshot(&ec_id, &updates, snapshot) + .await; assert!( outcome @@ -3827,13 +4145,14 @@ mod tests { ); let (stored, _) = kv .get(&ec_id) + .await .expect("should read store") .expect("tombstone should remain"); assert!(stored.ids.is_empty(), "no update should reach the store"); } - #[test] - fn snapshot_upsert_store_failure_returns_failed_not_request_local() { + #[tokio::test(flavor = "current_thread")] + async fn snapshot_upsert_store_failure_returns_failed_not_request_local() { let graph = KvIdentityGraph::new(WriteFailingEcKv::new()); let ec_id = snapshot_ec_id(); let snapshot = EcKvSnapshot::Present { @@ -3843,7 +4162,9 @@ mod tests { }; let updates = [PartnerIdUpdate::new("ssp_x", "uid-1")]; - let outcome = graph.upsert_partner_ids_from_snapshot(&ec_id, &updates, snapshot); + let outcome = graph + .upsert_partner_ids_from_snapshot(&ec_id, &updates, snapshot) + .await; assert!( matches!(outcome, EcKvSnapshot::Failed { .. }), @@ -3851,8 +4172,8 @@ mod tests { ); } - #[test] - fn tombstone_existing_from_snapshot_skips_backend_for_authoritative_tombstone() { + #[tokio::test(flavor = "current_thread")] + async fn tombstone_existing_from_snapshot_skips_backend_for_authoritative_tombstone() { for generation in [Some(7), None] { let operations = Arc::new(RecordedEcKvOperations::default()); let graph = KvIdentityGraph::new(RecordingEcKv::new(Arc::clone(&operations))); @@ -3863,7 +4184,9 @@ mod tests { generation, }; - let outcome = graph.tombstone_existing_from_snapshot(&ec_id, snapshot.clone()); + let outcome = graph + .tombstone_existing_from_snapshot(&ec_id, snapshot.clone()) + .await; assert_eq!( outcome, snapshot, @@ -3877,18 +4200,21 @@ mod tests { } } - #[test] - fn tombstone_existing_from_snapshot_repeated_request_preserves_first_write() { + #[tokio::test(flavor = "current_thread")] + async fn tombstone_existing_from_snapshot_repeated_request_preserves_first_write() { let operations = Arc::new(RecordedEcKvOperations::default()); let graph = KvIdentityGraph::new(RecordingEcKv::new(Arc::clone(&operations))); let ec_id = snapshot_ec_id(); graph .create(&ec_id, &live_entry()) + .await .expect("should seed live row"); - let live_snapshot = graph.load_snapshot(&ec_id); + let live_snapshot = graph.load_snapshot(&ec_id).await; operations.reset(); - graph.tombstone_existing_from_snapshot(&ec_id, live_snapshot); + graph + .tombstone_existing_from_snapshot(&ec_id, live_snapshot) + .await; assert_eq!( operations.lookup_count(), @@ -3909,7 +4235,7 @@ mod tests { ], "first withdrawal should write the root and its completion marker" ); - let first_snapshot = graph.load_snapshot(&ec_id); + let first_snapshot = graph.load_snapshot(&ec_id).await; let (first_entry, first_generation) = match &first_snapshot { EcKvSnapshot::Present { entry, generation, .. @@ -3918,7 +4244,9 @@ mod tests { }; operations.reset(); - let second_outcome = graph.tombstone_existing_from_snapshot(&ec_id, first_snapshot); + let second_outcome = graph + .tombstone_existing_from_snapshot(&ec_id, first_snapshot) + .await; assert_eq!( operations.operation_count(), @@ -3939,8 +4267,8 @@ mod tests { ); } - #[test] - fn tombstone_existing_from_repeated_stale_miss_preserves_first_write() { + #[tokio::test(flavor = "current_thread")] + async fn tombstone_existing_from_repeated_stale_miss_preserves_first_write() { let operations = Arc::new(RecordedEcKvOperations::default()); let graph = KvIdentityGraph::new(RecordingEcKv::with_stale_lookups( Arc::clone(&operations), @@ -3949,15 +4277,18 @@ mod tests { let ec_id = snapshot_ec_id(); graph .create(&ec_id, &live_entry()) + .await .expect("should seed live row"); operations.reset(); - let first_outcome = graph.tombstone_existing_from_snapshot( - &ec_id, - EcKvSnapshot::Missing { - ec_id: ec_id.clone(), - }, - ); + let first_outcome = graph + .tombstone_existing_from_snapshot( + &ec_id, + EcKvSnapshot::Missing { + ec_id: ec_id.clone(), + }, + ) + .await; let first_updated = first_outcome .entry_for(&ec_id) .expect("should return first tombstone") @@ -3985,12 +4316,14 @@ mod tests { ); operations.reset(); - graph.tombstone_existing_from_snapshot( - &ec_id, - EcKvSnapshot::Missing { - ec_id: ec_id.clone(), - }, - ); + graph + .tombstone_existing_from_snapshot( + &ec_id, + EcKvSnapshot::Missing { + ec_id: ec_id.clone(), + }, + ) + .await; assert_eq!( operations.lookup_count(), @@ -4018,6 +4351,7 @@ mod tests { ); let (stored, generation) = graph .get(&ec_id) + .await .expect("should read stored tombstone") .expect("should preserve tombstone"); assert_eq!( @@ -4030,19 +4364,24 @@ mod tests { ); } - #[test] - fn tombstone_existing_from_stale_parallel_snapshot_stops_after_conflict() { + #[tokio::test(flavor = "current_thread")] + async fn tombstone_existing_from_stale_parallel_snapshot_stops_after_conflict() { let operations = Arc::new(RecordedEcKvOperations::default()); let graph = KvIdentityGraph::new(RecordingEcKv::new(Arc::clone(&operations))); let ec_id = snapshot_ec_id(); graph .create(&ec_id, &live_entry()) + .await .expect("should seed live row"); - let stale_snapshot = graph.load_snapshot(&ec_id); + let stale_snapshot = graph.load_snapshot(&ec_id).await; operations.reset(); - let first_outcome = graph.tombstone_existing_from_snapshot(&ec_id, stale_snapshot.clone()); - let second_outcome = graph.tombstone_existing_from_snapshot(&ec_id, stale_snapshot); + let first_outcome = graph + .tombstone_existing_from_snapshot(&ec_id, stale_snapshot.clone()) + .await; + let second_outcome = graph + .tombstone_existing_from_snapshot(&ec_id, stale_snapshot) + .await; assert_eq!( operations.inserts(), @@ -4083,8 +4422,8 @@ mod tests { ); } - #[test] - fn tombstone_existing_from_snapshot_succeeds_without_backend_for_tombstone() { + #[tokio::test(flavor = "current_thread")] + async fn tombstone_existing_from_snapshot_succeeds_without_backend_for_tombstone() { let graph = KvIdentityGraph::failing("unavailable-store"); let ec_id = snapshot_ec_id(); let snapshot = EcKvSnapshot::Present { @@ -4093,7 +4432,9 @@ mod tests { generation: Some(3), }; - let outcome = graph.tombstone_existing_from_snapshot(&ec_id, snapshot.clone()); + let outcome = graph + .tombstone_existing_from_snapshot(&ec_id, snapshot.clone()) + .await; assert_eq!( outcome, snapshot, @@ -4101,8 +4442,8 @@ mod tests { ); } - #[test] - fn tombstone_existing_from_snapshot_non_authoritative_states_reread_live_row() { + #[tokio::test(flavor = "current_thread")] + async fn tombstone_existing_from_snapshot_non_authoritative_states_reread_live_row() { let ec_id = snapshot_ec_id(); let states = [ EcKvSnapshot::NotRead, @@ -4126,10 +4467,11 @@ mod tests { let graph = KvIdentityGraph::new(RecordingEcKv::new(Arc::clone(&operations))); graph .create(&ec_id, &live_entry()) + .await .expect("should seed live row"); operations.reset(); - let outcome = graph.tombstone_existing_from_snapshot(&ec_id, state); + let outcome = graph.tombstone_existing_from_snapshot(&ec_id, state).await; assert_eq!( operations.lookup_count(), @@ -4150,22 +4492,23 @@ mod tests { } } - #[test] - fn tombstone_stale_miss_valid_marker_skips_root_check_and_writes() { + #[tokio::test(flavor = "current_thread")] + async fn tombstone_stale_miss_valid_marker_skips_root_check_and_writes() { let ec_id = snapshot_ec_id(); let operations = Arc::new(RecordedEcKvOperations::default()); - let graph = KvIdentityGraph::new(RecordingEcKv::completed_with_root_check_failure( - Arc::clone(&operations), - &ec_id, - )); - - let outcome = graph.tombstone_existing_from_snapshot( - &ec_id, - EcKvSnapshot::Missing { - ec_id: ec_id.clone(), - }, + let graph = KvIdentityGraph::new( + RecordingEcKv::completed_with_root_check_failure(Arc::clone(&operations), &ec_id).await, ); + let outcome = graph + .tombstone_existing_from_snapshot( + &ec_id, + EcKvSnapshot::Missing { + ec_id: ec_id.clone(), + }, + ) + .await; + assert!( matches!(outcome, EcKvSnapshot::Missing { .. }), "a completion marker should resolve a repeated stale miss" @@ -4191,8 +4534,8 @@ mod tests { ); } - #[test] - fn tombstone_stale_miss_still_writes_when_marker_operations_fail() { + #[tokio::test(flavor = "current_thread")] + async fn tombstone_stale_miss_still_writes_when_marker_operations_fail() { let operations = Arc::new(RecordedEcKvOperations::default()); let graph = KvIdentityGraph::new(RecordingEcKv::with_marker_failures( Arc::clone(&operations), @@ -4201,15 +4544,18 @@ mod tests { let ec_id = snapshot_ec_id(); graph .create(&ec_id, &live_entry()) + .await .expect("should seed live row"); operations.reset(); - let outcome = graph.tombstone_existing_from_snapshot( - &ec_id, - EcKvSnapshot::Missing { - ec_id: ec_id.clone(), - }, - ); + let outcome = graph + .tombstone_existing_from_snapshot( + &ec_id, + EcKvSnapshot::Missing { + ec_id: ec_id.clone(), + }, + ) + .await; assert!( outcome @@ -4233,19 +4579,25 @@ mod tests { ); let (stored, _) = graph .get(&ec_id) + .await .expect("should read stored row") .expect("should preserve the root"); assert!(!stored.consent.ok, "root should remain tombstoned"); } - #[test] - fn tombstone_existing_from_snapshot_retries_cas_conflict() { + #[tokio::test(flavor = "current_thread")] + async fn tombstone_existing_from_snapshot_retries_cas_conflict() { let graph = KvIdentityGraph::new(ConflictInjectingEcKv::new(1, false)); let ec_id = snapshot_ec_id(); - graph.create(&ec_id, &live_entry()).expect("should seed"); - let snapshot = graph.load_snapshot(&ec_id); + graph + .create(&ec_id, &live_entry()) + .await + .expect("should seed"); + let snapshot = graph.load_snapshot(&ec_id).await; - let outcome = graph.tombstone_existing_from_snapshot(&ec_id, snapshot); + let outcome = graph + .tombstone_existing_from_snapshot(&ec_id, snapshot) + .await; assert!( outcome @@ -4255,21 +4607,26 @@ mod tests { ); } - #[test] - fn tombstone_gen_unavailable_survives_four_conflicts_then_writes() { + #[tokio::test(flavor = "current_thread")] + async fn tombstone_gen_unavailable_survives_four_conflicts_then_writes() { // A generation-unavailable snapshot refreshes once before its CAS. That // refresh must not spend a CAS attempt, so a withdrawal tombstone still // persists after four conflicts and a successful fifth write. let graph = KvIdentityGraph::new(ConflictInjectingEcKv::new(4, false)); let ec_id = snapshot_ec_id(); - graph.create(&ec_id, &live_entry()).expect("should seed"); + graph + .create(&ec_id, &live_entry()) + .await + .expect("should seed"); let snapshot = EcKvSnapshot::Present { ec_id: ec_id.clone(), entry: Box::new(live_entry()), generation: None, }; - let outcome = graph.tombstone_existing_from_snapshot(&ec_id, snapshot); + let outcome = graph + .tombstone_existing_from_snapshot(&ec_id, snapshot) + .await; assert!( outcome @@ -4279,17 +4636,22 @@ mod tests { ); } - #[test] - fn tombstone_existing_from_snapshot_returns_failed_after_cas_exhaustion() { + #[tokio::test(flavor = "current_thread")] + async fn tombstone_existing_from_snapshot_returns_failed_after_cas_exhaustion() { // Every CAS attempt loses its race, so the row stays live with consent // granted while the browser cookie is already cleared. The caller must // see a failure it can report rather than a silent no-op. let graph = KvIdentityGraph::new(ConflictInjectingEcKv::new(MAX_CAS_RETRIES, false)); let ec_id = snapshot_ec_id(); - graph.create(&ec_id, &live_entry()).expect("should seed"); - let snapshot = graph.load_snapshot(&ec_id); + graph + .create(&ec_id, &live_entry()) + .await + .expect("should seed"); + let snapshot = graph.load_snapshot(&ec_id).await; - let outcome = graph.tombstone_existing_from_snapshot(&ec_id, snapshot); + let outcome = graph + .tombstone_existing_from_snapshot(&ec_id, snapshot) + .await; assert!( matches!(outcome, EcKvSnapshot::Failed { .. }), @@ -4297,6 +4659,7 @@ mod tests { ); let (stored, _) = graph .get(&ec_id) + .await .expect("should read store") .expect("row should remain"); assert!( @@ -4305,16 +4668,19 @@ mod tests { ); } - #[test] - fn tombstone_existing_from_snapshot_overrides_concurrent_live_update() { + #[tokio::test(flavor = "current_thread")] + async fn tombstone_existing_from_snapshot_overrides_concurrent_live_update() { let graph = KvIdentityGraph::new(ConflictInjectingEcKv::with_partner_update_on_conflict(1)); let ec_id = snapshot_ec_id(); graph .create(&ec_id, &live_entry()) + .await .expect("should seed live row"); - let snapshot = graph.load_snapshot(&ec_id); + let snapshot = graph.load_snapshot(&ec_id).await; - let outcome = graph.tombstone_existing_from_snapshot(&ec_id, snapshot); + let outcome = graph + .tombstone_existing_from_snapshot(&ec_id, snapshot) + .await; let entry = outcome .entry_for(&ec_id) @@ -4326,19 +4692,23 @@ mod tests { ); } - #[test] - fn upsert_partner_id_rejects_tombstone() { + #[tokio::test(flavor = "current_thread")] + async fn upsert_partner_id_rejects_tombstone() { let graph = KvIdentityGraph::in_memory("test-store"); let ec_id = snapshot_ec_id(); graph .create(&ec_id, &KvEntry::tombstone(1_000)) + .await .expect("should seed tombstone"); - let result = graph.upsert_partner_id(&ec_id, "ssp.example.com", "uid-1"); + let result = graph + .upsert_partner_id(&ec_id, "ssp.example.com", "uid-1") + .await; assert!(result.is_err(), "public upsert should reject a tombstone"); let (stored, _) = graph .get(&ec_id) + .await .expect("should read store") .expect("should preserve tombstone"); assert!(!stored.consent.ok, "entry should remain withdrawn"); @@ -4348,8 +4718,8 @@ mod tests { ); } - #[test] - fn tombstone_existing_from_snapshot_store_failure_returns_failed() { + #[tokio::test(flavor = "current_thread")] + async fn tombstone_existing_from_snapshot_store_failure_returns_failed() { let graph = KvIdentityGraph::new(WriteFailingEcKv::new()); let ec_id = snapshot_ec_id(); let snapshot = EcKvSnapshot::Present { @@ -4358,7 +4728,9 @@ mod tests { generation: Some(1), }; - let outcome = graph.tombstone_existing_from_snapshot(&ec_id, snapshot); + let outcome = graph + .tombstone_existing_from_snapshot(&ec_id, snapshot) + .await; assert!( matches!(outcome, EcKvSnapshot::Failed { .. }), @@ -4366,41 +4738,51 @@ mod tests { ); } - #[test] - fn tombstone_existing_from_snapshot_noop_when_row_disappears_on_retry() { + #[tokio::test(flavor = "current_thread")] + async fn tombstone_existing_from_snapshot_noop_when_row_disappears_on_retry() { let store = DisappearOnConflictEcKv::new(1); - store.seed_live(&snapshot_ec_id()); + store.seed_live(&snapshot_ec_id()).await; let graph = KvIdentityGraph::new(store); let ec_id = snapshot_ec_id(); - let snapshot = graph.load_snapshot(&ec_id); + let snapshot = graph.load_snapshot(&ec_id).await; - let outcome = graph.tombstone_existing_from_snapshot(&ec_id, snapshot); + let outcome = graph + .tombstone_existing_from_snapshot(&ec_id, snapshot) + .await; assert!( matches!(outcome, EcKvSnapshot::Missing { .. }), "a row that disappears during retry becomes a no-op" ); assert!( - graph.get(&ec_id).expect("should read store").is_none(), + graph + .get(&ec_id) + .await + .expect("should read store") + .is_none(), "must not recreate the disappeared key" ); } - #[test] - fn tombstone_existing_from_snapshot_reretries_failed_snapshot_read() { + #[tokio::test(flavor = "current_thread")] + async fn tombstone_existing_from_snapshot_reretries_failed_snapshot_read() { // A prior request-scoped read failed, so the snapshot is `Failed`. A // withdrawal must not silently drop consent removal: re-read the store // and tombstone the row if it is authoritatively present. let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = snapshot_ec_id(); - kv.create(&ec_id, &live_entry()).expect("should seed live"); + kv.create(&ec_id, &live_entry()) + .await + .expect("should seed live"); - let outcome = kv.tombstone_existing_from_snapshot( - &ec_id, - EcKvSnapshot::Failed { - ec_id: ec_id.clone(), - }, - ); + let outcome = kv + .tombstone_existing_from_snapshot( + &ec_id, + EcKvSnapshot::Failed { + ec_id: ec_id.clone(), + }, + ) + .await; assert!( outcome @@ -4410,26 +4792,33 @@ mod tests { ); let (stored, _) = kv .get(&ec_id) + .await .expect("should read store") .expect("should preserve existing key"); assert!(!stored.consent.ok, "withdrawal must reach the store"); } - #[test] - fn key_exists_confirmed_distinguishes_absence_from_a_stale_point_read() { + #[tokio::test(flavor = "current_thread")] + async fn key_exists_confirmed_distinguishes_absence_from_a_stale_point_read() { let graph = KvIdentityGraph::stale_lookup("stale-store", 1); let ec_id = snapshot_ec_id(); graph .create(&ec_id, &live_entry()) + .await .expect("should seed live"); assert!( - graph.get(&ec_id).expect("should read store").is_none(), + graph + .get(&ec_id) + .await + .expect("should read store") + .is_none(), "the first point read is stale by construction" ); assert!( graph .key_exists_confirmed(&ec_id) + .await .expect("should list the store"), "the list API must still see a row the point read missed" ); @@ -4438,13 +4827,14 @@ mod tests { assert!( !absent .key_exists_confirmed(&ec_id) + .await .expect("should list the store"), "an empty store must prove absence" ); } - #[test] - fn snapshot_upsert_keeps_add_confirmed_present_when_refresh_misses() { + #[tokio::test(flavor = "current_thread")] + async fn snapshot_upsert_keeps_add_confirmed_present_when_refresh_misses() { // `generate_if_needed` records a successful `Add` as `Present` without a // generation. EID ingestion refreshes that snapshot to obtain one; on an // eventually-consistent store the refresh can miss. Enrichment is best @@ -4459,20 +4849,26 @@ mod tests { }; let updates = [PartnerIdUpdate::new("ssp_x", "uid-1")]; - let outcome = graph.upsert_partner_ids_from_snapshot(&ec_id, &updates, add_confirmed); + let outcome = graph + .upsert_partner_ids_from_snapshot(&ec_id, &updates, add_confirmed) + .await; assert!( outcome.entry_for(&ec_id).is_some(), "an Add-confirmed row must survive a non-authoritative refresh miss" ); assert!( - graph.get(&ec_id).expect("should read store").is_none(), + graph + .get(&ec_id) + .await + .expect("should read store") + .is_none(), "a missed refresh must not create or overwrite a root" ); } - #[test] - fn snapshot_upsert_keeps_add_confirmed_present_when_refresh_fails() { + #[tokio::test(flavor = "current_thread")] + async fn snapshot_upsert_keeps_add_confirmed_present_when_refresh_fails() { let graph = KvIdentityGraph::failing("failing-store"); let ec_id = snapshot_ec_id(); let add_confirmed = EcKvSnapshot::Present { @@ -4482,7 +4878,9 @@ mod tests { }; let updates = [PartnerIdUpdate::new("ssp_x", "uid-1")]; - let outcome = graph.upsert_partner_ids_from_snapshot(&ec_id, &updates, add_confirmed); + let outcome = graph + .upsert_partner_ids_from_snapshot(&ec_id, &updates, add_confirmed) + .await; assert!( outcome.entry_for(&ec_id).is_some(), @@ -4490,16 +4888,17 @@ mod tests { ); } - #[test] - fn snapshot_upsert_without_proof_still_reports_a_refresh_miss() { + #[tokio::test(flavor = "current_thread")] + async fn snapshot_upsert_without_proof_still_reports_a_refresh_miss() { // No prior proof of existence: a `NotRead` snapshot that refreshes into // a miss must stay `Missing` so finalization can run orphan recovery. let graph = KvIdentityGraph::in_memory("empty-store"); let ec_id = snapshot_ec_id(); let updates = [PartnerIdUpdate::new("ssp_x", "uid-1")]; - let outcome = - graph.upsert_partner_ids_from_snapshot(&ec_id, &updates, EcKvSnapshot::NotRead); + let outcome = graph + .upsert_partner_ids_from_snapshot(&ec_id, &updates, EcKvSnapshot::NotRead) + .await; assert!( matches!(outcome, EcKvSnapshot::Missing { .. }), @@ -4507,8 +4906,8 @@ mod tests { ); } - #[test] - fn a_store_error_never_carries_the_whole_identifier() { + #[tokio::test(flavor = "current_thread")] + async fn a_store_error_never_carries_the_whole_identifier() { // Every message in this module goes through `log_id`, so a report that // reaches a log cannot disclose the identifier it is about. let kv = KvIdentityGraph::failing("test_store"); @@ -4516,6 +4915,7 @@ mod tests { let report = kv .create(&ec_id, &live_entry()) + .await .expect_err("the failing store should error"); let rendered = format!("{report:?}"); @@ -4525,8 +4925,8 @@ mod tests { ); } - #[test] - fn a_locally_built_error_never_carries_the_whole_identifier() { + #[tokio::test(flavor = "current_thread")] + async fn a_locally_built_error_never_carries_the_whole_identifier() { // The injected-failure case above covers errors the backend produces. // These are built in this module from the identifier itself, on every // path a request can reach: a duplicate create, single upserts naming a @@ -4534,22 +4934,28 @@ mod tests { // terminal errors. let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); - kv.create(&ec_id, &live_entry()).expect("should create"); + kv.create(&ec_id, &live_entry()) + .await + .expect("should create"); let duplicate = kv .create(&ec_id, &live_entry()) + .await .expect_err("a second create should be refused"); let missing = kv .upsert_partner_id(&format!("{}.ZZZ999", "b".repeat(64)), "partner", "uid") + .await .expect_err("an upsert on a missing key should be refused"); let withdrawn = { assert_eq!( kv.write_withdrawal_tombstone(&ec_id, drop) + .await .expect("should tombstone"), TombstoneOutcome::Written, "should tombstone the seeded identity" ); kv.upsert_partner_id(&ec_id, "partner", "uid") + .await .expect_err("an upsert on a withdrawn key should be refused") }; @@ -4557,23 +4963,26 @@ mod tests { // and a store that never lets a write land is the only way to reach them. let cas_revive = { let store = ConflictInjectingEcKv::new(MAX_CAS_RETRIES + 1, false); - store.seed_tombstone(&ec_id); + store.seed_tombstone(&ec_id).await; KvIdentityGraph::new(store) .create_or_revive(&ec_id, &live_entry()) + .await .expect_err("should exhaust CAS retries") }; let cas_upsert = { let store = ConflictInjectingEcKv::new(MAX_CAS_RETRIES + 1, false); - store.seed_live(&ec_id); + store.seed_live(&ec_id).await; KvIdentityGraph::new(store) .upsert_partner_id(&ec_id, "partner", "uid") + .await .expect_err("should exhaust CAS retries") }; let cas_if_exists = { let store = ConflictInjectingEcKv::new(MAX_CAS_RETRIES + 1, false); - store.seed_live(&ec_id); + store.seed_live(&ec_id).await; KvIdentityGraph::new(store) .upsert_partner_id_if_exists(&ec_id, "partner", "uid") + .await .expect_err("should exhaust CAS retries") }; @@ -4593,25 +5002,26 @@ mod tests { } } - #[test] - fn write_withdrawal_tombstone_ignores_an_identity_the_store_does_not_hold() { + #[tokio::test(flavor = "current_thread")] + async fn write_withdrawal_tombstone_ignores_an_identity_the_store_does_not_hold() { let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = format!("{}.ABC123", "b".repeat(64)); assert_eq!( kv.write_withdrawal_tombstone(&ec_id, drop) + .await .expect("should resolve the withdrawal"), TombstoneOutcome::UnknownIdentity, "an identity that was never issued has nothing to withdraw" ); assert!( - kv.get(&ec_id).expect("should read back").is_none(), + kv.get(&ec_id).await.expect("should read back").is_none(), "should not create a row for an identity the store never held" ); } - #[test] - fn withdrawing_many_unheld_identities_creates_no_rows() { + #[tokio::test(flavor = "current_thread")] + async fn withdrawing_many_unheld_identities_creates_no_rows() { let kv = KvIdentityGraph::in_memory("test_store"); let hash = "c".repeat(64); @@ -4620,6 +5030,7 @@ mod tests { for suffix in ["aaaaaa", "bbbbbb", "cccccc", "dddddd"] { assert_eq!( kv.write_withdrawal_tombstone(&format!("{hash}.{suffix}"), drop) + .await .expect("should resolve the withdrawal"), TombstoneOutcome::UnknownIdentity, "suffix `{suffix}` was never issued" @@ -4628,41 +5039,46 @@ mod tests { assert_eq!( kv.count_hash_prefix_keys(&hash) + .await .expect("should count the prefix"), 0, "should hold no rows under a hash nothing was issued for" ); } - #[test] - fn withdrawal_does_not_lose_a_new_identity_to_lookup_lag() { + #[tokio::test(flavor = "current_thread")] + async fn withdrawal_does_not_lose_a_new_identity_to_lookup_lag() { let operations = Arc::new(RecordedEcKvOperations::default()); let kv = KvIdentityGraph::new(RecordingEcKv::with_lagging_live_lookups(operations)); let ec_id = format!("{}.ABC123", "a".repeat(64)); kv.create(&ec_id, &live_entry()) + .await .expect("should issue an identity"); assert!( kv.lookup_raw(&ec_id) + .await .expect("should read lagging replica") .is_none(), "should model the issuance replication gap" ); assert_eq!( kv.write_withdrawal_tombstone(&ec_id, drop) + .await .expect("should process withdrawal"), TombstoneOutcome::Written, "should tombstone an issued identity despite a lagging lookup" ); assert_eq!( kv.upsert_partner_id_if_exists(&ec_id, "partner", "uid") + .await .expect("should check batch-sync eligibility"), UpsertResult::ConsentWithdrawn, "should not revive the withdrawn identity through batch sync" ); } - #[test] - fn a_withdrawal_checks_strong_existence_once_without_eventual_lookup() { + #[tokio::test(flavor = "current_thread")] + async fn a_withdrawal_checks_strong_existence_once_without_eventual_lookup() { // One existence operation may page at the backend, but must not be // followed by an eventually consistent lookup. let operations = Arc::new(RecordedEcKvOperations::default()); @@ -4671,6 +5087,7 @@ mod tests { let absent = format!("{}.ABC123", "e".repeat(64)); assert_eq!( kv.write_withdrawal_tombstone(&absent, drop) + .await .expect("should resolve the withdrawal"), TombstoneOutcome::UnknownIdentity, "an absent identity is not held" @@ -4682,10 +5099,13 @@ mod tests { ); let held = format!("{}.ABC123", "a".repeat(64)); - kv.create(&held, &live_entry()).expect("should create"); + kv.create(&held, &live_entry()) + .await + .expect("should create"); let before = operations.exact_check_count(); assert_eq!( kv.write_withdrawal_tombstone(&held, drop) + .await .expect("should resolve the withdrawal"), TombstoneOutcome::Written, "a held identity is tombstoned" @@ -4702,20 +5122,23 @@ mod tests { ); } - #[test] - fn write_withdrawal_tombstone_refuses_an_empty_identifier() { + #[tokio::test(flavor = "current_thread")] + async fn write_withdrawal_tombstone_refuses_an_empty_identifier() { let kv = KvIdentityGraph::in_memory("test_store"); kv.create(&format!("{}.ABC123", "f".repeat(64)), &live_entry()) + .await .expect("should create"); assert_eq!( kv.write_withdrawal_tombstone("", drop) + .await .expect("should resolve the withdrawal"), TombstoneOutcome::UnknownIdentity, "an empty identifier names no key and must not withdraw anything" ); let (held, _) = kv .get(&format!("{}.ABC123", "f".repeat(64))) + .await .expect("should read back") .expect("should still hold the identity"); assert!( @@ -4737,47 +5160,51 @@ mod tests { } } + #[async_trait::async_trait(?Send)] impl EcKvStore for ReadFailingEcKv { fn store_name(&self) -> &str { self.inner.store_name() } - fn lookup(&self, _key: &str) -> Result, Report> { + async fn lookup( + &self, + _key: &str, + ) -> Result, Report> { Err(Report::new(TrustedServerError::KvStore { store_name: "test_store".to_owned(), message: "reads unavailable".to_owned(), })) } - fn key_exists(&self, key: &str) -> Result> { - self.lookup(key).map(|entry| entry.is_some()) + async fn key_exists(&self, key: &str) -> Result> { + self.lookup(key).await.map(|entry| entry.is_some()) } - fn insert( + async fn insert( &self, key: &str, write: EcKvWrite<'_>, ) -> Result> { - self.inner.insert(key, write) + self.inner.insert(key, write).await } // Left working so a test can prove no row was created without going // through the read path it just made fail. - fn list_keys_with_prefix( + async fn list_keys_with_prefix( &self, prefix: &str, limit: u32, ) -> Result, Report> { - self.inner.list_keys_with_prefix(prefix, limit) + self.inner.list_keys_with_prefix(prefix, limit).await } - fn delete(&self, key: &str) -> Result<(), Report> { - self.inner.delete(key) + async fn delete(&self, key: &str) -> Result<(), Report> { + self.inner.delete(key).await } } - #[test] - fn a_failing_check_is_not_a_way_to_write_for_an_identity_that_was_never_issued() { + #[tokio::test(flavor = "current_thread")] + async fn a_failing_check_is_not_a_way_to_write_for_an_identity_that_was_never_issued() { // The caller controls the identifier and can drive load, so a store // failure must not become a route to the write this gate exists to // prevent. @@ -4786,20 +5213,21 @@ mod tests { let ec_id = format!("{hash}.ABC123"); assert!( - kv.write_withdrawal_tombstone(&ec_id, drop).is_err(), + kv.write_withdrawal_tombstone(&ec_id, drop).await.is_err(), "a check that cannot answer is a fault, so a caller inspecting only \ the error case still reports it" ); assert_eq!( kv.count_hash_prefix_keys(&hash) + .await .expect("should count the prefix"), 0, "should not create a row while the store is degraded" ); } - #[test] - fn a_caller_that_only_inspects_the_error_case_still_sees_a_failed_check() { + #[tokio::test(flavor = "current_thread")] + async fn a_caller_that_only_inspects_the_error_case_still_sees_a_failed_check() { // The withdrawal call site is edited by more than one branch. Reporting // a failed check through `Err` means the common // `if let Err(..) = ...` shape cannot discard it, where a third `Ok` @@ -4808,51 +5236,57 @@ mod tests { let ec_id = format!("{}.ABC123", "6".repeat(64)); let mut reported = false; - if let Err(_err) = kv.write_withdrawal_tombstone(&ec_id, drop) { + if let Err(_err) = kv.write_withdrawal_tombstone(&ec_id, drop).await { reported = true; } assert!(reported, "a failed check must reach an error-only caller"); } - #[test] - fn a_longer_key_does_not_answer_for_the_identity_it_starts_with() { + #[tokio::test(flavor = "current_thread")] + async fn a_longer_key_does_not_answer_for_the_identity_it_starts_with() { let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = format!("{}.ABC123", "7".repeat(64)); // Only a longer key exists. The identity itself was never issued, so a // check that matched by prefix would report it as held and tombstone it. kv.create(&format!("{ec_id}trailing"), &live_entry()) + .await .expect("should create"); assert!( - !kv.key_exists_confirmed(&ec_id).expect("should check"), + !kv.key_exists_confirmed(&ec_id).await.expect("should check"), "a longer key is a different identity" ); assert_eq!( kv.write_withdrawal_tombstone(&ec_id, drop) + .await .expect("should resolve the withdrawal"), TombstoneOutcome::UnknownIdentity, "should not tombstone an identity the store never held" ); assert!( - kv.get(&ec_id).expect("should read back").is_none(), + kv.get(&ec_id).await.expect("should read back").is_none(), "should not create a row via a prefix match" ); } - #[test] - fn key_exists_confirmed_distinguishes_held_identities() { + #[tokio::test(flavor = "current_thread")] + async fn key_exists_confirmed_distinguishes_held_identities() { let kv = KvIdentityGraph::in_memory("test_store"); let held = format!("{}.ABC123", "d".repeat(64)); let sibling = format!("{}.ZZZ999", "d".repeat(64)); - kv.create(&held, &live_entry()).expect("should create"); + kv.create(&held, &live_entry()) + .await + .expect("should create"); assert!( - kv.key_exists_confirmed(&held).expect("should check"), + kv.key_exists_confirmed(&held).await.expect("should check"), "should confirm a held identity" ); assert!( - !kv.key_exists_confirmed(&sibling).expect("should check"), + !kv.key_exists_confirmed(&sibling) + .await + .expect("should check"), "a different suffix under the same hash is a different identity" ); } diff --git a/crates/trusted-server-core/src/ec/kv_backend.rs b/crates/trusted-server-core/src/ec/kv_backend.rs index bfe74ca6f3..a6f1830c23 100644 --- a/crates/trusted-server-core/src/ec/kv_backend.rs +++ b/crates/trusted-server-core/src/ec/kv_backend.rs @@ -70,6 +70,10 @@ pub enum EcKvWriteOutcome { /// Infrastructure failures are reported as [`TrustedServerError::KvStore`]; /// write precondition failures are part of the normal control flow and are /// returned as [`EcKvWriteOutcome::PreconditionFailed`] instead of errors. +/// +/// I/O returns local futures so native backends can yield without blocking the +/// request runtime. Neither stores nor their futures need to be `Send` or `Sync`. +#[async_trait::async_trait(?Send)] pub trait EcKvStore { /// Returns the platform store name, used in log and error messages. fn store_name(&self) -> &str; @@ -81,12 +85,18 @@ pub trait EcKvStore { /// # Errors /// /// Returns [`TrustedServerError::KvStore`] on store open or read failure. - fn lookup(&self, key: &str) -> Result, Report>; + async fn lookup(&self, key: &str) -> Result, Report>; /// Checks exact-key existence against strongly consistent store state. /// /// A completed issuance must be visible even when [`Self::lookup`] lags. /// Prefix matches are insufficient and an inconclusive check is an error. + /// Logical expiry alone does not prove absence when a previously authorized + /// conditional write can still commit against the expired row. Such backends + /// must include physically held expired rows in this check for withdrawal + /// and orphan protection, even when lookup and listing hide logical expiry. + /// Returning `false` must establish that an old generation cannot still + /// update the apparently absent row. /// /// # Implementing this method /// @@ -108,7 +118,7 @@ pub trait EcKvStore { /// Returns [`TrustedServerError::KvStore`] on store failure or when a /// bounded check cannot determine existence. Never falls back to an /// eventually consistent read. - fn key_exists(&self, key: &str) -> Result>; + async fn key_exists(&self, key: &str) -> Result>; /// Writes an entry according to the requested precondition mode. /// @@ -117,7 +127,7 @@ pub trait EcKvStore { /// Returns [`TrustedServerError::KvStore`] on store open or write /// failure. Precondition failures are reported through the /// [`EcKvWriteOutcome`] instead. - fn insert( + async fn insert( &self, key: &str, write: EcKvWrite<'_>, @@ -138,7 +148,7 @@ pub trait EcKvStore { /// # Errors /// /// Returns [`TrustedServerError::KvStore`] on store open or list failure. - fn list_keys_with_prefix( + async fn list_keys_with_prefix( &self, prefix: &str, limit: u32, @@ -153,12 +163,12 @@ pub trait EcKvStore { /// # Errors /// /// Returns [`TrustedServerError::KvStore`] on store open or list failure. - fn count_keys_with_prefix( + async fn count_keys_with_prefix( &self, prefix: &str, limit: u32, ) -> Result> { - let count = self.list_keys_with_prefix(prefix, limit)?.len(); + let count = self.list_keys_with_prefix(prefix, limit).await?.len(); #[allow(clippy::cast_possible_truncation)] Ok(count as u32) } @@ -168,7 +178,7 @@ pub trait EcKvStore { /// # Errors /// /// Returns [`TrustedServerError::KvStore`] on store open or delete failure. - fn delete(&self, key: &str) -> Result<(), Report>; + async fn delete(&self, key: &str) -> Result<(), Report>; } #[cfg(test)] @@ -197,39 +207,43 @@ pub(crate) mod test_support { } } + #[async_trait::async_trait(?Send)] impl EcKvStore for CountingEcKv { fn store_name(&self) -> &str { self.inner.store_name() } - fn lookup(&self, key: &str) -> Result, Report> { + async fn lookup( + &self, + key: &str, + ) -> Result, Report> { self.lookups .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - self.inner.lookup(key) + self.inner.lookup(key).await } - fn key_exists(&self, key: &str) -> Result> { - self.inner.key_exists(key) + async fn key_exists(&self, key: &str) -> Result> { + self.inner.key_exists(key).await } - fn insert( + async fn insert( &self, key: &str, write: EcKvWrite<'_>, ) -> Result> { - self.inner.insert(key, write) + self.inner.insert(key, write).await } - fn list_keys_with_prefix( + async fn list_keys_with_prefix( &self, prefix: &str, limit: u32, ) -> Result, Report> { - self.inner.list_keys_with_prefix(prefix, limit) + self.inner.list_keys_with_prefix(prefix, limit).await } - fn delete(&self, key: &str) -> Result<(), Report> { - self.inner.delete(key) + async fn delete(&self, key: &str) -> Result<(), Report> { + self.inner.delete(key).await } } @@ -258,42 +272,48 @@ pub(crate) mod test_support { } } + #[async_trait::async_trait(?Send)] impl EcKvStore for StaleLookupEcKv { fn store_name(&self) -> &str { self.inner.store_name() } - fn lookup(&self, key: &str) -> Result, Report> { - let mut remaining = self - .stale_lookups_remaining - .lock() - .expect("should lock stale-lookup counter"); - if *remaining > 0 { - *remaining -= 1; - return Ok(None); + async fn lookup( + &self, + key: &str, + ) -> Result, Report> { + { + let mut remaining = self + .stale_lookups_remaining + .lock() + .expect("should lock stale-lookup counter"); + if *remaining > 0 { + *remaining -= 1; + return Ok(None); + } } - self.inner.lookup(key) + self.inner.lookup(key).await } - fn key_exists(&self, key: &str) -> Result> { + async fn key_exists(&self, key: &str) -> Result> { if self.list_fails { return Err(Report::new(TrustedServerError::KvStore { store_name: self.inner.store_name().to_owned(), message: "existence check unavailable".to_owned(), })); } - self.inner.key_exists(key) + self.inner.key_exists(key).await } - fn insert( + async fn insert( &self, key: &str, write: EcKvWrite<'_>, ) -> Result> { - self.inner.insert(key, write) + self.inner.insert(key, write).await } - fn list_keys_with_prefix( + async fn list_keys_with_prefix( &self, prefix: &str, limit: u32, @@ -304,11 +324,11 @@ pub(crate) mod test_support { message: "list unavailable".to_owned(), })); } - self.inner.list_keys_with_prefix(prefix, limit) + self.inner.list_keys_with_prefix(prefix, limit).await } - fn delete(&self, key: &str) -> Result<(), Report> { - self.inner.delete(key) + async fn delete(&self, key: &str) -> Result<(), Report> { + self.inner.delete(key).await } } @@ -333,12 +353,16 @@ pub(crate) mod test_support { } } + #[async_trait::async_trait(?Send)] impl EcKvStore for InMemoryEcKv { fn store_name(&self) -> &str { &self.name } - fn lookup(&self, key: &str) -> Result, Report> { + async fn lookup( + &self, + key: &str, + ) -> Result, Report> { let entries = self.entries.lock().expect("should lock in-memory store"); Ok(entries.get(key).map(|stored| EcKvLookup { body: stored.body.clone(), @@ -347,12 +371,12 @@ pub(crate) mod test_support { })) } - fn key_exists(&self, key: &str) -> Result> { + async fn key_exists(&self, key: &str) -> Result> { let entries = self.entries.lock().expect("should lock in-memory store"); Ok(entries.contains_key(key)) } - fn insert( + async fn insert( &self, key: &str, write: EcKvWrite<'_>, @@ -385,7 +409,7 @@ pub(crate) mod test_support { Ok(EcKvWriteOutcome::Written) } - fn list_keys_with_prefix( + async fn list_keys_with_prefix( &self, prefix: &str, limit: u32, @@ -399,7 +423,7 @@ pub(crate) mod test_support { .collect()) } - fn delete(&self, key: &str) -> Result<(), Report> { + async fn delete(&self, key: &str) -> Result<(), Report> { let mut entries = self.entries.lock().expect("should lock in-memory store"); entries.remove(key); Ok(()) @@ -425,21 +449,25 @@ pub(crate) mod test_support { } } + #[async_trait::async_trait(?Send)] impl EcKvStore for FailingEcKv { fn store_name(&self) -> &str { &self.name } - fn lookup(&self, _key: &str) -> Result, Report> { + async fn lookup( + &self, + _key: &str, + ) -> Result, Report> { Err(self.error("lookup")) } - fn key_exists(&self, key: &str) -> Result> { + async fn key_exists(&self, key: &str) -> Result> { let _ = key; Err(self.error("key_exists")) } - fn insert( + async fn insert( &self, _key: &str, _write: EcKvWrite<'_>, @@ -447,7 +475,7 @@ pub(crate) mod test_support { Err(self.error("insert")) } - fn list_keys_with_prefix( + async fn list_keys_with_prefix( &self, _prefix: &str, _limit: u32, @@ -455,7 +483,7 @@ pub(crate) mod test_support { Err(self.error("list")) } - fn delete(&self, _key: &str) -> Result<(), Report> { + async fn delete(&self, _key: &str) -> Result<(), Report> { Err(self.error("delete")) } } diff --git a/crates/trusted-server-core/src/ec/kv_types.rs b/crates/trusted-server-core/src/ec/kv_types.rs index 589f280c11..628e54b63f 100644 --- a/crates/trusted-server-core/src/ec/kv_types.rs +++ b/crates/trusted-server-core/src/ec/kv_types.rs @@ -442,7 +442,7 @@ impl KvGeo { pub fn from_geo_info(geo: Option<&GeoInfo>) -> Self { match geo { Some(info) => { - let dma = (info.metro_code > 0).then_some(info.metro_code); + let dma = info.metro_code.filter(|code| *code > 0); Self { country: info.country.clone(), region: info.region.clone(), @@ -474,12 +474,14 @@ mod tests { fn sample_geo_info() -> GeoInfo { GeoInfo { - city: "San Francisco".to_owned(), + city: Some("San Francisco".to_owned()), country: "US".to_owned(), - continent: "NorthAmerica".to_owned(), - latitude: 37.7749, - longitude: -122.4194, - metro_code: 807, + continent: Some("NorthAmerica".to_owned()), + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 37.7749, + longitude: -122.4194, + }), + metro_code: Some(807), region: Some("CA".to_owned()), asn: Some(7922), } diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index f24c489edd..db87be786a 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -34,6 +34,7 @@ mod auth; pub mod admin; pub mod batch_sync; pub mod consent; +pub mod consent_update; pub mod cookies; pub mod device; pub mod eids; @@ -49,6 +50,7 @@ pub mod pull_sync; pub(crate) mod pull_sync_marker; pub mod rate_limiter; pub mod registry; +pub mod storage; /// Characters of an identifier kept when redacting it for a log. const LOG_ID_PREFIX_CHARS: usize = 8; @@ -380,7 +382,7 @@ impl EcContext { /// /// Returns an error if the client IP is unavailable and generation is /// needed, or if HMAC generation fails. - pub fn generate_if_needed( + pub async fn generate_if_needed( &mut self, settings: &Settings, kv: Option<&KvIdentityGraph>, @@ -420,7 +422,7 @@ impl EcContext { .map(DeviceSignals::to_kv_device); if let Some(graph) = kv { - match graph.create_if_absent(&ec_id, &entry) { + match graph.create_if_absent(&ec_id, &entry).await { Ok(CreateIfAbsentOutcome::Written) => { self.kv_snapshot = EcKvSnapshot::Present { ec_id: ec_id.clone(), @@ -789,18 +791,22 @@ mod tests { } } + #[async_trait::async_trait(?Send)] impl EcKvStore for AddCollidingEcKv { fn store_name(&self) -> &str { self.inner.store_name() } - fn lookup(&self, key: &str) -> Result, Report> { - self.inner.lookup(key) + async fn lookup( + &self, + key: &str, + ) -> Result, Report> { + self.inner.lookup(key).await } - fn key_exists(&self, key: &str) -> Result> { - self.inner.key_exists(key) + async fn key_exists(&self, key: &str) -> Result> { + self.inner.key_exists(key).await } - fn insert( + async fn insert( &self, key: &str, write: EcKvWrite<'_>, @@ -815,17 +821,17 @@ mod tests { return Ok(EcKvWriteOutcome::PreconditionFailed); } } - self.inner.insert(key, write) + self.inner.insert(key, write).await } - fn list_keys_with_prefix( + async fn list_keys_with_prefix( &self, prefix: &str, limit: u32, ) -> Result, Report> { - self.inner.list_keys_with_prefix(prefix, limit) + self.inner.list_keys_with_prefix(prefix, limit).await } - fn delete(&self, key: &str) -> Result<(), Report> { - self.inner.delete(key) + async fn delete(&self, key: &str) -> Result<(), Report> { + self.inner.delete(key).await } } @@ -837,14 +843,15 @@ mod tests { } } - #[test] - fn generate_if_needed_retries_id_collision_then_persists() { + #[tokio::test(flavor = "current_thread")] + async fn generate_if_needed_retries_id_collision_then_persists() { let settings = create_test_settings(); let mut ec = EcContext::new_for_test_with_ip(None, granting_consent(), Some("192.0.2.5".to_owned())); let graph = KvIdentityGraph::new(AddCollidingEcKv::new(2)); ec.generate_if_needed(&settings, Some(&graph)) + .await .expect("should generate after bounded collisions"); assert!(ec.ec_value().is_some(), "should allocate a fresh EC ID"); @@ -855,15 +862,15 @@ mod tests { ); } - #[test] - fn generate_if_needed_errors_after_collision_exhaustion() { + #[tokio::test(flavor = "current_thread")] + async fn generate_if_needed_errors_after_collision_exhaustion() { let settings = create_test_settings(); let mut ec = EcContext::new_for_test_with_ip(None, granting_consent(), Some("192.0.2.6".to_owned())); // Collide on every attempt so the bounded retry is exhausted. let graph = KvIdentityGraph::new(AddCollidingEcKv::new(u32::MAX)); - let result = ec.generate_if_needed(&settings, Some(&graph)); + let result = ec.generate_if_needed(&settings, Some(&graph)).await; assert!(result.is_err(), "should fail after exhausting attempts"); assert!( @@ -1075,8 +1082,8 @@ mod tests { ); } - #[test] - fn generate_if_needed_skips_when_ec_exists() { + #[tokio::test(flavor = "current_thread")] + async fn generate_if_needed_skips_when_ec_exists() { let settings = create_test_settings(); let ec_id = valid_ec_id("d", "Exist1"); let cookie = format!("ts-ec={ec_id}"); @@ -1085,6 +1092,7 @@ mod tests { let mut ec = EcContext::read_from_request(&settings, &req, &noop_services()) .expect("should read EC context"); ec.generate_if_needed(&settings, None) + .await .expect("should not error when EC already exists"); assert_eq!( diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index 191e6c6d24..bc64724b9e 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -113,7 +113,7 @@ pub fn build_pull_sync_context( /// /// Panics if the HTTP request builder produces an invalid request, which /// cannot happen with the hardcoded method and well-formed URI used here. -pub fn dispatch_pull_sync( +pub async fn dispatch_pull_sync( settings: &Settings, kv: &KvIdentityGraph, registry: &PartnerRegistry, @@ -163,7 +163,7 @@ pub fn dispatch_pull_sync( return; } - let live_snapshot = kv.load_snapshot(context.ec_id()); + let live_snapshot = kv.load_snapshot(context.ec_id()).await; let Some(live_entry) = live_snapshot.entry_for(context.ec_id()) else { log::warn!( "Pull sync: skipping dispatch for '{}' because the live identity row could not be \ @@ -256,11 +256,11 @@ pub fn dispatch_pull_sync( .body(EdgeBody::empty()) .expect("should build pull sync request"); - let pending = match futures::executor::block_on( - services - .http_client() - .send_async(PlatformHttpRequest::new(request, backend_name)), - ) { + let pending = match services + .http_client() + .send_async(PlatformHttpRequest::new(request, backend_name)) + .await + { Ok(pending) => pending, Err(err) => { log::warn!( @@ -277,17 +277,18 @@ pub fn dispatch_pull_sync( }); if in_flight.len() >= max_concurrency { - drain_pull_batch(&mut in_flight, services, &mut updates); + drain_pull_batch(&mut in_flight, services, &mut updates).await; } } - drain_pull_batch(&mut in_flight, services, &mut updates); + drain_pull_batch(&mut in_flight, services, &mut updates).await; if !updates.is_empty() { // Write back from the revalidated snapshot, not the request one: its // generation is current, so the first CAS attempt is not spent losing a // conflict against the read that authorized this dispatch. - let outcome = - kv.upsert_partner_ids_from_snapshot(context.ec_id(), &updates, live_snapshot.clone()); + let outcome = kv + .upsert_partner_ids_from_snapshot(context.ec_id(), &updates, live_snapshot.clone()) + .await; if matches!(outcome, EcKvSnapshot::Failed { .. }) { log::warn!( "Pull sync: failed to persist partner updates for '{}'", @@ -374,7 +375,7 @@ fn pull_rate_limit_key(source_domain: &str, ec_id: &str) -> String { format!("pull:{source_domain}:{}", ec_hash(ec_id)) } -fn drain_pull_batch( +async fn drain_pull_batch( in_flight: &mut Vec, services: &RuntimeServices, updates: &mut Vec, @@ -383,17 +384,16 @@ fn drain_pull_batch( let source_domain = pending.source_domain; // All requests were dispatched up front via send_async, so waiting on // each in turn does not change concurrency. - let response = - match futures::executor::block_on(services.http_client().wait(pending.pending)) { - Ok(response) => response, - Err(err) => { - log::warn!( - "Pull sync: request failed for partner '{}': {err:?}", - source_domain - ); - continue; - } - }; + let response = match services.http_client().wait(pending.pending).await { + Ok(response) => response, + Err(err) => { + log::warn!( + "Pull sync: request failed for partner '{}': {err:?}", + source_domain + ); + continue; + } + }; let Some(uid) = extract_pull_uid(response, &source_domain) else { continue; @@ -563,8 +563,8 @@ mod tests { } } - #[test] - fn build_pull_sync_context_returns_context_when_valid() { + #[tokio::test(flavor = "current_thread")] + async fn build_pull_sync_context_returns_context_when_valid() { let consent = ConsentContext { jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, ..ConsentContext::default() @@ -572,7 +572,7 @@ mod tests { let ec_id = format!("{}.ABC123", "a".repeat(64)); let mut ec_context = EcContext::new_for_test(Some(ec_id.clone()), consent); let graph = KvIdentityGraph::in_memory("pull_store"); - ec_context.set_kv_snapshot(seed_present_snapshot(&graph, &ec_id)); + ec_context.set_kv_snapshot(seed_present_snapshot(&graph, &ec_id).await); let registry = PartnerRegistry::from_config(&[pull_enabled_ec_partner("ssp.example.com")]) .expect("should build registry"); @@ -602,8 +602,8 @@ mod tests { ); } - #[test] - fn build_pull_sync_context_skips_when_eid_persistence_is_denied() { + #[tokio::test(flavor = "current_thread")] + async fn build_pull_sync_context_skips_when_eid_persistence_is_denied() { // Purpose 1 granted keeps the EC, but Purpose 4 denied withholds EID // writes and leaves partner slots empty. Pull sync must not read those // empty slots as eligible and disclose the EC ID to partners. @@ -611,7 +611,7 @@ mod tests { let ec_id = format!("{}.ABC123", "a".repeat(64)); let mut ec_context = EcContext::new_for_test(Some(ec_id.clone()), consent); let graph = KvIdentityGraph::in_memory("pull_store"); - ec_context.set_kv_snapshot(seed_present_snapshot(&graph, &ec_id)); + ec_context.set_kv_snapshot(seed_present_snapshot(&graph, &ec_id).await); let registry = PartnerRegistry::from_config(&[pull_enabled_ec_partner("ssp.example.com")]) .expect("should build registry"); assert!( @@ -627,8 +627,8 @@ mod tests { ); } - #[test] - fn build_pull_sync_context_skips_empty_registry_and_complete_snapshot() { + #[tokio::test(flavor = "current_thread")] + async fn build_pull_sync_context_skips_empty_registry_and_complete_snapshot() { let consent = ConsentContext { jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, ..ConsentContext::default() @@ -636,7 +636,7 @@ mod tests { let ec_id = format!("{}.ABC123", "a".repeat(64)); let mut ec_context = EcContext::new_for_test(Some(ec_id.clone()), consent); let graph = KvIdentityGraph::in_memory("pull_store"); - let mut snapshot = seed_present_snapshot(&graph, &ec_id); + let mut snapshot = seed_present_snapshot(&graph, &ec_id).await; let registry = PartnerRegistry::from_config(&[pull_enabled_ec_partner("ssp.example.com")]) .expect("should build registry"); @@ -936,15 +936,59 @@ mod tests { format!("{}.ABC123", "a".repeat(64)) } - fn seed_present_snapshot(graph: &KvIdentityGraph, ec_id: &str) -> EcKvSnapshot { + async fn seed_present_snapshot(graph: &KvIdentityGraph, ec_id: &str) -> EcKvSnapshot { let mut entry = KvEntry::tombstone(1000); entry.consent.ok = true; - graph.create(ec_id, &entry).expect("should seed live entry"); - graph.load_snapshot(ec_id) + graph + .create(ec_id, &entry) + .await + .expect("should seed live entry"); + graph.load_snapshot(ec_id).await } #[test] - fn dispatch_pull_sync_aggregates_batches_into_one_bulk_write() { + fn dispatch_pull_sync_runs_inside_the_fastly_executor_boundary() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + PartnerRegistry::from_config(&[pull_enabled_ec_partner("alpha.example.com")]) + .expect("should build registry"); + let graph = KvIdentityGraph::in_memory("pull_store"); + let ec_id = snapshot_ec_id(); + let snapshot = seed_present_snapshot(&graph, &ec_id).await; + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, br#"{"uid":"synced-uid"}"#.to_vec()); + let services = build_services_with_http_client(stub.clone()); + let context = PullSyncContext { + ec_id: ec_id.clone(), + snapshot, + }; + + dispatch_pull_sync( + &settings, + &graph, + ®istry, + &AllowAllRateLimiter, + &context, + &services, + ) + .await; + + assert_eq!(stub.recorded_backend_names().len(), 1); + let (entry, _) = graph + .get(&ec_id) + .await + .expect("should read row") + .expect("row should exist"); + assert_eq!( + entry.ids.get("alpha.example.com").map(|id| id.uid.as_str()), + Some("synced-uid"), + ); + }); + } + + #[tokio::test(flavor = "current_thread")] + async fn dispatch_pull_sync_aggregates_batches_into_one_bulk_write() { let mut settings = create_test_settings(); // Force one partner per concurrency batch so responses span batches. settings.ec.pull_sync_concurrency = 1; @@ -956,7 +1000,7 @@ mod tests { let graph = KvIdentityGraph::in_memory("pull_store"); let ec_id = snapshot_ec_id(); - let snapshot = seed_present_snapshot(&graph, &ec_id); + let snapshot = seed_present_snapshot(&graph, &ec_id).await; let stub = Arc::new(StubHttpClient::new()); // One JSON response per partner, drained across two concurrency batches. @@ -975,10 +1019,12 @@ mod tests { &AllowAllRateLimiter, &context, &services, - ); + ) + .await; let (entry, generation) = graph .get(&ec_id) + .await .expect("should read store") .expect("entry should exist"); assert_eq!( @@ -997,8 +1043,8 @@ mod tests { ); } - #[test] - fn dispatch_pull_sync_calls_and_persists_only_missing_partner() { + #[tokio::test(flavor = "current_thread")] + async fn dispatch_pull_sync_calls_and_persists_only_missing_partner() { let mut settings = create_test_settings(); settings.ec.pull_sync_concurrency = 4; let registry = PartnerRegistry::from_config(&[ @@ -1013,8 +1059,9 @@ mod tests { &ec_id, &KvEntry::minimal("alpha.example.com", "existing-alpha", 1_000), ) + .await .expect("should seed partial entry"); - let snapshot = graph.load_snapshot(&ec_id); + let snapshot = graph.load_snapshot(&ec_id).await; let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, br#"{"uid":"new-beta"}"#.to_vec()); let services = build_services_with_http_client(stub.clone()); @@ -1030,7 +1077,8 @@ mod tests { &AllowAllRateLimiter, &context, &services, - ); + ) + .await; assert_eq!( stub.recorded_backend_names().len(), @@ -1039,6 +1087,7 @@ mod tests { ); let (entry, generation) = graph .get(&ec_id) + .await .expect("should read store") .expect("entry should exist"); assert_eq!( @@ -1060,8 +1109,8 @@ mod tests { assert_eq!(generation, 2, "missing UID should use one bulk CAS write"); } - #[test] - fn dispatch_pull_sync_skips_non_present_snapshots() { + #[tokio::test(flavor = "current_thread")] + async fn dispatch_pull_sync_skips_non_present_snapshots() { let mut settings = create_test_settings(); settings.ec.pull_sync_concurrency = 4; let registry = @@ -1092,7 +1141,8 @@ mod tests { &AllowAllRateLimiter, &context, &services, - ); + ) + .await; } assert!( @@ -1100,13 +1150,17 @@ mod tests { "not-read, missing, and failed snapshots must not dispatch pull sync" ); assert!( - graph.get(&ec_id).expect("should read store").is_none(), + graph + .get(&ec_id) + .await + .expect("should read store") + .is_none(), "no snapshot state should create a missing root" ); } - #[test] - fn dispatch_pull_sync_skips_tombstone_snapshot() { + #[tokio::test(flavor = "current_thread")] + async fn dispatch_pull_sync_skips_tombstone_snapshot() { let mut settings = create_test_settings(); settings.ec.pull_sync_concurrency = 4; let registry = @@ -1133,15 +1187,16 @@ mod tests { &AllowAllRateLimiter, &context, &services, - ); + ) + .await; assert!( stub.recorded_backend_names().is_empty(), "a tombstone snapshot must not dispatch pull sync" ); } - #[test] - fn dispatch_pull_sync_skips_dispatch_when_ec_is_tombstoned_after_snapshot_capture() { + #[tokio::test(flavor = "current_thread")] + async fn dispatch_pull_sync_skips_dispatch_when_ec_is_tombstoned_after_snapshot_capture() { // The request snapshot authorized pull sync, then a concurrent request // completed a CMP withdrawal while the page response was in flight. // Pull sync runs post-send and discloses the raw `ec_id` to partners, so @@ -1154,12 +1209,13 @@ mod tests { .expect("should build registry"); let graph = KvIdentityGraph::in_memory("pull_store"); let ec_id = snapshot_ec_id(); - let snapshot = seed_present_snapshot(&graph, &ec_id); + let snapshot = seed_present_snapshot(&graph, &ec_id).await; // Concurrent withdrawal lands after the snapshot was captured. assert_eq!( graph .write_withdrawal_tombstone(&ec_id, drop) + .await .expect("should tombstone the row"), TombstoneOutcome::Written, "should tombstone the row the snapshot still reports as present" @@ -1180,7 +1236,8 @@ mod tests { &AllowAllRateLimiter, &context, &services, - ); + ) + .await; assert!( stub.recorded_backend_names().is_empty(), @@ -1188,6 +1245,7 @@ mod tests { ); let (entry, _) = graph .get(&ec_id) + .await .expect("should read store") .expect("tombstone should remain"); assert!( @@ -1196,8 +1254,8 @@ mod tests { ); } - #[test] - fn dispatch_pull_sync_skips_revalidation_read_when_no_partner_is_eligible() { + #[tokio::test(flavor = "current_thread")] + async fn dispatch_pull_sync_skips_revalidation_read_when_no_partner_is_eligible() { // Every pull-enabled partner already has a UID in the request snapshot, // so there is nothing to dispatch. The revalidation read exists to // authorize outbound calls; with no calls to authorize it must not cost @@ -1219,6 +1277,7 @@ mod tests { ); graph .create(&ec_id, &entry) + .await .expect("should seed live entry"); let snapshot = EcKvSnapshot::Present { ec_id: ec_id.clone(), @@ -1240,7 +1299,8 @@ mod tests { &AllowAllRateLimiter, &context, &services, - ); + ) + .await; assert!( stub.recorded_backend_names().is_empty(), diff --git a/crates/trusted-server-core/src/ec/storage.rs b/crates/trusted-server-core/src/ec/storage.rs new file mode 100644 index 0000000000..4c7555ce39 --- /dev/null +++ b/crates/trusted-server-core/src/ec/storage.rs @@ -0,0 +1,223 @@ +//! Pure EC persistence configuration and serving-runtime resolution. + +use edgezero_store_celld::settings::CelldKvSettings; +use error_stack::Report; +use serde::{Deserialize, Deserializer, Serialize}; + +use crate::error::TrustedServerError; +use crate::settings::Ec; + +/// The serving adapter, independent of the host running configuration tooling. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EcRuntime { + /// Fastly Compute. + Fastly, + /// Native Axum. + Native, + /// Cloudflare Workers. + Cloudflare, + /// Fermyon Spin. + Spin, +} + +/// A named Fastly binding without an inferred provider in published config. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct FastlyEcStorage { + /// Exact Fastly binding name. + pub store: String, +} + +/// Single-region `DynamoDB` configuration. Credentials remain outside app config. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct DynamoDbEcStorage { + /// Explicit AWS region. + pub region: String, + /// Existing retained table. + pub table: String, + /// Exact stable namespace, without normalization. + pub namespace: String, + /// Explicit loopback emulator endpoint, never a production override. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local_endpoint: Option, +} + +/// Explicit provider configuration, validated without SDKs or network access. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "provider", rename_all = "lowercase", deny_unknown_fields)] +pub enum EcStorageProvider { + /// Fastly's conditional KV binding. + Fastly(FastlyEcStorage), + /// Native single-region `DynamoDB`. + DynamoDb(DynamoDbEcStorage), + /// Native scoped conditional records, not ordinary KV. + Celld(CelldKvSettings), +} + +/// Published storage input. An omitted provider stays omitted on serialization. +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub enum EcStorage { + /// Explicit provider selection. + Explicit(EcStorageProvider), + /// Fastly shorthand, resolved only when the serving runtime is known. + Implicit(FastlyEcStorage), +} + +impl<'de> Deserialize<'de> for EcStorage { + fn deserialize>(deserializer: D) -> Result { + let value = serde_json::Value::deserialize(deserializer)?; + if value.get("provider").is_some() { + // Report field names, never raw serde input that could contain an + // endpoint or an accidentally supplied credential value. + let fields = match value.get("provider").and_then(serde_json::Value::as_str) { + Some("fastly") => { + "ec.storage.store is required for fastly; other provider fields are forbidden" + } + Some("dynamodb") => { + "ec.storage.region, ec.storage.table and ec.storage.namespace are required for dynamodb; only ec.storage.local_endpoint is optional" + } + Some("celld") => { + "ec.storage.endpoint, ec.storage.store and ec.storage.access are required for celld; access requires a valid mode and credential reference, not a token value" + } + _ => "ec.storage.provider must be fastly, dynamodb or celld", + }; + serde_json::from_value(value) + .map(Self::Explicit) + .map_err(|_| serde::de::Error::custom(fields)) + } else { + serde_json::from_value(value).map(Self::Implicit).map_err(|_| { + serde::de::Error::custom("ec.storage.store is required; provider-specific fields require an explicit provider") + }) + } + } +} + +/// Validated selection for adapter-owned provider construction. +#[derive(Debug, Clone, Copy)] +pub enum EcStorageSelection<'a> { + /// Exact Fastly binding. + Fastly(&'a str), + /// Native `DynamoDB` settings. + DynamoDb(&'a DynamoDbEcStorage), + /// Native celld settings, without a captured token. + Celld(&'a CelldKvSettings), +} + +fn invalid(message: &'static str) -> Report { + Report::new(TrustedServerError::Configuration { + message: message.to_owned(), + }) +} + +impl DynamoDbEcStorage { + /// Validate exact bindings and the explicit local-only endpoint shape. + /// + /// # Errors + /// Returns field-oriented configuration errors without echoing input values. + pub fn validate(&self) -> Result<(), Report> { + if self.region.is_empty() || self.region.chars().any(char::is_whitespace) { + return Err(invalid( + "ec.storage.region is required and must not contain whitespace", + )); + } + if !(3..=255).contains(&self.table.len()) + || !self + .table + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) + { + return Err(invalid( + "ec.storage.table must be a valid DynamoDB table name", + )); + } + if self.namespace.is_empty() + || self.namespace.len() > 512 + || self.namespace.chars().any(char::is_control) + { + return Err(invalid( + "ec.storage.namespace must contain 1 through 512 bytes without control characters", + )); + } + if let Some(endpoint) = &self.local_endpoint { + let url=url::Url::parse(endpoint).map_err(|_|invalid("ec.storage.local_endpoint must be a loopback HTTP root URL with an explicit port"))?; + if url.scheme() != "http" + || url.host_str() != Some("127.0.0.1") + || url.port().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + { + return Err(invalid( + "ec.storage.local_endpoint must be a loopback HTTP root URL with an explicit port", + )); + } + } + Ok(()) + } +} + +impl Ec { + /// Validate publication shape without selecting according to the CLI host. + /// + /// # Errors + /// Rejects simultaneous legacy/new input or malformed provider bindings. + pub fn validate_storage(&self) -> Result<(), Report> { + if self.ec_store.is_some() && self.storage.is_some() { + return Err(invalid( + "ec.ec_store and ec.storage cannot both be configured", + )); + } + match &self.storage { + Some( + EcStorage::Implicit(config) + | EcStorage::Explicit(EcStorageProvider::Fastly(config)), + ) if config.store.is_empty() => Err(invalid("ec.storage.store must not be empty")), + Some(EcStorage::Explicit(EcStorageProvider::DynamoDb(config))) => config.validate(), + Some(EcStorage::Explicit(EcStorageProvider::Celld(config))) => config + .validate() + .map_err(|_| invalid("ec.storage celld endpoint, store or access is invalid")), + _ => Ok(()), + } + } + + /// Resolve validated provider input against the actual serving adapter. + /// + /// # Errors + /// Rejects unsupported explicit providers and omitted providers off Fastly. + pub fn resolve_storage( + &self, + runtime: EcRuntime, + ) -> Result>, Report> { + self.validate_storage()?; + let Some(storage) = &self.storage else { + return Ok(if runtime == EcRuntime::Fastly { + self.ec_store.as_deref().map(EcStorageSelection::Fastly) + } else { + None + }); + }; + match (storage, runtime) { + ( + EcStorage::Implicit(config) + | EcStorage::Explicit(EcStorageProvider::Fastly(config)), + EcRuntime::Fastly, + ) => Ok(Some(EcStorageSelection::Fastly(&config.store))), + (EcStorage::Explicit(EcStorageProvider::DynamoDb(config)), EcRuntime::Native) => { + Ok(Some(EcStorageSelection::DynamoDb(config))) + } + (EcStorage::Explicit(EcStorageProvider::Celld(config)), EcRuntime::Native) => { + Ok(Some(EcStorageSelection::Celld(config))) + } + (EcStorage::Implicit(_), _) => { + Err(invalid("ec.storage.provider is required outside Fastly")) + } + _ => Err(invalid( + "ec.storage.provider is unsupported by the selected serving runtime", + )), + } + } +} diff --git a/crates/trusted-server-core/src/geo.rs b/crates/trusted-server-core/src/geo.rs index 63f7907f53..d74c9795f6 100644 --- a/crates/trusted-server-core/src/geo.rs +++ b/crates/trusted-server-core/src/geo.rs @@ -20,22 +20,35 @@ use crate::constants::{ impl GeoInfo { /// Sets geo information headers on the response. /// - /// Adds `x-geo-city`, `x-geo-country`, `x-geo-continent`, `x-geo-coordinates`, - /// `x-geo-metro-code`, `x-geo-region` (when available), and - /// `x-geo-info-available: true` to the given response. + /// Adds available geographic fields and `x-geo-info-available: true`. + /// Missing fields never produce empty headers or default coordinates. pub fn set_response_headers(&self, response: &mut Response) { let headers = response.headers_mut(); + // An origin response may already carry geo headers. Missing request + // detail must not retain a different location from that response. + for name in [ + HEADER_X_GEO_CITY, + HEADER_X_GEO_COUNTRY, + HEADER_X_GEO_CONTINENT, + HEADER_X_GEO_COORDINATES, + HEADER_X_GEO_METRO_CODE, + HEADER_X_GEO_REGION, + ] { + headers.remove(name); + } - insert_geo_header(headers, HEADER_X_GEO_CITY, &self.city); + if let Some(ref city) = self.city { + insert_geo_header(headers, HEADER_X_GEO_CITY, city); + } insert_geo_header(headers, HEADER_X_GEO_COUNTRY, &self.country); - insert_geo_header(headers, HEADER_X_GEO_CONTINENT, &self.continent); - insert_geo_header( - headers, - HEADER_X_GEO_COORDINATES, - &self.coordinates_string(), - ); - if self.has_metro_code() { - let metro_code = self.metro_code.to_string(); + if let Some(ref continent) = self.continent { + insert_geo_header(headers, HEADER_X_GEO_CONTINENT, continent); + } + if let Some(coordinates) = self.coordinates_string() { + insert_geo_header(headers, HEADER_X_GEO_COORDINATES, &coordinates); + } + if let Some(code) = self.metro_code.filter(|code| *code > 0) { + let metro_code = code.to_string(); insert_geo_header(headers, HEADER_X_GEO_METRO_CODE, &metro_code); } if let Some(ref region) = self.region { @@ -100,12 +113,14 @@ mod tests { fn sample_geo_info() -> GeoInfo { GeoInfo { - city: "San Francisco".to_string(), + city: Some("San Francisco".to_string()), country: "US".to_string(), - continent: "NorthAmerica".to_string(), - latitude: 37.7749, - longitude: -122.4194, - metro_code: 807, + continent: Some("NorthAmerica".to_string()), + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 37.7749, + longitude: -122.4194, + }), + metro_code: Some(807), region: Some("CA".to_string()), asn: None, } @@ -200,7 +215,7 @@ mod tests { #[test] fn set_response_headers_omits_metro_code_when_zero() { let geo = GeoInfo { - metro_code: 0, + metro_code: Some(0), ..sample_geo_info() }; let mut response = build_response(); @@ -217,6 +232,45 @@ mod tests { ); } + #[test] + fn set_response_headers_omits_unavailable_partial_geo() { + for region in [None, Some("CA".to_owned())] { + let geo = GeoInfo::country_region("US", region.clone()); + let mut response = build_response(); + sample_geo_info().set_response_headers(&mut response); + geo.set_response_headers(&mut response); + let headers = response.headers(); + assert_eq!(headers[HEADER_X_GEO_COUNTRY], "US"); + assert_eq!(headers[HEADER_X_GEO_INFO_AVAILABLE], "true"); + for name in [ + HEADER_X_GEO_CITY, + HEADER_X_GEO_CONTINENT, + HEADER_X_GEO_COORDINATES, + HEADER_X_GEO_METRO_CODE, + ] { + assert!(!headers.contains_key(name)); + } + assert_eq!( + headers + .get(HEADER_X_GEO_REGION) + .map(|value| value.to_str().expect("should be valid header")), + region.as_deref() + ); + } + } + + #[test] + fn set_response_headers_preserves_supplied_zero_coordinates() { + let mut geo = GeoInfo::country_region("US", None); + geo.coordinates = Some(crate::platform::GeoCoordinates { + latitude: 0.0, + longitude: 0.0, + }); + let mut response = build_response(); + geo.set_response_headers(&mut response); + assert_eq!(response.headers()[HEADER_X_GEO_COORDINATES], "0,0"); + } + #[test] fn is_gdpr_country_detects_eu_members() { assert!(is_gdpr_country("DE"), "Germany is EU"); 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/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 4f45fbeb9c..a0a3e77dcd 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -1133,8 +1133,11 @@ impl ApsAuctionProvider { geo: device.geo.as_ref().map(|geo| Geo { country: Some(geo.country.clone()), region: geo.region.clone(), - city: Some(geo.city.clone()), - metro: (geo.metro_code > 0).then(|| geo.metro_code.to_string()), + city: geo.city.clone(), + metro: geo + .metro_code + .filter(|code| *code > 0) + .map(|code| code.to_string()), r#type: Some(2), ..Default::default() }), @@ -1927,6 +1930,7 @@ pub fn register( providers: BTreeMap::from([( "aps".parse().expect("should parse APS provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "aps".to_string(), endpoint: default_endpoint(), @@ -2402,6 +2406,41 @@ mod tests { ); } + #[test] + fn builds_aps_partial_geo_without_missing_detail() { + let provider = ApsAuctionProvider::new(config()); + let mut auction_request = canonical_parity_auction_request(); + auction_request + .device + .as_mut() + .expect("should have device") + .geo = Some(crate::platform::GeoInfo::country_region( + "US", + Some("CA".to_owned()), + )); + let settings = create_test_settings(); + let services = noop_services(); + let downstream = http::Request::builder() + .uri("https://publisher.example/auction") + .body(EdgeBody::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &downstream, + timeout_ms: 321, + transport_timeout_ms: 321, + provider_responses: None, + services: &services, + }; + let serialized = + serde_json::to_value(provider.build_openrtb_request(&auction_request, &context)) + .expect("should serialize APS request"); + assert_eq!( + serialized["device"]["geo"], + json!({"country": "US", "region": "CA", "type": 2}) + ); + } + #[test] fn request_language_enforces_byte_limit() { let settings = create_test_settings(); @@ -3065,6 +3104,7 @@ mod tests { providers: BTreeMap::from([( "aps".parse().expect("should parse APS provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "aps".to_string(), endpoint: default_endpoint(), diff --git a/crates/trusted-server-core/src/integrations/datadome/protection.rs b/crates/trusted-server-core/src/integrations/datadome/protection.rs index a7c55bf864..e1cfd3f624 100644 --- a/crates/trusted-server-core/src/integrations/datadome/protection.rs +++ b/crates/trusted-server-core/src/integrations/datadome/protection.rs @@ -1562,12 +1562,14 @@ mod tests { ..protection_config() }; let geo_info = GeoInfo { - city: String::new(), + city: None, country: String::new(), - continent: String::new(), - latitude: 0.0, - longitude: 0.0, - metro_code: 0, + continent: None, + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 0.0, + longitude: 0.0, + }), + metro_code: Some(0), region: None, asn: Some(64500), }; diff --git a/crates/trusted-server-core/src/integrations/didomi.rs b/crates/trusted-server-core/src/integrations/didomi.rs index e1e7c87e88..df8a59be09 100644 --- a/crates/trusted-server-core/src/integrations/didomi.rs +++ b/crates/trusted-server-core/src/integrations/didomi.rs @@ -593,16 +593,7 @@ mod tests { } fn geo_info(country: &str, region: Option<&str>) -> GeoInfo { - GeoInfo { - city: String::new(), - country: country.to_string(), - continent: String::new(), - latitude: 0.0, - longitude: 0.0, - metro_code: 0, - region: region.map(str::to_string), - asn: None, - } + GeoInfo::country_region(country, region.map(str::to_string)) } fn config_with_geo_query_parameters() -> DidomiIntegrationConfig { 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/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index f1d1d0b509..90693534e4 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -2786,19 +2786,15 @@ impl PrebidAuctionProvider { ip: d.ip.clone(), geo: d.geo.as_ref().map(|geo| Geo { country: Some(geo.country.clone()), - city: Some(geo.city.clone()), + city: geo.city.clone(), region: geo.region.clone(), - lat: Some(geo.latitude), - lon: Some(geo.longitude), - // DMA/metro code: convert i64 to string for OpenRTB. - // Fastly returns 0 for "no metro code"; negative values - // are not realistic for DMA codes so the > 0 guard is - // sufficient. - metro: if geo.metro_code > 0 { - Some(geo.metro_code.to_string()) - } else { - None - }, + lat: geo.coordinates.map(|coordinates| coordinates.latitude), + lon: geo.coordinates.map(|coordinates| coordinates.longitude), + // Fastly returns 0 for "no metro code". + metro: geo + .metro_code + .filter(|code| *code > 0) + .map(|code| code.to_string()), r#type: Some(2), ..Default::default() }), @@ -5470,6 +5466,7 @@ external_bundle_sri = "sha384-AAAA" ( ProviderId::from_str("pbs-primary").expect("should parse provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "prebid-server".to_string(), endpoint: "https://primary.example.test/openrtb".to_string(), @@ -5482,6 +5479,7 @@ external_bundle_sri = "sha384-AAAA" ( ProviderId::from_str("pbs-secondary").expect("should parse provider ID"), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "prebid-server".to_string(), endpoint: "https://secondary.example.test/openrtb".to_string(), @@ -6020,12 +6018,14 @@ external_bundle_sri = "sha384-AAAA" user_agent: Some("TestAgent".to_string()), ip: None, geo: Some(GeoInfo { - city: "Berlin".to_string(), + city: Some("Berlin".to_string()), country: "DE".to_string(), - continent: "EU".to_string(), - latitude: 52.52, - longitude: 13.405, - metro_code: 0, + continent: Some("EU".to_string()), + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 52.52, + longitude: 13.405, + }), + metro_code: Some(0), region: None, asn: None, }), @@ -6114,12 +6114,14 @@ external_bundle_sri = "sha384-AAAA" user_agent: Some("TestAgent".to_string()), ip: None, geo: Some(GeoInfo { - city: "New York".to_string(), + city: Some("New York".to_string()), country: "US".to_string(), - continent: "NA".to_string(), - latitude: 40.7128, - longitude: -74.006, - metro_code: 501, + continent: Some("NA".to_string()), + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 40.7128, + longitude: -74.006, + }), + metro_code: Some(501), region: Some("NY".to_string()), asn: None, }), @@ -6153,12 +6155,14 @@ external_bundle_sri = "sha384-AAAA" user_agent: Some("TestAgent".to_string()), ip: None, geo: Some(GeoInfo { - city: "New York".to_string(), + city: Some("New York".to_string()), country: "US".to_string(), - continent: "NA".to_string(), - latitude: 40.7128, - longitude: -74.006, - metro_code: 501, + continent: Some("NA".to_string()), + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 40.7128, + longitude: -74.006, + }), + metro_code: Some(501), region: Some("NY".to_string()), asn: None, }), @@ -6587,12 +6591,14 @@ external_bundle_sri = "sha384-AAAA" user_agent: Some("TestAgent".to_string()), ip: Some("1.2.3.4".to_string()), geo: Some(GeoInfo { - city: "New York".to_string(), + city: Some("New York".to_string()), country: "US".to_string(), - continent: "NA".to_string(), - latitude: 40.7128, - longitude: -74.006, - metro_code: 501, + continent: Some("NA".to_string()), + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 40.7128, + longitude: -74.006, + }), + metro_code: Some(501), region: Some("NY".to_string()), asn: None, }), @@ -6626,6 +6632,44 @@ external_bundle_sri = "sha384-AAAA" assert_eq!(geo.region.as_deref(), Some("NY")); } + #[test] + fn to_openrtb_partial_geo_omits_missing_detail_and_preserves_supplied_zero() { + let provider = PrebidAuctionProvider::new(base_config()); + let settings = make_settings(); + let request = build_test_request(); + let context = create_test_auction_context(&settings, &request); + for coordinates in [ + None, + Some(crate::platform::GeoCoordinates { + latitude: 0.0, + longitude: 0.0, + }), + ] { + let mut auction_request = create_test_auction_request(); + let mut geo = GeoInfo::country_region("US", Some("CA".to_owned())); + geo.coordinates = coordinates; + auction_request.device = Some(DeviceInfo { + user_agent: None, + ip: None, + geo: Some(geo), + }); + let openrtb = provider.to_openrtb( + &auction_request, + &context, + None, + make_request_info(&context), + ); + let serialized = serde_json::to_value(openrtb).expect("should serialize request"); + let geo = &serialized["device"]["geo"]; + let mut expected = serde_json::json!({"country": "US", "region": "CA", "type": 2}); + if coordinates.is_some() { + expected["lat"] = serde_json::json!(0.0); + expected["lon"] = serde_json::json!(0.0); + } + assert_eq!(*geo, expected); + } + } + #[test] fn to_openrtb_sets_tmax_and_cur() { let provider = PrebidAuctionProvider::new(base_config()); @@ -8946,6 +8990,7 @@ set = { networkId = 42 } providers: BTreeMap::from([( provider_id.clone(), ProviderConfig { + allow_insecure_http: false, protocol: "openrtb-2.6".to_string(), profile: "prebid-server".to_string(), endpoint: "https://pbs.example/openrtb2/auction".to_string(), diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index d3c09cd7d7..85dd161ed1 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -804,6 +804,9 @@ pub struct ProxyDispatchInput<'a> { pub path: &'a str, pub settings: &'a Settings, pub kv: Option<&'a KvIdentityGraph>, + /// Whether this adapter permits identity generation on integration routes. + /// Native keeps issuance on publisher navigations, not static JS proxies. + pub allow_generation: bool, pub ec_context: &'a mut EcContext, pub services: &'a RuntimeServices, pub req: Request, @@ -1037,6 +1040,7 @@ impl IntegrationRegistry { path, settings, kv, + allow_generation, ec_context, services, mut req, @@ -1045,14 +1049,12 @@ impl IntegrationRegistry { // Organic proxy handler: generate if needed (best effort). // Only generate for document navigations — subresource requests // may lack consent signals such as the Sec-GPC header. - if is_navigation_request(&req) { - if let Err(err) = ec_context.generate_if_needed(settings, kv) { + if allow_generation && is_navigation_request(&req) { + if let Err(err) = ec_context.generate_if_needed(settings, kv).await { log::warn!("EC generation failed for integration proxy: {err:?}"); } } else { - log::debug!( - "EC generation skipped for integration proxy: non-document request (path={path})", - ); + log::debug!("EC generation skipped for integration proxy (path={path})",); } // Remove any caller-supplied EC header rather than forwarding it. @@ -1688,6 +1690,7 @@ mod tests { path: "/integrations/test/echo", settings: &settings, kv: None, + allow_generation: true, ec_context: &mut ec_context, services: &noop_services(), req, @@ -2045,6 +2048,7 @@ mod tests { path: "/integrations/test/ec", settings: &settings, kv: None, + allow_generation: true, ec_context: &mut ec_context, services: &services, req, @@ -2095,6 +2099,7 @@ mod tests { path: "/integrations/test/ec", settings: &settings, kv: None, + allow_generation: true, ec_context: &mut ec_context, services: &services, req, @@ -2144,6 +2149,7 @@ mod tests { path: "/integrations/test/ec", settings: &settings, kv: None, + allow_generation: true, ec_context: &mut ec_context, services: &services, req, @@ -2190,6 +2196,7 @@ mod tests { path: "/integrations/test/ec", settings: &settings, kv: None, + allow_generation: true, ec_context: &mut ec_context, services: &services, req, 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..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; @@ -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,61 @@ 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)), } } + /// 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 { + 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`] @@ -439,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/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 63f5c2a94e..8b9359469d 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -74,8 +74,8 @@ pub use template_cache::{ }; pub use traits::{PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore}; pub use types::{ - ClientInfo, GeoInfo, PlatformBackendSpec, RuntimeServices, RuntimeServicesBuilder, StoreId, - StoreName, + ClientInfo, GeoCoordinates, GeoInfo, PlatformBackendSpec, RuntimeServices, + RuntimeServicesBuilder, StoreId, StoreName, }; /// Default first-byte timeout for platform backends. @@ -243,32 +243,112 @@ mod tests { #[test] fn geo_info_coordinates_string_formats_correctly() { let geo = GeoInfo { - city: "New York".to_owned(), + city: Some("New York".to_owned()), country: "US".to_owned(), - continent: "NorthAmerica".to_owned(), - latitude: 40.7128, - longitude: -74.0060, - metro_code: 501, + continent: Some("NorthAmerica".to_owned()), + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 40.7128, + longitude: -74.0060, + }), + metro_code: Some(501), region: Some("NY".to_owned()), asn: None, }; assert_eq!( geo.coordinates_string(), - "40.7128,-74.006", + Some("40.7128,-74.006".to_owned()), "should format coordinates as lat,lon" ); } + #[test] + fn geo_info_full_serialization_preserves_flat_fields() { + let original = serde_json::json!({ + "city": "San Francisco", + "country": "US", + "continent": "NorthAmerica", + "latitude": 37.7749, + "longitude": -122.4194, + "metro_code": 807, + "region": "CA", + "asn": 7922 + }); + let geo: GeoInfo = serde_json::from_value(original.clone()) + .expect("should deserialize the existing populated wire format"); + assert_eq!( + geo.coordinates, + Some(GeoCoordinates { + latitude: 37.7749, + longitude: -122.4194, + }) + ); + assert_eq!( + serde_json::to_value(&geo).expect("should serialize geo"), + original + ); + assert_eq!( + serde_json::to_string(&geo).expect("should serialize full geo"), + r#"{"city":"San Francisco","country":"US","continent":"NorthAmerica","latitude":37.7749,"longitude":-122.4194,"metro_code":807,"region":"CA","asn":7922}"# + ); + } + + #[test] + fn geo_info_partial_serialization_omits_unavailable_fields() { + for region in [None, Some("CA".to_owned())] { + let geo = GeoInfo::country_region("US", region.clone()); + let mut expected = serde_json::json!({"country": "US"}); + if let Some(region) = region { + expected["region"] = serde_json::json!(region); + } + let serialized = serde_json::to_value(&geo).expect("should serialize partial geo"); + assert_eq!(serialized, expected); + assert_eq!( + serde_json::from_value::(serialized) + .expect("should deserialize country without coordinates"), + geo + ); + assert_eq!(geo.coordinates_string(), None); + assert!(!geo.has_metro_code()); + } + } + + #[test] + fn geo_info_rejects_incomplete_coordinate_pairs() { + for input in [ + serde_json::json!({"country": "US", "latitude": 0.0}), + serde_json::json!({"country": "US", "longitude": 0.0}), + serde_json::json!({"country": "US", "latitude": 0.0, "longitude": null}), + ] { + let error = serde_json::from_value::(input) + .expect_err("should reject half a coordinate pair"); + assert!(error.to_string().contains("supplied together")); + } + } + + #[test] + fn geo_info_preserves_supplied_zero_coordinates() { + let original = serde_json::json!({"country": "US", "latitude": 0.0, "longitude": 0.0}); + let geo: GeoInfo = serde_json::from_value(original.clone()) + .expect("should accept a supplied zero coordinate pair"); + assert_eq!(geo.coordinates_string().as_deref(), Some("0,0")); + assert_eq!( + serde_json::to_value(geo).expect("should serialize geo"), + original + ); + } + #[test] fn geo_info_has_metro_code_returns_true_for_nonzero() { let geo = GeoInfo { - city: String::new(), + city: None, country: String::new(), - continent: String::new(), - latitude: 0.0, - longitude: 0.0, - metro_code: 807, + continent: None, + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 0.0, + longitude: 0.0, + }), + metro_code: Some(807), region: None, asn: None, }; @@ -281,12 +361,14 @@ mod tests { #[test] fn geo_info_has_metro_code_returns_false_for_zero() { let geo = GeoInfo { - city: String::new(), + city: None, country: String::new(), - continent: String::new(), - latitude: 0.0, - longitude: 0.0, - metro_code: 0, + continent: None, + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 0.0, + longitude: 0.0, + }), + metro_code: Some(0), region: None, asn: None, }; diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 3570bdc102..e8e4e9e3d6 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -10,43 +10,115 @@ use super::{ PlatformSecretStore, }; +/// A supplied latitude/longitude pair. Neither coordinate implies a default. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct GeoCoordinates { + /// Latitude coordinate. + pub latitude: f64, + /// Longitude coordinate. + pub longitude: f64, +} + /// Geographic information extracted from a request. /// -/// Serde derives are required because `GeoInfo` is embedded in -/// `AuctionRequest`, which is serialised for bid-request payloads. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +/// Country-only results do not imply city or coordinate precision. Populated +/// coordinates retain the flat `latitude`/`longitude` JSON fields used in +/// serialised `AuctionRequest` payloads. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] pub struct GeoInfo { - /// City name. - pub city: String, + /// City name, when supplied. + #[serde(skip_serializing_if = "Option::is_none")] + pub city: Option, /// Two-letter country code. pub country: String, - /// Continent name. - pub continent: String, - /// Latitude coordinate. - pub latitude: f64, - /// Longitude coordinate. - pub longitude: f64, - /// DMA (Designated Market Area) / metro code. - pub metro_code: i64, - /// Region code. + /// Continent name, when supplied. + #[serde(skip_serializing_if = "Option::is_none")] + pub continent: Option, + /// Coordinates, available only as a complete pair. + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub coordinates: Option, + /// DMA (Designated Market Area) / metro code, when supplied. + #[serde(skip_serializing_if = "Option::is_none")] + pub metro_code: Option, + /// Region code, when supplied. + #[serde(skip_serializing_if = "Option::is_none")] pub region: Option, /// Autonomous System Number (e.g. `7922` = Comcast). /// Used to distinguish home ISP vs. corporate VPN. - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(skip_serializing_if = "Option::is_none")] pub asn: Option, } +impl<'de> serde::Deserialize<'de> for GeoInfo { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + // Deserialising a flattened Option directly would + // silently discard an incomplete pair as None. Reject that input. + #[derive(serde::Deserialize)] + struct Fields { + city: Option, + country: String, + continent: Option, + latitude: Option, + longitude: Option, + metro_code: Option, + region: Option, + asn: Option, + } + + let fields = Fields::deserialize(deserializer)?; + let coordinates = match (fields.latitude, fields.longitude) { + (Some(latitude), Some(longitude)) => Some(GeoCoordinates { + latitude, + longitude, + }), + (None, None) => None, + _ => { + return Err(serde::de::Error::custom( + "latitude and longitude must be supplied together", + )); + } + }; + Ok(Self { + city: fields.city, + country: fields.country, + continent: fields.continent, + coordinates, + metro_code: fields.metro_code, + region: fields.region, + asn: fields.asn, + }) + } +} + impl GeoInfo { - /// Returns coordinates as a formatted string `"latitude,longitude"`. + /// Construct country and optional region data without inventing detail. + #[must_use] + pub fn country_region(country: impl Into, region: Option) -> Self { + Self { + city: None, + country: country.into(), + continent: None, + coordinates: None, + metro_code: None, + region, + asn: None, + } + } + + /// Returns supplied coordinates as `"latitude,longitude"`. #[must_use] - pub fn coordinates_string(&self) -> String { - format!("{},{}", self.latitude, self.longitude) + pub fn coordinates_string(&self) -> Option { + self.coordinates + .map(|coordinates| format!("{},{}", coordinates.latitude, coordinates.longitude)) } /// Checks if a valid metro code is available. #[must_use] pub fn has_metro_code(&self) -> bool { - self.metro_code > 0 + self.metro_code.is_some_and(|code| code > 0) } } 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..f42fd058f4 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) } } @@ -4870,7 +4870,7 @@ pub async fn handle_publisher_request( ); } if should_preload_ec && let (Some(graph), Some(active_ec_id)) = (kv, active_ec_id) { - let refreshed = graph.load_snapshot(active_ec_id); + let refreshed = graph.load_snapshot(active_ec_id).await; // Never downgrade an in-request Add-confirmed Present snapshot: a // freshly created row can read back Missing/Failed on an // eventually-consistent store, and rotating or suppressing that @@ -6894,8 +6894,8 @@ fn normalize_page_bids_path(raw: &str) -> String { /// Called by the client-side SPA navigation hook after `pushState` / `popstate`. /// /// `kv` enriches the bid request with server-side EIDs from the EC identity -/// graph. Only the Fastly adapter has a KV identity store, so Axum, Cloudflare, -/// and Spin pass `None`; client cookie EIDs are still resolved and consent-gated +/// graph. Fastly and selected native EC providers supply an identity store. +/// Cloudflare and Spin pass `None`; client cookie EIDs are still resolved and consent-gated /// on every adapter, so no adapter forwards unconsented EIDs. /// /// # Errors @@ -7085,7 +7085,7 @@ pub async fn handle_page_bids( // EIDs. Kill-switch, no-slot, bot/prefetch, and no-registry requests // never reach here, so they incur no billable KV read. let page_bids_kv_snapshot = match (kv, ec_id.as_deref(), auction.registry) { - (Some(graph), Some(ec_id), Some(_)) => graph.load_snapshot(ec_id), + (Some(graph), Some(ec_id), Some(_)) => graph.load_snapshot(ec_id).await, _ => crate::ec::EcKvSnapshot::NotRead, }; // Hand the loaded row to the request context so response @@ -7273,7 +7273,7 @@ fn mark_deprecated_alias(response: &mut Response, is_legacy_alias: boo #[cfg(test)] mod tests { use std::future::Future as _; - use std::io::{self, Read as _, Write as _}; + use std::io::{self, Read as _}; use std::sync::atomic::{AtomicUsize, Ordering}; use brotli::Decompressor; @@ -7616,37 +7616,41 @@ mod tests { ok: bool, } + #[async_trait::async_trait(?Send)] impl EcKvStore for OrderRecordingKv { fn store_name(&self) -> &str { self.inner.store_name() } - fn lookup(&self, _key: &str) -> Result, Report> { + async fn lookup( + &self, + _key: &str, + ) -> Result, Report> { self.lookups.fetch_add(1, Ordering::SeqCst); self.http_calls_at_lookup .store(self.http.recorded_backend_names().len(), Ordering::SeqCst); // Report a miss: the scheduling assertions only care about ordering. Ok(None) } - fn key_exists(&self, key: &str) -> Result> { - self.inner.key_exists(key) + async fn key_exists(&self, key: &str) -> Result> { + self.inner.key_exists(key).await } - fn insert( + async fn insert( &self, key: &str, write: EcKvWrite<'_>, ) -> Result> { - self.inner.insert(key, write) + self.inner.insert(key, write).await } - fn list_keys_with_prefix( + async fn list_keys_with_prefix( &self, prefix: &str, limit: u32, ) -> Result, Report> { - self.inner.list_keys_with_prefix(prefix, limit) + self.inner.list_keys_with_prefix(prefix, limit).await } - fn delete(&self, key: &str) -> Result<(), Report> { - self.inner.delete(key) + async fn delete(&self, key: &str) -> Result<(), Report> { + self.inner.delete(key).await } } @@ -10583,18 +10587,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(), } } @@ -12834,12 +12835,14 @@ mod tests { _client_ip: Option, ) -> Result, Report> { Ok(Some(GeoInfo { - city: self.0.to_string(), + city: Some(self.0.to_string()), country: self.0.to_string(), - continent: self.0.to_string(), - latitude: 1.0, - longitude: 2.0, - metro_code: 3, + continent: Some(self.0.to_string()), + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 1.0, + longitude: 2.0, + }), + metro_code: Some(3), region: Some(self.0.to_string()), asn: Some(4), })) @@ -14058,6 +14061,7 @@ mod tests { crate::ec::current_timestamp(), ), ) + .await .expect("should seed a live reader identity"); } let registry = IntegrationRegistry::new(&settings) @@ -14143,7 +14147,8 @@ mod tests { None, None, &mut response, - ); + ) + .await; assert_eq!( response.headers()[HEADER_X_TS_TEMPLATE_CACHE], if index == 0 { "miss-stored" } else { "hit" }, @@ -14227,6 +14232,7 @@ mod tests { for (index, identity) in identities.iter().enumerate() { let (entry, _) = graph .get(identity) + .await .expect("should read reader identity") .expect("should retain the live row or its tombstone"); assert_eq!( @@ -14586,7 +14592,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 +19925,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 +19962,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 +20543,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 +20908,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..f6e3f90159 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -548,6 +548,10 @@ pub struct Ec { #[serde(default)] pub ec_store: Option, + /// Explicit persistence provider or the Fastly store-only shorthand. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub storage: Option, + /// Maximum number of concurrent pull-sync requests. #[serde(default = "Ec::default_pull_sync_concurrency")] pub pull_sync_concurrency: usize, @@ -3051,9 +3055,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() @@ -3121,6 +3131,7 @@ impl Settings { /// opportunity slot is invalid, or if /// [`AuctionDebugCommentOptions::metadata_keys`] names an unsupported key. pub fn prepare_runtime(&mut self) -> Result<(), Report> { + self.ec.validate_storage()?; self.image_optimizer.prepare_runtime()?; self.cache.prepare_runtime()?; self.proxy.prepare_runtime()?; @@ -6258,6 +6269,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-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 37b8a9e657..2006a8a6e0 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -319,11 +319,13 @@ pub mod nextjs_auction { ) -> Result, Report> { Ok(Some(GeoInfo { country: "AU".to_owned(), - city: "Example City".to_owned(), - continent: "Oceania".to_owned(), - latitude: 0.0, - longitude: 0.0, - metro_code: 0, + city: Some("Example City".to_owned()), + continent: Some("Oceania".to_owned()), + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 0.0, + longitude: 0.0, + }), + metro_code: Some(0), region: None, asn: None, })) diff --git a/crates/trusted-server-core/tests/ec_storage_config.rs b/crates/trusted-server-core/tests/ec_storage_config.rs new file mode 100644 index 0000000000..24e7a8ebd4 --- /dev/null +++ b/crates/trusted-server-core/tests/ec_storage_config.rs @@ -0,0 +1,190 @@ +//! Provider input remains pure publication data, not runtime or credential lookup. + +use edgezero_core::blob_envelope::BlobEnvelope; +use serde_json::json; +use trusted_server_core::config::TrustedServerAppConfig; +use trusted_server_core::config_payload::VerifiedConfigBlob; +use trusted_server_core::ec::storage::{EcRuntime, EcStorageSelection}; +use trusted_server_core::settings::Settings; + +fn source(storage: &str) -> String { + format!( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "fixture" + password = "fixture-password" + [publisher] + domain = "publisher.example.com" + cookie_domain = ".publisher.example.com" + origin_url = "https://origin.example.com" + proxy_secret = "unit-test-proxy-secret" + [ec] + passphrase = "test-secret-key-32-bytes-minimum" + {storage} + "# + ) +} + +#[test] +fn every_runtime_resolves_only_its_supported_explicit_provider() { + let rows = [ + ("", ["none", "none", "none", "none"]), + ( + r#"ec_store = "exact-fixture-binding""#, + ["fastly", "none", "none", "none"], + ), + ( + "[ec.storage]\nstore = \"exact-fixture-binding\"", + ["fastly", "error", "error", "error"], + ), + ( + "[ec.storage]\nprovider = \"fastly\"\nstore = \"exact-fixture-binding\"", + ["fastly", "error", "error", "error"], + ), + ( + "[ec.storage]\nprovider = \"dynamodb\"\nregion = \"us-east-1\"\ntable = \"example-identity\"\nnamespace = \" exact \"", + ["error", "dynamodb", "error", "error"], + ), + ( + "[ec.storage]\nprovider = \"celld\"\nendpoint = \"http://127.0.0.1:7780\"\nstore = \"identity\"\n[ec.storage.access]\nmode = \"trusted-network\"", + ["error", "celld", "error", "error"], + ), + ]; + for (input, expected) in rows { + let settings = + Settings::from_toml(&source(input)).expect("should parse shape independent of runtime"); + for (runtime, expected) in [ + EcRuntime::Fastly, + EcRuntime::Native, + EcRuntime::Cloudflare, + EcRuntime::Spin, + ] + .into_iter() + .zip(expected) + { + let actual = match settings.ec.resolve_storage(runtime) { + Ok(None) => "none", + Ok(Some(EcStorageSelection::Fastly(store))) => { + assert_eq!(store, "exact-fixture-binding"); + "fastly" + } + Ok(Some(EcStorageSelection::DynamoDb(config))) => { + assert_eq!(config.namespace, " exact "); + "dynamodb" + } + Ok(Some(EcStorageSelection::Celld(_))) => "celld", + Err(_) => "error", + }; + assert_eq!( + actual, expected, + "should resolve runtime {runtime:?} for {input}" + ); + } + let value = serde_json::to_value(&settings).expect("should serialize input"); + let published: TrustedServerAppConfig = serde_json::from_value(value.clone()) + .expect("should preserve runtime-independent CLI input"); + let envelope = BlobEnvelope::new( + serde_json::to_value(published).expect("should publish typed input"), + "2026-01-01T00:00:00Z".to_owned(), + ); + assert_eq!( + envelope.data, value, + "should not inject defaults into published envelope" + ); + let encoded = serde_json::to_string(&envelope).expect("should encode existing envelope"); + VerifiedConfigBlob::parse(&encoded) + .expect("should reload and verify unchanged envelope format"); + let roundtrip = + Settings::from_json_value(value.clone()).expect("should reload published shape"); + assert_eq!( + serde_json::to_value(roundtrip).expect("should serialize roundtrip"), + value + ); + if !input.contains("provider") { + assert!( + value["ec"]["storage"].get("provider").is_none(), + "should not publish an inferred provider" + ); + } + } +} + +#[test] +fn ec_storage_published_shape_accepts_explicit_celld_without_credentials() { + let settings = Settings::from_toml(&source( + r#" + [ec.storage] + provider = "celld" + endpoint = "https://stores.example.com:7780" + store = "identity" + [ec.storage.access] + mode = "authenticated" + token_env = "FIXTURE_STORE_TOKEN" + "#, + )) + .expect("should accept pure celld config without reading a token"); + let value = serde_json::to_value(&settings).expect("should serialize publication input"); + assert_eq!(value["ec"]["storage"]["provider"], "celld"); + assert_eq!( + value["ec"]["storage"]["access"], + json!({"mode":"authenticated","token_env":"FIXTURE_STORE_TOKEN"}) + ); +} + +#[test] +fn absent_ec_storage_stays_absent_in_json() { + let settings = Settings::from_toml(&source("")).expect("should preserve unconfigured settings"); + let value = serde_json::to_value(settings).expect("should serialize settings"); + assert!( + value["ec"].get("storage").is_none(), + "should not inject provider defaults or null storage" + ); +} + +#[test] +fn ambiguous_incomplete_unknown_and_inappropriate_fields_are_rejected() { + for input in [ + "ec_store = \"same\"\n[ec.storage]\nstore = \"same\"", + "[ec.storage]", + "[ec.storage]\nstore = \"\"", + "[ec.storage]\nprovider = \"unknown\"\nstore = \"identity\"", + "[ec.storage]\nprovider = \"fastly\"\nstore = \"identity\"\nregion = \"us-east-1\"", + "[ec.storage]\nprovider = \"dynamodb\"\ntable = \"example-identity\"\nnamespace = \"fixture\"", + "[ec.storage]\nprovider = \"dynamodb\"\nregion = \"us-east-1\"\ntable = \"example-identity\"\nnamespace = \"\"", + "[ec.storage]\nprovider = \"celld\"\nendpoint = \"https://stores.example.com\"\nstore = \"identity\"", + "[ec.storage]\nprovider = \"celld\"\nendpoint = \"http://stores.example.com\"\nstore = \"identity\"\n[ec.storage.access]\nmode = \"authenticated\"\ntoken_env = \"FIXTURE_STORE_TOKEN\"", + ] { + let error = Settings::from_toml(&source(input)) + .expect_err("should reject invalid provider input without a runtime"); + assert!( + format!("{error:?}").contains("ec.storage"), + "should identify EC storage configuration" + ); + } +} + +#[test] +fn typed_local_endpoint_preserves_emulator_restrictions_and_redacts_errors() { + for endpoint in [ + "http://example.com:8000", + "https://127.0.0.1:8000", + "http://user:secret@127.0.0.1:8000", + "http://127.0.0.1:8000/private", + "http://127.0.0.1:8000/?secret=value", + "http://127.0.0.1:8000/#secret", + "http://127.0.0.1", + ] { + let input = format!( + "[ec.storage]\nprovider = \"dynamodb\"\nregion = \"us-east-1\"\ntable = \"example-identity\"\nnamespace = \"fixture\"\nlocal_endpoint = {endpoint:?}" + ); + let error = Settings::from_toml(&source(&input)) + .expect_err("should reject a nonlocal or ambiguous endpoint"); + let rendered = format!("{error:?}"); + assert!(rendered.contains("ec.storage.local_endpoint")); + assert!( + !rendered.contains(endpoint), + "should not echo endpoint values" + ); + } +} 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/fixtures/configs/trusted-server.integration.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml index c3d9ff53d8..14e3fecb10 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml @@ -11,9 +11,11 @@ proxy_secret = "integration_proxy_secret" [ec] passphrase = "integration_ec_passphrase" -ec_store = "ec_identity_store" pull_sync_concurrency = 3 +[ec.storage] +store = "ec_identity_store" + [[ec.partners]] name = "Integration Test Partner" source_domain = "inttest.example.com" diff --git a/crates/trusted-server-integration-tests/tests/common/config.rs b/crates/trusted-server-integration-tests/tests/common/config.rs index db4e011c5e..be2aaf0481 100644 --- a/crates/trusted-server-integration-tests/tests/common/config.rs +++ b/crates/trusted-server-integration-tests/tests/common/config.rs @@ -9,7 +9,12 @@ const GENERATED_AT: &str = "2026-06-23T00:00:00Z"; const APP_CONFIG: &str = include_str!("../../fixtures/configs/trusted-server.integration.toml"); pub fn integration_app_config_envelope(origin_port: u16) -> TestResult { - app_config_envelope(origin_port, |_| Ok(())) + app_config_envelope(origin_port, |table| { + // The shared source names a Fastly binding. Native and Cloudflare + // fixtures intentionally retain their previous unconfigured graph. + table_mut(table, "ec")?.remove("storage"); + Ok(()) + }) } /// Builds the integration app-config envelope after `customize` edits the @@ -61,6 +66,7 @@ pub fn cloudflare_config_json(origin_port: u16) -> TestResult { /// through the orchestrator and telemetry without any outbound provider call. pub fn cloudflare_auction_config_json(origin_port: u16) -> TestResult { let envelope = app_config_envelope(origin_port, |table| { + table_mut(table, "ec")?.remove("storage"); let auction = table_mut(table, "auction")?; auction.insert("enabled".to_string(), toml::Value::Boolean(true)); auction.remove("providers"); @@ -91,6 +97,35 @@ fn cloudflare_binding_json(binding: &serde_json::Value) -> TestResult { #[cfg(test)] mod tests { + #[test] + fn native_and_cloudflare_fixtures_do_not_select_a_fastly_graph() { + let native = super::integration_app_config_envelope(8888) + .expect("should generate native fixture envelope"); + let envelope: edgezero_core::blob_envelope::BlobEnvelope = + serde_json::from_str(&native).expect("should parse native envelope"); + assert!( + envelope.data["ec"].get("storage").is_none(), + "should retain native unconfigured persistence" + ); + for binding in [ + super::cloudflare_config_json(8888), + super::cloudflare_auction_config_json(8888), + ] { + let binding: serde_json::Value = + serde_json::from_str(&binding.expect("should generate Cloudflare fixture")) + .expect("should parse Cloudflare binding"); + let encoded = binding[trusted_server_core::config_payload::CONFIG_BLOB_KEY] + .as_str() + .expect("should contain existing envelope"); + let envelope: edgezero_core::blob_envelope::BlobEnvelope = + serde_json::from_str(encoded).expect("should parse Cloudflare envelope"); + assert!( + envelope.data["ec"].get("storage").is_none(), + "should retain Cloudflare unconfigured persistence" + ); + } + } + const FASTLY_CONFIG: &str = include_str!("../../../../fastly.toml"); const VICEROY_TEMPLATE: &str = include_str!("../../fixtures/configs/viceroy-template.toml"); const LOGICAL_SECRET_STORE_ID: &str = "trusted_server_secrets"; 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/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 82c0e8c6b7..e2e2529262 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -33,6 +33,7 @@ import { userIdConfigNameAliases, userIdSubmoduleKey, } from './user_id_modules'; +import { createRelayConsentGuard } from './relay_consent'; /** * Prebid.js public API surface (type-only; erased at build time). @@ -2389,6 +2390,11 @@ export function installPrebidNpm(config?: Partial): typeof pbjs }); const originalRequestBids = pbjs.requestBids.bind(pbjs); + const prepareRelayConsent = createRelayConsentGuard( + ADAPTER_CODE, + (key) => (pbjs as unknown as { getConfig?: PrebidGetConfig }).getConfig?.(key), + (value) => pbjs.setConfig(value as PbjsConfig) + ); // Browser demand ownership is explicit. Only validated auction-plan route // codes are folded; every other publisher bidder entry remains in Prebid.js. @@ -2568,6 +2574,7 @@ export function installPrebidNpm(config?: Partial): typeof pbjs }; try { + if (adUnits.length > 0) prepareRelayConsent(); return originalRequestBids(opts); } catch (error) { for (const [adUnitCode, token] of firstImpressionTokens) { diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/relay_consent.ts b/crates/trusted-server-js/lib/src/integrations/prebid/relay_consent.ts new file mode 100644 index 0000000000..cb408e8d00 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/prebid/relay_consent.ts @@ -0,0 +1,84 @@ +import { log } from '../../core/log'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Creates a per-auction TCF guard for a first-party auction relay. + * + * The relay has no GVL identity of its own. A basic-ads soft vendor exception + * retains Prebid's purpose check while downstream auction endpoints receive + * the original consent signals for their vendor-specific enforcement. + */ +export function createRelayConsentGuard( + bidder: string, + getConfig: (key: string) => unknown, + setConfig: (config: { consentManagement: Record }) => unknown +): () => void { + let addedException = false; + let applying = false; + + return () => { + if (applying) return; + try { + const consent = getConfig('consentManagement'); + if (!isRecord(consent)) return; + // tcfControl reads namespaced rules. Do not migrate publisher-owned + // legacy settings: later mergeConfig calls must retain their semantics. + const gdpr = consent.gdpr; + if (!isRecord(gdpr) || gdpr.enabled === false) return; + if (gdpr.rules !== undefined && !Array.isArray(gdpr.rules)) return; + + const mapping = getConfig('gvlMapping'); + const mapped = isRecord(mapping) && mapping[bidder] !== undefined; + // An operator-supplied vendor identity keeps its vendor-consent gate. + if (mapped && !addedException) return; + + const rules: unknown[] = gdpr.rules ?? []; + let foundBasicAds = false; + let changed = false; + const nextRules = rules.map((rule) => { + if (!isRecord(rule) || rule.purpose !== 'basicAds') return rule; + foundBasicAds = true; + const exceptions = rule.softVendorExceptions ?? []; + if (!Array.isArray(exceptions)) return rule; + const containsRelay = exceptions.includes(bidder); + if (mapped ? !containsRelay : containsRelay) return rule; + changed = true; + return { + ...rule, + softVendorExceptions: mapped + ? exceptions.filter((entry) => entry !== bidder) + : [...exceptions, bidder], + }; + }); + + if (!foundBasicAds && !mapped) { + // Custom rules replace Prebid's defaults, so retain both default gates. + nextRules.push({ + purpose: 'basicAds', + enforcePurpose: true, + enforceVendor: true, + softVendorExceptions: [bidder], + }); + changed = true; + } + if (!changed) { + if (mapped) addedException = false; + return; + } + + const nextGdpr = { ...gdpr, rules: nextRules }; + applying = true; + addedException = !mapped; + setConfig({ + consentManagement: { ...consent, gdpr: nextGdpr }, + }); + } catch (error) { + log.error('[tsjs-prebid] auction relay TCF configuration could not be prepared', error); + } finally { + applying = false; + } + }; +} diff --git a/crates/trusted-server-js/lib/src/integrations/sourcepoint/consent_sync.ts b/crates/trusted-server-js/lib/src/integrations/sourcepoint/consent_sync.ts new file mode 100644 index 0000000000..6e84f7ee25 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/sourcepoint/consent_sync.ts @@ -0,0 +1,54 @@ +import { log } from '../../core/log'; + +export interface ConsentSnapshot { + gppString: string | null; + applicableSections: number[]; +} + +/** Publishes each real decision without coalescing away an intervening withdrawal. */ +export class ConsentSync { + private lastSnapshot?: string; + private stopped = false; + + constructor(private readonly fetch: typeof window.fetch) {} + + publish(snapshot: ConsentSnapshot): void { + if (this.stopped) return; + const body = JSON.stringify(snapshot); + if (body === this.lastSnapshot) return; + this.lastSnapshot = body; + // Each request carries its own snapshot. Cookie reads at network dispatch + // must not replace an earlier opt-out with a later decision. The endpoint + // cannot mint or restore identity, so response order cannot revive an EC. + try { + void this.fetch('/_ts/consent', { + method: 'POST', + credentials: 'same-origin', + redirect: 'error', + cache: 'no-store', + keepalive: true, + headers: { 'Content-Type': 'application/json' }, + body, + }) + .then((response) => { + // The endpoint returns no identity data; discard any error body too. + void response.body?.cancel().catch(() => {}); + if (response.status !== 204) this.failed(body); + }) + .catch(() => this.failed(body)); + } catch { + this.failed(body); + } + } + + stop(): void { + // An in-flight withdrawal must survive navigation; do not abort keepalive. + this.stopped = true; + } + + private failed(body: string): void { + // Retry on a subsequent CMP/focus event, not a background retry loop. + if (this.lastSnapshot === body) this.lastSnapshot = undefined; + log.warn('sourcepoint: server consent reconciliation was not confirmed'); + } +} diff --git a/crates/trusted-server-js/lib/src/integrations/sourcepoint/gpp_capture.ts b/crates/trusted-server-js/lib/src/integrations/sourcepoint/gpp_capture.ts new file mode 100644 index 0000000000..3fb97da26a --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/sourcepoint/gpp_capture.ts @@ -0,0 +1,230 @@ +import { log } from '../../core/log'; + +export interface GppConsent { + gppString: string; + applicableSections: number[]; +} + +type GppCallback = (data: unknown, success?: boolean) => void; +export type GppApi = (command: string, callback: GppCallback, parameter?: number) => unknown; +export type GppWindow = Window & { __gpp?: GppApi }; + +/** GPP's encoded alphabet, safe as one cookie value without truncation. */ +export function isCookieSafeGppString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && !/[^A-Za-z0-9._~=-]/.test(value); +} + +function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Observes the existing CMP API. It does not install a CMP or synthesize consent. */ +export class GppConsentCapture { + private api?: GppApi; + private listenerId?: number; + private registrationPending = false; + private generation = 0; + private snapshotEpoch = 0; + private stopped = false; + private restoreProperty?: () => void; + private readonly onScriptLoaded = () => { + if (this.target.__gpp !== this.api) this.refresh(); + }; + + constructor( + private readonly target: GppWindow, + private readonly onConsent: (consent: GppConsent) => void, + private readonly onUnavailable: () => void + ) {} + + start(): void { + if (this.stopped) return; + const original = Object.getOwnPropertyDescriptor(this.target, '__gpp'); + // A plain writable global is how the standard CMP stub publishes its API. + // Preserve custom/non-configurable accessors rather than changing their semantics. + if (!original || (original.configurable && 'value' in original && original.writable)) { + let published = this.target.__gpp; + const getter = (): GppApi | undefined => published; + Object.defineProperty(this.target, '__gpp', { + configurable: true, + enumerable: original?.enumerable ?? true, + get: getter, + set: (value: GppApi | undefined) => { + published = value; + this.refresh(); + }, + }); + this.restoreProperty = () => { + if (Object.getOwnPropertyDescriptor(this.target, '__gpp')?.get !== getter) return; + if (!original && published === undefined) { + delete this.target.__gpp; + } else { + Object.defineProperty(this.target, '__gpp', { + configurable: original?.configurable ?? true, + enumerable: original?.enumerable ?? true, + writable: original && 'writable' in original ? original.writable : true, + value: published, + }); + } + }; + } + // Resource completion also observes SDK publication through a descriptor we + // deliberately did not replace. No timer/polling or synthetic GPP stub. + this.target.document.addEventListener('load', this.onScriptLoaded, true); + this.target.document.addEventListener('DOMContentLoaded', this.onScriptLoaded); + this.refresh(); + } + + hasApi(): boolean { + return typeof this.target.__gpp === 'function'; + } + + refresh(): void { + if (this.stopped) return; + const next = this.target.__gpp; + if (typeof next !== 'function') { + if (this.api) { + this.unsubscribe(); + this.api = undefined; + this.onUnavailable(); + } + return; + } + if (next !== this.api) { + // A queued stub subscription transfers to the real CMP. Registering again + // while it is pending would leave two listeners after the queue drains. + if (!this.registrationPending) this.unsubscribe(); + this.api = next; + } + this.ping(next); + } + + stop(): void { + if (this.stopped) return; + this.stopped = true; + this.unsubscribe(); + this.target.document.removeEventListener('load', this.onScriptLoaded, true); + this.target.document.removeEventListener('DOMContentLoaded', this.onScriptLoaded); + this.restoreProperty?.(); + } + + private subscribe(api: GppApi, version: '1.0' | '1.1'): void { + this.registrationPending = true; + const generation = this.generation; + const onEvent = (event: unknown, success?: boolean) => { + if (this.stopped || generation !== this.generation) return; + this.snapshotEpoch += 1; + if ((success !== true && !(version === '1.0' && success === undefined)) || !record(event)) { + this.unsubscribe(); + this.onUnavailable(); + return; + } + if (event.eventName === 'listenerRegistered') { + if ( + event.data !== true || + typeof event.listenerId !== 'number' || + !Number.isInteger(event.listenerId) + ) { + this.unsubscribe(); + this.onUnavailable(); + return; + } + this.listenerId = event.listenerId; + this.registrationPending = false; + } + if (this.listenerId === undefined) return; + // Event pingData and outstanding asynchronous reads can predate a newer + // choice. Read current state and discard superseded ping callbacks. + if (this.api) this.ping(this.api); + }; + try { + const returned = api.call(this.target, 'addEventListener', onEvent); + // The published 1.1 example stub still returns this acknowledgment + // synchronously. Accept registration only, never synchronous 1.1 consent. + if ( + this.listenerId === undefined && + record(returned) && + returned.eventName === 'listenerRegistered' + ) { + onEvent(returned, true); + } + } catch { + this.unsubscribe(); + this.onUnavailable(); + log.warn('sourcepoint: GPP event subscription failed'); + } + } + + private ping(api: GppApi): void { + const epoch = ++this.snapshotEpoch; + let callbackCalled = false; + const onPing = (data: unknown, success?: boolean) => { + callbackCalled = true; + if (this.stopped || api !== this.api || epoch !== this.snapshotEpoch) return; + if (!success || !record(data) || (data.gppVersion !== '1.0' && data.gppVersion !== '1.1')) { + this.onUnavailable(); + return; + } + if (this.listenerId === undefined) { + if (!this.registrationPending) this.subscribe(api, data.gppVersion); + if (this.listenerId === undefined) this.onUnavailable(); + return; + } + if (data.gppVersion === '1.0') { + // API 1.0 has synchronous getGPPData, not the 1.1 ready ping snapshot. + if (data.cmpStatus !== 'loaded') { + this.onUnavailable(); + return; + } + const legacy = api.call(this.target, 'getGPPData', () => {}); + if (record(legacy)) this.consume(legacy); + else this.onUnavailable(); + } else if (data.signalStatus === 'ready') this.consume(data); + else this.onUnavailable(); + }; + try { + const returned = api.call(this.target, 'ping', onPing); + if (!callbackCalled && record(returned)) { + if (returned.gppVersion === '1.0') onPing(returned, true); + else if (returned.gppVersion === '1.1' && epoch === this.snapshotEpoch) { + this.onUnavailable(); + if (this.listenerId === undefined && !this.registrationPending) + this.subscribe(api, '1.1'); + } + } + } catch { + this.onUnavailable(); + log.warn('sourcepoint: GPP state read failed'); + } + } + + private consume(data: Record): void { + if ( + !isCookieSafeGppString(data.gppString) || + !Array.isArray(data.applicableSections) || + data.applicableSections.length === 0 || + !data.applicableSections.every((id) => Number.isInteger(id) && id > 0 && id <= 65535) + ) { + this.onUnavailable(); + return; + } + this.onConsent({ + gppString: data.gppString, + applicableSections: [...data.applicableSections] as number[], + }); + } + + private unsubscribe(): void { + this.generation++; + this.snapshotEpoch++; + if (this.listenerId !== undefined && this.api) { + try { + this.api.call(this.target, 'removeEventListener', () => {}, this.listenerId); + } catch { + log.warn('sourcepoint: GPP listener cleanup failed'); + } + } + this.listenerId = undefined; + this.registrationPending = false; + } +} diff --git a/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts b/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts index f323468bbd..9e5194a98e 100644 --- a/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts @@ -1,8 +1,10 @@ import { log } from '../../core/log'; +import { ConsentSync } from './consent_sync'; +import { GppConsentCapture, isCookieSafeGppString, type GppWindow } from './gpp_capture'; import { installSourcepointGuard } from './script_guard'; -type SourcepointWindow = Window & { +type SourcepointWindow = GppWindow & { __tsjs_sourcepoint?: { rewriteSdk?: boolean; }; @@ -57,6 +59,10 @@ interface MirroredSourcepointConsent { let initialized = false; let initialRetryDone = false; let retryTimer: ReturnType | undefined; +let readyRetry: (() => void) | undefined; +let gppCapture: GppConsentCapture | undefined; +let apiMirrorSucceeded = false; +let consentSync: ConsentSync | undefined; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; @@ -173,13 +179,15 @@ function clearCookie(name: string): void { } function clearSourcepointCookies(): void { - if (!hasSourcepointMarker()) { - return; + try { + if (!hasSourcepointMarker()) return; + clearCookie(GPP_COOKIE_NAME); + clearCookie(GPP_SID_COOKIE_NAME); + clearCookie(GPP_SOURCE_COOKIE_NAME); + consentSync?.publish({ gppString: null, applicableSections: [] }); + } catch { + log.warn('sourcepoint: consent cookie storage is unavailable'); } - - clearCookie(GPP_COOKIE_NAME); - clearCookie(GPP_SID_COOKIE_NAME); - clearCookie(GPP_SOURCE_COOKIE_NAME); } function mirrorOnVisible(): void { @@ -189,12 +197,14 @@ function mirrorOnVisible(): void { } function clearInitialRetryTimer(): void { - if (retryTimer === undefined) { - return; + if (retryTimer !== undefined) { + window.clearTimeout(retryTimer); + retryTimer = undefined; + } + if (readyRetry) { + document.removeEventListener('DOMContentLoaded', readyRetry); + readyRetry = undefined; } - - window.clearTimeout(retryTimer); - retryTimer = undefined; } function scheduleInitialRetry(): void { @@ -213,6 +223,7 @@ function scheduleInitialRetry(): void { }; if (document.readyState === 'loading') { + readyRetry = retry; document.addEventListener('DOMContentLoaded', retry, { once: true }); } @@ -230,26 +241,43 @@ function scheduleInitialRetry(): void { * Returns `true` if cookies were written, `false` otherwise. */ export function mirrorSourcepointConsent(): boolean { - if (typeof localStorage === 'undefined' || typeof document === 'undefined') { - return false; + if (typeof document === 'undefined') return false; + if (gppCapture?.hasApi()) { + apiMirrorSucceeded = false; + gppCapture.refresh(); + return apiMirrorSucceeded; } - - const consent = findSourcepointConsent(); - if (!consent) { + try { + if (typeof localStorage === 'undefined') return false; + const consent = findSourcepointConsent(); + if (!consent) { + clearSourcepointCookies(); + log.debug('sourcepoint: no GPP data found in localStorage'); + return false; + } + return writeMirroredConsent(consent); + } catch { + // Sandboxed frames and browser storage restrictions must not break the + // publisher's scripts or revive an earlier permissive mirror. clearSourcepointCookies(); - log.debug('sourcepoint: no GPP data found in localStorage'); + log.warn('sourcepoint: consent storage is unavailable'); return false; } +} +function writeMirroredConsent(consent: MirroredSourcepointConsent): boolean { const { gppString, applicableSections } = consent; - if (!gppString) { + if ( + !isCookieSafeGppString(gppString) || + applicableSections?.some((id) => !Number.isInteger(id) || id <= 0 || id > 65535) + ) { clearSourcepointCookies(); - log.debug('sourcepoint: gppString is empty'); + log.debug('sourcepoint: GPP snapshot cannot be represented as consent cookies'); return false; } const existingGppCookie = readCookie(GPP_COOKIE_NAME); - if (existingGppCookie && existingGppCookie !== gppString && !hasSourcepointMarker()) { + if (existingGppCookie && !hasSourcepointMarker()) { log.debug('sourcepoint: preserving existing __gpp cookie from another writer'); return false; } @@ -265,6 +293,7 @@ export function mirrorSourcepointConsent(): boolean { initialRetryDone = true; clearInitialRetryTimer(); + consentSync?.publish({ gppString, applicableSections: applicableSections ?? [] }); log.info('sourcepoint: mirrored GPP consent to cookies', { gppLength: gppString.length, @@ -283,15 +312,44 @@ export function initializeSourcepointConsentMirror(): void { } initialized = true; + consentSync = + typeof window.fetch === 'function' ? new ConsentSync(window.fetch.bind(window)) : undefined; + gppCapture = new GppConsentCapture( + window as GppWindow, + (consent) => { + try { + apiMirrorSucceeded = writeMirroredConsent(consent); + } catch { + apiMirrorSucceeded = false; + log.warn('sourcepoint: consent cookie storage is unavailable'); + } + }, + () => { + apiMirrorSucceeded = false; + clearSourcepointCookies(); + } + ); + gppCapture.start(); - if (!mirrorSourcepointConsent()) { - scheduleInitialRetry(); - } + if (!mirrorSourcepointConsent() && !gppCapture.hasApi()) scheduleInitialRetry(); - // Sourcepoint persists consent changes to localStorage. Re-mirror when a - // user returns to the page so session cookies do not remain stale. + // Retain legacy localStorage refresh for CMPs without the GPP API. When an + // API exists, its ready snapshot is authoritative, including same-tab changes. document.addEventListener('visibilitychange', mirrorOnVisible); window.addEventListener('focus', mirrorSourcepointConsent); } +/** Releases this module's listeners without removing or replacing the publisher CMP. */ +export function disposeSourcepointConsentMirror(): void { + gppCapture?.stop(); + gppCapture = undefined; + consentSync?.stop(); + consentSync = undefined; + clearInitialRetryTimer(); + document.removeEventListener('visibilitychange', mirrorOnVisible); + window.removeEventListener('focus', mirrorSourcepointConsent); + initialized = false; + initialRetryDone = false; +} + initializeSourcepointConsentMirror(); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/relay_consent.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/relay_consent.test.ts new file mode 100644 index 0000000000..96d68ba700 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/prebid/relay_consent.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createRelayConsentGuard } from '../../../src/integrations/prebid/relay_consent'; + +function setup(consent: Record, mapping?: Record) { + let currentConsent = consent; + let currentMapping = mapping; + const setConfig = vi.fn((value: { consentManagement: Record }) => { + currentConsent = value.consentManagement; + }); + const prepare = createRelayConsentGuard( + 'trustedServer', + (key) => (key === 'consentManagement' ? currentConsent : currentMapping), + setConfig + ); + return { + prepare, + setConfig, + consent: () => currentConsent, + mapVendor: () => { + currentMapping = { trustedServer: 1 }; + }, + replaceConsent: (value: Record) => { + currentConsent = value; + }, + }; +} + +describe('first-party relay TCF configuration', () => { + it('retains both default basic-ads gates and is idempotent', () => { + const { prepare, setConfig, consent } = setup({ gdpr: { cmpApi: 'iab' } }); + + prepare(); + prepare(); + + expect(setConfig).toHaveBeenCalledTimes(1); + expect(consent()).toEqual({ + gdpr: { + cmpApi: 'iab', + rules: [ + { + purpose: 'basicAds', + enforcePurpose: true, + enforceVendor: true, + softVendorExceptions: ['trustedServer'], + }, + ], + }, + }); + }); + + it('preserves publisher settings, other rules, and existing exceptions without mutation', () => { + const storage = { purpose: 'storage', enforcePurpose: true, enforceVendor: true }; + const basicAds = { + purpose: 'basicAds', + enforcePurpose: true, + enforceVendor: true, + vendorExceptions: ['exampleBidder'], + softVendorExceptions: ['anotherBidder'], + deferS2Sbidders: true, + }; + const original = { + gdpr: { cmpApi: 'iab', timeout: 500, rules: [storage, basicAds] }, + gpp: { cmpApi: 'iab' }, + }; + const { prepare, consent } = setup(original); + + prepare(); + + expect(consent()).toEqual({ + ...original, + gdpr: { + ...original.gdpr, + rules: [storage, { ...basicAds, softVendorExceptions: ['anotherBidder', 'trustedServer'] }], + }, + }); + expect(basicAds.softVendorExceptions).toEqual(['anotherBidder']); + expect(original.gdpr.rules).toEqual([storage, basicAds]); + }); + + it('leaves legacy TCF settings and their merge semantics unchanged', () => { + const original = { + cmpApi: 'static', + consentData: { gdprApplies: true }, + strictStorageEnforcement: true, + }; + const { prepare, consent, setConfig } = setup(original); + + prepare(); + + expect(consent()).toEqual(original); + expect(setConfig).not.toHaveBeenCalled(); + }); + + it.each([{}, { gpp: { cmpApi: 'iab' } }, { gdpr: { enabled: false } }])( + 'does not activate GDPR collection for %j', + (original) => { + const { prepare, setConfig } = setup(original); + + prepare(); + + expect(setConfig).not.toHaveBeenCalled(); + } + ); + + it('honors an explicitly configured relay GVL identity', () => { + const { prepare, setConfig } = setup({ gdpr: { cmpApi: 'iab' } }, { trustedServer: 1 }); + + prepare(); + + expect(setConfig).not.toHaveBeenCalled(); + }); + + it('prepares new publisher rules again before a later auction', () => { + const { prepare, replaceConsent, consent } = setup({ gdpr: { cmpApi: 'iab' } }); + prepare(); + replaceConsent({ + strictStorageEnforcement: true, + gdpr: { + cmpApi: 'iab', + rules: [ + { purpose: 'basicAds', enforcePurpose: true, softVendorExceptions: ['exampleBidder'] }, + ], + }, + }); + + prepare(); + + expect(consent()).toMatchObject({ + strictStorageEnforcement: true, + gdpr: { + rules: [ + { + purpose: 'basicAds', + enforcePurpose: true, + softVendorExceptions: ['exampleBidder', 'trustedServer'], + }, + ], + }, + }); + }); + + it('retires its own exception when a publisher configures a GVL identity later', () => { + const { prepare, mapVendor, consent } = setup({ gdpr: { cmpApi: 'iab' } }); + + prepare(); + mapVendor(); + prepare(); + + expect(consent()).toMatchObject({ + gdpr: { + rules: [ + { + purpose: 'basicAds', + enforcePurpose: true, + enforceVendor: true, + softVendorExceptions: [], + }, + ], + }, + }); + }); + + it('leaves malformed publisher rules unchanged', () => { + const { prepare, setConfig } = setup({ gdpr: { cmpApi: 'iab', rules: 'invalid' } }); + + prepare(); + + expect(setConfig).not.toHaveBeenCalled(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/sourcepoint/consent_sync.test.ts b/crates/trusted-server-js/lib/test/integrations/sourcepoint/consent_sync.test.ts new file mode 100644 index 0000000000..40864e2fc0 --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/sourcepoint/consent_sync.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ConsentSync } from '../../../src/integrations/sourcepoint/consent_sync'; + +const ok = { status: 204, body: null } as Response; + +function snapshot(gppString: string | null) { + return { gppString, applicableSections: gppString ? [7] : [] }; +} + +describe('Sourcepoint server consent reconciliation', () => { + it('sends the exact decision with same-origin credentials and keepalive', () => { + const fetch = vi.fn().mockResolvedValue(ok); + const sync = new ConsentSync(fetch); + sync.publish(snapshot('synthetic-current-gpp')); + expect(fetch).toHaveBeenCalledWith('/_ts/consent', { + method: 'POST', + credentials: 'same-origin', + redirect: 'error', + cache: 'no-store', + keepalive: true, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(snapshot('synthetic-current-gpp')), + }); + }); + + it('deduplicates repeated snapshots but preserves a withdrawal between grants', () => { + const fetch = vi.fn().mockResolvedValue(ok); + const sync = new ConsentSync(fetch); + sync.publish(snapshot('synthetic-allow')); + sync.publish(snapshot('synthetic-allow')); + sync.publish(snapshot('synthetic-deny')); + sync.publish(snapshot('synthetic-allow')); + expect(fetch).toHaveBeenCalledTimes(3); + expect(fetch.mock.calls.map(([, init]) => JSON.parse(init!.body as string).gppString)).toEqual([ + 'synthetic-allow', + 'synthetic-deny', + 'synthetic-allow', + ]); + }); + + it('does not wait for an earlier response before dispatching a withdrawal', () => { + const fetch = vi.fn().mockImplementation(() => new Promise(() => {})); + const sync = new ConsentSync(fetch); + sync.publish(snapshot('synthetic-allow')); + sync.publish(snapshot('synthetic-deny')); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('permits a later event to retry an unconfirmed update without a retry timer', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce({ status: 503, body: null } as Response) + .mockResolvedValue(ok); + const sync = new ConsentSync(fetch); + sync.publish(snapshot('synthetic-deny')); + await Promise.resolve(); + sync.publish(snapshot('synthetic-deny')); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it.each([200, 202])( + 'does not treat a generic HTTP %i as reconciliation success', + async (status) => { + const fetch = vi + .fn() + .mockResolvedValueOnce({ status, body: null } as Response) + .mockResolvedValue(ok); + const sync = new ConsentSync(fetch); + sync.publish(snapshot('synthetic-deny')); + await Promise.resolve(); + sync.publish(snapshot('synthetic-deny')); + expect(fetch).toHaveBeenCalledTimes(2); + } + ); + + it('contains a synchronous fetch failure and permits a later retry', () => { + const fetch = vi + .fn() + .mockImplementationOnce(() => { + throw new Error('synthetic failure'); + }) + .mockResolvedValue(ok); + const sync = new ConsentSync(fetch); + expect(() => sync.publish(snapshot('synthetic-deny'))).not.toThrow(); + sync.publish(snapshot('synthetic-deny')); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('does not cancel an in-flight keepalive update when disposed', () => { + const fetch = vi.fn().mockImplementation(() => new Promise(() => {})); + const sync = new ConsentSync(fetch); + sync.publish(snapshot('synthetic-deny')); + sync.stop(); + sync.publish(snapshot('synthetic-allow')); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch.mock.calls[0][1]?.signal).toBeUndefined(); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/sourcepoint/gpp_capture.test.ts b/crates/trusted-server-js/lib/test/integrations/sourcepoint/gpp_capture.test.ts new file mode 100644 index 0000000000..e61a5f4afd --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/sourcepoint/gpp_capture.test.ts @@ -0,0 +1,457 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + GppConsentCapture, + type GppApi, + type GppWindow, +} from '../../../src/integrations/sourcepoint/gpp_capture'; + +type Callback = Parameters[1]; +const ready = (gppString = 'synthetic-gpp-allow', applicableSections = [7]) => ({ + gppVersion: '1.1', + cmpStatus: 'loaded', + signalStatus: 'ready', + gppString, + applicableSections, +}); + +function cmp(initial: Record = ready()) { + let state = initial; + let nextId = 1; + const listeners = new Map(); + const api: GppApi = vi.fn((command, callback, parameter) => { + if (command === 'ping') callback(state, true); + else if (command === 'addEventListener') { + const id = nextId++; + listeners.set(id, callback); + callback( + { eventName: 'listenerRegistered', listenerId: id, data: true, pingData: state }, + true + ); + } else if (command === 'removeEventListener') { + callback(listeners.delete(parameter!), true); + } else callback(undefined, false); + }); + return { + api, + listeners, + change(next: Record, eventName = 'sectionChange') { + state = next; + for (const [id, callback] of listeners) { + callback({ eventName, listenerId: id, pingData: state }, true); + } + }, + }; +} + +describe('Sourcepoint GPP API capture', () => { + const target = window as GppWindow; + let capture: GppConsentCapture; + let onConsent: ReturnType; + let onUnavailable: ReturnType; + + beforeEach(() => { + delete target.__gpp; + onConsent = vi.fn(); + onUnavailable = vi.fn(); + capture = new GppConsentCapture(target, onConsent, onUnavailable); + }); + + afterEach(() => { + capture.stop(); + delete target.__gpp; + vi.useRealTimers(); + }); + + it('uses callback ping and listener registration rather than unsupported getGPPData', () => { + const vendor = cmp(); + target.__gpp = vendor.api; + capture.start(); + expect(onConsent).toHaveBeenLastCalledWith({ + gppString: 'synthetic-gpp-allow', + applicableSections: [7], + }); + expect(vendor.listeners.size).toBe(1); + expect(vi.mocked(vendor.api).mock.calls.map(([command]) => command)).not.toContain( + 'getGPPData' + ); + }); + + it('captures an API published long after the old startup retry without polling', () => { + vi.useFakeTimers(); + capture.start(); + vi.advanceTimersByTime(60_000); + expect(onConsent).not.toHaveBeenCalled(); + const vendor = cmp(); + target.__gpp = vendor.api; + expect(onConsent).toHaveBeenLastCalledWith({ + gppString: 'synthetic-gpp-allow', + applicableSections: [7], + }); + expect(vi.getTimerCount()).toBe(0); + }); + + it('does not publish a grant when the event subscription failed', () => { + target.__gpp = (command, callback) => { + if (command === 'addEventListener') callback(undefined, false); + if (command === 'ping') callback(ready(), true); + }; + capture.start(); + expect(onConsent).not.toHaveBeenCalled(); + expect(onUnavailable).toHaveBeenCalled(); + }); + + it('requires the registration data flag, not just a successful command', () => { + target.__gpp = (command, callback) => { + if (command === 'addEventListener') + callback({ eventName: 'listenerRegistered', listenerId: 0, data: false }, true); + if (command === 'ping') callback(ready(), true); + }; + capture.start(); + expect(onConsent).not.toHaveBeenCalled(); + expect(onUnavailable).toHaveBeenCalled(); + }); + + it('preserves the window receiver when invoking the publisher API', () => { + const vendor = cmp(); + const receivers: unknown[] = []; + target.__gpp = function (this: unknown, ...args) { + receivers.push(this); + vendor.api(...args); + }; + capture.start(); + capture.stop(); + expect(receivers.length).toBeGreaterThan(0); + expect(receivers.every((receiver) => receiver === window)).toBe(true); + }); + + it('waits for real readiness rather than accepting an unready snapshot', () => { + const vendor = cmp({ ...ready(), signalStatus: 'not ready' }); + target.__gpp = vendor.api; + capture.start(); + expect(onConsent).not.toHaveBeenCalled(); + expect(onUnavailable).toHaveBeenCalled(); + vendor.change(ready(), 'signalStatus'); + expect(onConsent).toHaveBeenCalledWith({ + gppString: 'synthetic-gpp-allow', + applicableSections: [7], + }); + }); + + it('captures same-tab restrictive changes without focus or reload', () => { + const vendor = cmp(); + target.__gpp = vendor.api; + capture.start(); + vendor.change(ready('synthetic-gpp-opt-out', [7, 22])); + expect(onConsent).toHaveBeenLastCalledWith({ + gppString: 'synthetic-gpp-opt-out', + applicableSections: [7, 22], + }); + }); + + it('reports unavailable state instead of keeping an earlier permissive snapshot', () => { + const vendor = cmp(); + target.__gpp = vendor.api; + capture.start(); + onConsent.mockClear(); + onUnavailable.mockClear(); + vendor.change({ ...ready(), signalStatus: 'not ready' }); + expect(onUnavailable).toHaveBeenCalledOnce(); + expect(onConsent).not.toHaveBeenCalled(); + }); + + it.each([ + { ...ready(), gppString: '' }, + { ...ready(), gppString: 'synthetic-grant; Path=/' }, + { ...ready(), gppString: 'synthetic-grant\n' }, + { ...ready(), applicableSections: [70000] }, + { ...ready(), applicableSections: ['7'] }, + { ...ready(), applicableSections: [7.5] }, + { ...ready(), applicableSections: [] }, + { ...ready(), applicableSections: [-1] }, + { ...ready(), applicableSections: undefined }, + ])('does not accept malformed API consent data: %j', (state) => { + target.__gpp = cmp(state).api; + capture.start(); + expect(onConsent).not.toHaveBeenCalled(); + expect(onUnavailable).toHaveBeenCalled(); + }); + + it('transfers a queued stub subscription without registering twice', () => { + const queued: Array<[string, Callback, number | undefined]> = []; + target.__gpp = (command, callback, parameter) => queued.push([command, callback, parameter]); + capture.start(); + const vendor = cmp(); + target.__gpp = vendor.api; + for (const [command, callback, parameter] of queued) vendor.api(command, callback, parameter); + expect(vendor.listeners.size).toBe(1); + expect(onConsent).toHaveBeenLastCalledWith({ + gppString: 'synthetic-gpp-allow', + applicableSections: [7], + }); + }); + + it('handles the synchronous acknowledgment in the published 1.1 stub without granting from it', () => { + target.__gpp = (command) => { + if (command === 'ping') return { ...ready(), cmpStatus: 'stub' }; + if (command === 'addEventListener') + return { eventName: 'listenerRegistered', listenerId: 1, data: true }; + }; + capture.start(); + expect(onConsent).not.toHaveBeenCalled(); + expect(onUnavailable).toHaveBeenCalled(); + target.__gpp = cmp().api; + expect(onConsent).toHaveBeenCalled(); + }); + + it('uses synchronous API 1.0 getGPPData only after discovering that version', () => { + let state = { gppString: 'synthetic-legacy-gpp', applicableSections: [7] }; + let listener: Callback | undefined; + target.__gpp = (command, callback) => { + if (command === 'ping') return { gppVersion: '1.0', cmpStatus: 'loaded' }; + if (command === 'getGPPData') return state; + if (command === 'addEventListener') { + listener = callback; + return { eventName: 'listenerRegistered', listenerId: 1, data: true }; + } + }; + capture.start(); + expect(onConsent).toHaveBeenLastCalledWith(state); + state = { gppString: 'synthetic-legacy-opt-out', applicableSections: [7] }; + listener!({ eventName: 'sectionChange' }); + expect(onConsent).toHaveBeenLastCalledWith(state); + }); + + it('discards an older ping callback after a newer decision event', () => { + const pending: Callback[] = []; + let listener: Callback | undefined; + target.__gpp = (command, callback) => { + if (command === 'ping') pending.push(callback); + if (command === 'addEventListener') { + listener = callback; + callback({ eventName: 'listenerRegistered', listenerId: 1, data: true }, true); + } + }; + capture.start(); + pending[0](ready(), true); + const obsolete = pending[1]; + listener!({ eventName: 'sectionChange' }, true); + pending[2](ready('synthetic-current-opt-out'), true); + obsolete(ready('synthetic-obsolete-allow'), true); + expect(onConsent).toHaveBeenCalledOnce(); + expect(onConsent).toHaveBeenLastCalledWith({ + gppString: 'synthetic-current-opt-out', + applicableSections: [7], + }); + }); + + it('removes the old listener when an installed CMP API is replaced', () => { + const old = cmp(); + const next = cmp(ready('synthetic-current-gpp')); + target.__gpp = old.api; + capture.start(); + target.__gpp = next.api; + expect(old.listeners.size).toBe(0); + expect(next.listeners.size).toBe(1); + expect(onConsent).toHaveBeenLastCalledWith({ + gppString: 'synthetic-current-gpp', + applicableSections: [7], + }); + old.change(ready('synthetic-stale-gpp')); + expect(onConsent).not.toHaveBeenLastCalledWith({ + gppString: 'synthetic-stale-gpp', + applicableSections: [7], + }); + }); + + it('keeps the publisher API identity and descriptor semantics after disposal', () => { + const vendor = cmp(); + capture.start(); + target.__gpp = vendor.api; + capture.stop(); + expect(vendor.listeners.size).toBe(0); + expect(target.__gpp).toBe(vendor.api); + expect(Object.getOwnPropertyDescriptor(target, '__gpp')).toMatchObject({ + value: vendor.api, + writable: true, + configurable: true, + enumerable: true, + }); + }); + + it('observes SDK replacement through a custom accessor at resource completion', () => { + let published: GppApi | undefined = undefined; + const getter = () => published; + Object.defineProperty(target, '__gpp', { configurable: true, get: getter }); + capture.start(); + published = cmp().api; + document.dispatchEvent(new Event('load')); + expect(onConsent).toHaveBeenCalled(); + expect(Object.getOwnPropertyDescriptor(target, '__gpp')?.get).toBe(getter); + }); + + it('leaves a publisher custom accessor intact', () => { + const vendor = cmp(); + const getter = () => vendor.api; + Object.defineProperty(target, '__gpp', { configurable: true, get: getter }); + capture.start(); + expect(Object.getOwnPropertyDescriptor(target, '__gpp')?.get).toBe(getter); + expect(onConsent).toHaveBeenCalled(); + capture.stop(); + expect(Object.getOwnPropertyDescriptor(target, '__gpp')?.get).toBe(getter); + }); +}); + +describe('Sourcepoint GPP cookie bridge', () => { + const target = window as GppWindow; + let module: typeof import('../../../src/integrations/sourcepoint'); + let previousFetch: PropertyDescriptor | undefined; + let fetchMock: ReturnType>; + + function clearCookies(): void { + for (const cookie of document.cookie.split(';')) { + document.cookie = `${cookie.trim().split('=')[0]}=; Path=/; Max-Age=0`; + } + } + + function cookie(name: string): string | undefined { + return document.cookie + .split('; ') + .find((value) => value.startsWith(`${name}=`)) + ?.slice(name.length + 1); + } + + beforeEach(() => { + vi.resetModules(); + localStorage.clear(); + clearCookies(); + delete target.__gpp; + previousFetch = Object.getOwnPropertyDescriptor(window, 'fetch'); + fetchMock = vi + .fn() + .mockResolvedValue({ status: 204, body: null } as Response); + Object.defineProperty(window, 'fetch', { configurable: true, value: fetchMock }); + Object.assign(window, { __tsjs_sourcepoint: { rewriteSdk: false } }); + }); + + afterEach(() => { + module?.disposeSourcepointConsentMirror(); + delete target.__gpp; + Reflect.deleteProperty(window, '__tsjs_sourcepoint'); + if (previousFetch) Object.defineProperty(window, 'fetch', previousFetch); + else Reflect.deleteProperty(window, 'fetch'); + localStorage.clear(); + clearCookies(); + vi.useRealTimers(); + }); + + it('uses the ready API rather than another property’s stale localStorage record', async () => { + localStorage.setItem( + '_sp_user_consent_old', + JSON.stringify({ gppData: { gppString: 'synthetic-stale-gpp', applicableSections: [8] } }) + ); + target.__gpp = cmp(ready('synthetic-current-gpp')).api; + module = await import('../../../src/integrations/sourcepoint'); + expect(cookie('__gpp')).toBe('synthetic-current-gpp'); + expect(cookie('__gpp_sid')).toBe('7'); + }); + + it('writes actual restrictive updates immediately in the same tab', async () => { + const vendor = cmp(); + target.__gpp = vendor.api; + module = await import('../../../src/integrations/sourcepoint'); + vendor.change(ready('synthetic-gpp-opt-out', [7, 22])); + expect(cookie('__gpp')).toBe('synthetic-gpp-opt-out'); + expect(cookie('__gpp_sid')).toBe('7,22'); + expect(cookie('_ts_gpp_src')).toBe('sp'); + expect( + fetchMock.mock.calls.map(([, init]) => JSON.parse(init!.body as string).gppString) + ).toEqual(['synthetic-gpp-allow', 'synthetic-gpp-opt-out']); + }); + + it('does not fall back to a stale grant while the GPP API is not ready', async () => { + const vendor = cmp(); + target.__gpp = vendor.api; + module = await import('../../../src/integrations/sourcepoint'); + localStorage.setItem( + '_sp_user_consent_old', + JSON.stringify({ gppData: { gppString: 'synthetic-stale-gpp', applicableSections: [7] } }) + ); + vendor.change({ ...ready(), signalStatus: 'not ready' }); + window.dispatchEvent(new Event('focus')); + expect(cookie('__gpp')).toBeUndefined(); + expect(cookie('_ts_gpp_src')).toBeUndefined(); + expect(JSON.parse(fetchMock.mock.calls.at(-1)![1]!.body as string)).toEqual({ + gppString: null, + applicableSections: [], + }); + }); + + it('preserves another writer’s cookies on ready and unavailable events', async () => { + document.cookie = '__gpp=synthetic-other-cmp; Path=/'; + document.cookie = '__gpp_sid=2; Path=/'; + const vendor = cmp(); + target.__gpp = vendor.api; + module = await import('../../../src/integrations/sourcepoint'); + expect(cookie('__gpp')).toBe('synthetic-other-cmp'); + vendor.change({ ...ready(), signalStatus: 'not ready' }); + expect(cookie('__gpp')).toBe('synthetic-other-cmp'); + expect(cookie('__gpp_sid')).toBe('2'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not claim another writer's identical GPP cookie", async () => { + document.cookie = '__gpp=synthetic-gpp-allow; Path=/'; + document.cookie = '__gpp_sid=2; Path=/'; + target.__gpp = cmp().api; + module = await import('../../../src/integrations/sourcepoint'); + expect(cookie('_ts_gpp_src')).toBeUndefined(); + expect(cookie('__gpp_sid')).toBe('2'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('contains blocked localStorage without breaking publisher scripts', async () => { + vi.useFakeTimers(); + const storage = vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => { + throw new DOMException('Synthetic blocked storage', 'SecurityError'); + }); + try { + module = await import('../../../src/integrations/sourcepoint'); + expect(() => window.dispatchEvent(new Event('focus'))).not.toThrow(); + expect(() => vi.advanceTimersByTime(600)).not.toThrow(); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + storage.mockRestore(); + } + }); + + it('removes a pending DOM-ready legacy retry when disposed', async () => { + vi.useFakeTimers(); + const loading = vi.spyOn(document, 'readyState', 'get').mockReturnValue('loading'); + try { + module = await import('../../../src/integrations/sourcepoint'); + module.disposeSourcepointConsentMirror(); + localStorage.setItem( + '_sp_user_consent_fixture', + JSON.stringify({ + gppData: { gppString: 'synthetic-after-stop', applicableSections: [7] }, + }) + ); + document.dispatchEvent(new Event('DOMContentLoaded')); + vi.runAllTimers(); + expect(cookie('__gpp')).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + loading.mockRestore(); + } + }); + + it('captures a late GPP publication after the legacy startup retry expired', async () => { + vi.useFakeTimers(); + module = await import('../../../src/integrations/sourcepoint'); + vi.advanceTimersByTime(5_000); + target.__gpp = cmp().api; + expect(cookie('__gpp')).toBe('synthetic-gpp-allow'); + expect(cookie('__gpp_sid')).toBe('7'); + }); +}); diff --git a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs index 24db647d31..7f1fa10b7e 100644 --- a/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs +++ b/crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs @@ -299,18 +299,85 @@ function expectManifest(manifest, analytics) { } describe('tsjs-prebid production artifacts', () => { + it.each([ + { purpose2: true, mapping: undefined, auctionRequests: 1 }, + { purpose2: false, mapping: undefined, auctionRequests: 0 }, + { purpose2: true, mapping: 1, auctionRequests: 0 }, + ])( + 'enforces actual TCF rules for relay consent: %j', + async ({ purpose2, mapping, auctionRequests }) => { + const dom = createPage(); + try { + const pageWindow = dom.window; + const stubs = installNetworkAndConsoleStubs(pageWindow); + installServerState(pageWindow); + pageWindow.eval(noAnalyticsArtifact.bundleCode); + pageWindow.eval(shimCode); + expect(pageWindow.pbjs.installedModules).toContain('tcfControl'); + + // Grant purposes but no vendor identities: the first-party relay must + // not inherit a third-party bidder's GVL identity or vendor exemption. + const gdpr = { + cmpApi: 'static', + consentData: { + gdprApplies: true, + tcString: 'example-consent', + purpose: { consents: { 1: true, 2: purpose2 }, legitimateInterests: {} }, + vendor: { consents: {}, legitimateInterests: {} }, + }, + }; + pageWindow.pbjs.setConfig({ + ...(mapping === undefined ? {} : { gvlMapping: { trustedServer: mapping } }), + consentManagement: { gdpr }, + }); + const slot = pageWindow.document.createElement('div'); + slot.id = 'consent-slot'; + pageWindow.document.body.appendChild(slot); + + await new Promise((resolve) => { + pageWindow.pbjs.requestBids({ + adUnits: [ + { + code: slot.id, + mediaTypes: { banner: { sizes: [[300, 250]] } }, + bids: [ + { bidder: 'appnexus', params: { placementId: 1 } }, + { bidder: 'rubicon', params: { accountId: 1, siteId: 1, zoneId: 1 } }, + ], + }, + ], + timeout: 1000, + bidsBackHandler: resolve, + }); + }); + + const requests = stubs.fetchSpy.mock.calls.filter( + ([resource]) => new URL(requestUrl(resource)).pathname === '/auction' + ); + expect(requests).toHaveLength(auctionRequests); + const enforcement = pageWindow.pbjs + .getEvents() + .filter(({ eventType }) => eventType === 'tcf2Enforcement') + .at(-1).args; + expect(enforcement.biddersBlocked).toContain('rubicon'); + expect(enforcement.biddersBlocked.includes('trustedServer')).toBe(auctionRequests === 0); + expectNoUnexpectedNetworkActivity(stubs); + } finally { + dom.window.close(); + } + } + ); + it('keeps the served shim Prebid-free', () => { expect(analyticsArtifact.bundleCode).toContain(analyticsArtifact.manifest.prebidVersion); expect(analyticsArtifact.bundleCode).toContain('_pbjsGlobals'); expect(shimCode).not.toContain(analyticsArtifact.manifest.prebidVersion); expect(shimCode).not.toContain('_pbjsGlobals'); expect(analyticsArtifact.bundleCode.length).toBeGreaterThan(200_000); - // A value-import of Prebid or a private rendering helper would multiply - // the shim size. The bound sits just above the normal compact shim output, - // which is roughly 40 KB: tight enough that material growth has to be - // noticed and re-justified here, and far enough below a multiplication - // that one still fails loudly. The bundle beside it is 200 KB and up. - expect(shimCode.length).toBeLessThan(41_000); + // Keep a narrow 42 KB guard including relay consent handling. + // Prebid remains a separate artifact above 200 KB, and importing + // its runtime or private rendering helpers must still fail this regression. + expect(shimCode.length).toBeLessThan(42_000); expect(shimCode).toContain('markWinningBidAsUsed'); }); diff --git a/deploy/examples/native-ec-dynamodb/.terraform.lock.hcl b/deploy/examples/native-ec-dynamodb/.terraform.lock.hcl new file mode 100644 index 0000000000..38e6eaafe6 --- /dev/null +++ b/deploy/examples/native-ec-dynamodb/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.64.0" + constraints = "6.64.0" + hashes = [ + "h1:2fTLxzUDmp/KVIHbIeLTB4bIzWHx8E6Dw+1ALLUi+Yw=", + "zh:07172315d67bc9781240272759cdfc7bd32b7e72384a56862c2c1da3cca99a81", + "zh:154ce7d2659de9a59ddfe96d7cab41a9ddc2cb267a7d4bcdf4e737ff2ffdec06", + "zh:17324d4335a7a7ac01cc23eded530775606680ff53b47cb74a3cb95d1121f836", + "zh:307ab92324ec5a61b124881ab8cac1d9e316f4527dfd0e1b59794c229407eb4e", + "zh:31e25f1903661332e36a95283042dd3ec50b47c186db00663fbd976a11e6a6b2", + "zh:3311d9f3bd12a24886027dbe73859dcd1e67bd0e3046227a338cf2c7ca04d18e", + "zh:37916156a3aac3b29be3acebd15d53145ea4ab5d4aaa825eaebe75481fa00500", + "zh:4158cb8c38b3ac6aa98eb15935ec6bd7c30838d85d2b00acc9812df8382ae908", + "zh:5bfb9499c66d9db5b34dc5c60f426a1ab1baa5457ce2aefebca826a9c3f92fb0", + "zh:6eb29ead5a4aca3b1f35812e7e8c75419180e1928e479b458f206861277736db", + "zh:7a82b6dd0c0cdef8045a4adfbddd36acb86b6b23fcbed8e189c2d71f7dc4a502", + "zh:9556bd792032c3f7e73ea4dd08cec88dc1327f5a4a57d79c30ba844ae2b9a3c0", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:c5234180464cb800c83a41f57462742b802c150ad7d4417626fcd9cb511c01d2", + "zh:cd776b83b1f7b36635957350afe7ce28ba4e4ea3a5e2deb00d13dbd3b35d9d40", + "zh:fb583a7b791c6f915b86573d04f05ddbf7f1a5e4120c5d8a7450a3086c1225c4", + ] +} diff --git a/deploy/examples/native-ec-dynamodb/README.md b/deploy/examples/native-ec-dynamodb/README.md new file mode 100644 index 0000000000..729181aa39 --- /dev/null +++ b/deploy/examples/native-ec-dynamodb/README.md @@ -0,0 +1,44 @@ +# Retained native EC DynamoDB example + +This Terraform root creates one regional EC table and attaches a scoped inline policy to an existing ECS application task role. It does not create ECS, change a permissions boundary, grant operator permissions, or touch the execution role. + +See [the deployment guide](../../../docs/guide/native-ec-persistence.md) before using it. No AWS deployment is authorized by this example. + +## Ownership + +Keep this root in retained state, separate from disposable workload state. Supply a separately owned S3 backend bucket, a unique state key, region, encryption and access controls. S3 lockfile locking is enabled. Do not copy an existing workload's backend key. Commit the provider lock file from an approved initialization before deployment. + +The required `owner` owns the table, both reserved namespace partitions, allocator continuity, retention decisions, ongoing cost and eventual decommissioning. The task role remains owned by its workload. Removing the workload must not remove this table. Before retiring a role, remove or transfer this root's inline-policy attachment so retained state does not reference a deleted role. + +The table has `PAY_PER_REQUEST` capacity, string keys `pk` and `sk`, service TTL `ttl_epoch_s`, default service-managed encryption, deletion protection and Terraform `prevent_destroy`. It has no indexes or replicas. TTL is not an immediate deletion guarantee. The backend enforces logical expiration with `expires_at_ms`. + +Reserve both exact partition keys for one app/environment: + +- `ec:{namespace}` contains identities, tombstones and withdrawal markers. +- `ec-allocator:{namespace}` contains the internal `sk=generation` counter. It has no TTL and is not an identity row. Never delete or reset it if the namespace may be reused. + +Terraform does not seed or manage individual items. The runtime atomically allocates generations. Protect allocator continuity during backup, restore, migration and cleanup. Restoring an older allocator while serving newer rows can allow a stale generation to match again. + +## Review and local checks + +Copy `terraform.tfvars.example` to an approved, untracked input location and replace every illustrative identifier. Review the account, region, retained state key, task role, table/data lifetime and both namespace partitions. A namespace is not a secret and must not contain customer data. + +The no-network format check is: + +```sh +terraform fmt -check -recursive deploy/examples/native-ec-dynamodb +``` + +Provider initialization, planning and any application need separate authorization and approved credentials. This root pins Terraform and the AWS provider but intentionally supplies no live backend coordinates or credentials. It does not call AWS during the format check. + +## IAM integration + +`task_policy_json` contains exactly `DescribeTable`, `GetItem`, `PutItem`, `DeleteItem`, `Query` and `UpdateItem` on the table ARN. Row operations require the exact `ec:` partition. Only `UpdateItem` may access the exact allocator partition. `DescribeTable` is table-level and has no partition condition. There are no index ARNs, wildcard table grants, `Scan`, table creation or TTL-management permissions. + +The root attaches the policy to `task_role_name`; that is not enough when the existing task boundary explicitly denies DynamoDB. Have the boundary owner review both its action ceiling and its constrained allow statements, then review the task inline policy. Do not remove the boundary, broaden the execution role, or weaken the deny to get a successful test. Other policies can still deny the calls. + +`ec_storage` replaces the former `runtime_environment` output. Publish this non-secret object as `[ec.storage]` in the existing Trusted Server app-config BlobEnvelope before launching an application built with `aws-dynamodb-ec`. Use `terraform output -json ec_storage` only against the separately approved retained state; this guide does not run it. Keep the exact table and namespace unchanged. Config publication is the existing `ts config push`/AppConfig workflow, not container environment injection or another envelope format. The old enabled `TS_EC_DYNAMODB_ENABLED` launch contract now fails before readiness, even alongside new config. Remove the old selection and binding variables after preparing publication. External consumers of the removed output must migrate with this change. The ordinary AWS workload credential chain still supplies task credentials. Do not add access keys to Terraform or the task definition. + +## Retirement + +Stopping ECS leaves this table and state intact. The owner must separately approve data erasure, allocator retirement, backup disposition and table removal. Only reuse a namespace if its allocator history is preserved. A deliberate data deletion is not permission to reset generation history. `prevent_destroy` and deletion protection block ordinary table removal; they are not a backup policy and cannot prevent an authorized task from deleting identity rows. diff --git a/deploy/examples/native-ec-dynamodb/main.tf b/deploy/examples/native-ec-dynamodb/main.tf new file mode 100644 index 0000000000..0d42b4eaa5 --- /dev/null +++ b/deploy/examples/native-ec-dynamodb/main.tf @@ -0,0 +1,77 @@ +locals { + identity_partition = "ec:${var.namespace}" + allocator_partition = "ec-allocator:${var.namespace}" + + # This allow document does not override an explicit deny in a boundary, SCP + # or endpoint policy. It is also the input for the separate boundary review. + task_policy = { + Version = "2012-10-17" + Statement = [ + { + Sid = "ValidateExactEcTable" + Effect = "Allow" + Action = ["dynamodb:DescribeTable"] + Resource = aws_dynamodb_table.ec.arn + }, + { + Sid = "IdentityRowsOnly" + Effect = "Allow" + Action = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem", "dynamodb:Query"] + Resource = aws_dynamodb_table.ec.arn + Condition = { + "ForAllValues:StringEquals" = { "dynamodb:LeadingKeys" = [local.identity_partition] } + Null = { "dynamodb:LeadingKeys" = "false" } + } + }, + { + Sid = "AllocateGenerationsOnly" + Effect = "Allow" + Action = ["dynamodb:UpdateItem"] + Resource = aws_dynamodb_table.ec.arn + Condition = { + "ForAllValues:StringEquals" = { "dynamodb:LeadingKeys" = [local.allocator_partition] } + Null = { "dynamodb:LeadingKeys" = "false" } + } + } + ] + } +} + +resource "aws_dynamodb_table" "ec" { + name = var.table_name + billing_mode = "PAY_PER_REQUEST" + hash_key = "pk" + range_key = "sk" + deletion_protection_enabled = true + + attribute { + name = "pk" + type = "S" + } + + attribute { + name = "sk" + type = "S" + } + + ttl { + attribute_name = "ttl_epoch_s" + enabled = true + } + + server_side_encryption { + enabled = true + } + + # The allocator lives as long as this namespace can be used. Do not put this + # table in disposable service state or remove this protection to stop ECS. + lifecycle { + prevent_destroy = true + } +} + +resource "aws_iam_role_policy" "ec" { + name = "trusted-server-native-ec" + role = var.task_role_name + policy = jsonencode(local.task_policy) +} diff --git a/deploy/examples/native-ec-dynamodb/outputs.tf b/deploy/examples/native-ec-dynamodb/outputs.tf new file mode 100644 index 0000000000..1212aa6015 --- /dev/null +++ b/deploy/examples/native-ec-dynamodb/outputs.tf @@ -0,0 +1,27 @@ +output "table_arn" { + description = "Exact table ARN for operator and retained boundary review." + value = aws_dynamodb_table.ec.arn +} + +output "task_policy_json" { + description = "Task allow document. Merge equivalent constrained allows into the reviewed boundary separately." + value = jsonencode(local.task_policy) +} + +output "ec_storage" { + description = "Non-secret ec.storage object to publish in the existing Trusted Server app-config envelope. No credential or execution-role changes." + value = { + provider = "dynamodb" + region = var.aws_region + table = aws_dynamodb_table.ec.name + namespace = var.namespace + } +} + +output "owned_partitions" { + description = "Reserve both partition values for the named owner. Never reset the allocator while reusing the namespace." + value = { + identity = local.identity_partition + allocator = local.allocator_partition + } +} diff --git a/deploy/examples/native-ec-dynamodb/terraform.tf b/deploy/examples/native-ec-dynamodb/terraform.tf new file mode 100644 index 0000000000..fa125d57ff --- /dev/null +++ b/deploy/examples/native-ec-dynamodb/terraform.tf @@ -0,0 +1,31 @@ +terraform { + required_version = ">= 1.16.2, < 1.17.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "= 6.64.0" + } + } + + # Supply a retained bucket, unique key and region at init. The bucket and its + # access policy are owned separately from this example and the ECS workload. + backend "s3" { + encrypt = true + use_lockfile = true + } +} + +provider "aws" { + region = var.aws_region + allowed_account_ids = [var.aws_account_id] + + default_tags { + tags = { + Application = "trusted-server" + Owner = var.owner + Namespace = var.namespace + Lifecycle = "retained-ec-state" + } + } +} diff --git a/deploy/examples/native-ec-dynamodb/terraform.tfvars.example b/deploy/examples/native-ec-dynamodb/terraform.tfvars.example new file mode 100644 index 0000000000..7b04f82852 --- /dev/null +++ b/deploy/examples/native-ec-dynamodb/terraform.tfvars.example @@ -0,0 +1,7 @@ +# Illustrative identifiers only. Do not use a disposable workload ID here. +aws_account_id = "123456789012" +aws_region = "us-east-1" +table_name = "example-retained-ec" +namespace = "example-staging" +owner = "example-platform-team" +task_role_name = "example-application-task" diff --git a/deploy/examples/native-ec-dynamodb/variables.tf b/deploy/examples/native-ec-dynamodb/variables.tf new file mode 100644 index 0000000000..69984f7628 --- /dev/null +++ b/deploy/examples/native-ec-dynamodb/variables.tf @@ -0,0 +1,37 @@ +variable "aws_account_id" { + description = "Reviewed AWS account. The provider refuses another account." + type = string + validation { + condition = can(regex("^[0-9]{12}$", var.aws_account_id)) + error_message = "Supply a 12-digit AWS account ID." + } +} + +variable "aws_region" { + description = "Single approved commercial AWS region for both table and runtime." + type = string +} + +variable "table_name" { + description = "Stable retained table name, not an ephemeral workload/run name." + type = string +} + +variable "namespace" { + description = "Exclusive app/environment identity namespace. Reserve its ec: and ec-allocator: partitions together." + type = string +} + +variable "owner" { + description = "Named owner of data retention, allocator continuity, costs, state and approved decommissioning." + type = string + validation { + condition = length(trimspace(var.owner)) > 0 + error_message = "An accountable retention owner is required." + } +} + +variable "task_role_name" { + description = "Existing ECS application task role name. Not the execution role. Boundary review is required separately." + type = string +} diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index edbc384719..cf05ddaf00 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -763,6 +763,7 @@ Common provider fields and defaults: | `protocol` | Required | `openrtb-2.6` | | `profile` | `standard` | Typed OpenRTB behavior | | `endpoint` | Required | Fixed absolute HTTPS endpoint | +| `allow_insecure_http` | `false` | Explicit per-provider HTTP opt-in for approved demos/tests; traffic is unencrypted | | `timeout_ms` | Profile default | PBS 1000 ms, APS 800 ms, standard inherits auction timeout | | `routing` | `explicit` | `explicit`, or `all_eligible` for non-PBS profiles | | `profile_config` | `{}` | Profile-owned typed settings | diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index 18db20e237..9e464b33f6 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -268,6 +268,18 @@ rule, and Purpose 4 controls user-provided-data activity rather than IdentityLink resolution. Validate a regenerated bundle against a live CMP before rolling it out broadly. +The `trustedServer` bidder is a first-party auction relay without its own GVL +vendor ID. With `consentManagement.gdpr` configured, before each relay auction +the shim adds it to the `basicAds` rule's `softVendorExceptions`. Purpose 2 +enforcement remains active; unrelated bidders, +storage, and User ID modules keep their existing rules. Publisher rules and +existing exceptions are preserved. If the publisher supplies a +`gvlMapping.trustedServer` identity, that identity's vendor consent is enforced +instead. The relay forwards the original consent signals; downstream auction +endpoints remain responsible for enforcing consent for their individual vendors. +Legacy top-level TCF settings are left unchanged; use the namespaced +`consentManagement.gdpr` configuration for relay consent handling. + When managed User IDs are configured and the page exposes a callable `window.__tcfapi`, the Trusted Server shim activates Prebid's standard IAB GDPR collector by adding only `consentManagement.gdpr.cmpApi = "iab"`. It does not diff --git a/docs/guide/integrations/sourcepoint.md b/docs/guide/integrations/sourcepoint.md index 0253f02290..01a294adcc 100644 --- a/docs/guide/integrations/sourcepoint.md +++ b/docs/guide/integrations/sourcepoint.md @@ -1,6 +1,6 @@ # Sourcepoint Integration -Sourcepoint provides consent and privacy messaging for publishers. This integration proxies the Sourcepoint CDN endpoint through Trusted Server so the browser loads it from a first-party path. +Sourcepoint provides consent and privacy messaging for publishers. This integration captures its GPP signals for Trusted Server and can proxy its SDK through a first-party path. ## Overview @@ -83,6 +83,37 @@ Trusted Server also injects a `window._sp_` property trap that rewrites known So Single-page apps often insert CMP scripts after the initial HTML response. The `sourcepoint` tsjs module installs a DOM insertion guard so dynamically inserted Sourcepoint script and preload URLs are rewritten to first-party paths before the browser fetches them. +## Consent capture + +To capture consent without rewriting the publisher's existing CMP URLs: + +```toml +[integrations.sourcepoint] +enabled = true +rewrite_sdk = false +``` + +The browser module observes the publisher's existing `__gpp` API. With API 1.1 it subscribes to events and reads callback `ping` snapshots only when `signalStatus` is `ready`. With API 1.0 it uses synchronous `ping` and `getGPPData`. It observes late SDK publication without polling, removes replaced listeners, and ignores superseded asynchronous reads. It does not install a CMP or infer permission from Sourcepoint's accept-all flags. + +Actual applicable GPP signals become host-scoped, Secure, SameSite=Lax session cookies named `__gpp` and `__gpp_sid`. The `_ts_gpp_src=sp` marker identifies cookies maintained by this integration. Unready or unusable API state clears only Sourcepoint-owned mirrors. Cookies owned by another writer are not overwritten or cleared. The older localStorage formats remain a compatibility fallback only when no GPP API exists. + +```mermaid +flowchart TD + CMP[Publisher CMP readiness or choice event] --> Read[Read current GPP snapshot] + Read --> Mirror[Update Sourcepoint-owned consent cookies] + Mirror --> POST[POST /_ts/consent with the same snapshot] + POST --> Policy[Existing server consent and Geo policy] + Policy --> Finalize[Finalize EC and persist authoritative withdrawal] +``` + +`POST /_ts/consent` requires a matching same-origin `Origin`. It accepts a bounded JSON snapshot containing `gppString` and `applicableSections`, not identity or partner credentials. Each request carries its own decision so a later cookie update cannot replace an intervening opt-out. It preserves other privacy inputs, including GPC. Missing GPP state is not fabricated into an opt-out or permission grant. + +The endpoint never creates or recovers identities, ingests EIDs, or restores a revoked row. Creation remains on eligible publisher navigation after consent cookies are available. This keeps an older grant response from recreating identity after withdrawal. Explicit withdrawal follows the existing server jurisdiction/consent policy and expires the HttpOnly EC cookie while tombstoning its stored identity. + +Only `204` confirms processing; generic publisher `200` responses do not. Store absence or a failed withdrawal write returns `503`. Responses are private and no-store. Fastly uses its EC identity graph; native reconciliation requires native EC persistence. Adapters without an identity graph reserve the path and report failure rather than forwarding its payload to the publisher. Network failures are reported without logging consent values and can retry on a later CMP or focus event. + +Deploy the server endpoint and persistence support before enabling capture. Reload already-open pages to load the new observer. Validate actual CMP readiness, same-tab opt-out, EC expiry, and the backing tombstone before treating withdrawal as verified. + ## Cookie Forwarding and Caching Trusted Server forwards only Sourcepoint's documented cookie names upstream, plus the optional `auth_cookie_name` when configured. Unrelated publisher cookies are deliberately excluded so first-party application state is not leaked to Sourcepoint. diff --git a/docs/guide/native-ec-persistence.md b/docs/guide/native-ec-persistence.md new file mode 100644 index 0000000000..514a3dcce7 --- /dev/null +++ b/docs/guide/native-ec-persistence.md @@ -0,0 +1,182 @@ +# Native EC persistence + +The native Axum adapter can use dedicated DynamoDB or celld EC persistence. Build with `aws-dynamodb-ec` or `celld-ec` and select the provider explicitly in published `[ec.storage]` configuration. This is separate from ordinary byte KV and from immutable AppConfig or secret snapshots. A local database file on an ephemeral ECS volume is not a substitute for retained identity state. + +Persistence does not grant consent. Keep the shared EC consent, bot, browser-navigation and withdrawal rules intact. A request can be eligible for an auction but ineligible for an EC. Missing qualifying consent in a jurisdiction that requires it must still prevent EC issuance. Consent capture and CMP integration are separate work. + +```mermaid +flowchart TD + Start[Native startup] --> Validate[Validate selected regional table] + Validate --> Ready[Application ready] + Ready --> Request[Checked request and real consent signals] + Request --> Gate{Eligible publisher navigation and EC consent?} + Gate -->|No| NoMint[Do not mint an identity] + Gate -->|Yes| Allocate[Atomically allocate a fresh generation] + Allocate --> Persist[Conditionally persist identity row] + Persist -->|Confirmed| Finalize[Await shared EC response finalization] + Persist -->|Failed or uncertain| NoMint + Finalize --> Cookie[Commit response headers and ts-ec cookie] +``` + +## Native eligibility and route boundaries + +Native eligibility uses checked effective client metadata from managed ingress and a user-agent platform/browser-engine heuristic. It does not invent Fastly TLS or JA4 headers. The browser heuristic is not spoof-resistant authentication. Only a GET publisher document navigation that passes integration request filters may issue a new identity. Health checks, static assets, preflights and rejected authentication do not mint ECs. Consent behavior is unchanged. + +Native supplies the live graph and partner registry to existing auction, page-bids and integration routes, and to read-only identify. EID cookies and IDs supplied in auction bodies are supported. Identify does not create identities. The adapter awaits shared EC response finalization exactly once, including handled errors, before committing response headers. + +Native admin EC management and lookup remain unsupported and require separate enablement. This work does not provide Fastly S2S batch or post-send pull-sync parity; those native routes and background tasks are not integrated here. + +## Build and select the backend + +From the repository root, add the EC feature to the features your native image already needs: + +```sh +docker build --platform linux/amd64 \ + --build-arg TRUSTED_SERVER__GIT_VERSION="$(git rev-parse HEAD)" \ + --build-arg NATIVE_FEATURES=aws-appconfig-agent,aws-secrets-manager,aws-dynamodb-ec \ + -t trusted-server-native-ec:local . +``` + +This command is an operator example, not evidence of a qualified image. Rebuild the final runtime image from the new binary and record its source, dependency and image digests. A historical AppConfig/Secrets Manager image qualification does not cover EC persistence merely because a provider was added to published configuration. + +Publish the non-secret selection in the existing Trusted Server app-config BlobEnvelope, through the same `ts config push`/AppConfig publication path used for other settings: + +```toml +[ec.storage] +provider = "dynamodb" +region = "us-east-1" +table = "example-retained-ec" +namespace = "example-staging" +``` + +Region, table and namespace are required. Preserve the exact existing table and namespace when migrating. Use the same region as the table; unrelated `AWS_REGION` values do not select the EC region. No provider or default binding is inferred on native. An ordinary KV declaration does not enable EC persistence. Selected-provider preparation must succeed before readiness. An error never permits fallback to memory, another provider or an unbacked cookie. + +This is a hard cutover from native environment-only selection. `TS_EC_DYNAMODB_ENABLED=true` now fails before readiness with migration guidance, even if new config is also present. Malformed enablement also fails. Absent or `false` does not select a backend; orphaned old binding variables are ignored, not another source. Prepare and publish the new config, then remove the old enablement/region/table/namespace/endpoint launch inputs. There is no fallback reader. + +Legacy `[ec] ec_store = "exact-binding"` remains supported on Fastly and inactive on other adapters. New Fastly config may use `[ec.storage] store = "exact-binding"`, with an omitted or explicit `fastly` provider. Both forms together are rejected. Cloudflare and Spin reject any explicit storage selection before routed application work. Shared parsing and publication do not resolve providers according to the CLI host. Missing storage stays absent when serialized, with no injected provider defaults. + +Older binaries reject the new section. Deploy a compatible binary before publishing it; rollback requires an old-compatible config and matching binary together. A provider or namespace switch does not migrate identity data or withdrawal obligations. + +The provider uses the standard AWS workload credential chain. In ECS, the application uses the task role. Keep credential values out of deployment inputs, logs, Terraform and container environment. The execution role still handles image pull, logs and task startup secrets, not application DynamoDB calls. Without a local-test selection, production preparation ignores configured endpoint overrides and uses the regional AWS endpoint. Local-only acceptance can set `local_endpoint = "http://127.0.0.1:8000"` inside `[ec.storage]`. This seam accepts only loopback HTTP with an explicit port and root path, bypasses the AWS config and credential chain, and uses dummy credentials. Never set it in a deployed task. In-process fixtures can also inject a client through the test preparation API. + +The table is a single-region base table. Do not add Global Tables or route writes to different regions and assume the same CAS guarantees. A single namespace partition and generation counter can become hot; `PAY_PER_REQUEST` is not a throughput qualification. + +## Celld conditional records + +Build the native adapter with `celld-ec` and publish an explicit binding: + +```toml +[ec.storage] +provider = "celld" +endpoint = "https://stores.example.com:7780" +store = "checkout-identity" + +[ec.storage.access] +mode = "authenticated" +token_env = "CHECKOUT_STORE_TOKEN" +``` + +The endpoint, service-owned alias and access policy use EdgeZero's pure celld settings. Do not publish a physical namespace or bearer token. The selected bootstrap variable is captured once during preparation; request paths do not reread it. Authenticated access requires HTTPS with normal certificate verification. Trusted-network access is an explicit alternative under the existing celld policy, never an authentication fallback. + +The provider requires the additive conditional-record protocol and checks both store and record version headers before readiness. Ordinary raw celld KV and the `celld-kv` feature do not supply the EC contract. Missing compiled provider, invalid credentials, unsupported protocol and unavailable service all fail closed. + +The typed client maps the unchanged EC trait onto body and opaque metadata, full-u64 server generations, atomic Add/Overwrite/IfGenerationMatch, exact physical existence, bounded strong prefix listing, TTL and hard deletion. A full list page is not proof of completeness. The server samples relative millisecond expiry inside the write transaction; finer positive client TTLs round upward. The core graph still owns every lifetime, tombstone and CAS retry. Transport does not replay uncertain mutations. Record cleanup must never reset the durable allocator. Raw KV keys and conditional record keys are separate datasets. + +Run the real disposable local-server proof explicitly with an existing tested celld binary: + +```sh +CELLD_EC_TEST_BINARY=/absolute/path/to/celld \ + cargo test --locked --target x86_64-unknown-linux-gnu \ + -p trusted-server-adapter-axum --features celld-ec \ + --test native_ec --test startup -- --include-ignored --test-threads=1 + +CELLD_EC_TEST_BINARY=/absolute/path/to/celld \ + cargo test --locked --target x86_64-unknown-linux-gnu \ + -p trusted-server-adapter-axum --features celld-ec,geo-cloudfront \ + --test native_acceptance \ + real_celld_envelope_drives_native_http_issuance_withdrawal_restart_and_outage \ + -- --ignored --test-threads=1 +``` + +Missing prerequisites fail instead of silently skipping this selected check. Fixtures own temporary policy/data and loopback listeners, not operator configuration. They exercise the real typed client and graph through native issuance, EID update, withdrawal, restart, outage and published-envelope readiness. The second check drives actual native HTTP issuance from the published envelope using a real origin and celld, synthetic protected-ingress geography and isolated secret references. It covers application recreation, server restart and outage without replacing the graph with a memory double. Tests are serialized to keep subprocess listener ownership separate. Synthetic local geography and GPC inputs are not genuine live-browser consent acceptance. Same-disk fixture restart is not replicated disaster recovery or production storage qualification. The server's separate fresh-node-cache LTX proof does not certify a production object-store deployment either. + +## Table and namespace ownership + +The [self-contained Terraform example](../../deploy/examples/native-ec-dynamodb/README.md) creates a retained table and attaches exact-table permissions to a supplied task role. It does not create or deploy an ECS service. + +| Item | Required layout or owner | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| Table | String hash key `pk`, string range key `sk`, `PAY_PER_REQUEST`, no indexes or replicas | +| Identity partition | `pk=ec:{namespace}`, `sk` is the exact EC or withdrawal-marker key | +| Allocator partition | `pk=ec-allocator:{namespace}`, `sk=generation`, numeric generation counter only, no TTL | +| Service TTL | Enabled on numeric `ttl_epoch_s` for expiring identity/marker rows | +| Logical expiry | `expires_at_ms` filters lookup/list results and expiry-sensitive write conditions; held-row existence stays physical | +| Retained owner | Named team/person owns state, table, namespace reservations, retention, allocator continuity, costs and decommissioning | +| Workload owner | Owns the application task role, provider-enabled image and task environment | + +The namespace must be nonempty, at most 512 UTF-8 bytes, and contain no Unicode control characters. The runtime does not trim or normalize it. Backend keys must be nonempty, at most 1024 bytes, and contain no control characters; withdrawal-marker keys are supported. + +Reserve both partition values together for exactly one app/environment. Reuse the namespace across task replacements that should share identities. Use a distinct reservation for a separate environment. Do not share a namespace between unrelated applications or use an ephemeral task/run ID as its identity. + +The internal allocator is not part of EC prefix listing. It has no `ttl_epoch_s` or logical expiration. Never delete, expire or reset it while the namespace can be used again. A delete/recreate cycle must not reuse a generation that an in-flight stale writer still holds. Failed conditional writes may consume unused generations; gaps are harmless. + +Retain the allocator with the graph through backup, restore and migration. Rewinding it independently of rows can break stale-CAS protection. Do not restore old state into an active namespace without an explicit continuity plan. This example does not implement migration or disaster recovery. + +The graph owns live-row, tombstone and withdrawal-marker lifetimes. DynamoDB TTL only collects expired rows eventually. It does not define when a row stops authorizing EC use. `expires_at_ms` uses the application's UTC wall clock, so logical expiry and expiry-dependent Add/CAS conditions assume adequately synchronized task clocks. The deployment owner must qualify clock synchronization and skew across tasks. The backend adds no runtime skew guard or clock configuration. Current graph defaults are 31,536,000 seconds for live rows and 86,400 seconds for tombstones and completion markers. Do not apply a blanket TTL or cleanup job to the allocator partition. + +Lookup and prefix listing hide logically expired rows. Authoritative `key_exists` instead uses a strongly consistent exact-key read to confirm physical row presence, including logically expired rows. This means the identity is still held, not that it is logically readable. The read validates the expiry format; malformed rows are errors, not absence. Physical presence never authorizes identity use, EIDs or cookie issuance. It lets the graph write a fresh-generation withdrawal tombstone even when a CAS captured `:now` before expiry but arrives afterward. Treating the expired, still-held root as absent would skip that tombstone and let the delayed CAS match the old generation and revive the identity or partner IDs. + +## Task permissions and the existing boundary + +The runtime needs the following permissions on the exact table ARN: + +| Actions | Scope | +| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `dynamodb:DescribeTable` | Exact table, no row condition; startup schema validation | +| `dynamodb:GetItem`, `dynamodb:PutItem`, `dynamodb:DeleteItem`, `dynamodb:Query` | Exact table and `dynamodb:LeadingKeys` equal to `ec:{namespace}` | +| `dynamodb:UpdateItem` | Exact table and `dynamodb:LeadingKeys` equal to `ec-allocator:{namespace}` | + +No task permission is needed for `Scan`, `CreateTable`, `DescribeTimeToLive`, `UpdateTimeToLive`, index creation or table deletion. Table creation and TTL configuration belong to the infrastructure operator. The allocator does not require `GetItem`. The example uses separate row and allocator statements, so ordinary identity deletion cannot delete the allocator through this policy. IAM partition scoping does not enforce a particular `sk` or stop a compromised task from modifying permitted rows. + +The public native-demo design has two relevant policy layers. Its task inline policy grants runtime reads. The retained bootstrap task boundary has an explicit `Deny` with `NotAction` based on a read-action ceiling that excludes DynamoDB. Adding an inline allow alone still fails. + +Before any deployment, the boundary and workload owners must review both changes: + +1. Extend the task boundary's action ceiling with only the six required DynamoDB actions. Also add constrained boundary allows for the exact table and the two reviewed partitions. Preserve existing unrelated restrictions. An action-ceiling edit alone does not grant permission. +2. Add the equivalent exact-table, partition-scoped allow to the application's task inline policy. Review all effective denies, including organization and endpoint policies, with the actual task credentials. + +The example's `task_policy_json` is the constrained allow input for that review, not a replacement boundary document. Do not replace the boundary, add administrator access, remove explicit denies indiscriminately, or move the application calls to the execution role. No execution-role change is needed. Existing Secrets Manager-only KMS statements do not authorize unrelated KMS use. The example uses default service-managed DynamoDB encryption and adds no task KMS calls; a customer-managed key needs a separate review. + +## Retained infrastructure, disposable workload + +Keep the EC Terraform root in its own retained state, separate from the service's disposable run state. Give it a unique locked backend key and an accountable retention owner. The example enables table deletion protection and Terraform `prevent_destroy`. Neither replaces a backup, retention or erasure policy. + +Existing disposable-workload writers, manifests, inventory and cleanup code must not be assumed to know about DynamoDB. Provision this retained root through a separately reviewed operator path. Do not quietly add a retained table to automated disposable cleanup. If that ownership model changes, review creation grants, manifests, inventory, state and destruction together. + +Stopping or replacing an ECS task must leave EC state available. Keep the selected backend live while issued identities and withdrawal obligations exist. Disabling it is not a safe persistence rollback; qualify a rollback that preserves those obligations before changing provider selection. Before deleting the task role, remove or transfer the retained root's policy attachment. Identity erasure, namespace retirement, backups and table removal require their own reviewed procedure. Never reset the generation allocator as part of routine row cleanup. + +The workload still needs working regional HTTPS access to DynamoDB. This guide does not require a new NAT gateway or endpoint without checking the existing routing and endpoint policies. A read-only application root and ephemeral bindings volume can stay unchanged; neither stores the identity graph. + +## Qualification before deployment + +No AWS apply or live acceptance is implied by this guide. Local fixtures cannot prove installed IAM policies, capacity or real browser consent transport. + +With an already-built `aws-dynamodb-ec,geo-cloudfront` native binary and DynamoDB Local running on loopback, run: + +```sh +node scripts/test-native-ec-image.mjs \ + --binary /absolute/path/to/trusted-server-axum \ + --dynamodb http://127.0.0.1:8000 +``` + +Alternatively, supply `--image LOCAL_IMAGE` instead of `--binary`. Linux image mode uses host networking and never pulls or builds an image. The fixture generates isolated envelopes and secrets, owns a unique local table, and writes JSON evidence. It calls the app executable directly, so it does not qualify a deployment bootstrap wrapper. Use `--help` for image executable and source-version assertions. + +Before authorizing a deployment, retain evidence for the rebuilt image and these checks: + +- Real conditional expressions against DynamoDB Local, including concurrent Add, stale CAS, delete/recreate, metadata, logical expiry before TTL deletion, marker keys and short or empty Query pages with continuation. A bounded list that cannot prove exhaustion must fail rather than return an unsafe short result. +- Native lifecycle tests proving persistence acknowledgement precedes new `Set-Cookie: ts-ec`, reuse survives application recreation, withdrawal writes a tombstone and expires the browser cookie, and an old EID update cannot undo withdrawal. +- Failure cases for denied, unavailable and uncertain writes. No phantom identity may be issued. Test readiness failure and current-thread runtime scheduling while storage is delayed. +- Existing route exclusions and consent rules. Health, static assets, preflight and rejected authentication must not mint identities. Missing qualifying consent must remain blocked. Synthetic local consent fixtures are not live-browser consent acceptance. +- The actual final image's provider selection, non-root/read-only execution, startup, readiness, normal HTTPS trust and shutdown behavior, plus affected Fastly/WASM compatibility after shared EC changes. + +After separate AWS authorization, qualify the real task role with its retained boundary and inline policy. Prove allowed operations on the reviewed table/partitions and denial elsewhere, allocator retention, logical TTL and persistence across task replacement. Separately verify a real browser request carrying genuine qualifying consent. Do not relax privacy configuration or fabricate a live signal to make that acceptance pass. diff --git a/docs/guide/native-rc-demo.md b/docs/guide/native-rc-demo.md new file mode 100644 index 0000000000..d86e0b17a1 --- /dev/null +++ b/docs/guide/native-rc-demo.md @@ -0,0 +1,286 @@ +# 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 +`fda87556f5aab160711eb5244c1d1065a6bb48d6`. PRs #1410, #1411 and #1412, +including their #777 module/configuration/permission stack, are excluded. +The newer EdgeZero revision includes optional local GeoIP support, but this +candidate retains its existing Off/CloudFront selections. MaxMind is not enabled +or configured by the dependency update. + +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 +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 are unchanged. Existing populated Geo wire fields keep their names; partial +results omit unavailable detail. 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 + +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: + +| 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. + +## Optional protected native Geo + +The `geo-cloudfront` feature is default-off. Compile it explicitly, then select +it through a separately delivered TS boot profile. EdgeZero does not read a +Geo setting from store bindings, publisher AppConfig or an `EDGEZERO__GEO__*` +environment variable. + +```toml +version = 1 + +[geo] +provider = "cloudfront" +alb_peer_cidrs = ["10.42.1.0/24", "10.42.2.0/24"] +origin_credential_header_name = "x-origin-example-removal" +``` + +Set `TS_NATIVE_GEO_PROFILE_FILE` to that file. With no profile, local hosting +remains Off. A selected invalid/unreadable profile or compiled-out provider +fails before readiness. The protected policy accepts actual ALB peer ranges and +only the credential removal name, never its expected value. Keep real header +names and values out of logs and public configuration. The deployment wrapper +can atomically render `TS_NATIVE_GEO_PROFILE_TOML` into its private runtime +volume, unset those content bytes, and export the file pointer. + +The required front door is CloudFront with generated country/subdivision +headers, HTTPS to an exact-credential-gated ALB, CloudFront-only ALB ingress, +and an ALB-only private application port. Disable shared caching, including +cacheable errors. Ordinary forwarding trust and protected Geo source admission +are separate. See the [native deployment draft](../../deploy/native-demo/README.md) +for staged origin/DNS cutover and cleanup ownership. + +TS reads only the immutable `RequestContext::geo()` result. Country-only data +has no invented city or coordinates. Missing, invalid or untrusted results stay +unavailable; US country without a recognized subdivision stays Unknown for +consent policy. Real geographic estimates do not grant consent. The existing +TCF/GPC/identity rules and unavailable identity KV remain unchanged. + +The production options runner preserves SIGINT/SIGTERM and bounded drain. The +admitted error-response finalizer preserves Geo/TS headers on framework-rendered +errors without replacing EdgeZero's status, required headers or failure +classification. Pre-admission failures without a managed outcome remain outside +that finalizer. + +## 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 store/Geo 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`, `aws-secrets-manager` and `geo-cloudfront` are accepted +by this recipe. Add `geo-cloudfront` to the build argument for the protected +profile; compiling it alone does not select Geo. 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. Optional +native Geo comes from the independently protected CloudFront assertion path, +not the deployment region or raw reader/forwarding headers. No GeoIP database or +remote lookup ships with this provider. Country/subdivision results do not +reproduce Fastly city/coordinate precision, and consent remains fail-closed. + +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/guide/native-rc-validation.md b/docs/guide/native-rc-validation.md new file mode 100644 index 0000000000..e4ab77b19d --- /dev/null +++ b/docs/guide/native-rc-validation.md @@ -0,0 +1,242 @@ +# EdgeZero RC431 validation record + +## Relay consent and EdgeZero pin update, 2026-11-04 + +PR #1498 was backported as `0a7c30176`. All six workspace EdgeZero declarations +and ten resolved EdgeZero packages now use +`fda87556f5aab160711eb5244c1d1065a6bb48d6`. Existing Off/CloudFront selection is +unchanged; the optional MaxMind provider is not enabled or qualified here. + +Checks ran with Rust 1.98.1 and Node 24.12.0 on the working tree over +`0a7c30176`, including the pin update and the pre-existing uncommitted Prebid +10.28.0 builder, publisher-processing tests and documentation changes. + +- Full Vitest suite: 53 files, 1,284 tests passed, including real Prebid relay + dispatch with Purpose 2 granted, denial without Purpose 2, and mapped-vendor + denial. JS build, ESLint and Prettier passed. +- `cargo test --locked -p trusted-server-adapter-axum --all-features -- --test-threads=1`: + 101 passed; two runtime cases and one doctest remain ignored. +- `cargo clippy --locked -p trusted-server-adapter-axum --all-targets --all-features -- -D warnings`: + passed. +- `cargo check --locked -p trusted-server-cli --all-targets`: passed with an + unused `clap::Parser` test import warning in `src/run.rs`. +- `cargo check-fastly --locked`, `cargo check-cloudflare --locked` and + `cargo check-spin --locked`: passed for their WASM targets. +- `cargo test --locked -p trusted-server-adapter-cloudflare -p trusted-server-adapter-spin`: + 156 passed; two doctests remain ignored. These are native tests, not deployed + Worker/Spin runtime checks. +- `cargo metadata --locked --format-version 1` confirmed ten EdgeZero packages + from the single new Git source. Rust formatting and changed-guide formatting + passed. + +The first parallel native run failed the missing-config test's no-listener +assertion. The isolated startup suite and full serial native suite then passed +without source changes. The fixture releases its reserved port before spawning; +port reuse is a possible cause, not a confirmed diagnosis. + +No image was rebuilt or deployed. Live CMP/browser acceptance, cloud-provider +qualification and WASM runtime suites were not rerun for this update. Earlier +validation sections retain their original source and image identities. + +## Geo follow-up, 2026-10-23 + +This follow-up targeted EdgeZero RC +`9896233cb4274ec14a3ed33babac1846ab3d3715`, including the explicit production +options runner and admitted-error finalizer. Partial TS geography preserves +populated field names and omits unavailable detail. Native Geo remains +explicitly selected through the protected CloudFront profile. + +The 2026-10-20 results below are historical evidence for their recorded pins, +not acceptance of this newer source, image or CloudFront deployment. New +integration results and remaining cloud qualification must be recorded against +the final source and image digests before deployment. + +## Initial RC431 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. 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/docs/superpowers/specs/2026-11-03-ec-storage-provider-configuration-design.md b/docs/superpowers/specs/2026-11-03-ec-storage-provider-configuration-design.md new file mode 100644 index 0000000000..911154c24c --- /dev/null +++ b/docs/superpowers/specs/2026-11-03-ec-storage-provider-configuration-design.md @@ -0,0 +1,377 @@ +# EC storage provider configuration + +Status: Design decisions confirmed. This document specifies configuration and +adapter selection changes, not an implemented feature. Native environment +configuration gets a hard cutover, and supplying both configuration forms is +always an error. + +## Outcome + +Configure EC identity persistence through `trusted-server.toml` and its existing +published app-config envelope. Preserve existing Fastly configurations and the +behavior of deployments without configured persistence. Make provider selection +independent of the serving runtime, while rejecting combinations that have no +implementation. + +Fastly defaults to its own KV backend when a store is configured without an +explicit provider. Native, Cloudflare and Spin require explicit new provider +configuration to enable persistence. Merely running on a platform, declaring an +ordinary KV store, or supplying AWS credentials does not enable EC persistence. + +DynamoDB remains native-only in this change. Supporting another provider on +Fastly or Cloudflare later must not require a different configuration model or +changes to EC identity business logic. + +## Current behavior and constraints + +- `Ec.ec_store` in `crates/trusted-server-core/src/settings.rs` is an optional + Fastly store name. The example config sets it to `ec_identity_store`. +- Fastly's `maybe_identity_graph` and `require_identity_graph` in + `crates/trusted-server-adapter-fastly/src/main.rs` construct `FastlyEcKvStore` + directly from that name. The backend opens exactly that Fastly binding. +- Native startup loads verified Trusted Server settings, then separately calls + `ec_kv::prepare_from_env`. `TS_EC_DYNAMODB_*` variables select and configure + its dedicated EC backend. The provider requires `aws-dynamodb-ec`. +- Cloudflare and Spin do not currently wire an EC identity graph. Their ordinary + KV handles are not evidence of the conditional writes, generations and strong + reads required by `EcKvStore`. +- Native uses immutable startup config and secret snapshots, but identity rows + remain live. Changing how the provider is configured must not snapshot rows. + +Unconfigured persistence is not a new global EC-disable switch. Shared EC code +can generate an ID without a graph, while native publisher issuance currently +requires a graph. Preserve each adapter's existing unconfigured behavior rather +than using this migration to unify cookie issuance or route eligibility. + +Relevant contracts are documented in +[the native persistence guide](../../guide/native-ec-persistence.md) and +[the RC431 compatibility design](2026-10-20-edgezero-431-compatibility-design.md). +The EC backend operations live in +`crates/trusted-server-core/src/ec/kv_backend.rs`. + +## Configuration + +### Existing Fastly config remains valid + +```toml +[ec] +ec_store = "ec_identity_store" +``` + +On Fastly this continues to use the named Fastly KV binding. No new section, +provider field or environment variable is required. The name is not rewritten +or replaced with a manifest default. + +### New Fastly configuration + +```toml +[ec.storage] +store = "ec_identity_store" +``` + +On Fastly, omitting `provider` selects `fastly`. The fully explicit equivalent is: + +```toml +[ec.storage] +provider = "fastly" +store = "ec_identity_store" +``` + +Neither example supplies a default store name. An empty or incomplete storage +section is an error, not permission to enable `ec_identity_store` implicitly. + +### Native DynamoDB configuration + +```toml +[ec.storage] +provider = "dynamodb" +region = "us-east-1" +table = "example-retained-ec" +namespace = "example-production" +``` + +Region, table and namespace are required. Preserve current validation rules, +including exact namespace bytes. Do not derive the namespace from a task ID, +publisher domain or deployment run. Do not infer the provider from AWS settings, +field names, or an ordinary EdgeZero KV declaration. + +AWS credentials stay outside application config and use the standard SDK +credential chain, including an ECS task role. There are no access-key fields. +The existing `aws-dynamodb-ec` build feature is still required. + +For explicit local testing, the DynamoDB variant may also contain: + +```toml +local_endpoint = "http://127.0.0.1:8000" +``` + +This carries forward the existing emulator selection, not a general endpoint +override. Keep its current loopback HTTP, explicit-port and root-path validation, +and dummy credentials that bypass the ambient credential chain. Without it, +production uses the regional AWS endpoint and ignores endpoint overrides as it +does today. Do not place this field in production examples. + +### Native celld configuration + +```toml +[ec.storage] +provider = "celld" +endpoint = "https://stores.example.internal:7780" +store = "checkout-identity" + +[ec.storage.access] +mode = "authenticated" +token_env = "CHECKOUT_STORE_TOKEN" +``` + +The endpoint, exact service-owned alias and access policy use EdgeZero's pure +celld settings validation. No physical namespace or token value belongs in +application config. Capture the explicitly referenced bootstrap credential once +when preparing the backend. Authenticated mode requires HTTPS with normal +certificate verification. Trusted-network mode is an explicit alternative with +the existing celld rules, not a fallback after authentication fails. + +The native build must include `celld-ec`, which enables the separate typed +conditional-record client in `edgezero-store-celld`. An ordinary KV declaration +or `celld-kv` feature does not select an identity backend. Native preparation +requires the additive record protocol, not just the original raw-value API. + +Celld implements the same `EcKvStore` contract, including opaque metadata, +server-assigned u64 generations, Add/Overwrite/IfGenerationMatch, physical exact +existence, strong bounded prefix listing, expiry and deletion. The server owns +atomic preconditions and durable generation allocation. Reuse core graph and +consent logic unchanged. Do not emulate conditional writes with client GET/PUT, +redirect credentials, replay ambiguous mutations, or reset an allocator during +ordinary identity cleanup. + +Celld is initially native-only, like DynamoDB. This does not claim a WASM +transport or add a placeholder implementation for another serving runtime. + +## Resolution rules + +Resolve configuration against an explicit serving-runtime identity, not the +host that runs the CLI or the Rust compilation target alone. + +| Input | Fastly | Native Axum | Cloudflare / Spin | +| ---------------------------------------------- | --------------------------- | --------------------------------- | --------------------------------- | +| Neither `ec_store` nor `storage` | No graph, existing behavior | No graph, existing behavior | Existing behavior, no graph | +| Legacy `ec_store` only | Named Fastly graph | Legacy field remains inactive | Legacy field remains inactive | +| `storage.store`, provider omitted | Fastly, named store | Error, explicit provider required | Error, explicit provider required | +| Explicit `provider = "fastly"`, valid store | Fastly graph | Unsupported-provider error | Unsupported-provider error | +| Explicit `provider = "dynamodb"`, valid fields | Unsupported-provider error | DynamoDB graph, feature required | Unsupported-provider error | +| Explicit `provider = "celld"`, valid fields | Unsupported-provider error | Celld graph, feature required | Unsupported-provider error | +| Both `ec_store` and `storage` | Configuration error | Configuration error | Configuration error | + +Additional rules: + +- Any simultaneous legacy field and new section is ambiguous and rejected, + even if they name the same Fastly store. Do not invent precedence or merge + fields across the two forms. Switching providers means removing `ec_store`. +- Reject unknown provider names, unknown fields, inappropriate variant fields, + missing required fields and empty store names with field-oriented errors. +- Do not select the first available store or fall back after a selected-provider + error. An unavailable configured store is not an unconfigured deployment. +- The legacy field remains inactive on non-Fastly adapters because that is its + existing behavior. Explicit new configuration must never be silently ignored. + This distinction keeps shared legacy config files usable during migration. +- Absence must survive serialization as absence. Do not publish an inferred + provider, `storage = null`, or a generated default table into old config blobs. + +The initial support matrix is deliberately small. Cloudflare and Spin have no +supported EC persistence provider in this change. New explicit storage config +there fails; deployments without it continue to work as before. Do not add +placeholder `cloudflare` or generic `kv` variants that cannot meet the contract. + +## Ownership and implementation boundaries + +Use one shared configuration resolver to produce either no persistence or a +validated provider-specific selection. A typed resolved enum should distinguish +Fastly binding configuration, DynamoDB configuration and celld configuration. Parsing compatibility +and the omitted-provider rule must not become several loosely synchronized +booleans and optional fields used throughout the request path. + +Shared settings, validation and resolution must not depend on Fastly or AWS +SDKs. Adapters own provider construction and platform resources. A future +provider implementation may be usable on multiple runtimes; a runtime name is +not a permanent restriction on the provider model. No dynamic plugin registry +or universal byte-KV wrapper is needed for these implementations. + +```mermaid +flowchart TD + A[Load and verify existing app-config envelope] --> B[Validate storage shape] + B --> C[Resolve against serving runtime] + C --> D{Persistence configured?} + D -->|No| E[Preserve existing unconfigured behavior] + D -->|Yes| F{Provider supported by this runtime and build?} + F -->|No| X[Configuration error before routed application work] + F -->|Yes| G[Adapter prepares selected backend] + G --> H[Supply live store to shared EC graph] + G -->|Preparation fails| X +``` + +Native replaces `prepare_from_env()` with typed configuration input and awaits +provider preparation before readiness. Keep network I/O asynchronous, existing +bounds and sanitized errors, table validation, allocator continuity, logical +expiry, conditional writes and withdrawal semantics unchanged. + +Fastly must use the resolved selection everywhere a graph is constructed, +including organic routes, auctions, identify, batch sync, finalization and +post-send pull sync. Do not update only one helper and leave other consumers +reading `ec.ec_store` directly. Preserve existing Fastly store-opening timing +and health/probe exceptions; this spec does not introduce remote readiness +checks or alter request/egress ownership. + +Cloudflare and Spin must validate explicit storage selection during application +initialization, before routed application work, even though no new backend is +added there. A successful schema parse alone is not support for a provider. + +Keep two validation stages distinct: + +1. Shared TOML/JSON shape validation can run without a network or serving runtime. +2. Runtime-aware resolution and provider preparation run in the selected adapter. + A target-aware CLI validation path may reject unsupported combinations early, + but a native CLI must not reject Fastly config merely because it runs natively. + +`ts config push` continues to publish the existing BlobEnvelope. AppConfig +continues to carry that same envelope where selected. Do not add another file +loader, environment overlay or publication format. EdgeZero still owns the +framework lifecycle and config/secret provisioning. This change does not claim +that its ordinary KV interface supplies the EC backend contract. + +## Migration + +### Fastly compatibility + +Keep legacy `ec.ec_store` as a supported shorthand, without a removal deadline +in this change. New examples use `[ec.storage]`, but existing operator files and +already published envelopes need no rewrite. Migrate repository-owned active +examples and fixtures together, retaining dedicated legacy-compatibility tests. +Historical specs are records, not active config consumers to rewrite. + +Older binaries use `deny_unknown_fields` on EC settings and cannot read the new +section. Deploy a compatible binary before publishing new-shaped config. For a +rollback, restore an old-compatible config and the matching binary together. +The migration must preserve the exact binding, table and namespace, not move +identity data or reset the generation allocator. + +### Native environment-variable hard cutover + +Migrate repository-owned launchers and deployment outputs in the same change, +then remove the old selection path. There is no compatibility release or +fallback reader that prepares a backend from legacy environment values. + +- Move the four non-secret selection/binding values into `ec.storage` in the + published envelope. Remove `TS_EC_DYNAMODB_ENABLED`, `REGION`, `TABLE` and + `NAMESPACE` from application deployment inputs after publication is prepared. +- Replace Terraform's `runtime_environment` output in + `deploy/examples/native-ec-dynamodb/outputs.tf` with a documented `ec_storage` + configuration object for publication. This is an output-contract change; + inspect downstream consumers before removal. It does not apply or provision AWS. +- Update `scripts/test-native-ec-image.mjs` to place provider config and + `local_endpoint` in its fixture envelope instead of the app environment. +- Replace the app's `TS_EC_DYNAMODB_LOCAL_ENDPOINT` reader with the typed local + field. A manual test runner may retain a test-only endpoint input, clearly + distinguished from production application configuration. +- Update selection tests, the native persistence guide and the Terraform README. + Inventory deployment automation, including untracked local work, without + modifying unrelated work or operator-owned `trusted-server.toml` files. + +Do not silently ignore an old active selection and start without a graph. A +native process still receiving an enabled legacy selection fails before +readiness with migration guidance, including when new +config is also present. Malformed legacy enablement remains an error. Absent or +`false` enablement does not select a backend; orphaned old binding values are +not an alternate configuration source. Errors must not echo record contents, +credentials or endpoint values. + +This migration check detects an obsolete launch contract; it never prepares +DynamoDB from environment values. + +## Persistence invariants and non-goals + +For configured persistence, new identity issuance must still await a confirmed +write. Failed or uncertain writes must not become successful issuance through +a fallback store or a retry of a different backend. Consent, browser and route +gates remain unchanged. All graph consumers in an application use the same +resolved backend. + +Changing provider or binding configuration is not data migration. This change +provides no copying, dual writes, cross-provider failover, namespace rotation or +automatic recovery plan for identities left in another store. A real switch +requires a separate continuity and withdrawal plan. Even changing only a +DynamoDB namespace changes the identity dataset. + +Other non-goals are DynamoDB/celld EC on WASM runtimes, Cloudflare EC backend creation, +ordinary KV redesign, new native EC routes, pull-sync parity, hot reload, +credentials in TOML, table provisioning and changes to stored record formats. + +## Acceptance checks + +- Table-driven resolution tests cover every row in the matrix and all serving + runtimes. Test malformed, empty and unsupported selections and duplicate forms. +- TOML and JSON round trips preserve both legacy config and absent storage. + Config push and envelope reload preserve explicit new config without injecting + defaults or resolving providers according to the CLI host. +- An unchanged legacy Fastly fixture uses the exact previous binding. New + omitted-provider and explicit-Fastly fixtures use that same binding. No-store + fixtures retain their existing graph availability and cookie behavior. +- All Fastly graph consumers use the resolved selection. Unsupported explicit + selections fail before route work, with health/probe exceptions unchanged. +- Native feature-off builds accept unconfigured storage and reject selected + DynamoDB before readiness. Feature-on tests prepare from envelope data, not + legacy environment inputs. Obsolete active launch inputs cannot silently + disable persistence or override the new config. +- Emulator tests use the new local field and retain endpoint-validation and + dummy-credential isolation checks. Reuse existing backend tests for CAS, + expiry, deletion/recreation and withdrawal where inputs remain unchanged. +- Native end-to-end fixtures prove issuance, persistence across application + recreation, withdrawal and failed-write behavior through the new envelope path. +- Cloudflare and Spin compile without AWS dependencies and reject explicit + unsupported new storage config. Shared legacy configs remain accepted. +- Inspect a Terraform config output and its documented publication path. Search + active application launch inputs and docs for obsolete variables. Run focused + formatting and affected crate checks during implementation; deployment or live + IAM acceptance requires separate authorization. + +## Contradiction review + +### Default provider versus implicit enablement + +Resolved by separating the two. Fastly is the default provider only when a store +is configured. With neither configuration form, nothing new is enabled. There +is no invented `ec_identity_store` binding and no global EC-disable behavior. + +### Migrating configs versus not requiring existing users to change them + +Resolved for Fastly by retaining `ec.ec_store` as supported input while moving +repository examples to the new section. Native env-based deployments necessarily +need an input migration to remove environment-only configuration. The confirmed +rollout is a hard cutover with coordinated config and launch-input changes, +without a staged compatibility release. + +### Independent provider choice versus native-only DynamoDB + +Resolved by separating configuration from runtime support. The schema and shared +resolver allow explicit provider choice; the initial implementations still only +support Fastly KV on Fastly and DynamoDB on native. Cross-provider use on Fastly +or Cloudflare is an extension goal, not delivered behavior in this change. + +### Preserving legacy input versus rejecting unsupported providers + +Legacy `ec_store` was inactive on non-Fastly adapters and remains so. A new +explicit section is an intentional selection and must fail if unsupported. This +asymmetry is necessary to preserve existing shared configs without pretending +that a newly requested backend works. + +### Switching providers versus keeping identities + +Not solved by configuration. Supporting a different provider later does not +make switching an existing deployment safe. Data continuity and withdrawal +obligations require separate migration work. + +## Confirmed review decisions + +1. Native environment configuration gets a hard cutover. Migrate config and + launch inputs together; do not retain an environment-backed fallback. +2. Supplying both `ec.ec_store` and `[ec.storage]` is always a configuration + error, even when equivalent. There are no precedence or merge rules. diff --git a/edgezero.toml b/edgezero.toml index 64e6e0f1f0..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" @@ -92,11 +88,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/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-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-cloudflare.sh b/scripts/smoke-cloudflare.sh index 161823217f..416ec81e57 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,61 @@ 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 +# 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 + 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}" + 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 +177,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 +187,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="" 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-ec-image.mjs b/scripts/test-native-ec-image.mjs new file mode 100644 index 0000000000..61d5a20313 --- /dev/null +++ b/scripts/test-native-ec-image.mjs @@ -0,0 +1,983 @@ +#!/usr/bin/env node +// Local synthetic EC smoke, using an already-built native runner or local image. +// Never builds, pulls images, reads operator configuration, or contacts AWS. +// Requires aws-dynamodb-ec + geo-cloudfront and an existing DynamoDB Local. +import assert from 'node:assert/strict' +import { execFile, spawn } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from 'node:fs/promises' +import http from 'node:http' +import net from 'node:net' +import { tmpdir } from 'node:os' +import { isAbsolute, join, resolve } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { promisify } from 'node:util' + +const usage = `Usage: + node scripts/test-native-ec-image.mjs --binary /absolute/trusted-server-axum --dynamodb http://127.0.0.1:8000 + node scripts/test-native-ec-image.mjs --image LOCAL_IMAGE --dynamodb http://127.0.0.1:8000 +Options: + --image-binary PATH Image executable, default /usr/local/bin/app + --expected-version SHA Assert each document's X-TS-Version + --output PATH JSON evidence, default a unique file in the temp directory + +Linux image mode uses host networking for loopback-only synthetic services, +--pull=never, a read-only root, UID 10001 and no capabilities. It tests the app +executable directly, not a deployment bootstrap wrapper. Binary mode avoids +image creation and disk use. Supply a disposable DynamoDB Local instance, never +an AWS endpoint. The script creates and deletes only its own uniquely named +table. All config, origins, geography and credentials are synthetic. This is not +real consent transport, AWS IAM, production capacity or image security proof. +` +function options(args) { + const known = new Set([ + 'binary', + 'image', + 'dynamodb', + 'image-binary', + 'expected-version', + 'output', + ]) + const result = {} + for (let index = 0; index < args.length; index++) { + const key = args[index].replace(/^--/, '') + assert( + args[index].startsWith('--') && known.has(key), + `unknown option ${args[index]}` + ) + assert(!Object.hasOwn(result, key), `duplicate --${key}`) + const value = args[++index] + assert(value && !value.startsWith('--'), `missing --${key} value`) + result[key] = value + } + assert( + Boolean(result.binary) !== Boolean(result.image), + 'supply exactly one of --binary or --image' + ) + assert(result.dynamodb, 'supply --dynamodb pointing to DynamoDB Local') + if (result.binary) + assert(isAbsolute(result.binary), '--binary must be an absolute path') + if (result.image) + assert.equal( + process.platform, + 'linux', + 'image host-network mode requires Linux' + ) + return result +} +function localEndpoint(value) { + const url = new URL(value) + assert.equal(url.protocol, 'http:', 'local endpoint must use HTTP') + assert.equal( + url.hostname, + '127.0.0.1', + 'use literal 127.0.0.1, never an external endpoint or DNS name' + ) + assert( + url.port && + !url.username && + !url.password && + !url.search && + !url.hash && + url.pathname === '/', + 'supply a plain loopback HTTP endpoint with explicit port' + ) + return url.origin +} +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-27T00:00:00Z', + data, + sha256: createHash('sha256').update(JSON.stringify(data)).digest('hex'), + }) +} +// Direct HTTP, no proxy environment, redirects, DNS, or credential lookup. +function request( + endpoint, + path = '/', + { method = 'GET', body, headers = {}, timeout = 10000, onHeaders } = {} +) { + return new Promise((resolve, reject) => { + const req = http.request( + `${endpoint}${path}`, + { method, headers, agent: false }, + (res) => { + onHeaders?.(res) + const chunks = [] + let bytes = 0 + res.on('data', (chunk) => { + bytes += chunk.length + if (bytes > 2 * 1024 * 1024) + res.destroy(new Error('fixture response exceeds 2 MiB')) + else chunks.push(chunk) + }) + res.on('error', reject) + res.on('end', () => + resolve({ + status: res.statusCode, + headers: res.headers, + body: Buffer.concat(chunks), + }) + ) + } + ) + const timer = setTimeout( + () => req.destroy(new Error('fixture HTTP deadline')), + timeout + ) + req.on('close', () => clearTimeout(timer)) + req.on('error', reject) + req.end(body) + }) +} +async function listen(server) { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + return `http://127.0.0.1:${server.address().port}` +} +async function freePort() { + const server = net.createServer() + await listen(server) + const port = server.address().port + await new Promise((resolve) => server.close(resolve)) + return port +} +function minted(response) { + return (response.headers['set-cookie'] ?? []) + .filter((value) => value.startsWith('ts-ec=')) + .map((value) => value.slice('ts-ec='.length).split(';')[0]) + .filter(Boolean) +} + +if (process.argv.includes('--help')) { + console.log(usage) + process.exit(0) +} +const opt = options(process.argv.slice(2)) +const dynamo = localEndpoint(opt.dynamodb) +const id = randomUUID() +const table = `ts-native-ec-fixture-${id}` +const namespace = `fixture-${id}` +const partition = `ec:${namespace}` +const allocatorPartition = `ec-allocator:${namespace}` +const work = await mkdtemp(join(tmpdir(), 'ts-native-ec-smoke-')) +const output = resolve( + opt.output ?? join(tmpdir(), `ts-native-ec-smoke-${id}.json`) +) +const exec = promisify(execFile) +const docker = async (...args) => + ( + await exec('docker', args, { timeout: 20000, maxBuffer: 1024 * 1024 }) + ).stdout.trim() +const proof = { + outcome: 'running', + startedAt: new Date().toISOString(), + table, + namespace, + dynamo, + checks: [], + events: [], + artifacts: {}, + limitations: [ + 'synthetic geography and local consent policy only', + 'no live AWS IAM or browser acceptance', + 'direct app executable, not deployment wrapper', + 'local harness image or binary smoke does not qualify production release build, linkage or Docker recipe', + ], +} +let interrupted = false +const interrupt = () => { + interrupted = true +} +process.on('SIGINT', interrupt) +process.on('SIGTERM', interrupt) +const record = (name) => { + proof.checks.push(name) + console.log(`PASS ${name}`) +} +const event = (name, extra = {}) => { + const entry = { + sequence: proof.events.length, + at: new Date().toISOString(), + name, + ...extra, + } + proof.events.push(entry) + return entry.sequence +} +async function bounded(operation, milliseconds, message) { + const deadline = Date.now() + milliseconds + while (Date.now() < deadline) { + assert(!interrupted, 'interrupted') + if (await operation()) return + await delay(25) + } + throw new Error(message) +} +// DynamoDB Local checks the SigV4-shaped authorization header, not AWS keys. +// Fake credentials are generated here and are never read from the environment. +async function ddb(operation, payload) { + const date = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '') + const body = JSON.stringify(payload) + const response = await request(dynamo, '/', { + method: 'POST', + body, + headers: { + 'content-type': 'application/x-amz-json-1.0', + 'content-length': Buffer.byteLength(body), + 'x-amz-target': `DynamoDB_20120810.${operation}`, + 'x-amz-date': date, + authorization: `AWS4-HMAC-SHA256 Credential=DUMMYLOCAL/${date.slice(0, 8)}/us-east-1/dynamodb/aws4_request, SignedHeaders=host;x-amz-date, Signature=${'0'.repeat(64)}`, + }, + }) + assert.equal( + response.status, + 200, + `DynamoDB Local ${operation} failed: ${response.body.toString('utf8').slice(0, 500)}` + ) + return JSON.parse(response.body.toString('utf8')) +} +const key = (sk, pk = partition) => ({ pk: { S: pk }, sk: { S: sk } }) +const get = async (sk, pk) => + ( + await ddb('GetItem', { + TableName: table, + Key: key(sk, pk), + ConsistentRead: true, + }) + ).Item +const decode = (item) => + JSON.parse(Buffer.from(item.body.B, 'base64').toString('utf8')) +let fault = 'normal' +const pending = new Set() +const verifiedPuts = [] +const ownedApps = [] +let tableCreated = false +let origin, proxy +// Faults alter only this fixture's app-to-local-Dynamo traffic. Administration +// bypasses the proxy so it can prove a commit before returning the SDK's ack. +async function proxyRequest(req, res) { + try { + const chunks = [] + let bytes = 0 + for await (const chunk of req) { + bytes += chunk.length + assert(bytes <= 2 * 1024 * 1024, 'fixture request limit') + chunks.push(chunk) + } + const body = Buffer.concat(chunks) + const payload = JSON.parse(body.toString('utf8')) + const operation = String(req.headers['x-amz-target']).split('.').at(-1) + assert.equal(payload.TableName, table, 'app attempted a non-fixture table') + const write = operation === 'PutItem' + if (write) + assert.equal( + payload.Item.pk.S, + partition, + 'app attempted a non-fixture identity partition' + ) + if (operation === 'UpdateItem') + assert.equal(payload.Key.pk.S, allocatorPartition) + const selectedFault = fault + event('ddb-request', { + operation, + fault: selectedFault, + key: payload.Item?.sk?.S ?? payload.Key?.sk?.S, + condition: payload.ConditionExpression, + expectedGeneration: payload.ExpressionAttributeValues?.[':expected']?.N, + capturedNowMs: payload.ExpressionAttributeValues?.[':now']?.N, + }) + if (write && selectedFault === 'deny') { + res.writeHead(400, { + 'content-type': 'application/x-amz-json-1.0', + 'x-amzn-errortype': 'AccessDeniedException', + }) + res.end( + JSON.stringify({ + __type: 'com.amazonaws.dynamodb.v20120810#AccessDeniedException', + message: 'synthetic fixture denial', + }) + ) + return + } + if ( + write && + (selectedFault === 'pause' || + (selectedFault === 'pause-cas' && + payload.ConditionExpression?.includes(':expected'))) + ) { + await new Promise((resolve) => { + const entry = { + resolve, + key: payload.Item.sk.S, + condition: payload.ConditionExpression, + expectedGeneration: + payload.ExpressionAttributeValues?.[':expected']?.N, + capturedNowMs: payload.ExpressionAttributeValues?.[':now']?.N, + } + pending.add(entry) + res.once('close', () => { + pending.delete(entry) + resolve() + }) + entry.resolve = () => { + pending.delete(entry) + resolve() + } + }) + if (res.destroyed) return + } + const response = await request(dynamo, '/', { + method: 'POST', + body, + headers: { ...req.headers, host: new URL(dynamo).host }, + timeout: 5000, + }) + if (write && response.status === 200) { + const sk = payload.Item.sk.S + const row = await get(sk) + assert(row, 'DynamoDB acknowledged PutItem without a readable row') + assert.equal(row.generation.N, payload.Item.generation.N) + assert.equal(row.body.B, payload.Item.body.B) + verifiedPuts.push({ + key: sk, + sequence: event('put-verified-before-ack', { + key: sk, + generation: row.generation.N, + fault: selectedFault, + }), + }) + } + if (write && selectedFault === 'uncertain' && response.status === 200) { + // The row exists, but the caller never receives its successful ack. + event('commit-ack-lost', { key: payload.Item.sk.S }) + res.destroy() + return + } + res.writeHead(response.status, response.headers) + res.end(response.body) + } catch (error) { + proof.proxyFailure ??= error.message + if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain' }) + res.end('synthetic fixture proxy failure') + } +} +async function state(app) { + if (app.child) + return { + Running: app.child.exitCode === null && app.child.signalCode === null, + ExitCode: app.child.exitCode, + Signal: app.child.signalCode, + } + return JSON.parse( + await docker('inspect', '--format={{json .State}}', app.name) + ) +} +async function logs(app) { + if (app.child) return app.log + const result = await exec('docker', ['logs', app.name], { + timeout: 5000, + maxBuffer: 1024 * 1024, + }) + return result.stdout + result.stderr +} +async function signal(app, value) { + if (app.child) app.child.kill(value) + else await docker('kill', `--signal=${value}`, app.name) +} +async function stop(app, graceful = true) { + if (!(await state(app)).Running) return + await signal(app, graceful ? 'SIGTERM' : 'SIGKILL') + await bounded( + async () => !(await state(app)).Running, + 7000, + 'runner shutdown deadline' + ) + if (graceful) + assert.equal((await state(app)).ExitCode, 0, 'runner must exit normally') +} +async function launch(label, originEndpoint, proxyEndpoint) { + const port = await freePort() + const dir = join(work, label) + await mkdir(dir, { mode: 0o755 }) + await chmod(dir, 0o755) + const fixture = { + ec: { + passphrase: 'ec_key', + storage: { + provider: 'dynamodb', + region: 'us-east-1', + table, + namespace, + local_endpoint: proxyEndpoint, + }, + partners: [ + { + name: 'synthetic fixture', + source_domain: 'partner.example.com', + bidstream_enabled: true, + pull_sync_enabled: false, + }, + ], + }, + handlers: [ + { path: '^/_ts/admin', username: 'admin', password: 'admin_key' }, + ], + publisher: { + domain: 'publisher.example', + cookie_domain: '.publisher.example', + origin_url: originEndpoint, + proxy_secret: 'proxy_key', + }, + auction: { enabled: false }, + request_signing: null, + } + await writeFile( + join(dir, 'local-config-trusted_server_config.json'), + JSON.stringify({ trusted_server_config: envelope(fixture) }) + ) + await writeFile( + join(dir, 'native-geo.toml'), + 'version = 1\n[geo]\nprovider = "cloudfront"\nalb_peer_cidrs = ["127.0.0.1/32"]\norigin_credential_header_name = "x-origin-synthetic-fixture"\n' + ) + await chmod(join(dir, 'local-config-trusted_server_config.json'), 0o444) + await chmod(join(dir, 'native-geo.toml'), 0o444) + const root = opt.binary ? dir : '/fixture' + const env = { + HOME: root, + PATH: '/usr/local/bin:/usr/bin:/bin', + AWS_CONFIG_FILE: `${root}/nonexistent-aws-config`, + AWS_SHARED_CREDENTIALS_FILE: `${root}/nonexistent-aws-credentials`, + AWS_EC2_METADATA_DISABLED: 'true', + AWS_REGION: 'us-east-1', + AWS_DEFAULT_REGION: 'us-east-1', + TS_NATIVE_GEO_PROFILE_FILE: `${root}/native-geo.toml`, + EDGEZERO__ADAPTER__CONFIG_DIR: root, + EDGEZERO__ADAPTER__DATA_DIR: opt.binary ? dir : '/data', + EDGEZERO__ADAPTER__HOST: '127.0.0.1', + EDGEZERO__ADAPTER__PORT: String(port), + EDGEZERO__ADAPTER__SHUTDOWN_GRACE_SECONDS: '3', + EDGEZERO__ADAPTER__TRUSTED_PROXY_CIDRS: '127.0.0.1/32', + EDGEZERO__ADAPTER__FORWARDING_HEADER_FAMILY: 'x-forwarded', + EDGEZERO__SECRETS__EC_KEY: 'synthetic-ec-passphrase-32-bytes-minimum', + EDGEZERO__SECRETS__PROXY_KEY: 'synthetic-proxy-secret-32-bytes-minimum', + EDGEZERO__SECRETS__ADMIN_KEY: 'synthetic-admin-password-32-bytes-minimum', + RUST_LOG: 'info', + } + const app = { label, endpoint: `http://127.0.0.1:${port}`, log: '' } + ownedApps.push(app) + if (opt.binary) { + app.child = spawn(opt.binary, [], { + cwd: dir, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + app.child.on('error', (error) => { + app.spawnError = error.message + }) + const capture = (chunk) => { + app.log = (app.log + chunk.toString('utf8')).slice(-256 * 1024) + } + app.child.stdout.on('data', capture) + app.child.stderr.on('data', capture) + } else { + app.name = `ts-native-ec-${label}-${id}` + await docker( + 'run', + '-d', + '--pull=never', + '--name', + app.name, + '--network=host', + '--read-only', + '--user=10001:10001', + '--cap-drop=ALL', + '--security-opt=no-new-privileges', + '--mount', + `type=bind,src=${dir},dst=/fixture,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', + ...Object.entries(env).flatMap(([key, value]) => [ + '--env', + `${key}=${value}`, + ]), + '--entrypoint', + opt['image-binary'] ?? '/usr/local/bin/app', + opt.image + ) + } + await bounded( + async () => { + assert(!app.spawnError, `runner spawn failed: ${app.spawnError}`) + assert( + (await state(app)).Running, + `runner exited before readiness: ${await logs(app)}` + ) + const health = await request(app.endpoint, '/health', { + timeout: 300, + }).catch(() => null) + if (!health) return false + assert.equal(health.status, 200) + assert.equal(health.body.toString('utf8'), 'ok') + assert.equal(minted(health).length, 0) + return true + }, + 15000, + 'runner readiness deadline; confirm local endpoint seam and required features' + ) + return app +} +const headers = (country = 'AU', extra = {}) => ({ + host: 'publisher.example', + accept: 'text/html', + 'user-agent': + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/146.0.0.0 Safari/537.36', + 'sec-fetch-dest': 'document', + 'sec-fetch-mode': 'navigate', + 'x-forwarded-host': 'publisher.example', + 'x-forwarded-proto': 'https', + 'x-forwarded-for': '198.51.100.24', + 'cloudfront-viewer-country': country, + 'x-origin-synthetic-fixture': 'synthetic-fixture-only', + ...extra, +}) +async function document( + app, + country = 'AU', + extra = {}, + allowStorageError = false +) { + let responseSequence + const result = await request(app.endpoint, '/article', { + headers: headers(country, extra), + onHeaders: () => { + responseSequence = event('document-response-headers', { + label: app.label, + country, + }) + }, + }) + if (allowStorageError) + assert( + result.status === 200 || result.status >= 500, + 'storage failure must not become an unrelated redirect or client rejection' + ) + else + assert.equal( + result.status, + 200, + 'synthetic publisher document must succeed' + ) + if (result.status === 200) + assert.equal( + result.headers['x-geo-country'], + country, + 'synthetic trusted Geo must be admitted' + ) + if (opt['expected-version']) + assert.equal(result.headers['x-ts-version'], opt['expected-version']) + return { ...result, responseSequence } +} +try { + await chmod(work, 0o755) + if (opt.binary) { + proof.artifacts = { + binary: opt.binary, + sha256: createHash('sha256') + .update(await readFile(opt.binary)) + .digest('hex'), + } + } else { + const inspect = JSON.parse(await docker('image', 'inspect', opt.image))[0] + proof.artifacts = { + image: opt.image, + imageId: inspect.Id, + repoDigests: inspect.RepoDigests, + executable: opt['image-binary'] ?? '/usr/local/bin/app', + } + } + // No table can pre-exist under this unpredictable UUID. Register ownership + // before CreateTable so an ambiguous local response still gets cleanup. + tableCreated = true + await ddb('CreateTable', { + TableName: table, + BillingMode: 'PAY_PER_REQUEST', + KeySchema: [ + { AttributeName: 'pk', KeyType: 'HASH' }, + { AttributeName: 'sk', KeyType: 'RANGE' }, + ], + AttributeDefinitions: [ + { AttributeName: 'pk', AttributeType: 'S' }, + { AttributeName: 'sk', AttributeType: 'S' }, + ], + }) + await bounded( + async () => + (await ddb('DescribeTable', { TableName: table })).Table.TableStatus === + 'ACTIVE', + 5000, + 'DynamoDB Local table activation' + ) + await ddb('UpdateTimeToLive', { + TableName: table, + TimeToLiveSpecification: { AttributeName: 'ttl_epoch_s', Enabled: true }, + }) + origin = http.createServer((req, res) => { + if (req.headers['x-origin-synthetic-fixture'] !== undefined) { + proof.originFailure = + 'protected-origin credential header reached synthetic origin' + res.writeHead(502) + res.end('fixture origin credential removal failed') + return + } + res.writeHead(200, { 'content-type': 'text/html' }) + res.end( + 'synthetic native EC fixturefixture article' + ) + }) + const originEndpoint = await listen(origin) + proxy = http.createServer((req, res) => { + void proxyRequest(req, res) + }) + const proxyEndpoint = await listen(proxy) + let app = await launch('initial', originEndpoint, proxyEndpoint) + const beforeMissing = verifiedPuts.length + assert.equal( + minted( + await document(app, 'US', { 'cloudfront-viewer-country-region': 'TN' }) + ).length, + 0 + ) + assert.equal( + verifiedPuts.length, + beforeMissing, + 'US TN missing consent must not create an identity' + ) + record( + 'US TN without qualifying consent does not mint or persist an identity' + ) + for (const path of ['/health', '/_ts/admin/ec']) { + const result = await request(app.endpoint, path, { headers: headers() }) + assert.equal(minted(result).length, 0) + } + record('health and rejected admin route do not mint') + const first = await document(app) + assert.equal( + minted(first).length, + 1, + 'eligible synthetic AU navigation must issue exactly one EC' + ) + const ec = minted(first)[0] + const create = verifiedPuts.find((put) => put.key === ec) + assert( + create && create.sequence < first.responseSequence, + 'real row must be verified before SDK ack and cookie response headers' + ) + const live = await get(ec) + assert.equal(decode(live).consent.ok, true) + assert(Number(live.expires_at_ms.N) > Date.now()) + assert.equal( + BigInt(live.ttl_epoch_s.N), + (BigInt(live.expires_at_ms.N) + 999n) / 1000n + ) + assert.match(first.headers['cache-control'], /private/) + const allocator = await get('generation', allocatorPartition) + assert(allocator?.generation?.N) + assert.equal(allocator.ttl_epoch_s, undefined) + assert.equal(allocator.expires_at_ms, undefined) + record( + 'real DynamoDB row and non-expiring allocator precede new cookie headers' + ) + await stop(app) + app = await launch('recreated', originEndpoint, proxyEndpoint) + const returning = await document(app, 'AU', { cookie: `ts-ec=${ec}` }) + assert.equal( + minted(returning).length, + 0, + 'ordinary returning document must not refresh the EC cookie' + ) + assert.equal((await get(ec)).generation.N, live.generation.N) + record( + 'recreated native process reuses the retained identity without cookie refresh' + ) + // AU grants initial creation without a consent string, but unchanged core + // policy only treats this GPC signal as explicit EC withdrawal in US TN. + const withdrawn = await document(app, 'US', { + 'cloudfront-viewer-country-region': 'TN', + cookie: `ts-ec=${ec}`, + 'sec-gpc': '1', + 'user-agent': 'privacy-client', + }) + assert.equal(minted(withdrawn).length, 0) + assert( + (withdrawn.headers['set-cookie'] ?? []).some( + (value) => /^ts-ec=;/.test(value) && /max-age=0/i.test(value) + ), + 'withdrawal must expire the browser cookie' + ) + const tombstone = await get(ec) + assert.equal(decode(tombstone).consent.ok, false) + assert.equal(Object.keys(decode(tombstone).ids ?? {}).length, 0) + assert(BigInt(tombstone.generation.N) > BigInt(live.generation.N)) + assert(Number(tombstone.expires_at_ms.N) > Date.now()) + assert(Number(tombstone.ttl_epoch_s.N) * 1000 < Date.now() + 2 * 86400000) + assert.equal( + minted(await document(app, 'AU', { cookie: `ts-ec=${ec}` })).length, + 0 + ) + assert.equal( + decode(await get(ec)).consent.ok, + false, + 'returning cookie must not revive a tombstone' + ) + record( + 'US TN GPC withdrawal expires cookie, persists a fresh-generation tombstone and resists AU reuse' + ) + // Hold a real EID CAS after the client captured :now but before DynamoDB + // evaluates it. Let the physical row expire logically, then withdraw it. + // Filtering key_exists by logical expiry would skip the protective tombstone + // and allow the delayed CAS to match the old physical generation afterward. + const raceCreated = await document(app) + assert.equal(minted(raceCreated).length, 1) + const raceEc = minted(raceCreated)[0] + const raceItem = await get(raceEc) + const raceExpiry = Date.now() + 500 + raceItem.expires_at_ms = { N: String(raceExpiry) } + raceItem.ttl_epoch_s = { N: String(Math.ceil(raceExpiry / 1000)) } + await ddb('PutItem', { TableName: table, Item: raceItem }) + const raceEids = Buffer.from( + JSON.stringify([ + { + source: 'partner.example.com', + uids: [{ id: 'synthetic-late-eid', atype: 1 }], + }, + ]) + ).toString('base64') + fault = 'pause-cas' + const eidUpdate = document(app, 'AU', { + cookie: `ts-ec=${raceEc}; ts-eids=${raceEids}`, + }).then( + (value) => ({ value }), + (error) => ({ error }) + ) + await bounded( + () => [...pending].some((entry) => entry.key === raceEc), + 2500, + 'EID update must reach the real conditional PutItem before logical expiry' + ) + const pausedCas = [...pending].find((entry) => entry.key === raceEc) + assert(pausedCas.condition.includes(':expected')) + assert.equal(pausedCas.expectedGeneration, raceItem.generation.N) + assert( + Number(pausedCas.capturedNowMs) < raceExpiry, + 'CAS must capture its expiry check while the row is live' + ) + proof.expiryWithdrawalRace = { + ec: raceEc, + expiresAtMs: raceExpiry, + expectedGeneration: pausedCas.expectedGeneration, + capturedNowMs: pausedCas.capturedNowMs, + } + await bounded( + () => Date.now() > raceExpiry + 50, + 1500, + 'wait for logical expiry while the physical CAS remains pending' + ) + const raceWithdrawal = await document(app, 'US', { + 'cloudfront-viewer-country-region': 'TN', + cookie: `ts-ec=${raceEc}`, + 'sec-gpc': '1', + 'user-agent': 'privacy-client', + }) + proof.expiryWithdrawalRace.withdrawalExpiredCookie = ( + raceWithdrawal.headers['set-cookie'] ?? [] + ).some((value) => /^ts-ec=;/.test(value) && /max-age=0/i.test(value)) + proof.expiryWithdrawalRace.afterWithdrawalItem = await get(raceEc) + assert( + proof.expiryWithdrawalRace.withdrawalExpiredCookie, + 'racing withdrawal must expire the browser cookie' + ) + pausedCas.resolve() + const racedUpdate = await eidUpdate + fault = 'normal' + assert( + !racedUpdate.error, + `delayed EID request failed: ${racedUpdate.error?.message}` + ) + const afterRace = await get(raceEc) + proof.expiryWithdrawalRace.finalItem = afterRace + assert(afterRace, 'racing withdrawal must retain an authoritative tombstone') + const afterRaceBody = decode(afterRace) + assert.equal( + afterRaceBody.consent.ok, + false, + 'expired physical row must remain withdrawn after a delayed stale-time CAS' + ) + assert.equal( + Object.keys(afterRaceBody.ids ?? {}).length, + 0, + 'delayed CAS must not restore partner IDs after withdrawal' + ) + assert.equal(minted(racedUpdate.value).length, 0) + record( + 'logical expiry plus delayed EID CAS cannot bypass US TN withdrawal or restore partner IDs' + ) + fault = 'deny' + const beforeDenied = verifiedPuts.length + assert.equal(minted(await document(app, 'AU', {}, true)).length, 0) + assert.equal(verifiedPuts.length, beforeDenied) + assert( + proof.events.some( + (entry) => entry.operation === 'PutItem' && entry.fault === 'deny' + ) + ) + record('denied PutItem never emits a phantom cookie') + fault = 'uncertain' + const beforeUncertain = verifiedPuts.length + assert.equal(minted(await document(app, 'AU', {}, true)).length, 0) + assert( + verifiedPuts.length > beforeUncertain, + 'uncertain fixture must actually commit before losing acknowledgement' + ) + assert(proof.events.some((entry) => entry.name === 'commit-ack-lost')) + record('post-commit acknowledgement loss never emits a phantom cookie') + fault = 'pause' + const article = document(app).then( + (value) => ({ value }), + (error) => ({ error }) + ) + await bounded(() => pending.size > 0, 3000, 'pending real DynamoDB PutItem') + const healthStarted = Date.now() + const health = await request(app.endpoint, '/health', { timeout: 750 }) + assert.equal(health.status, 200) + assert.equal(minted(health).length, 0) + proof.healthWhileStoragePausedMs = Date.now() - healthStarted + const shutdownStarted = Date.now() + await signal(app, 'SIGTERM') + await bounded( + async () => /phase=draining/.test(await logs(app)), + 1000, + 'runner must begin drain while real storage is paused' + ) + for (const entry of [...pending]) entry.resolve() + const completed = await article + assert( + !completed.error, + `in-flight request failed during drain: ${completed.error?.message}` + ) + assert.equal(minted(completed.value).length, 1) + const delayedEc = minted(completed.value)[0] + assert( + verifiedPuts.some( + (put) => + put.key === delayedEc && put.sequence < completed.value.responseSequence + ) + ) + await bounded( + async () => !(await state(app)).Running, + 7000, + 'current-thread runner drain deadline' + ) + assert.equal((await state(app)).ExitCode, 0) + proof.shutdownMs = Date.now() - shutdownStarted + const finalLogs = await logs(app) + assert.match(finalLogs, /phase=stopped/) + record( + 'actual runner serves health and starts shutdown while storage waits, then drains normally' + ) + assert(!proof.proxyFailure, `fixture proxy error: ${proof.proxyFailure}`) + assert(!proof.originFailure, proof.originFailure) + proof.outcome = 'pass' +} catch (error) { + proof.outcome = 'fail' + proof.failure = error.stack ?? error.message + process.exitCode = 1 + console.error(`FAIL ${error.message}`) +} finally { + fault = 'normal' + for (const entry of [...pending]) entry.resolve() + proof.logs = {} + for (const app of [...ownedApps].reverse()) { + try { + await stop(app) + } catch (error) { + proof.cleanupErrors ??= [] + proof.cleanupErrors.push(`stop ${app.label}: ${error.message}`) + try { + await signal(app, 'SIGKILL') + } catch { + /* Already stopped. */ + } + } + try { + proof.logs[app.label] = (await logs(app)).slice(-256 * 1024) + } catch { + /* Failed launch. */ + } + if (app.name) { + try { + await docker('rm', '-f', app.name) + } catch (error) { + proof.cleanupErrors ??= [] + proof.cleanupErrors.push(`container ${app.label}: ${error.message}`) + } + } + } + for (const server of [proxy, origin]) { + if (server) { + server.closeAllConnections() + await new Promise((resolve) => server.close(resolve)) + } + } + if (tableCreated) { + try { + await ddb('DeleteTable', { TableName: table }) + } catch (error) { + proof.cleanupErrors ??= [] + proof.cleanupErrors.push(`local table: ${error.message}`) + } + } + if (proof.cleanupErrors?.length) { + proof.outcome = 'fail' + process.exitCode = 1 + } + proof.finishedAt = new Date().toISOString() + try { + await writeFile(output, JSON.stringify(proof, null, 2) + '\n', { + mode: 0o600, + }) + console.log(`Evidence: ${output}`) + } catch (error) { + process.exitCode = 1 + console.error(`Evidence write failed: ${error.code ?? error.message}`) + console.log(JSON.stringify(proof)) + } finally { + 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') + } + } +) 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 }); + } + } +); diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 066ad659e3..db3dcb2b46 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -75,16 +75,19 @@ proxy_secret = "publisher_proxy_secret" [ec] # Secret Store key for the secret used to derive EC identifiers. passphrase = "ec_passphrase" -# KV store that persists EC identity state. This is the physical store name -# bound per adapter (e.g. `ec_identity_store` in fastly.toml); edgezero.toml's -# logical KV id is `trusted_server_kv`. -ec_store = "ec_identity_store" # Max concurrent partner pull-sync requests. pull_sync_concurrency = 3 # Optional cluster-heuristic tuning (defaults shown): # cluster_trust_threshold = 10 # entries with cluster_size <= this are individual users # cluster_recheck_secs = 3600 # re-evaluate cluster_size after this many seconds +# Fastly persistence uses this exact physical binding. Provider omission is +# Fastly-only; native providers require explicit configuration. An ordinary +# logical KV declaration does not select EC storage. Legacy ec.ec_store remains +# supported on Fastly, but cannot be combined with this section. +[ec.storage] +store = "ec_identity_store" + # Example partner configuration. Provision referenced keys in # trusted_server_secrets before validating/pushing. # [[ec.partners]]