diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index da467583c..ba64fec1e 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -83,6 +83,54 @@ jobs: INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} RUST_LOG: info + integration-tests-edgezero: + name: integration tests (EdgeZero entry point) + needs: prepare-artifacts + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Set up integration test runtime + id: shared-setup + uses: ./.github/actions/setup-integration-test-env + with: + origin-port: ${{ env.ORIGIN_PORT }} + check-dependency-versions: "false" + install-viceroy: "true" + build-wasm: "false" + build-test-images: "false" + + - name: Download integration test artifacts + uses: actions/download-artifact@v4 + with: + name: integration-test-artifacts + path: ${{ env.ARTIFACTS_DIR }} + + # Exercises the EdgeZero entry point against the same WASM binary by + # pointing Viceroy at a config store with `edgezero_enabled = "true"`. + # Scoped to the container-free EC lifecycle suite (minimal TCP origin), a + # focused parity subset covering Fastly request conversion, config-store + # dispatch, publisher fallback proxying, and end-to-end EC/API wiring on + # the EdgeZero path. The legacy `integration-tests` job above still covers + # the full framework matrix. + - name: Run EdgeZero EC lifecycle tests + run: >- + cargo test + --manifest-path crates/integration-tests/Cargo.toml + --target x86_64-unknown-linux-gnu + test_ec_lifecycle_fastly + -- --include-ignored --test-threads=1 + env: + WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }} + INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} + VICEROY_CONFIG_PATH: ${{ github.workspace }}/crates/integration-tests/fixtures/configs/viceroy-template-edgezero.toml + # Opt into the EdgeZero entry-point canary in test_ec_lifecycle_fastly. + # Only set here, so the legacy integration-tests job runs the same + # scenarios through legacy_main without asserting the EdgeZero-only 405. + EXPECT_EDGEZERO_ENTRY_POINT: "true" + RUST_LOG: info + browser-tests: name: browser integration tests needs: prepare-artifacts diff --git a/Cargo.lock b/Cargo.lock index ed7b74869..2ed0ec2b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -750,7 +750,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=170b74b#170b74bd2c9933b7d561f7ccdb67c53b239e9527" +source = "git+https://github.com/stackpop/edgezero?rev=38198f9839b70aef03ab971ae5876982773fc2a1#38198f9839b70aef03ab971ae5876982773fc2a1" dependencies = [ "anyhow", "async-stream", @@ -771,7 +771,7 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=170b74b#170b74bd2c9933b7d561f7ccdb67c53b239e9527" +source = "git+https://github.com/stackpop/edgezero?rev=38198f9839b70aef03ab971ae5876982773fc2a1#38198f9839b70aef03ab971ae5876982773fc2a1" dependencies = [ "anyhow", "async-compression", @@ -799,7 +799,7 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=170b74b#170b74bd2c9933b7d561f7ccdb67c53b239e9527" +source = "git+https://github.com/stackpop/edgezero?rev=38198f9839b70aef03ab971ae5876982773fc2a1#38198f9839b70aef03ab971ae5876982773fc2a1" dependencies = [ "log", "proc-macro2", @@ -1576,9 +1576,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "log-fastly" @@ -2264,9 +2264,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -2568,28 +2568,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "tokio" -version = "1.52.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" -dependencies = [ - "bytes", - "pin-project-lite", - "tokio-macros", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -2685,6 +2663,7 @@ dependencies = [ "serde", "serde_json", "trusted-server-core", + "url", "urlencoding", ] @@ -2724,7 +2703,6 @@ dependencies = [ "sha2 0.10.9", "subtle", "temp-env", - "tokio", "toml", "trusted-server-js", "trusted-server-openrtb", diff --git a/Cargo.toml b/Cargo.toml index 9f2f4c673..05a0eaf77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,10 +56,10 @@ config = "0.15.19" cookie = "0.18.1" derive_more = { version = "2.0", features = ["display", "error"] } ed25519-dalek = { version = "2.2", features = ["rand_core"] } -edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", rev = "170b74b", default-features = false } -edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "170b74b", default-features = false } -edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", rev = "170b74b", default-features = false } -edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "170b74b", default-features = false } +edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", rev = "38198f9839b70aef03ab971ae5876982773fc2a1", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "38198f9839b70aef03ab971ae5876982773fc2a1", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", rev = "38198f9839b70aef03ab971ae5876982773fc2a1", default-features = false } +edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "38198f9839b70aef03ab971ae5876982773fc2a1", default-features = false } error-stack = "0.6" fastly = "0.11.12" fern = "0.7.1" @@ -83,7 +83,7 @@ sha2 = "0.10.9" subtle = "2.6" temp-env = "0.3.6" tokio = { version = "1.49", features = ["sync", "macros", "io-util", "rt", "time"] } -toml = "1.0" +toml = "1.1" trusted-server-core = { path = "crates/trusted-server-core" } url = "2.5.8" urlencoding = "2.1" diff --git a/crates/integration-tests/Cargo.lock b/crates/integration-tests/Cargo.lock index 891817e48..a27e26904 100644 --- a/crates/integration-tests/Cargo.lock +++ b/crates/integration-tests/Cargo.lock @@ -996,7 +996,7 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=170b74b#170b74bd2c9933b7d561f7ccdb67c53b239e9527" +source = "git+https://github.com/stackpop/edgezero?rev=38198f9839b70aef03ab971ae5876982773fc2a1#38198f9839b70aef03ab971ae5876982773fc2a1" dependencies = [ "anyhow", "async-compression", @@ -1024,7 +1024,7 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?rev=170b74b#170b74bd2c9933b7d561f7ccdb67c53b239e9527" +source = "git+https://github.com/stackpop/edgezero?rev=38198f9839b70aef03ab971ae5876982773fc2a1#38198f9839b70aef03ab971ae5876982773fc2a1" dependencies = [ "log", "proc-macro2", @@ -2142,9 +2142,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lol_html" @@ -3410,9 +3410,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -4146,7 +4146,6 @@ dependencies = [ "serde_json", "sha2 0.10.9", "subtle", - "tokio", "toml", "trusted-server-js", "trusted-server-openrtb", diff --git a/crates/integration-tests/fixtures/configs/viceroy-template-edgezero.toml b/crates/integration-tests/fixtures/configs/viceroy-template-edgezero.toml new file mode 100644 index 000000000..9c3900df4 --- /dev/null +++ b/crates/integration-tests/fixtures/configs/viceroy-template-edgezero.toml @@ -0,0 +1,93 @@ +# Viceroy local server configuration template for integration tests — +# EdgeZero entry-point variant. +# +# Identical to `viceroy-template.toml` but adds the `trusted_server_config` +# config store with `edgezero_enabled = "true"`, so the same WASM binary routes +# requests through the EdgeZero entry point instead of the legacy path. Used by +# the `integration-tests-edgezero` CI job (via `VICEROY_CONFIG_PATH`) to exercise +# Fastly request conversion, config-store dispatch, and end-to-end EC wiring on +# the EdgeZero path. Keep the shared stores in sync with `viceroy-template.toml`. +# +# This configures the Viceroy runtime itself (backends, KV stores, etc.), +# separate from the application config (trusted-server.toml). + +[local_server] + + [local_server.backends] + + [local_server.kv_stores] + # These inline placeholders satisfy Viceroy's local KV configuration + # requirements without exercising KV-backed application behavior. + [[local_server.kv_stores.counter_store]] + key = "placeholder" + data = "placeholder" + + [[local_server.kv_stores.opid_store]] + key = "placeholder" + data = "placeholder" + + [[local_server.kv_stores.creative_store]] + key = "placeholder" + data = "placeholder" + + [[local_server.kv_stores.ec_identity_store]] + key = "placeholder" + data = "placeholder" + + # Pre-seeded EC rows for KV-backed EC lifecycle tests. Each scenario + # uses a separate row so withdrawal tombstones do not leak across + # sequential scenario execution in the same Viceroy instance. + [[local_server.kv_stores.ec_identity_store]] + key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.test01" + data = '{"v":1,"created":1700000000,"consent":{"ok":true,"updated":1700000000},"geo":{"country":"US","region":"CA"}}' + + [[local_server.kv_stores.ec_identity_store]] + key = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.test02" + data = '{"v":1,"created":1700000000,"consent":{"ok":true,"updated":1700000000},"geo":{"country":"US","region":"CA"}}' + + [[local_server.kv_stores.ec_identity_store]] + key = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.test03" + data = '{"v":1,"created":1700000000,"consent":{"ok":true,"updated":1700000000},"geo":{"country":"US","region":"CA"}}' + + [[local_server.kv_stores.ec_identity_store]] + key = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd.test04" + data = '{"v":1,"created":1700000000,"consent":{"ok":true,"updated":1700000000},"geo":{"country":"US","region":"CA"}}' + + [[local_server.kv_stores.ec_identity_store]] + key = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee.test05" + data = '{"v":1,"created":1700000000,"consent":{"ok":true,"updated":1700000000},"geo":{"country":"US","region":"CA"}}' + + [[local_server.kv_stores.ec_partner_store]] + key = "placeholder" + data = "placeholder" + + # These are generated test-only key pairs, not production credentials. + # The Ed25519 private key (data) and its matching public key (x in jwks_store below) + # exist solely for signing and verifying tokens in the integration test environment. + # They were generated specifically for testing and are safe to commit — they + # have never been used in any production or staging environment. + [local_server.secret_stores] + [[local_server.secret_stores.signing_keys]] + key = "ts-2025-10-A" + data = "NVnTYrw5xoyTJDOwoUWoPJO3A6UCCXOJJUzgGTxxx7k=" + + [[local_server.secret_stores.api-keys]] + key = "api_key" + data = "test-api-key" + + [local_server.config_stores] + # Routes requests through the EdgeZero entry point. `is_edgezero_enabled` + # in the Fastly adapter reads this key at runtime; `"true"` (or `"1"`) + # enables EdgeZero, anything else falls back to the legacy path. + [local_server.config_stores.trusted_server_config] + format = "inline-toml" + [local_server.config_stores.trusted_server_config.contents] + edgezero_enabled = "true" + + [local_server.config_stores.jwks_store] + format = "inline-toml" + [local_server.config_stores.jwks_store.contents] + ts-2025-10-A = "{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"kid\":\"ts-2025-10-A\",\"use\":\"sig\",\"x\":\"UVTi04QLrIuB7jXpVfHjUTVN5aIdcbPNr50umTtN8pw\"}" + ts-2025-10-B = "{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"kid\":\"ts-2025-10-B\",\"use\":\"sig\",\"x\":\"HVTi04QLrIuB7jXpVfHjUTVN5aIdcbPNr50umTtN8pw\"}" + current-kid = "ts-2025-10-A" + active-kids = "ts-2025-10-A,ts-2025-10-B" diff --git a/crates/integration-tests/tests/common/ec.rs b/crates/integration-tests/tests/common/ec.rs index 851d8f684..c78499396 100644 --- a/crates/integration-tests/tests/common/ec.rs +++ b/crates/integration-tests/tests/common/ec.rs @@ -270,6 +270,31 @@ fn mappings_to_json(mappings: &[BatchMapping]) -> Vec { // --------------------------------------------------------------------------- /// Asserts the response has a specific HTTP status code. +/// Asserts the running Viceroy instance is serving the EdgeZero entry point. +/// +/// `main()` silently falls back to the legacy entry point when the config store +/// cannot be opened or read, and the EC lifecycle scenarios pass on either path. +/// This canary distinguishes them: the EdgeZero router returns a router-level +/// `405` for methods outside its registered set (e.g. `TRACE`), whereas the +/// legacy path proxied every method through to the publisher origin. Without it, +/// a fixture/env/config-store regression could green the EdgeZero CI job while +/// it actually exercises legacy. +pub fn assert_edgezero_entry_point(base_url: &str) -> TestResult<()> { + let client = Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("should build EdgeZero canary client"); + let response = client + .request(reqwest::Method::TRACE, format!("{base_url}/")) + .send() + .change_context(TestError::HttpRequest) + .attach("TRACE / (EdgeZero entry-point canary)")?; + assert_status(&response, 405).attach( + "EdgeZero canary: TRACE should return a router-level 405; a non-405 status \ + means main() fell back to the legacy entry point", + ) +} + pub fn assert_status(resp: &Response, expected: u16) -> TestResult<()> { let actual = resp.status().as_u16(); if actual != expected { diff --git a/crates/integration-tests/tests/environments/fastly.rs b/crates/integration-tests/tests/environments/fastly.rs index ec758432c..34a49d283 100644 --- a/crates/integration-tests/tests/environments/fastly.rs +++ b/crates/integration-tests/tests/environments/fastly.rs @@ -67,7 +67,19 @@ impl FastlyViceroy { /// /// This contains `[local_server]` configuration (backends, KV stores, /// secret stores) that Viceroy needs, separate from the application config. + /// + /// Honors the `VICEROY_CONFIG_PATH` environment variable so a CI job can + /// point the same WASM binary at an alternative config store — e.g. the + /// EdgeZero fixture that sets `trusted_server_config.edgezero_enabled = + /// "true"` to exercise the EdgeZero entry point. Mirrors the browser + /// harness's `global-setup.ts`, which reads the same variable. Falls back to + /// the default legacy template when unset. fn viceroy_config_path(&self) -> std::path::PathBuf { + if let Ok(path) = std::env::var("VICEROY_CONFIG_PATH") { + if !path.is_empty() { + return std::path::PathBuf::from(path); + } + } std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("fixtures/configs/viceroy-template.toml") } diff --git a/crates/integration-tests/tests/integration.rs b/crates/integration-tests/tests/integration.rs index 5c6dcf606..34c81064c 100644 --- a/crates/integration-tests/tests/integration.rs +++ b/crates/integration-tests/tests/integration.rs @@ -166,6 +166,19 @@ fn test_ec_lifecycle_fastly() { log::info!("EC lifecycle tests: Viceroy running at {}", process.base_url); + // EdgeZero entry-point canary. This same test runs in two CI jobs: the + // legacy `integration-tests` job (default Viceroy config, legacy_main) and + // the `integration-tests-edgezero` job (EdgeZero config store, edgezero_main). + // Only assert the canary when the job opted into the EdgeZero path via + // EXPECT_EDGEZERO_ENTRY_POINT; on the legacy path TRACE is proxied (not 405ed) + // and the scenarios still validate legacy behavior. The canary guards against + // the EdgeZero job silently greening on legacy if the config store cannot be + // read (main() falls back to legacy_main). + if std::env::var("EXPECT_EDGEZERO_ENTRY_POINT").as_deref() == Ok("true") { + common::ec::assert_edgezero_entry_point(&process.base_url) + .expect("EdgeZero entry-point canary failed: TRACE did not return a router-level 405"); + } + for scenario in EcScenario::all() { log::info!(" Running EC scenario: {scenario:?}"); scenario diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index adb067bc3..8547fd519 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -22,7 +22,10 @@ log-fastly = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } trusted-server-core = { workspace = true } +url = { workspace = true } urlencoding = { workspace = true } [dev-dependencies] +bytes = { workspace = true } edgezero-core = { workspace = true, features = ["test-utils"] } +trusted-server-core = { workspace = true, features = ["test-utils"] } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs new file mode 100644 index 000000000..96260019a --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -0,0 +1,1860 @@ +//! Full `EdgeZero` application wiring for Trusted Server. +//! +//! Registers all routes from the legacy [`crate::route_request`] into a +//! [`RouterService`]. On successful startup, attaches [`FinalizeResponseMiddleware`] +//! (outermost) and [`AuthMiddleware`] (inner). When startup fails, +//! [`startup_error_router`] returns a bare router without middleware. +//! Builds the [`AppState`] once per Wasm instance. +//! +//! `EdgeZero`'s current Fastly request context exposes client IP but not TLS +//! protocol or cipher metadata. `edgezero_main` injects a trusted `fastly-ssl` +//! header after stripping client-spoofable headers, so [`detect_request_scheme`] +//! in `http_util` can still derive the correct scheme for HTTPS traffic. +//! +//! # Route inventory +//! +//! | Method | Path pattern | Handler | +//! |--------|-------------|---------| +//! | GET | `/.well-known/trusted-server.json` | [`handle_trusted_server_discovery`] | +//! | POST | `/verify-signature` | [`handle_verify_signature`] | +//! | POST | `/_ts/admin/keys/rotate` | [`handle_rotate_key`] | +//! | POST | `/_ts/admin/keys/deactivate` | [`handle_deactivate_key`] | +//! | POST | `/admin/keys/rotate` (legacy alias) | [`handle_rotate_key`] | +//! | POST | `/admin/keys/deactivate` (legacy alias) | [`handle_deactivate_key`] | +//! | POST | `/_ts/api/v1/batch-sync` | [`handle_batch_sync`] | +//! | GET | `/_ts/api/v1/identify` | [`handle_identify`] | +//! | OPTIONS | `/_ts/api/v1/identify` | [`cors_preflight_identify`] | +//! | POST | `/auction` | [`handle_auction`] | +//! | GET | `/first-party/proxy` | [`handle_first_party_proxy`] | +//! | GET | `/first-party/click` | [`handle_first_party_click`] | +//! | GET | `/first-party/sign` | [`handle_first_party_proxy_sign`] | +//! | POST | `/first-party/sign` | [`handle_first_party_proxy_sign`] | +//! | POST | `/first-party/proxy-rebuild` | [`handle_first_party_proxy_rebuild`] | +//! | GET | `/` and `/{*rest}` | tsjs (if `/static/tsjs=` prefix), integration proxy, or publisher fallback | +//! | POST, HEAD, OPTIONS, PUT, PATCH, DELETE | `/` and `/{*rest}` | integration proxy or publisher fallback | +//! | POST, HEAD, OPTIONS, PUT, PATCH, DELETE | named paths above | publisher fallback (legacy parity for non-primary methods) | +//! +//! > **Note:** Methods not in the list above (e.g. `TRACE`, `CONNECT`, WebDAV verbs) return a +//! > router-level 405. Legacy routing proxied *every* method through to the publisher origin. +//! > This is a known intentional restriction of the EdgeZero router; the entry-point +//! > `apply_finalize_headers` call in `main.rs` still adds TS headers to those 405 responses. +//! +//! # EC identity lifecycle +//! +//! The `EdgeZero` path mirrors the EC identity lifecycle of the legacy +//! `route_request` (tracked in issue #495): +//! +//! - [`build_ec_request_state`] runs before every dispatched route (except +//! batch-sync, which uses Bearer auth) and reproduces the legacy +//! pre-routing prelude: device signals, bot gate, `ts-eids`/`sharedid` +//! cookie capture, geo lookup, [`EcContext`] creation, and KV-graph gating. +//! - `handle_auction` and integration proxy dispatch receive the same +//! [`EcContext`], [`KvIdentityGraph`], and [`PartnerRegistry`] inputs as +//! legacy; the publisher fallback generates EC IDs for browser navigations. +//! - Handlers attach an [`EcFinalizeState`] to the response via extensions; +//! `edgezero_main` pops it and runs `ec_finalize_response` plus the +//! pull-sync hook on the converted fastly response before sending. +//! +//! ## Intentional deviations from legacy +//! +//! - **401 auth challenges**: [`AuthMiddleware`] short-circuits before the +//! handler runs, so no EC state is built and `ec_finalize_response` does not +//! run on these responses. Legacy ran EC finalization on its own auth +//! challenges. Like the 401 geo-skip, this is privacy-conservative: no EC +//! cookies are issued to unauthenticated callers. +//! - **Streaming publisher responses** are buffered (bounded by +//! `publisher.max_buffered_body_bytes`) instead of streamed to the client. +//! - **Router-level 405s** (unregistered verbs) skip EC finalization along +//! with the middleware chain; the entry point still adds TS headers. +//! +//! # Startup error handling +//! +//! When [`build_state`] fails, [`startup_error_router`] returns a minimal router +//! that responds to all routes with the startup error. This router does **not** +//! attach middleware. Startup-error responses may still receive entry-point +//! finalization (geo and TS headers) when settings can be reloaded via +//! [`trusted_server_core::settings_data::get_settings`]; if settings loading itself +//! fails, they are returned without geo or TS headers. + +use std::sync::Arc; + +use edgezero_adapter_fastly::FastlyRequestContext; +use edgezero_core::app::Hooks; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{ + header, HandlerFuture, HeaderValue, Method, Request, Response, StatusCode, +}; +use edgezero_core::router::RouterService; +use error_stack::Report; +use trusted_server_core::auction::endpoints::handle_auction; +use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator}; +use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; +use trusted_server_core::ec::batch_sync::handle_batch_sync; +use trusted_server_core::ec::consent::ec_consent_withdrawn; +use trusted_server_core::ec::device::DeviceSignals; +use trusted_server_core::ec::identify::{cors_preflight_identify, handle_identify}; +use trusted_server_core::ec::kv::KvIdentityGraph; +use trusted_server_core::ec::rate_limiter::{FastlyRateLimiter, RATE_COUNTER_NAME}; +use trusted_server_core::ec::registry::PartnerRegistry; +use trusted_server_core::ec::EcContext; +use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; +use trusted_server_core::http_util::is_navigation_request; +use trusted_server_core::integrations::{ + IntegrationRegistry, ProxyDispatchInput, RequestFilterEffects, RequestFilterRegistryInput, + RequestFilterRegistryOutcome, +}; +use trusted_server_core::platform::{ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices}; +use trusted_server_core::proxy::{ + handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy, + handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, stream_asset_body, + AssetProxyCachePolicy, +}; +use trusted_server_core::publisher::{ + handle_publisher_request, handle_tsjs_dynamic, BoundedWriter, +}; +use trusted_server_core::request_signing::{ + handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, + handle_verify_signature, +}; +use trusted_server_core::settings::{ProxyAssetRoute, Settings}; +use trusted_server_core::settings_data::get_settings; + +use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; +use crate::platform::{ + open_kv_store, FastlyPlatformBackend, FastlyPlatformConfigStore, FastlyPlatformGeo, + FastlyPlatformHttpClient, FastlyPlatformSecretStore, UnavailableKvStore, +}; + +// --------------------------------------------------------------------------- +// AppState +// --------------------------------------------------------------------------- + +/// Application state built once per Wasm instance and shared for its lifetime. +/// +/// In Fastly Compute each request spawns a new Wasm instance, so this struct is +/// effectively per-request. It holds pre-parsed settings and all service handles. +pub(crate) struct AppState { + pub(crate) settings: Arc, + pub(crate) orchestrator: Arc, + pub(crate) registry: Arc, + pub(crate) default_kv_store: Arc, +} + +/// 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. +pub(crate) fn build_state() -> Result, Report> { + build_state_from_settings(get_settings()?) +} + +pub(crate) fn build_state_from_settings( + settings: Settings, +) -> Result, Report> { + let orchestrator = build_orchestrator(&settings)?; + let registry = IntegrationRegistry::new(&settings)?; + + let default_kv_store = Arc::new(UnavailableKvStore) as Arc; + + Ok(Arc::new(AppState { + settings: Arc::new(settings), + orchestrator: Arc::new(orchestrator), + registry: Arc::new(registry), + default_kv_store, + })) +} + +/// Resolves per-request consent KV store services for routes that read consent data. +/// +/// When `settings.consent.consent_store` is configured and the named KV store cannot +/// be opened, returns `Err` so the caller can respond with 503 (fail-closed). This +/// matches the legacy `route_request` behavior where a misconfigured consent store +/// makes consent-dependent routes unavailable rather than proceeding without consent. +/// +/// # Errors +/// +/// Returns an error when the configured consent store cannot be opened. +pub(crate) fn runtime_services_for_consent_route( + settings: &Settings, + runtime_services: &RuntimeServices, +) -> Result> { + let Some(store_name) = settings.consent.consent_store.as_deref() else { + return Ok(runtime_services.clone()); + }; + + open_kv_store(store_name) + .map(|store| runtime_services.clone().with_kv_store(store)) + .map_err(|e| { + Report::new(TrustedServerError::KvStore { + store_name: store_name.to_string(), + message: e.to_string(), + }) + }) +} + +// --------------------------------------------------------------------------- +// Per-request RuntimeServices +// --------------------------------------------------------------------------- + +/// Construct per-request [`RuntimeServices`] from the `EdgeZero` request context. +/// +/// Prefers the full [`ClientInfo`] captured by `edgezero_main` from the original +/// `FastlyRequest` (TLS protocol/cipher, JA4, H2 fingerprint, and server +/// hostname/region) and stored in the request extensions — the metadata +/// integration bot protection (e.g. `DataDome`) serializes, which the +/// reconstructed `EdgeZero` request cannot expose. Falls back to the client IP +/// from the dispatch-inserted [`FastlyRequestContext`] when the extension is +/// absent (e.g. tests that dispatch without the entry point). Scheme detection +/// continues to rely on the trusted `fastly-ssl` header injected by +/// `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), + ..ClientInfo::default() + }); + + RuntimeServices::builder() + .config_store(Arc::new(FastlyPlatformConfigStore)) + .secret_store(Arc::new(FastlyPlatformSecretStore)) + .kv_store(Arc::clone(&state.default_kv_store)) + .backend(Arc::new(FastlyPlatformBackend)) + .http_client(Arc::new(FastlyPlatformHttpClient)) + .geo(Arc::new(FastlyPlatformGeo)) + .client_info(client_info) + .build() +} + +fn publisher_fallback_methods() -> [Method; 7] { + [ + Method::GET, + Method::POST, + Method::HEAD, + Method::OPTIONS, + Method::PUT, + Method::PATCH, + Method::DELETE, + ] +} + +fn uses_dynamic_tsjs_fallback(method: &Method, path: &str) -> bool { + *method == Method::GET && path.starts_with("/static/tsjs=") +} + +// --------------------------------------------------------------------------- +// EC request state +// --------------------------------------------------------------------------- + +/// EC state threaded from route handlers to the `main.rs` entry point via +/// response extensions. +/// +/// `edgezero_main` pops this from the response after dispatch and runs +/// [`trusted_server_core::ec::finalize::ec_finalize_response`] plus the +/// pull-sync hook on the converted fastly response — the same EC response +/// lifecycle the legacy path drives through `RouteResult`. +#[derive(Clone)] +pub(crate) struct EcFinalizeState { + pub(crate) ec_context: EcContext, + pub(crate) finalize_kv_graph: Option, + pub(crate) eids_cookie: Option, + pub(crate) sharedid_cookie: Option, + pub(crate) is_real_browser: bool, + /// Per-request services carried to the entry point so the pull-sync + /// dispatcher can reuse the same platform HTTP client. + pub(crate) services: RuntimeServices, +} + +/// Per-request EC identity state built before dispatch, mirroring the +/// pre-routing prelude of the legacy `route_request` (device signals, bot +/// gate, cookie capture, consent/geo-aware [`EcContext`], and KV graph +/// gating). +struct EcRequestState { + ec_context: EcContext, + kv_graph: Option, + finalize_kv_graph: Option, + eids_cookie: Option, + sharedid_cookie: Option, + is_real_browser: bool, + services: RuntimeServices, + /// Geo lookup result reused by the pre-route request-filter step so it does + /// not repeat the lookup the legacy path also shares between EC setup and + /// filtering. + geo_info: Option, + /// Error from [`EcContext`] creation. When set, handlers return this as + /// the response without running the route handler (legacy parity: the + /// legacy path short-circuits with an error response and a default + /// context). + setup_error: Option>, +} + +impl EcRequestState { + fn into_finalize_state(self) -> EcFinalizeState { + EcFinalizeState { + ec_context: self.ec_context, + finalize_kv_graph: self.finalize_kv_graph, + eids_cookie: self.eids_cookie, + sharedid_cookie: self.sharedid_cookie, + is_real_browser: self.is_real_browser, + services: self.services, + } + } +} + +/// Derives device signals from the request's `User-Agent` header. +/// +/// Used as the fallback when no entry-point-captured [`DeviceSignals`] are +/// present in the request extensions (see [`device_signals_for`]). TLS and H2 +/// fingerprints cannot be reconstructed from the `EdgeZero` request, so this +/// fallback is UA-only — matching the signals the legacy path effectively had +/// after request conversion. +fn derive_request_device_signals(req: &Request) -> DeviceSignals { + let user_agent = req + .headers() + .get(header::USER_AGENT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + DeviceSignals::derive(user_agent, None, None) +} + +/// Returns the device signals for an `EdgeZero` request. +/// +/// `edgezero_main` derives [`DeviceSignals`] from the original `FastlyRequest` +/// — the only place Fastly's TLS JA4 and HTTP/2 fingerprint accessors return +/// real values — and stores them in the request extensions. Reading them back +/// here preserves the bot gate's browser classification, which the `EdgeZero` +/// request cannot expose on its own. Falls back to UA-only +/// [`derive_request_device_signals`] when the extension is absent (e.g. in +/// tests that dispatch without the entry point). +fn device_signals_for(req: &Request) -> DeviceSignals { + req.extensions() + .get::() + .cloned() + .unwrap_or_else(|| derive_request_device_signals(req)) +} + +/// Builds the per-request EC state, mirroring the pre-routing prelude of the +/// legacy `route_request` step by step. +fn build_ec_request_state( + settings: &Settings, + services: &RuntimeServices, + req: &Request, +) -> EcRequestState { + let device_signals = device_signals_for(req); + let is_real_browser = device_signals.looks_like_browser(); + if !is_real_browser { + log::info!( + "Bot gate: blocking EC operations (ja4={:?}, platform={:?}, is_mobile={})", + device_signals.ja4_class, + device_signals.platform_class, + device_signals.is_mobile, + ); + } + + let eids_cookie = crate::extract_cookie_value(req, COOKIE_TS_EIDS); + let sharedid_cookie = crate::extract_cookie_value(req, COOKIE_SHAREDID); + + let geo_info = services + .geo() + .lookup(services.client_info().client_ip) + .unwrap_or_else(|e| { + log::warn!("geo lookup failed during EC setup: {e}"); + None + }); + + let (ec_context, setup_error) = + match EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) { + Ok(mut context) => { + context.set_device_signals(device_signals); + (context, None) + } + Err(report) => (EcContext::default(), Some(report)), + }; + + // 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 finalize_kv_graph = if setup_error.is_none() + && (is_real_browser || ec_consent_withdrawn(ec_context.consent())) + { + kv_graph.clone() + } else { + None + }; + let kv_graph = if is_real_browser { kv_graph } else { None }; + + EcRequestState { + ec_context, + kv_graph, + finalize_kv_graph, + eids_cookie, + sharedid_cookie, + is_real_browser, + services: services.clone(), + geo_info, + setup_error, + } +} + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +/// Result of the pre-route integration request-filter step. +enum PreRoute { + /// A filter elected to respond (or errored); return this response without + /// running the route handler. Carries the accumulated response effects. + ShortCircuit { + response: Response, + effects: RequestFilterEffects, + }, + /// Continue to the route handler; apply these response effects afterward. + Continue { effects: RequestFilterEffects }, +} + +/// Runs the integration request-filter pipeline before route dispatch. +/// +/// Mirrors the legacy `route_request` ordering: filters run after auth +/// (`AuthMiddleware` on this path) and before route matching. Request header +/// mutations are applied to `req` so the routed handler observes them; response +/// effects are returned for the entry point to apply after EC finalization. A +/// filter that responds (e.g. a `DataDome` challenge) short-circuits routing. +async fn run_pre_route_filters( + state: &AppState, + services: &RuntimeServices, + req: &mut Request, + geo_info: Option<&GeoInfo>, +) -> PreRoute { + match state + .registry + .filter_request(RequestFilterRegistryInput { + settings: &state.settings, + services, + req, + geo_info, + }) + .await + { + Ok(RequestFilterRegistryOutcome::Continue(effects)) => PreRoute::Continue { effects }, + Ok(RequestFilterRegistryOutcome::Respond { response, effects }) => PreRoute::ShortCircuit { + response: *response, + effects, + }, + Err(report) => { + log::error!("Failed to run integration request filters: {report:?}"); + PreRoute::ShortCircuit { + response: http_error(&report), + effects: RequestFilterEffects::default(), + } + } + } +} + +/// Attaches the EC finalize state and any non-empty request-filter response +/// effects to a dispatched response via extensions, so `edgezero_main` can run +/// EC finalization and apply the filter effects after it (legacy ordering). +fn attach_dispatch_extensions( + mut response: Response, + ec: EcRequestState, + effects: RequestFilterEffects, +) -> Response { + response.extensions_mut().insert(ec.into_finalize_state()); + if !effects.response_headers.is_empty() { + response.extensions_mut().insert(effects); + } + response +} + +async fn execute_named( + state: Arc, + ctx: RequestContext, + handler: NamedRouteHandler, +) -> Result { + let services = build_per_request_services(&state, &ctx); + let mut req = ctx.into_request(); + + // S2S batch sync uses Bearer auth (not EC cookies), so it skips EC + // context creation entirely — mirroring the dedicated early arm in the + // 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)); + } + + let mut ec = build_ec_request_state(&state.settings, &services, &req); + // EcContext creation errors short-circuit before filters, mirroring legacy: + // the legacy path returns its error response before running filter_request. + if let Some(report) = ec.setup_error.take() { + let response = http_error(&report); + return Ok(attach_dispatch_extensions( + response, + ec, + RequestFilterEffects::default(), + )); + } + + let effects = + match run_pre_route_filters(&state, &services, &mut req, ec.geo_info.as_ref()).await { + PreRoute::ShortCircuit { response, effects } => { + return Ok(attach_dispatch_extensions(response, ec, effects)); + } + PreRoute::Continue { effects } => effects, + }; + + let response = run_named_route(&state, &services, req, handler, &mut ec) + .await + .unwrap_or_else(|e| http_error(&e)); + Ok(attach_dispatch_extensions(response, ec, effects)) +} + +async fn run_named_route( + state: &AppState, + services: &RuntimeServices, + req: Request, + handler: NamedRouteHandler, + ec: &mut EcRequestState, +) -> Result> { + match handler { + NamedRouteHandler::TrustedServerDiscovery => { + handle_trusted_server_discovery(&state.settings, services, req) + } + NamedRouteHandler::VerifySignature => { + handle_verify_signature(&state.settings, services, req) + } + NamedRouteHandler::RotateKey => handle_rotate_key(&state.settings, services, req), + NamedRouteHandler::DeactivateKey => handle_deactivate_key(&state.settings, services, req), + NamedRouteHandler::BatchSync => { + // Dispatched by execute_named before EC state is built. + unreachable!("batch-sync should be handled by run_batch_sync") + } + NamedRouteHandler::Identify => { + if req.method() == Method::OPTIONS { + cors_preflight_identify(&state.settings, &req) + } else { + let kv = crate::require_identity_graph(&state.settings)?; + let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; + handle_identify( + &state.settings, + &kv, + &partner_registry, + &req, + &ec.ec_context, + ) + } + } + NamedRouteHandler::Auction => { + // The auction reads consent data, so the consent KV store must be + // available — fail closed with 503 when it is configured but + // cannot be opened, matching legacy behavior. + let consent_services = runtime_services_for_consent_route(&state.settings, services)?; + let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; + let registry_ref = if partner_registry.is_empty() { + None + } else { + Some(&partner_registry) + }; + handle_auction( + &state.settings, + &state.orchestrator, + ec.kv_graph.as_ref(), + registry_ref, + &ec.ec_context, + &consent_services, + req, + ) + .await + } + NamedRouteHandler::FirstPartyProxy => { + handle_first_party_proxy(&state.settings, services, req).await + } + NamedRouteHandler::FirstPartyClick => { + handle_first_party_click(&state.settings, services, req).await + } + NamedRouteHandler::FirstPartySign => { + handle_first_party_proxy_sign(&state.settings, services, req).await + } + NamedRouteHandler::FirstPartyProxyRebuild => { + handle_first_party_proxy_rebuild(&state.settings, services, req).await + } + } +} + +/// 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 { + 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 partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; + let limiter = FastlyRateLimiter::new(RATE_COUNTER_NAME); + handle_batch_sync(&kv, &partner_registry, &limiter, req) + }); + + let mut response = result.unwrap_or_else(|e| http_error(&e)); + // Legacy parity: batch-sync responses still pass through + // ec_finalize_response with a default EC context and no finalize KV graph. + response.extensions_mut().insert(EcFinalizeState { + ec_context: EcContext::default(), + finalize_kv_graph: None, + eids_cookie, + sharedid_cookie, + is_real_browser, + services: services.clone(), + }); + response +} + +async fn execute_fallback( + state: Arc, + ctx: RequestContext, +) -> Result { + let services = build_per_request_services(&state, &ctx); + let req = ctx.into_request(); + Ok(dispatch_fallback(&state, &services, req).await) +} + +async fn dispatch_fallback( + state: &AppState, + services: &RuntimeServices, + mut req: Request, +) -> Response { + let path = req.uri().path().to_string(); + let method = req.method().clone(); + + let mut ec = build_ec_request_state(&state.settings, services, &req); + if let Some(report) = ec.setup_error.take() { + let response = http_error(&report); + return attach_dispatch_extensions(response, ec, RequestFilterEffects::default()); + } + + // Pre-route integration request filters (DataDome protection, etc.) run + // before the route-type decision, matching legacy `route_request` ordering. + let effects = match run_pre_route_filters(state, services, &mut req, ec.geo_info.as_ref()).await + { + PreRoute::ShortCircuit { response, effects } => { + return attach_dispatch_extensions(response, ec, effects); + } + PreRoute::Continue { effects } => effects, + }; + + let result = if uses_dynamic_tsjs_fallback(&method, &path) { + handle_tsjs_dynamic(&req, &state.registry) + } else if state.registry.has_route(&method, &path) { + // Integration-proxy responses are not bounded by publisher.max_buffered_body_bytes. + // Only the handle_publisher_request branch below routes through + // resolve_publisher_response_buffered. Integration responses are small in practice + // and the EdgeZero flag is off by default; extend the cap here if that changes. + state + .registry + .handle_proxy(ProxyDispatchInput { + method: &method, + path: &path, + settings: &state.settings, + kv: ec.kv_graph.as_ref(), + ec_context: &mut ec.ec_context, + services, + req, + }) + .await + .unwrap_or_else(|| { + Err(Report::new(TrustedServerError::BadRequest { + message: format!("Unknown integration route: {path}"), + })) + }) + } else { + // Asset-route fallback (GET/HEAD), mirroring the legacy catch-all arm: + // matched asset paths proxy to the configured asset origin instead of the + // publisher origin. Must be checked after tsjs/integration routes and + // before the publisher fallback. Asset responses skip EC finalization + // (no EcFinalizeState attached), matching legacy's `should_finalize_ec = false`. + let matched_asset_route = matches!(method, Method::GET | Method::HEAD) + .then(|| state.settings.asset_route_for_path(&path)) + .flatten(); + if let Some(asset_route) = matched_asset_route { + return dispatch_asset_fallback(state, services, req, asset_route, &effects).await; + } + + // Generate an EC ID if needed — mirrors the legacy catch-all arm. + // Only for document navigations by recognised browsers; subresource + // requests may lack consent signals such as Sec-GPC. + if ec.is_real_browser && is_navigation_request(&req) { + if let Err(err) = ec + .ec_context + .generate_if_needed(&state.settings, ec.kv_graph.as_ref()) + { + log::warn!("EC generation failed for publisher proxy: {err:?}"); + } + } + + // Publisher pages read consent data, so the consent KV store must be + // available — fail closed with 503 when it is configured but cannot + // be opened, matching legacy behavior. + match runtime_services_for_consent_route(&state.settings, services) { + Ok(publisher_services) => { + handle_publisher_request(&state.settings, &state.registry, &publisher_services, req) + .await + .and_then(|pub_response| { + crate::resolve_publisher_response_buffered( + pub_response, + &method, + &state.settings, + &state.registry, + ) + }) + } + Err(e) => Err(e), + } + }; + + let response = result.unwrap_or_else(|e| http_error(&e)); + attach_dispatch_extensions(response, ec, effects) +} + +/// Returns `true` when an asset response should carry a buffered body and a +/// recomputed `Content-Length`. +/// +/// `HEAD` responses and bodiless statuses (204, 304) advertise the origin +/// representation length in their `Content-Length` header while carrying no +/// body. Rewriting that header to the buffered byte count (0) would corrupt the +/// metadata, so those responses keep the origin's `Content-Length` untouched. +fn asset_response_carries_body(method: &Method, status: StatusCode) -> bool { + *method != Method::HEAD + && status != StatusCode::NO_CONTENT + && status != StatusCode::NOT_MODIFIED +} + +/// Handles the asset-route fallback on the `EdgeZero` path, mirroring the legacy +/// `route_request` asset branch. +/// +/// Proxies the request to the configured asset origin and threads the +/// [`AssetProxyCachePolicy`] out via response extensions so `edgezero_main` +/// can reapply protected cache directives after finalization. EC finalization +/// is intentionally skipped: no [`EcFinalizeState`] is attached, matching the +/// legacy `should_finalize_ec = false` behavior for asset responses. +/// +/// Unlike legacy `route_request`, which streams asset bodies straight to the +/// client with no cap, the `EdgeZero` path buffers them: `edgezero_main` +/// converts the whole response before sending, so there is no streaming seam +/// yet. The buffer is bounded by `publisher.max_buffered_body_bytes` as an +/// interim Wasm-heap OOM guard. Reusing the publisher cap and restoring +/// uncapped streaming are both resolved by the streaming cutover (issue #495); +/// whether assets get a dedicated cap is deferred to that work. +async fn dispatch_asset_fallback( + state: &AppState, + services: &RuntimeServices, + req: Request, + asset_route: &ProxyAssetRoute, + effects: &RequestFilterEffects, +) -> Response { + log::info!("No explicit route matched; proxying via configured asset route"); + + let method = req.method().clone(); + + match handle_asset_proxy_request(&state.settings, services, req, asset_route).await { + Ok(asset_response) => { + let cache_policy = asset_response.cache_policy(); + let (mut response, stream_body) = asset_response.into_response_and_body(); + + if let Some(body) = stream_body { + match buffer_asset_body(body, state.settings.publisher.max_buffered_body_bytes) + .await + { + Ok(bytes) => { + // Preserve the origin's Content-Length for HEAD and + // bodiless statuses; only body-bearing responses get a + // recomputed length and the buffered body attached. + if asset_response_carries_body(&method, response.status()) { + response.headers_mut().insert( + header::CONTENT_LENGTH, + HeaderValue::from(bytes.len() as u64), + ); + *response.body_mut() = edgezero_core::body::Body::from(bytes); + } + } + Err(report) => { + let mut response = http_error(&report); + response + .extensions_mut() + .insert(AssetProxyCachePolicy::NoStorePrivate); + attach_request_filter_effects(&mut response, effects); + return response; + } + } + } + + response.extensions_mut().insert(cache_policy); + attach_request_filter_effects(&mut response, effects); + response + } + Err(report) => { + let mut response = http_error(&report); + response + .extensions_mut() + .insert(AssetProxyCachePolicy::NoStorePrivate); + attach_request_filter_effects(&mut response, effects); + response + } + } +} + +/// Attaches non-empty request-filter response effects to an asset response. +/// +/// Asset responses skip EC finalization but still carry filter effects so the +/// entry point applies them after finalization, matching the legacy asset +/// streaming path which applies `request_filter_effects` to every asset +/// response. +fn attach_request_filter_effects(response: &mut Response, effects: &RequestFilterEffects) { + if !effects.response_headers.is_empty() { + response.extensions_mut().insert(effects.clone()); + } +} + +/// Buffers a streaming asset body into memory, bounded by `max_bytes` +/// (the interim `publisher.max_buffered_body_bytes` OOM guard; see +/// [`dispatch_asset_fallback`]). +/// +/// # Errors +/// +/// Returns an error if the body exceeds the configured cap or the underlying +/// stream yields an error. +async fn buffer_asset_body( + body: edgezero_core::body::Body, + max_bytes: usize, +) -> Result, Report> { + let mut output = BoundedWriter::new(max_bytes); + stream_asset_body(body, &mut output).await?; + Ok(output.into_inner()) +} + +// --------------------------------------------------------------------------- +// Error helper +// --------------------------------------------------------------------------- + +/// Convert a [`Report`] into an HTTP [`Response`], +/// mirroring [`crate::http_error_response`] exactly. +/// +/// The near-identical function in `main.rs` is intentional: the legacy path +/// uses fastly HTTP types while this path uses `edgezero_core` types. +pub(crate) fn http_error(report: &Report) -> Response { + let root_error = report.current_context(); + log::error!("Error occurred: {:?}", report); + + let body = edgezero_core::body::Body::from(format!("{}\n", root_error.user_message())); + let mut response = Response::new(body); + *response.status_mut() = root_error.status_code(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response +} + +// --------------------------------------------------------------------------- +// Startup error fallback +// --------------------------------------------------------------------------- + +/// Returns a [`RouterService`] that responds to every registered route with the startup error. +/// +/// Called when [`build_state`] fails so that request handling degrades to a +/// structured HTTP error response rather than an unrecoverable panic. +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 = move |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) } + } + }; + + let mut router = RouterService::builder(); + for method in publisher_fallback_methods() { + router = router.route("/", method.clone(), make(Arc::clone(&message))); + router = router.route("/{*rest}", method, make(Arc::clone(&message))); + } + router.build() +} + +// --------------------------------------------------------------------------- +// Route registration +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy)] +enum NamedRouteHandler { + TrustedServerDiscovery, + VerifySignature, + RotateKey, + DeactivateKey, + BatchSync, + Identify, + Auction, + FirstPartyProxy, + FirstPartyClick, + FirstPartySign, + FirstPartyProxyRebuild, +} + +struct NamedRoute { + path: &'static str, + primary_methods: &'static [Method], + handler: NamedRouteHandler, +} + +const NAMED_ROUTES: &[NamedRoute] = &[ + NamedRoute { + path: "/.well-known/trusted-server.json", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::TrustedServerDiscovery, + }, + NamedRoute { + path: "/verify-signature", + primary_methods: &[Method::POST], + handler: NamedRouteHandler::VerifySignature, + }, + NamedRoute { + path: "/_ts/admin/keys/rotate", + primary_methods: &[Method::POST], + handler: NamedRouteHandler::RotateKey, + }, + NamedRoute { + path: "/_ts/admin/keys/deactivate", + primary_methods: &[Method::POST], + handler: NamedRouteHandler::DeactivateKey, + }, + // Legacy aliases without the `/_ts` prefix, kept for parity with + // route_request in main.rs. Auth coverage comes from settings.handlers + // (enforced by AuthMiddleware), same as on the legacy path. + NamedRoute { + path: "/admin/keys/rotate", + primary_methods: &[Method::POST], + handler: NamedRouteHandler::RotateKey, + }, + NamedRoute { + path: "/admin/keys/deactivate", + primary_methods: &[Method::POST], + handler: NamedRouteHandler::DeactivateKey, + }, + NamedRoute { + path: "/_ts/api/v1/batch-sync", + primary_methods: &[Method::POST], + handler: NamedRouteHandler::BatchSync, + }, + NamedRoute { + path: "/_ts/api/v1/identify", + primary_methods: &[Method::GET, Method::OPTIONS], + handler: NamedRouteHandler::Identify, + }, + NamedRoute { + path: "/auction", + primary_methods: &[Method::POST], + handler: NamedRouteHandler::Auction, + }, + NamedRoute { + path: "/first-party/proxy", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::FirstPartyProxy, + }, + NamedRoute { + path: "/first-party/click", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::FirstPartyClick, + }, + NamedRoute { + path: "/first-party/sign", + primary_methods: &[Method::GET, Method::POST], + handler: NamedRouteHandler::FirstPartySign, + }, + NamedRoute { + path: "/first-party/proxy-rebuild", + primary_methods: &[Method::POST], + handler: NamedRouteHandler::FirstPartyProxyRebuild, + }, +]; + +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_named(state, ctx, handler)) + } +} + +fn fallback_route_handler( + state: Arc, +) -> impl Fn(RequestContext) -> HandlerFuture + Clone + Send + Sync + 'static { + move |ctx: RequestContext| { + let state = Arc::clone(&state); + Box::pin(execute_fallback(state, ctx)) + } +} + +// --------------------------------------------------------------------------- +// TrustedServerApp +// --------------------------------------------------------------------------- + +/// `EdgeZero` [`Hooks`] implementation for the Trusted Server application. +pub struct TrustedServerApp; + +impl TrustedServerApp { + fn routes_for_state(state: &Arc) -> RouterService { + let mut router = RouterService::builder() + .middleware(FinalizeResponseMiddleware::new( + Arc::clone(&state.settings), + Arc::new(FastlyPlatformGeo), + )) + .middleware(AuthMiddleware::new(Arc::clone(&state.settings))); + + let fallback_handler = fallback_route_handler(Arc::clone(state)); + + // matchit prefers exact path+method over a wildcard catch-all. Each + // named route is registered from this single table, then every + // non-primary publisher fallback method is registered from the same + // row. Adding a named route now requires editing only this table. + for route in NAMED_ROUTES { + for method in route.primary_methods { + router = router.route( + route.path, + method.clone(), + named_route_handler(Arc::clone(state), route.handler), + ); + } + + for method in publisher_fallback_methods() { + if !route.primary_methods.contains(&method) { + router = router.route(route.path, method, fallback_handler.clone()); + } + } + } + + // matchit's `/{*rest}` does not match the bare root `/` — register + // explicit root routes so `/` reaches the publisher fallback too. + for method in publisher_fallback_methods() { + router = router.route("/", method.clone(), fallback_handler.clone()); + router = router.route("/{*rest}", method, fallback_handler.clone()); + } + + router.build() + } +} + +impl Hooks for TrustedServerApp { + fn name() -> &'static str { + "TrustedServer" + } + + 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); + } + }; + + Self::routes_for_state(&state) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::{build_state_from_settings, startup_error_router, AppState, TrustedServerApp}; + + use edgezero_core::body::Body; + use edgezero_core::http::{header, request_builder, Method, StatusCode}; + use edgezero_core::router::RouterService; + use std::net::{IpAddr, Ipv4Addr}; + use std::sync::Mutex; + + use error_stack::Report; + use futures::executor::block_on; + use serde_json::json; + use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; + use trusted_server_core::ec::device::DeviceSignals; + use trusted_server_core::error::TrustedServerError; + use trusted_server_core::integrations::{ + HeaderMutation, IntegrationRegistry, IntegrationRequestFilter, RequestFilterDecision, + RequestFilterEffects, RequestFilterInput, + }; + use trusted_server_core::platform::ClientInfo; + use trusted_server_core::settings::Settings; + + fn settings_with_missing_consent_store() -> Settings { + Settings::from_toml( + r#" + [[handlers]] + path = "^/(_ts/)?admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.com" + cookie_domain = ".test-publisher.com" + origin_url = "https://origin.test-publisher.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + passphrase = "test-passphrase-at-least-32-bytes!!" + + [request_signing] + enabled = false + config_store_id = "test-config-store-id" + secret_store_id = "test-secret-store-id" + + [consent] + consent_store = "missing-consent-store" + + [integrations.prebid] + enabled = true + server_url = "https://test-prebid.com/openrtb2/auction" + + [integrations.datadome] + enabled = true + + [auction] + enabled = true + providers = ["prebid"] + timeout_ms = 2000 + "#, + ) + .expect("should parse EdgeZero app test settings") + } + + fn app_state_for_settings(settings: Settings) -> Arc { + build_state_from_settings(settings).expect("should build app state from settings") + } + + fn empty_request(method: Method, path: &str) -> edgezero_core::http::Request { + // Production requests arrive with absolute URIs from the fastly + // adapter — mirror that here so URI-derived logic behaves the same. + let uri = format!("https://test-publisher.com{path}"); + request_builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .expect("should build request") + } + + fn test_settings() -> Settings { + Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [[handlers]] + path = "^/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.com" + cookie_domain = ".test-publisher.com" + origin_url = "https://origin.test-publisher.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + passphrase = "test-secret-key-32-bytes-minimum" + + [request_signing] + enabled = false + config_store_id = "test-config-store-id" + secret_store_id = "test-secret-store-id" + + [integrations.prebid] + enabled = true + server_url = "https://test-prebid.com/openrtb2/auction" + + [auction] + enabled = true + providers = ["prebid"] + timeout_ms = 2000 + "#, + ) + .expect("should parse test settings") + } + + fn test_router() -> RouterService { + let state = build_state_from_settings(test_settings()).expect("should build test state"); + TrustedServerApp::routes_for_state(&state) + } + + /// Builds a router whose `AppState` uses a registry containing the given + /// request filters (and no routes), so dispatch-level request-filter + /// behavior can be exercised without a real integration. + fn router_with_request_filters( + filters: Vec>, + ) -> RouterService { + let settings = test_settings(); + let orchestrator = trusted_server_core::auction::build_orchestrator(&settings) + .expect("should build orchestrator"); + let registry = IntegrationRegistry::from_request_filters(filters); + let default_kv_store = + Arc::new(crate::platform::UnavailableKvStore) as Arc; + let state = Arc::new(super::AppState { + settings: Arc::new(settings), + orchestrator: Arc::new(orchestrator), + registry: Arc::new(registry), + default_kv_store, + }); + TrustedServerApp::routes_for_state(&state) + } + + /// Continues routing while mutating the request and emitting a response + /// header effect — mirrors `DataDome`'s allow path. + struct RecordingRequestFilter; + + #[async_trait::async_trait(?Send)] + impl IntegrationRequestFilter for RecordingRequestFilter { + fn integration_id(&self) -> &'static str { + "recording" + } + + async fn filter_request( + &self, + _input: RequestFilterInput<'_>, + ) -> Result> { + Ok(RequestFilterDecision::Continue(RequestFilterEffects { + request_headers: vec![HeaderMutation::set("x-filter-ran", "1")], + response_headers: vec![HeaderMutation::set("x-filter-effect", "applied")], + })) + } + } + + /// Short-circuits routing with a 403 — mirrors a `DataDome` challenge/block. + struct ChallengeRequestFilter; + + #[async_trait::async_trait(?Send)] + impl IntegrationRequestFilter for ChallengeRequestFilter { + fn integration_id(&self) -> &'static str { + "challenge" + } + + async fn filter_request( + &self, + _input: RequestFilterInput<'_>, + ) -> Result> { + let mut response = edgezero_core::http::Response::new(Body::from("blocked")); + *response.status_mut() = StatusCode::FORBIDDEN; + Ok(RequestFilterDecision::Respond { + response: Box::new(response), + effects: RequestFilterEffects { + request_headers: Vec::new(), + response_headers: vec![HeaderMutation::set("x-challenge", "1")], + }, + }) + } + } + + /// Records the [`ClientInfo`] a request filter observes via its + /// [`RequestFilterInput`], so a test can assert the entry-point-captured + /// bot-protection metadata reaches integration filters like `DataDome`. + struct ClientInfoCapturingFilter(Arc>>); + + #[async_trait::async_trait(?Send)] + impl IntegrationRequestFilter for ClientInfoCapturingFilter { + fn integration_id(&self) -> &'static str { + "client-info-capture" + } + + async fn filter_request( + &self, + input: RequestFilterInput<'_>, + ) -> Result> { + *self.0.lock().expect("should lock captured client info") = + Some(input.services.client_info().clone()); + Ok(RequestFilterDecision::Continue(RequestFilterEffects { + request_headers: Vec::new(), + response_headers: Vec::new(), + })) + } + } + + #[test] + fn startup_error_router_handles_head_and_options() { + let report = Report::new(TrustedServerError::BadRequest { + message: "startup failed".to_string(), + }); + let router = startup_error_router(&report); + + let head_response = block_on(router.oneshot(empty_request(Method::HEAD, "/"))); + let options_response = block_on(router.oneshot(empty_request(Method::OPTIONS, "/any"))); + + assert_eq!( + head_response.status(), + StatusCode::BAD_REQUEST, + "HEAD should use the degraded startup-error response" + ); + assert_eq!( + options_response.status(), + StatusCode::BAD_REQUEST, + "OPTIONS should use the degraded startup-error response" + ); + assert_eq!( + head_response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("text/plain; charset=utf-8"), + "startup errors should stay plain-text for HEAD requests" + ); + assert_eq!( + options_response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("text/plain; charset=utf-8"), + "startup errors should stay plain-text for OPTIONS requests" + ); + } + + #[test] + fn dynamic_tsjs_fallback_is_get_only() { + assert!( + super::uses_dynamic_tsjs_fallback(&Method::GET, "/static/tsjs=tsjs-unified.js"), + "GET should use the dynamic tsjs shortcut" + ); + assert!( + !super::uses_dynamic_tsjs_fallback(&Method::HEAD, "/static/tsjs=tsjs-unified.js"), + "HEAD should fall through to the publisher/integration fallback" + ); + assert!( + !super::uses_dynamic_tsjs_fallback(&Method::OPTIONS, "/static/tsjs=tsjs-unified.js"), + "OPTIONS should fall through to the publisher/integration fallback" + ); + } + + #[test] + fn asset_response_carries_body_preserves_bodiless_content_length() { + // GET/200 buffers a body and recomputes Content-Length. + assert!( + super::asset_response_carries_body(&Method::GET, StatusCode::OK), + "a GET 200 asset response should carry a buffered body" + ); + // HEAD advertises the origin representation length with no body — the + // recomputed (zero) length must not overwrite it. + assert!( + !super::asset_response_carries_body(&Method::HEAD, StatusCode::OK), + "HEAD asset responses must preserve the origin Content-Length" + ); + // Bodiless statuses keep their origin metadata regardless of method. + assert!( + !super::asset_response_carries_body(&Method::GET, StatusCode::NO_CONTENT), + "204 responses must preserve the origin Content-Length" + ); + assert!( + !super::asset_response_carries_body(&Method::GET, StatusCode::NOT_MODIFIED), + "304 responses must preserve the origin Content-Length" + ); + } + + // --------------------------------------------------------------------------- + // Full EdgeZero dispatch-path tests + // --------------------------------------------------------------------------- + + #[test] + fn dispatch_auth_rejected_401_carries_finalize_headers() { + // Verifies FinalizeResponseMiddleware is outermost: an auth-rejected 401 + // must still carry standard TS headers before reaching the client. + // + // Test settings protect `^/(_ts/)?admin` with basic-auth. Sending the + // request without an Authorization header causes AuthMiddleware to + // short-circuit with a 401, which then bubbles through + // FinalizeResponseMiddleware for header injection. + // + // This is safe to run without Viceroy: enforce_basic_auth is pure Rust + // (reads settings + request headers only) and FastlyPlatformGeo.lookup(None) + // short-circuits without calling any Fastly ABI. + let router = test_router(); + let req = empty_request(Method::POST, "/_ts/admin/keys/rotate"); + + let response = block_on(router.oneshot(req)); + + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "request without credentials should be rejected" + ); + assert_eq!( + response + .headers() + .get(HEADER_X_GEO_INFO_AVAILABLE) + .and_then(|v| v.to_str().ok()), + Some("false"), + "FinalizeResponseMiddleware must run even for auth-rejected responses" + ); + } + + #[test] + fn dispatch_admin_alias_routes_are_registered_and_auth_gated() { + // Parity guard for the legacy non-`/_ts` admin aliases: both alias + // paths must be registered (no router-level 405) and protected by the + // `^/admin` handler in the test settings, mirroring how legacy + // route_request applies enforce_basic_auth before its route match. + let router = test_router(); + + for path in ["/admin/keys/rotate", "/admin/keys/deactivate"] { + let req = empty_request(Method::POST, path); + + let response = block_on(router.oneshot(req)); + + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "POST {path} without credentials should be rejected by AuthMiddleware" + ); + } + } + + #[test] + fn dispatch_identify_options_routes_to_cors_preflight() { + // Parity guard: OPTIONS /_ts/api/v1/identify must reach + // cors_preflight_identify (200 for a request without an Origin + // header), not the publisher fallback, which would fail with a + // gateway error without a live backend. + let router = test_router(); + let response = + block_on(router.oneshot(empty_request(Method::OPTIONS, "/_ts/api/v1/identify"))); + + assert_eq!( + response.status(), + StatusCode::OK, + "OPTIONS identify should be answered by the CORS preflight handler" + ); + } + + #[test] + fn dispatch_identify_get_routes_to_identity_handler() { + // Parity guard: GET /_ts/api/v1/identify must reach the identify + // handler chain. The test settings configure no ec.ec_store, so + // require_identity_graph fails with a KvStore error (503) — proving + // the request was NOT proxied to the publisher origin. + let router = test_router(); + let response = block_on(router.oneshot(empty_request(Method::GET, "/_ts/api/v1/identify"))); + + assert_eq!( + response.status(), + StatusCode::SERVICE_UNAVAILABLE, + "GET identify without ec_store should fail with the KvStore error, not a publisher proxy error" + ); + } + + #[test] + fn dispatch_batch_sync_routes_to_batch_sync_handler() { + // Parity guard: POST /_ts/api/v1/batch-sync must reach the batch-sync + // handler chain instead of forwarding the request (body and + // Authorization header included) to the publisher origin. With no + // ec.ec_store configured, require_identity_graph fails with a KvStore + // error (503). + let router = test_router(); + let response = + block_on(router.oneshot(empty_request(Method::POST, "/_ts/api/v1/batch-sync"))); + + assert_eq!( + response.status(), + StatusCode::SERVICE_UNAVAILABLE, + "POST batch-sync without ec_store should fail with the KvStore error, not reach the publisher" + ); + } + + #[test] + fn dispatch_fallback_attaches_ec_finalize_state() { + // The publisher fallback must thread EC finalize state to the entry + // point via response extensions — even on error responses — so that + // edgezero_main can run ec_finalize_response and pull sync. + let router = test_router(); + let response = block_on(router.oneshot(empty_request(Method::GET, "/some-page"))); + + assert!( + response.extensions().get::().is_some(), + "publisher fallback responses should carry EcFinalizeState for entry-point EC finalization" + ); + } + + #[test] + fn browser_device_signals_from_extension_reach_ec_finalize_state() { + // Regression guard for the EdgeZero JA4/H2 signal loss: `edgezero_main` + // derives DeviceSignals from the original FastlyRequest (the only place + // get_tls_ja4/get_client_h2_fingerprint return real values) and stores + // them in the request extensions. A browser-shaped signal must survive + // dispatch so the EC bot gate classifies the request as a real browser + // and keeps the KV-backed generation/finalize path active. + let router = test_router(); + let mut req = empty_request(Method::GET, "/some-page"); + req.extensions_mut().insert(DeviceSignals::derive( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \ + (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + Some("t13d1516h2_8daaf6152771_b186095e22b6"), + Some("1:65536;2:0;4:6291456;6:262144"), + )); + + let response = block_on(router.oneshot(req)); + + let finalize = response + .extensions() + .get::() + .expect("fallback response should carry EcFinalizeState"); + assert!( + finalize.is_real_browser, + "browser-shaped JA4/H2 signals from the request extension must mark the request as a real browser" + ); + } + + #[test] + fn missing_device_signals_extension_classifies_as_non_browser() { + // Without the entry-point-captured signals, the reconstructed request + // cannot expose JA4/H2, so the bot gate falls back to non-browser. This + // documents the regression the extension threading fixes: the same + // request that looks like a browser above is treated as a bot here. + let router = test_router(); + let response = block_on(router.oneshot(empty_request(Method::GET, "/some-page"))); + + let finalize = response + .extensions() + .get::() + .expect("fallback response should carry EcFinalizeState"); + assert!( + !finalize.is_real_browser, + "a request without captured device signals should not be classified as a real browser" + ); + } + + #[test] + fn entry_point_client_info_reaches_request_filters() { + // Regression guard for the EdgeZero bot-protection metadata loss: + // `edgezero_main` captures the full ClientInfo (TLS protocol/cipher, JA4, + // H2 fingerprint, server hostname/region) from the original FastlyRequest + // and stores it in the request extensions. It must survive dispatch so + // integration request filters (e.g. DataDome) serialize the same signals + // the legacy path provides, not an empty payload. + let captured = Arc::new(Mutex::new(None)); + let filter = Arc::new(ClientInfoCapturingFilter(Arc::clone(&captured))) + as Arc; + let router = router_with_request_filters(vec![filter]); + + let mut req = empty_request(Method::GET, "/some-page"); + req.extensions_mut().insert(ClientInfo { + client_ip: Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), + tls_protocol: Some("TLSv1.3".to_string()), + tls_cipher: Some("TLS_AES_128_GCM_SHA256".to_string()), + tls_ja4: Some("t13d1516h2_8daaf6152771_b186095e22b6".to_string()), + h2_fingerprint: Some("1:65536;2:0;4:6291456;6:262144".to_string()), + server_hostname: Some("edge-test.example.net".to_string()), + server_region: Some("US-East".to_string()), + }); + + let _ = block_on(router.oneshot(req)); + + let observed = captured + .lock() + .expect("should lock captured client info") + .clone() + .expect("request filter should have observed the entry-point ClientInfo"); + assert_eq!( + observed.tls_protocol.as_deref(), + Some("TLSv1.3"), + "filter should see the captured TLS protocol" + ); + assert_eq!( + observed.tls_cipher.as_deref(), + Some("TLS_AES_128_GCM_SHA256"), + "filter should see the captured TLS cipher" + ); + assert_eq!( + observed.tls_ja4.as_deref(), + Some("t13d1516h2_8daaf6152771_b186095e22b6"), + "filter should see the captured JA4 fingerprint" + ); + assert_eq!( + observed.h2_fingerprint.as_deref(), + Some("1:65536;2:0;4:6291456;6:262144"), + "filter should see the captured H2 fingerprint" + ); + assert_eq!( + observed.server_hostname.as_deref(), + Some("edge-test.example.net"), + "filter should see the captured server hostname" + ); + assert_eq!( + observed.server_region.as_deref(), + Some("US-East"), + "filter should see the captured server region" + ); + } + + #[test] + fn dispatch_named_route_attaches_ec_finalize_state() { + // Named routes must also thread EC finalize state, mirroring how the + // legacy path finalizes every response with the pre-routing EcContext. + let router = test_router(); + let response = block_on(router.oneshot(empty_request( + Method::GET, + "/.well-known/trusted-server.json", + ))); + + assert!( + response + .extensions() + .get::() + .is_some(), + "named-route responses should carry EcFinalizeState for entry-point EC finalization" + ); + } + + #[test] + fn dispatch_head_on_named_get_route_falls_through_to_publisher_fallback() { + // Regression guard: HEAD /first-party/proxy must reach the publisher + // fallback, not return a router-level 405. Legacy route_request proxies + // every (method, path) combination not matched by a specific arm through + // to the publisher origin. + // + // Without a live backend the publisher proxy errors (502/503), but the + // important invariant is that the status is NOT 405. + let router = test_router(); + let req = empty_request(Method::HEAD, "/first-party/proxy"); + + let response = block_on(router.oneshot(req)); + + assert_ne!( + response.status(), + StatusCode::METHOD_NOT_ALLOWED, + "HEAD on a named GET path should reach the publisher fallback, not return 405" + ); + } + + #[test] + fn dispatch_auction_with_missing_consent_store_returns_503() { + let state = app_state_for_settings(settings_with_missing_consent_store()); + let router = TrustedServerApp::routes_for_state(&state); + let body = json!({ "adUnits": [] }).to_string(); + let req = request_builder() + .method(Method::POST) + .uri("/auction") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("should build auction request"); + + let response = block_on(router.oneshot(req)); + + assert_eq!( + response.status(), + StatusCode::SERVICE_UNAVAILABLE, + "auction route should fail closed when configured consent store cannot be opened" + ); + } + + #[test] + fn dispatch_unregistered_method_returns_405_at_router_level() { + // Documents the known router-level behavior for verbs outside the + // publisher_fallback_methods() list (e.g. TRACE, CONNECT): the RouterService + // returns 405 before the middleware chain runs, so FinalizeResponseMiddleware + // does not inject TS headers at this layer. + // + // The full-system guarantee (TS headers on ALL responses including these 405s) + // is maintained by the entry-point apply_finalize_headers call in main.rs. + let router = test_router(); + let req = empty_request( + Method::from_bytes(b"TRACE").expect("should parse TRACE"), + "/", + ); + + let response = block_on(router.oneshot(req)); + + assert_eq!( + response.status(), + StatusCode::METHOD_NOT_ALLOWED, + "unregistered method should return 405 from the router layer" + ); + assert!( + response + .headers() + .get(HEADER_X_GEO_INFO_AVAILABLE) + .is_none(), + "router-level 405 bypasses FinalizeResponseMiddleware; main.rs entry-point covers this" + ); + } + + #[test] + fn edgezero_missing_consent_store_breaks_only_consent_routes() { + let state = app_state_for_settings(settings_with_missing_consent_store()); + let router = TrustedServerApp::routes_for_state(&state); + + let admin_response = + block_on(router.oneshot(empty_request(Method::POST, "/admin/keys/rotate"))); + assert_eq!( + admin_response.status(), + StatusCode::UNAUTHORIZED, + "admin auth behavior should not depend on consent KV availability" + ); + + let auction_request = request_builder() + .method(Method::POST) + .uri("/auction") + .body(Body::from(r#"{"adUnits":[]}"#)) + .expect("should build auction request"); + let auction_response = block_on(router.oneshot(auction_request)); + assert_eq!( + auction_response.status(), + StatusCode::SERVICE_UNAVAILABLE, + "auction should fail closed when configured consent KV cannot be opened" + ); + + let publisher_response = + block_on(router.oneshot(empty_request(Method::GET, "/articles/example"))); + assert_eq!( + publisher_response.status(), + StatusCode::SERVICE_UNAVAILABLE, + "publisher fallback should fail closed when configured consent KV cannot be opened" + ); + + // Integration routes must NOT require the consent KV — runtime_services_for_consent_route + // is wired only into the publisher and auction branches of dispatch_fallback, not into + // the integration proxy branch. A missing consent store must not 503 integration routes. + let integration_response = + block_on(router.oneshot(empty_request(Method::GET, "/integrations/datadome/tags.js"))); + assert_ne!( + integration_response.status(), + StatusCode::SERVICE_UNAVAILABLE, + "integration routes should be unaffected by a missing consent KV store" + ); + } + + #[test] + fn dispatch_fallback_asset_route_skips_ec_finalization() { + // Parity guard for the configured asset-route fallback: a GET matching a + // proxy.asset_routes prefix must dispatch through the asset proxy (not the + // publisher fallback) and skip EC finalization. Without a live asset + // backend the proxy errors, but the response must carry the asset cache + // policy and must NOT carry an EcFinalizeState — proving the asset branch + // ran instead of the publisher fallback (which always attaches one). + let settings = Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.com" + cookie_domain = ".test-publisher.com" + origin_url = "https://origin.test-publisher.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + passphrase = "test-secret-key-32-bytes-minimum" + + [request_signing] + enabled = false + config_store_id = "test-config-store-id" + secret_store_id = "test-secret-store-id" + + [proxy] + + [[proxy.asset_routes]] + prefix = "/.image/" + origin_url = "https://assets.example.com" + "#, + ) + .expect("should parse asset-route settings"); + let state = build_state_from_settings(settings).expect("should build state"); + let router = TrustedServerApp::routes_for_state(&state); + + let response = block_on(router.oneshot(empty_request(Method::GET, "/.image/banner.png"))); + + assert!( + response + .extensions() + .get::() + .is_some(), + "asset-route responses should carry the asset cache policy" + ); + assert!( + response + .extensions() + .get::() + .is_none(), + "asset-route responses must skip EC finalization (no EcFinalizeState)" + ); + } + + #[test] + fn dispatch_runs_request_filter_and_threads_response_effects() { + // Regression guard for the EdgeZero request-filter bypass: the publisher + // fallback must run the integration request-filter pipeline (DataDome + // protection registers here) and thread the filter's response effects out + // via extensions so the entry point applies them after EC finalization. + // Without a live backend the publisher proxy errors, but the response must + // still carry the RequestFilterEffects the filter emitted — proving the + // filter ran on the dispatch path. + let router = router_with_request_filters(vec![Arc::new(RecordingRequestFilter)]); + let response = block_on(router.oneshot(empty_request(Method::GET, "/some-page"))); + + let effects = response + .extensions() + .get::() + .expect("dispatched response should carry request-filter effects"); + assert!( + effects + .response_headers + .iter() + .any(|m| m.name == "x-filter-effect"), + "the filter's response-header effect must be threaded out for the entry point to apply" + ); + } + + #[test] + fn dispatch_short_circuits_when_request_filter_responds() { + // Regression guard: a request filter that responds (a DataDome + // challenge/block) must short-circuit routing before the publisher + // fallback, return its own response, still carry EcFinalizeState (legacy + // parity: Respond keeps EC finalization), and thread its response effects. + let router = router_with_request_filters(vec![Arc::new(ChallengeRequestFilter)]); + let response = block_on(router.oneshot(empty_request(Method::GET, "/some-page"))); + + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "a filter Respond must short-circuit routing with its own response" + ); + assert!( + response + .extensions() + .get::() + .is_some(), + "a short-circuit filter response should still carry EcFinalizeState (legacy parity)" + ); + let effects = response + .extensions() + .get::() + .expect("short-circuit response should carry request-filter effects"); + assert!( + effects + .response_headers + .iter() + .any(|m| m.name == "x-challenge"), + "the filter's response-header effect must be threaded out" + ); + } +} diff --git a/crates/trusted-server-core/src/backend.rs b/crates/trusted-server-adapter-fastly/src/backend.rs similarity index 89% rename from crates/trusted-server-core/src/backend.rs rename to crates/trusted-server-adapter-fastly/src/backend.rs index 291c0863a..ae1e812c0 100644 --- a/crates/trusted-server-core/src/backend.rs +++ b/crates/trusted-server-adapter-fastly/src/backend.rs @@ -4,8 +4,8 @@ use error_stack::{Report, ResultExt}; use fastly::backend::Backend; use url::Url; -use crate::error::TrustedServerError; -use crate::host_header::validate_host_header_override_value; +use trusted_server_core::error::TrustedServerError; +use trusted_server_core::host_header::validate_host_header_override_value; /// Returns the default port for the given scheme (443 for HTTPS, 80 for HTTP). #[inline] @@ -214,10 +214,7 @@ impl<'a> BackendConfig<'a> { if self.scheme.eq_ignore_ascii_case("https") { builder = builder.enable_ssl().sni_hostname(self.host); if self.certificate_check { - builder = builder - .enable_ssl() - .sni_hostname(self.host) - .check_certificate(self.host); + builder = builder.check_certificate(self.host); } else { log::warn!( "INSECURE: certificate check disabled for backend: {}", @@ -255,11 +252,9 @@ impl<'a> BackendConfig<'a> { /// Parse an origin URL into its (scheme, host, port) components. /// - /// Centralises URL parsing so that [`from_url`](Self::from_url), - /// [`from_url_with_first_byte_timeout`](Self::from_url_with_first_byte_timeout), - /// [`from_url_with_host_header_override`](Self::from_url_with_host_header_override), - /// and [`backend_name_for_url`](Self::backend_name_for_url) share one - /// code-path. + /// Centralises URL parsing so that [`from_url`](Self::from_url) and + /// [`from_url_with_first_byte_timeout`](Self::from_url_with_first_byte_timeout) + /// share one code-path. fn parse_origin( origin_url: &str, ) -> Result<(String, String, Option), Report> { @@ -326,25 +321,6 @@ impl<'a> BackendConfig<'a> { ) } - /// Parse an origin URL and ensure a dynamic backend with a custom Host header. - /// - /// # Errors - /// - /// Returns an error if the URL cannot be parsed or lacks a host, or if - /// backend creation fails. - pub fn from_url_with_host_header_override( - origin_url: &str, - certificate_check: bool, - host_header_override: Option<&str>, - ) -> Result> { - Self::from_url_with_first_byte_timeout_and_host_header_override( - origin_url, - certificate_check, - DEFAULT_FIRST_BYTE_TIMEOUT, - host_header_override, - ) - } - fn from_url_with_first_byte_timeout_and_host_header_override( origin_url: &str, certificate_check: bool, @@ -360,37 +336,6 @@ impl<'a> BackendConfig<'a> { .host_header_override(host_header_override) .ensure() } - - /// Compute the backend name that - /// [`from_url_with_first_byte_timeout`](Self::from_url_with_first_byte_timeout) - /// would produce for the given URL and timeout, **without** registering a - /// backend. - /// - /// This is useful when callers need the name for mapping purposes (e.g. the - /// auction orchestrator correlating responses to providers) but want the - /// actual registration to happen later with specific settings. - /// - /// The `first_byte_timeout` must match the value that will be used at - /// registration time so that the predicted name is correct. - /// - /// # Errors - /// - /// Returns an error if the URL cannot be parsed or lacks a host. - pub fn backend_name_for_url( - origin_url: &str, - certificate_check: bool, - first_byte_timeout: Duration, - ) -> Result> { - let (scheme, host, port) = Self::parse_origin(origin_url)?; - - let (name, _) = BackendConfig::new(&scheme, &host) - .port(port) - .certificate_check(certificate_check) - .first_byte_timeout(first_byte_timeout) - .compute_name()?; - - Ok(name) - } } #[cfg(test)] diff --git a/crates/trusted-server-adapter-fastly/src/compat.rs b/crates/trusted-server-adapter-fastly/src/compat.rs new file mode 100644 index 000000000..8b1484489 --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/compat.rs @@ -0,0 +1,178 @@ +//! Compatibility bridge between `fastly` SDK types and `http` crate types. +//! +//! Contains only the functions used by the legacy `main()` entry point. +//! Relocated from `trusted-server-core` as part of removing all `fastly` crate +//! imports from the core library. + +use edgezero_core::body::Body as EdgeBody; +use edgezero_core::http::{Request as HttpRequest, RequestBuilder, Response as HttpResponse, Uri}; +use trusted_server_core::http_util::SPOOFABLE_FORWARDED_HEADERS; + +fn build_http_request(req: &fastly::Request, body: EdgeBody) -> HttpRequest { + // Does not panic in practice: a URL that Fastly accepts but `http::Uri` + // rejects degrades to "/" instead of aborting the Wasm instance. + let uri: Uri = req.get_url_str().parse().unwrap_or_else(|_| { + log::warn!( + "failed to parse fastly request URL '{}'; falling back to '/'", + req.get_url_str() + ); + Uri::from_static("/") + }); + + let mut builder: RequestBuilder = edgezero_core::http::request_builder() + .method(req.get_method().clone()) + .uri(uri); + + for (name, value) in req.get_headers() { + builder = builder.header(name.as_str(), value.as_bytes()); + } + + builder + .body(body) + .expect("should build http request from fastly request") +} + +/// Convert an owned `fastly::Request` into an [`HttpRequest`]. +/// +/// URLs that Fastly accepts but `http::Uri` rejects fall back to `/` with a +/// warning instead of panicking, preserving availability on the legacy path. +pub(crate) fn from_fastly_request(mut req: fastly::Request) -> HttpRequest { + let body = EdgeBody::from(req.take_body_bytes()); + build_http_request(&req, body) +} + +/// 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 headers that clients can spoof before any request-derived context +/// is built or the request is converted to core HTTP types. +pub(crate) fn sanitize_fastly_forwarded_headers(req: &mut fastly::Request) { + for &name in SPOOFABLE_FORWARDED_HEADERS { + if req.get_header(name).is_some() { + log::debug!("Stripped spoofable header: {name}"); + req.remove_header(name); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_fastly_forwarded_headers_strips_spoofable() { + let mut req = fastly::Request::get("https://example.com/"); + req.set_header("forwarded", "for=1.2.3.4"); + req.set_header("x-forwarded-host", "evil.example.com"); + req.set_header("x-forwarded-proto", "http"); + req.set_header("fastly-ssl", "1"); + req.set_header("host", "example.com"); + + sanitize_fastly_forwarded_headers(&mut req); + + assert!( + req.get_header("forwarded").is_none(), + "should strip forwarded" + ); + assert!( + req.get_header("x-forwarded-host").is_none(), + "should strip x-forwarded-host" + ); + assert!( + req.get_header("x-forwarded-proto").is_none(), + "should strip x-forwarded-proto" + ); + assert!( + req.get_header("fastly-ssl").is_none(), + "should strip fastly-ssl" + ); + assert!(req.get_header("host").is_some(), "should preserve host"); + } + + #[test] + fn from_fastly_request_falls_back_to_root_on_unparseable_url() { + // `url::Url` (used by fastly) has no length limit, but `http::Uri` + // rejects URIs longer than 65534 bytes — an accepted-by-Fastly, + // rejected-by-Uri divergence the fallback guards against. + let long_url = format!("https://example.com/{}", "a".repeat(70_000)); + let req = fastly::Request::get(long_url); + + let http_req = from_fastly_request(req); + + assert_eq!( + http_req.uri(), + &Uri::from_static("/"), + "should fall back to '/' when the fastly URL cannot be parsed as an http::Uri" + ); + } + + #[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/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 9cd591cbe..02753b976 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -1,19 +1,20 @@ +use std::sync::Arc; + +use edgezero_adapter_fastly::{into_core_request, FastlyConfigStore}; +use edgezero_core::app::Hooks as _; use edgezero_core::body::Body as EdgeBody; +use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::http::{ - header, HeaderName, HeaderValue, Method, Request as HttpRequest, Response as HttpResponse, + header, HeaderValue, Method, Request as HttpRequest, Response as HttpResponse, StatusCode, }; use error_stack::Report; use fastly::http::Method as FastlyMethod; use fastly::{Request as FastlyRequest, Response as FastlyResponse}; use trusted_server_core::auction::endpoints::handle_auction; -use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator}; +use trusted_server_core::auction::AuctionOrchestrator; use trusted_server_core::auth::enforce_basic_auth; -use trusted_server_core::compat; -use trusted_server_core::constants::{ - COOKIE_SHAREDID, COOKIE_TS_EIDS, ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, - HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_TS_ENV, HEADER_X_TS_VERSION, -}; +use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; use trusted_server_core::ec::batch_sync::handle_batch_sync; use trusted_server_core::ec::consent::ec_consent_withdrawn; use trusted_server_core::ec::device::DeviceSignals; @@ -33,6 +34,7 @@ use trusted_server_core::integrations::{ IntegrationRegistry, ProxyDispatchInput, RequestFilterEffects, RequestFilterRegistryInput, RequestFilterRegistryOutcome, }; +use trusted_server_core::platform::PlatformGeo as _; use trusted_server_core::platform::RuntimeServices; use trusted_server_core::proxy::{ handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy, @@ -40,7 +42,7 @@ use trusted_server_core::proxy::{ AssetProxyCachePolicy, }; use trusted_server_core::publisher::{ - handle_publisher_request, handle_tsjs_dynamic, stream_publisher_body, + handle_publisher_request, handle_tsjs_dynamic, stream_publisher_body, BoundedWriter, OwnedProcessResponseParams, PublisherResponse, }; use trusted_server_core::request_signing::{ @@ -50,21 +52,29 @@ use trusted_server_core::request_signing::{ use trusted_server_core::settings::Settings; use trusted_server_core::settings_data::get_settings; +mod app; +mod backend; +mod compat; mod error; mod logging; mod management_api; +mod middleware; mod platform; #[cfg(test)] mod route_tests; +use crate::app::{build_state, TrustedServerApp}; use crate::error::to_error_response; -use crate::logging::init_logger; -use crate::platform::{build_runtime_services, UnavailableKvStore}; +use crate::middleware::{apply_finalize_headers, resolve_geo_for_response, HEADER_X_TS_FINALIZED}; +use crate::platform::{build_runtime_services, client_info_from_request, FastlyPlatformGeo}; + +const TRUSTED_SERVER_CONFIG_STORE: &str = "trusted_server_config"; +const EDGEZERO_ENABLED_KEY: &str = "edgezero_enabled"; /// Result of routing a request, distinguishing buffered from streaming publisher responses. /// /// The streaming arm keeps the publisher body out of WASM heap until it is written directly -/// to the client via [`fastly::Response::stream_to_client`]. All other routes are buffered. +/// to the client via [`fastly::Response::stream_to_client`]. All other legacy routes are buffered. /// /// [`AuthChallenge`](HandlerOutcome::AuthChallenge) marks responses produced by this server's /// own `enforce_basic_auth` so the geo-lookup gate can distinguish them from origin-forwarded @@ -94,6 +104,51 @@ impl HandlerOutcome { } } +/// Returns `true` if the raw config-store value represents an enabled flag. +/// +/// Accepted values (after whitespace trimming): `"1"` or `"true"` in any ASCII case. +/// All other values, including the empty string, are treated as disabled. +fn parse_edgezero_flag(value: &str) -> bool { + let v = value.trim(); + v.eq_ignore_ascii_case("true") || v == "1" +} + +/// Opens the shared Fastly Config Store used by both the `EdgeZero` flag read and +/// `EdgeZero` dispatch metadata. +/// +/// # Errors +/// +/// Returns [`fastly::Error`] if the config store cannot be opened. +fn open_trusted_server_config_store() -> Result { + let store = FastlyConfigStore::try_open(TRUSTED_SERVER_CONFIG_STORE) + .map_err(|e| fastly::Error::msg(format!("failed to open config store: {e}")))?; + Ok(ConfigStoreHandle::new(Arc::new(store))) +} + +/// Reads the `edgezero_enabled` key from the prepared Fastly Config Store +/// handle. +/// +/// Returns `Err` on any key-read failure, so callers should use the legacy path +/// as the safe default. +/// +/// # Errors +/// +/// - [`fastly::Error`] if the key cannot be read. +fn is_edgezero_enabled(config_store: &ConfigStoreHandle) -> Result { + let value = config_store + .get(EDGEZERO_ENABLED_KEY) + .map_err(|e| fastly::Error::msg(format!("failed to read edgezero_enabled: {e}")))?; + Ok(value.as_deref().is_some_and(parse_edgezero_flag)) +} + +fn health_response(req: &FastlyRequest) -> Option { + if req.get_method() == FastlyMethod::GET && req.get_path() == "/health" { + return Some(FastlyResponse::from_status(200).with_body_text_plain("ok")); + } + + None +} + /// Combined result from `route_request`, bundling the handler outcome with the /// EC context and cookies needed for post-send finalization and pull sync. struct RouteResult { @@ -111,64 +166,274 @@ struct RouteResult { /// Entry point for the Fastly Compute program. /// /// Uses an undecorated `main()` with `FastlyRequest::from_client()` instead of -/// `#[fastly::main]` so we can call `send_to_client()` explicitly when needed. +/// `#[fastly::main]` so the legacy streaming publisher path can call +/// [`fastly::Response::stream_to_client`] explicitly. fn main() { - init_logger(); - - let mut req = FastlyRequest::from_client(); + let req = FastlyRequest::from_client(); - // Keep the health probe independent from settings loading and routing so - // readiness checks still get a cheap liveness response during startup. - if req.get_method() == FastlyMethod::GET && req.get_path() == "/health" { - FastlyResponse::from_status(200) - .with_body_text_plain("ok") - .send_to_client(); + // 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; } - let settings = match get_settings() { - Ok(s) => s, + logging::init_logger(); + + let edgezero_config_store = match open_trusted_server_config_store() { + Ok(config_store) => config_store, Err(e) => { - log::error!("Failed to load settings: {:?}", e); - to_error_response(&e).send_to_client(); + log::warn!("failed to open EdgeZero config store, falling back to legacy path: {e}"); + legacy_main(req); return; } }; - // lgtm[rust/cleartext-logging] - // `Settings` uses `Redacted` for secrets, so this debug dump is redacted. - log::debug!("Settings {settings:?}"); - // Short-circuit the ja4 debug probe before finalize_response so that - // Cache-Control: no-store, private cannot be replaced by operator [response_headers]. + if is_edgezero_enabled(&edgezero_config_store).unwrap_or_else(|e| { + log::warn!("failed to read edgezero_enabled flag, falling back to legacy path: {e}"); + false + }) { + log::debug!("routing request through EdgeZero path"); + edgezero_main(req, edgezero_config_store); + } else { + log::debug!("routing request through legacy path"); + legacy_main(req); + } +} + +/// Handles a request through the `EdgeZero` router path. +fn edgezero_main(mut req: FastlyRequest, config_store: ConfigStoreHandle) { + // Short-circuit the JA4 debug probe before app construction, mirroring + // legacy_main. Must run here because TLS/JA4 accessors are only available + // on FastlyRequest before conversion to edgezero types. if req.get_method() == FastlyMethod::GET && req.get_path() == "/_ts/debug/ja4" { - if settings.debug.ja4_endpoint_enabled { - build_ja4_debug_response(&req).send_to_client(); - } else { - FastlyResponse::from_status(fastly::http::StatusCode::NOT_FOUND).send_to_client(); + match get_settings() { + Ok(settings) if settings.debug.ja4_endpoint_enabled => { + build_ja4_debug_response(&req).send_to_client(); + } + Ok(_) => { + FastlyResponse::from_status(fastly::http::StatusCode::NOT_FOUND).send_to_client(); + } + Err(e) => { + log::warn!("EdgeZero JA4 endpoint: failed to load settings: {e:?}"); + FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) + .with_body_text_plain("Internal Server Error") + .send_to_client(); + } } return; } - // Build the auction orchestrator once at startup - let orchestrator = match build_orchestrator(&settings) { - Ok(orchestrator) => orchestrator, - Err(e) => { - log::error!("Failed to build auction orchestrator: {:?}", e); - to_error_response(&e).send_to_client(); - return; + let app = TrustedServerApp::build_app(); + + // Strip client-spoofable forwarded headers before handing off to the + // EdgeZero dispatcher, mirroring the sanitization done in legacy_main. + compat::sanitize_fastly_forwarded_headers(&mut req); + + // Re-inject a trusted TLS scheme signal after sanitization has stripped any + // client-sent fastly-ssl header. Setting it from Fastly's native TLS + // metadata here is authoritative. detect_request_scheme in http_util + // checks this header so scheme-sensitive logic (publisher URL rewriting, + // etc.) produces https URLs on HTTPS traffic, matching legacy path parity. + if req.get_tls_protocol().is_some() || req.get_tls_cipher_openssl_name().is_some() { + req.set_header("fastly-ssl", "1"); + } + + // Capture client IP before the request is consumed by dispatch. + let client_ip = req.get_client_ip_addr(); + + // Capture the full ClientInfo (TLS protocol/cipher, JA4, H2 fingerprint, and + // server hostname/region) from the original FastlyRequest before conversion. + // These accessors only return real values on the client request; the + // reconstructed EdgeZero request cannot expose them. Stored in the request + // extensions so `build_per_request_services` reads the authoritative metadata + // that integration bot protection (e.g. DataDome) serializes, instead of + // defaulting those fields to empty as the EdgeZero context alone would. + let client_info = client_info_from_request(&req); + + // Derive device signals from the original FastlyRequest before conversion. + // Fastly's `get_tls_ja4()` and `get_client_h2_fingerprint()` accessors only + // return real values on the client request; a synthetic request rebuilt from + // EdgeZero HTTP types cannot expose them, which would strip the JA4/H2 class + // the EC bot gate needs and misclassify real browsers as bots. Stored in the + // request extensions so `build_ec_request_state` reads the authoritative + // signals instead of re-deriving from the reconstructed request. + let device_signals = derive_device_signals(&req); + + // Dispatch directly through the EdgeZero router without an intermediate + // fastly::Response conversion. The standard dispatch helpers + // (dispatch_with_config_handle, etc.) convert through fastly::Response using + // set_header, which drops duplicate header values — silently losing multiple + // Set-Cookie headers from publisher/origin responses. + // + // Bypassing to app.router().oneshot() preserves every header value in the + // http::HeaderMap and skips the logger-reinit that prevents using run_app_*. + 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); + futures::executor::block_on(app.router().oneshot(core_req)) + } + 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; + } } }; - let integration_registry = match IntegrationRegistry::new(&settings) { - Ok(r) => r, + // Pop the EC finalize state that route handlers thread out via response + // extensions. Must happen before the fastly conversion, which drops + // extensions. + let ec_state = response + .extensions_mut() + .remove::(); + + // Pop the asset cache policy threaded out by the asset-route fallback. Must + // happen before the fastly conversion, which drops extensions. Reapplied + // after finalization below so protected directives (e.g. no-store on asset + // errors) survive operator `response_headers`, mirroring legacy_main's + // asset_cache_policy.apply_after_route_finalization. + let asset_cache_policy = response.extensions_mut().remove::(); + + // Pop the integration request-filter response effects (e.g. DataDome + // challenge/allow headers) threaded out by the dispatch path. Applied to the + // response after EC finalization and before send, mirroring legacy_main's + // `request_filter_effects` application. Must happen before the fastly + // conversion, which drops extensions. + let request_filter_effects = response.extensions_mut().remove::(); + + if !take_finalize_sentinel(&mut response) { + // Apply finalize headers at the entry point so that router-level + // 405/404 responses for unregistered HTTP methods (e.g. TRACE, WebDAV + // verbs) carry TS/geo headers. Middleware-finalized responses are + // skipped here to avoid a second settings read and geo lookup on the + // normal registered-route path. + match get_settings() { + Ok(settings) => { + let geo_info = resolve_geo_for_response(&response, client_ip, |client_ip| { + FastlyPlatformGeo.lookup(client_ip).unwrap_or_else(|e| { + log::warn!("entry-point geo lookup failed: {e}"); + None + }) + }); + apply_finalize_headers(&settings, geo_info.as_ref(), &mut response); + } + Err(e) => { + log::warn!("entry-point finalize skipped: failed to reload settings: {e:?}"); + } + } + } + + // Reapply protected asset cache directives after finalization, mirroring + // legacy_main. A no-op for OriginControlled responses. + if let Some(policy) = asset_cache_policy { + policy.apply_after_route_finalization(&mut response); + } + + // EC response lifecycle, mirroring legacy_main: finalize EC cookies and + // request headers on the response, send it, then run pull sync for + // recognized browsers. When settings or the partner registry cannot be + // loaded the response is sent without EC finalization rather than + // dropped. + if let Some(ec_state) = ec_state { + match get_settings() { + Ok(settings) => match PartnerRegistry::from_config(&settings.ec.partners) { + Ok(partner_registry) => { + ec_finalize_response( + &settings, + &ec_state.ec_context, + ec_state.finalize_kv_graph.as_ref(), + &partner_registry, + ec_state.eids_cookie.as_deref(), + ec_state.sharedid_cookie.as_deref(), + &mut response, + ); + if let Some(effects) = &request_filter_effects { + effects.apply_to_response(&mut response); + } + compat::to_fastly_response(response).send_to_client(); + + if ec_state.is_real_browser { + if let Some(context) = build_pull_sync_context(&ec_state.ec_context) { + run_pull_sync_after_send( + &settings, + &partner_registry, + &context, + &ec_state.services, + ); + } + } + return; + } + Err(e) => { + log::error!( + "EdgeZero EC finalize skipped: failed to build partner registry: {e:?}" + ); + } + }, + Err(e) => { + log::warn!("EdgeZero EC finalize skipped: failed to reload settings: {e:?}"); + } + } + } + + if let Some(effects) = &request_filter_effects { + effects.apply_to_response(&mut response); + } + compat::to_fastly_response(response).send_to_client(); +} + +fn take_finalize_sentinel(response: &mut HttpResponse) -> bool { + response + .headers_mut() + .remove(HEADER_X_TS_FINALIZED) + .is_some() +} + +/// Handles a request using the original Fastly-native entry point. +/// +/// Preserves identical semantics to the pre-PR14 `main()`, with one +/// relocation: `GET /health` is short-circuited in [`main`] before the flag +/// dispatch, so it never reaches this function. The pre-PR14 entry point +/// answered `/health` with the same `200 ok` before settings loading and +/// routing; the only difference is that the probe now also skips logger +/// initialization. Called whenever +/// the `EdgeZero` flag is disabled or cannot be read/parsed as enabled — that +/// includes config-store open failures, key-read errors, missing keys, and +/// any value other than the accepted `"true"` / `"1"` forms. +/// +/// The thin fastly↔http conversion layer (via `compat::from_fastly_request` / +/// `compat::to_fastly_response`) lives here in the adapter crate. +// TODO: delete after Phase 5 EdgeZero cutover — see issue #495 +fn legacy_main(mut req: FastlyRequest) { + let state = match build_state() { + Ok(state) => state, Err(e) => { - log::error!("Failed to create integration registry: {:?}", e); + log::error!("Failed to build application state: {:?}", e); to_error_response(&e).send_to_client(); return; } }; + // lgtm[rust/cleartext-logging] + // `Settings` uses `Redacted` for secrets, so this debug dump is redacted. + log::debug!("Settings {:?}", state.settings); - let partner_registry = match PartnerRegistry::from_config(&settings.ec.partners) { + // Short-circuit the ja4 debug probe before finalize_response so that + // Cache-Control: no-store, private cannot be replaced by operator [response_headers]. + if req.get_method() == FastlyMethod::GET && req.get_path() == "/_ts/debug/ja4" { + if state.settings.debug.ja4_endpoint_enabled { + build_ja4_debug_response(&req).send_to_client(); + } else { + FastlyResponse::from_status(fastly::http::StatusCode::NOT_FOUND).send_to_client(); + } + return; + } + + let partner_registry = match PartnerRegistry::from_config(&state.settings.ec.partners) { Ok(registry) => registry, Err(e) => { log::error!("Failed to build partner registry: {:?}", e); @@ -177,25 +442,23 @@ fn main() { } }; - // Start with an unavailable primary KV slot. EC-backed routes lazily - // replace it with the configured EC identity store at dispatch time so - // unrelated routes stay available when EC KV is unavailable. - let kv_store = std::sync::Arc::new(UnavailableKvStore) - as std::sync::Arc; // Strip client-spoofable forwarded headers at the edge before building // any request-derived context or converting to the core HTTP types. compat::sanitize_fastly_forwarded_headers(&mut req); - let runtime_services = build_runtime_services(&req, kv_store); + let device_signals = derive_device_signals(&req); + let runtime_services = + build_runtime_services(&req, std::sync::Arc::clone(&state.default_kv_store)); let http_req = compat::from_fastly_request(req); let route_result = futures::executor::block_on(route_request( - &settings, - &orchestrator, - &integration_registry, + &state.settings, + &state.orchestrator, + &state.registry, &partner_registry, &runtime_services, http_req, + device_signals, )) .unwrap_or_else(|e| RouteResult { outcome: HandlerOutcome::Buffered(http_error_response(&e)), @@ -238,26 +501,30 @@ fn main() { match outcome { HandlerOutcome::Buffered(mut response) | HandlerOutcome::AuthChallenge(mut response) => { - finalize_response(&settings, geo_info.as_ref(), &mut response); + finalize_response(&state.settings, geo_info.as_ref(), &mut response); asset_cache_policy.apply_after_route_finalization(&mut response); - let mut fastly_resp = compat::to_fastly_response(response); if should_finalize_ec { ec_finalize_response( - &settings, + &state.settings, &ec_context, finalize_kv_graph.as_ref(), &partner_registry, eids_cookie.as_deref(), sharedid_cookie.as_deref(), - &mut fastly_resp, + &mut response, ); } - request_filter_effects.apply_to_fastly_response(&mut fastly_resp); - fastly_resp.send_to_client(); + request_filter_effects.apply_to_response(&mut response); + compat::to_fastly_response(response).send_to_client(); if is_real_browser { if let Some(context) = build_pull_sync_context(&ec_context) { - run_pull_sync_after_send(&settings, &partner_registry, &context); + run_pull_sync_after_send( + &state.settings, + &partner_registry, + &context, + &runtime_services, + ); } } } @@ -266,29 +533,29 @@ fn main() { body, params, } => { - finalize_response(&settings, geo_info.as_ref(), &mut response); + finalize_response(&state.settings, geo_info.as_ref(), &mut response); asset_cache_policy.apply_after_route_finalization(&mut response); - let mut fastly_resp = compat::to_fastly_response_skeleton(response); if should_finalize_ec { ec_finalize_response( - &settings, + &state.settings, &ec_context, finalize_kv_graph.as_ref(), &partner_registry, eids_cookie.as_deref(), sharedid_cookie.as_deref(), - &mut fastly_resp, + &mut response, ); } - request_filter_effects.apply_to_fastly_response(&mut fastly_resp); + request_filter_effects.apply_to_response(&mut response); + let fastly_resp = compat::to_fastly_response_skeleton(response); let mut streaming_body = fastly_resp.stream_to_client(); let mut stream_succeeded = false; match stream_publisher_body( body, &mut streaming_body, ¶ms, - &settings, - &integration_registry, + &state.settings, + &state.registry, ) { Ok(()) => { if let Err(e) = streaming_body.finish() { @@ -307,15 +574,20 @@ fn main() { if is_real_browser && stream_succeeded { if let Some(context) = build_pull_sync_context(&ec_context) { - run_pull_sync_after_send(&settings, &partner_registry, &context); + run_pull_sync_after_send( + &state.settings, + &partner_registry, + &context, + &runtime_services, + ); } } } HandlerOutcome::AssetStreaming { mut response, body } => { - finalize_response(&settings, geo_info.as_ref(), &mut response); + finalize_response(&state.settings, geo_info.as_ref(), &mut response); asset_cache_policy.apply_after_route_finalization(&mut response); - let mut fastly_resp = compat::to_fastly_response_skeleton(response); - request_filter_effects.apply_to_fastly_response(&mut fastly_resp); + request_filter_effects.apply_to_response(&mut response); + let fastly_resp = compat::to_fastly_response_skeleton(response); let mut streaming_body = fastly_resp.stream_to_client(); if let Err(e) = futures::executor::block_on(stream_asset_body(body, &mut streaming_body)) @@ -333,7 +605,7 @@ const FALLBACK_UNAVAILABLE: &str = "unavailable"; const FALLBACK_NOT_SENT: &str = "not sent"; const FALLBACK_NONE: &str = "none"; -// TODO: remove after JA4 evaluation completes — see #645 +// TODO: remove after JA4 evaluation completes - see #645 fn build_ja4_debug_response(req: &FastlyRequest) -> FastlyResponse { let ja4 = req.get_tls_ja4().unwrap_or(FALLBACK_UNAVAILABLE); let h2 = req @@ -378,15 +650,8 @@ async fn route_request( partner_registry: &PartnerRegistry, runtime_services: &RuntimeServices, mut req: HttpRequest, + device_signals: DeviceSignals, ) -> Result> { - // Build a Fastly request reference for APIs that require fastly types - // (EcContext, device signals, cookie extraction). This is headers/method/URI - // only — body has already been moved into `req`. - let fastly_req_ref = compat::to_fastly_request_ref(&req); - - // Extract device signals from TLS/H2/UA. TLS fingerprints are available - // on the fastly request reference even without the body. - let device_signals = derive_device_signals(&fastly_req_ref); let is_real_browser = device_signals.looks_like_browser(); if !is_real_browser { @@ -399,8 +664,8 @@ async fn route_request( } // Extract the Prebid EIDs and SharedID cookies before routing. - let eids_cookie = extract_cookie_value(&fastly_req_ref, COOKIE_TS_EIDS); - let sharedid_cookie = extract_cookie_value(&fastly_req_ref, COOKIE_SHAREDID); + let eids_cookie = extract_cookie_value(&req, COOKIE_TS_EIDS); + let sharedid_cookie = extract_cookie_value(&req, COOKIE_SHAREDID); // Extract geo info. let geo_info = runtime_services @@ -431,13 +696,12 @@ async fn route_request( Ok(None) => {} Err(e) => return Err(e), } - let fastly_req = compat::to_fastly_request(req); let result = require_identity_graph(settings).and_then(|kv| { let limiter = FastlyRateLimiter::new(RATE_COUNTER_NAME); - handle_batch_sync(&kv, partner_registry, &limiter, fastly_req) + handle_batch_sync(&kv, partner_registry, &limiter, req) }); let outcome = match result { - Ok(fastly_resp) => HandlerOutcome::Buffered(compat::from_fastly_response(fastly_resp)), + Ok(resp) => HandlerOutcome::Buffered(resp), Err(e) => HandlerOutcome::Buffered(http_error_response(&e)), }; return Ok(RouteResult { @@ -454,23 +718,27 @@ async fn route_request( } // Build EC context using the fastly request reference (headers/method/URI). - let mut ec_context = - match EcContext::read_from_request_with_geo(settings, &fastly_req_ref, geo_info.as_ref()) { - Ok(context) => context, - Err(err) => { - return Ok(RouteResult { - outcome: HandlerOutcome::Buffered(http_error_response(&err)), - ec_context: EcContext::default(), - finalize_kv_graph: None, - eids_cookie, - sharedid_cookie, - is_real_browser, - should_finalize_ec: true, - asset_cache_policy: AssetProxyCachePolicy::OriginControlled, - request_filter_effects: RequestFilterEffects::default(), - }); - } - }; + let mut ec_context = match EcContext::read_from_request_with_geo( + settings, + &req, + runtime_services, + geo_info.as_ref(), + ) { + Ok(context) => context, + Err(err) => { + return Ok(RouteResult { + outcome: HandlerOutcome::Buffered(http_error_response(&err)), + ec_context: EcContext::default(), + finalize_kv_graph: None, + eids_cookie, + sharedid_cookie, + is_real_browser, + should_finalize_ec: true, + asset_cache_policy: AssetProxyCachePolicy::OriginControlled, + request_filter_effects: RequestFilterEffects::default(), + }); + } + }; // Pass device signals to EcContext so they are stored on new entries. ec_context.set_device_signals(device_signals); @@ -588,21 +856,16 @@ async fn route_request( ) } (Method::GET, "/_ts/api/v1/identify") => { - let fastly_ref = compat::to_fastly_request_ref(&req); - let outcome = require_identity_graph(settings).and_then(|kv| { - handle_identify(settings, &kv, partner_registry, &fastly_ref, &ec_context) - .map(compat::from_fastly_response) - }); + let outcome = require_identity_graph(settings) + .and_then(|kv| handle_identify(settings, &kv, partner_registry, &req, &ec_context)); (outcome, false) } (Method::OPTIONS, "/_ts/api/v1/identify") => { - let fastly_ref = compat::to_fastly_request_ref(&req); - let outcome = - cors_preflight_identify(settings, &fastly_ref).map(compat::from_fastly_response); + let outcome = cors_preflight_identify(settings, &req); (outcome, false) } - // Unified auction endpoint (returns creative HTML inline) + // Unified auction endpoint. (Method::POST, "/auction") => { let registry_ref = if partner_registry.is_empty() { None @@ -775,7 +1038,7 @@ async fn route_request( }) } -fn maybe_identity_graph(settings: &Settings) -> Option { +pub(crate) fn maybe_identity_graph(settings: &Settings) -> Option { settings.ec.ec_store.as_ref().map(KvIdentityGraph::new) } @@ -783,6 +1046,7 @@ fn run_pull_sync_after_send( settings: &Settings, partner_registry: &PartnerRegistry, context: &PullSyncContext, + services: &RuntimeServices, ) { let kv = match require_identity_graph(settings) { Ok(kv) => kv, @@ -793,7 +1057,59 @@ fn run_pull_sync_after_send( }; let limiter = FastlyRateLimiter::new(RATE_COUNTER_NAME); - dispatch_pull_sync(settings, &kv, partner_registry, &limiter, context); + dispatch_pull_sync(settings, &kv, partner_registry, &limiter, context, services); +} + +pub(crate) fn resolve_publisher_response_buffered( + publisher_response: PublisherResponse, + method: &Method, + settings: &Settings, + integration_registry: &IntegrationRegistry, +) -> Result> { + match publisher_response { + PublisherResponse::Buffered(response) => Ok(response), + PublisherResponse::Stream { + mut response, + body, + params, + } => { + // HEAD and bodiless statuses (204, 304) carry no body but may + // advertise the GET representation's length. `handle_publisher_request` + // already stripped the origin Content-Length for processable Stream + // responses, so rewriting it here to the buffered byte count (0) + // would replace it with a misleading length. Skip the buffer, the + // length rewrite, and the body replacement for those responses, + // mirroring the asset path's `asset_response_carries_body` guard. + if !publisher_response_carries_body(method, response.status()) { + return Ok(response); + } + let mut output = BoundedWriter::new(settings.publisher.max_buffered_body_bytes); + stream_publisher_body(body, &mut output, ¶ms, settings, integration_registry)?; + let bytes = output.into_inner(); + response.headers_mut().insert( + header::CONTENT_LENGTH, + HeaderValue::from(bytes.len() as u64), + ); + *response.body_mut() = EdgeBody::from(bytes); + Ok(response) + } + PublisherResponse::PassThrough { mut response, body } => { + *response.body_mut() = body; + Ok(response) + } + } +} + +/// Returns `true` when a buffered publisher response should carry a body and a +/// recomputed `Content-Length`. +/// +/// `HEAD` responses and bodiless statuses (204, 304) carry no body; rewriting +/// their `Content-Length` to the (empty) buffered length would mislead clients +/// and caches. This mirrors the asset path's `asset_response_carries_body`. +fn publisher_response_carries_body(method: &Method, status: StatusCode) -> bool { + *method != Method::HEAD + && status != StatusCode::NO_CONTENT + && status != StatusCode::NOT_MODIFIED } /// Applies all standard response headers: geo, version, staging, and configured headers. @@ -805,35 +1121,7 @@ fn run_pull_sync_after_send( /// version/staging, then operator-configured `settings.response_headers`. /// This means operators can intentionally override any managed header. fn finalize_response(settings: &Settings, geo_info: Option<&GeoInfo>, response: &mut HttpResponse) { - if let Some(geo) = geo_info { - geo.set_response_headers(response); - } else { - response.headers_mut().insert( - HEADER_X_GEO_INFO_AVAILABLE, - HeaderValue::from_static("false"), - ); - } - - if let Ok(v) = ::std::env::var(ENV_FASTLY_SERVICE_VERSION) { - if let Ok(value) = HeaderValue::from_str(&v) { - response.headers_mut().insert(HEADER_X_TS_VERSION, value); - } else { - log::warn!("Skipping invalid FASTLY_SERVICE_VERSION response header value"); - } - } - if ::std::env::var(ENV_FASTLY_IS_STAGING).as_deref() == Ok("1") { - response - .headers_mut() - .insert(HEADER_X_TS_ENV, HeaderValue::from_static("staging")); - } - - for (key, value) in &settings.response_headers { - let header_name = HeaderName::from_bytes(key.as_bytes()) - .expect("settings.response_headers validated at load time"); - let header_value = - HeaderValue::from_str(value).expect("settings.response_headers validated at load time"); - response.headers_mut().insert(header_name, header_value); - } + apply_finalize_headers(settings, geo_info, response); } fn http_error_response(report: &Report) -> HttpResponse { @@ -852,7 +1140,7 @@ fn http_error_response(report: &Report) -> HttpResponse { /// Constructs a `KvIdentityGraph` from settings, or returns an error if the /// `ec_store` config is not set. -fn require_identity_graph( +pub(crate) fn require_identity_graph( settings: &Settings, ) -> Result> { let store_name = settings.ec.ec_store.as_deref().ok_or_else(|| { @@ -865,8 +1153,8 @@ fn require_identity_graph( } /// Extracts a named cookie value from the request's `Cookie` header. -fn extract_cookie_value(req: &FastlyRequest, name: &str) -> Option { - let cookie_header = req.get_header_str("cookie")?; +pub(crate) fn extract_cookie_value(req: &HttpRequest, name: &str) -> Option { + let cookie_header = req.headers().get("cookie").and_then(|v| v.to_str().ok())?; for pair in cookie_header.split(';') { let pair = pair.trim(); if let Some((key, value)) = pair.split_once('=') { @@ -882,7 +1170,7 @@ fn extract_cookie_value(req: &FastlyRequest, name: &str) -> Option { /// /// All extraction is pure in-memory — no KV I/O. The Fastly SDK provides /// `get_tls_ja4()` and `get_client_h2_fingerprint()` on client requests. -fn derive_device_signals(req: &FastlyRequest) -> DeviceSignals { +pub(crate) fn derive_device_signals(req: &FastlyRequest) -> DeviceSignals { let ua = req.get_header_str("user-agent").unwrap_or(""); let ja4 = req.get_tls_ja4(); let h2_fp = req.get_client_h2_fingerprint(); @@ -893,8 +1181,156 @@ fn derive_device_signals(req: &FastlyRequest) -> DeviceSignals { #[cfg(test)] mod tests { use super::*; + use edgezero_core::http::response_builder; use fastly::mime; + fn test_settings() -> Settings { + Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.com" + cookie_domain = ".test-publisher.com" + origin_url = "https://origin.test-publisher.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + passphrase = "test-secret-key-32-bytes-minimum" + + [request_signing] + enabled = false + config_store_id = "test-config-store-id" + secret_store_id = "test-secret-store-id" + "#, + ) + .expect("should parse test settings") + } + + #[test] + fn parses_true_flag_values() { + assert!(parse_edgezero_flag("true"), "should parse 'true'"); + assert!(parse_edgezero_flag("1"), "should parse '1'"); + assert!(parse_edgezero_flag(" true "), "should trim whitespace"); + assert!( + parse_edgezero_flag(" 1 "), + "should trim whitespace around '1'" + ); + assert!(parse_edgezero_flag("TRUE"), "should parse uppercase 'TRUE'"); + assert!( + parse_edgezero_flag("True"), + "should parse mixed-case 'True'" + ); + } + + #[test] + fn rejects_non_true_flag_values() { + assert!(!parse_edgezero_flag("false"), "should not parse 'false'"); + assert!(!parse_edgezero_flag(""), "should not parse empty string"); + assert!( + !parse_edgezero_flag(" "), + "should not parse whitespace-only" + ); + assert!(!parse_edgezero_flag("yes"), "should not parse 'yes'"); + } + + #[test] + fn health_response_short_circuits_get_health() { + let req = FastlyRequest::get("https://example.com/health"); + + let mut response = health_response(&req).expect("should build health response"); + + assert_eq!( + response.get_status(), + fastly::http::StatusCode::OK, + "should return 200 OK" + ); + assert_eq!( + response.take_body_str(), + "ok", + "should return the health body" + ); + } + + #[test] + fn health_response_ignores_non_health_paths() { + let req = FastlyRequest::get("https://example.com/auction"); + + assert!( + health_response(&req).is_none(), + "should only short-circuit /health" + ); + } + + #[test] + fn publisher_response_carries_body_preserves_bodiless_content_length() { + // A processable GET 200 publisher response buffers a body and recomputes + // Content-Length. + assert!( + super::publisher_response_carries_body(&Method::GET, StatusCode::OK), + "a GET 200 publisher response should carry a buffered body" + ); + // HEAD responses carry no body; recomputing Content-Length to 0 would + // mislead clients/caches about the GET representation length. + assert!( + !super::publisher_response_carries_body(&Method::HEAD, StatusCode::OK), + "HEAD publisher responses must not get a recomputed Content-Length" + ); + // Bodiless statuses keep their metadata regardless of method. + assert!( + !super::publisher_response_carries_body(&Method::GET, StatusCode::NO_CONTENT), + "204 responses must not get a recomputed Content-Length" + ); + assert!( + !super::publisher_response_carries_body(&Method::GET, StatusCode::NOT_MODIFIED), + "304 responses must not get a recomputed Content-Length" + ); + } + + #[test] + fn take_finalize_sentinel_strips_sentinel() { + let mut response = HttpResponse::new(EdgeBody::empty()); + response + .headers_mut() + .insert("x-ts-finalized", HeaderValue::from_static("1")); + + assert!( + take_finalize_sentinel(&mut response), + "should detect middleware-finalized responses" + ); + assert!( + response.headers().get("x-ts-finalized").is_none(), + "sentinel should not be sent to clients" + ); + } + + #[test] + #[allow(clippy::panic)] + fn entry_point_finalize_skips_geo_lookup_for_401() { + let settings = test_settings(); + let mut response = response_builder() + .status(edgezero_core::http::StatusCode::UNAUTHORIZED) + .body(EdgeBody::empty()) + .expect("should build response"); + + let geo_info = resolve_geo_for_response(&response, None, |_| { + panic!("should skip entry-point geo lookup for 401 responses"); + }); + apply_finalize_headers(&settings, geo_info.as_ref(), &mut response); + + assert_eq!( + response + .headers() + .get(trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE) + .and_then(|v| v.to_str().ok()), + Some("false"), + "401 responses should still carry geo-unavailable headers" + ); + } + #[test] fn ja4_debug_response_uses_plain_text_and_fallback_values() { let req = FastlyRequest::get("https://example.com/_ts/debug/ja4"); diff --git a/crates/trusted-server-adapter-fastly/src/management_api.rs b/crates/trusted-server-adapter-fastly/src/management_api.rs index 92ae8e6c0..58be9711b 100644 --- a/crates/trusted-server-adapter-fastly/src/management_api.rs +++ b/crates/trusted-server-adapter-fastly/src/management_api.rs @@ -24,6 +24,7 @@ use fastly::http::{Method, StatusCode}; use fastly::{Request, Response}; use trusted_server_core::platform::{PlatformError, PlatformSecretStore, StoreName}; +use crate::backend::BackendConfig; use crate::platform::FastlyPlatformSecretStore; const FASTLY_API_HOST: &str = "https://api.fastly.com"; @@ -122,8 +123,6 @@ impl FastlyManagementApiClient { /// be registered, or [`PlatformError::SecretStore`] if the API key cannot /// be read. pub(crate) fn new() -> Result> { - use trusted_server_core::backend::BackendConfig; - let backend_name = BackendConfig::from_url(FASTLY_API_HOST, true) .change_context(PlatformError::Backend) .attach("failed to register Fastly management API backend")?; diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs new file mode 100644 index 000000000..34d4b3491 --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -0,0 +1,503 @@ +//! Middleware implementations for the dual-path entry point. +//! +//! Provides two middleware types that mirror the finalization and auth logic +//! from the legacy [`crate::finalize_response`] and [`crate::route_request`]: +//! +//! - [`FinalizeResponseMiddleware`] — geo lookup and standard TS header injection +//! - [`AuthMiddleware`] — basic-auth enforcement via [`enforce_basic_auth`] +//! +//! Registration order in [`crate::app`]: `FinalizeResponseMiddleware` outermost, +//! then `AuthMiddleware`. This ensures auth-rejected responses also receive the +//! standard TS headers before being returned to the client. + +use std::sync::Arc; + +use async_trait::async_trait; +use edgezero_adapter_fastly::FastlyRequestContext; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{HeaderName, 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::constants::{ + ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, HEADER_X_GEO_INFO_AVAILABLE, + HEADER_X_TS_ENV, HEADER_X_TS_VERSION, +}; +use trusted_server_core::geo::GeoInfo; +use trusted_server_core::platform::PlatformGeo; +use trusted_server_core::settings::Settings; + +pub(crate) const HEADER_X_TS_FINALIZED: &str = "x-ts-finalized"; + +// --------------------------------------------------------------------------- +// FinalizeResponseMiddleware +// --------------------------------------------------------------------------- + +/// Outermost middleware: performs geo lookup and injects all standard TS response headers. +/// +/// Registered first in the middleware chain so that it wraps all inner middleware +/// (including [`AuthMiddleware`]) and the handler. This guarantees every registered-route +/// response — including auth-rejected ones — carries a consistent set of headers. +/// +/// Router-level 405/404 responses for unregistered HTTP methods (e.g. TRACE) bypass the +/// middleware chain. Those are covered by a second call to [`apply_finalize_headers`] at +/// the `main.rs` entry point. Middleware-finalized responses carry +/// [`HEADER_X_TS_FINALIZED`] so the entry point can skip duplicate finalization. +/// +/// # Header precedence +/// +/// Headers are written in this order (last write wins): +/// 1. Geo headers (or `X-Geo-Info-Available: false` when geo is unavailable) +/// 2. `X-TS-Version` from `FASTLY_SERVICE_VERSION` env var +/// 3. `X-TS-ENV: staging` when `FASTLY_IS_STAGING == "1"` +/// 4. Operator-configured `settings.response_headers` (can override any managed header) +pub struct FinalizeResponseMiddleware { + settings: Arc, + geo: Arc, +} + +impl FinalizeResponseMiddleware { + /// Creates a new [`FinalizeResponseMiddleware`] with the given settings and geo lookup service. + pub fn new(settings: Arc, geo: Arc) -> Self { + Self { settings, geo } + } +} + +#[async_trait(?Send)] +impl Middleware for FinalizeResponseMiddleware { + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + let client_ip = FastlyRequestContext::get(ctx.request()).and_then(|c| c.client_ip); + + let mut response = match next.run(ctx).await { + Ok(r) => r, + Err(e) => { + log::error!("request handler failed: {e:?}"); + e.into_response() + } + }; + + let geo_info = resolve_geo_for_response(&response, client_ip, |ip| { + self.geo.lookup(ip).unwrap_or_else(|e| { + log::warn!("geo lookup failed: {e}"); + None + }) + }); + + apply_finalize_headers(&self.settings, geo_info.as_ref(), &mut response); + response + .headers_mut() + .insert(HEADER_X_TS_FINALIZED, HeaderValue::from_static("1")); + + Ok(response) + } +} + +// --------------------------------------------------------------------------- +// AuthMiddleware +// --------------------------------------------------------------------------- + +/// Inner middleware: enforces basic-auth before the handler runs. +/// +/// - `Ok(Some(response))` from [`enforce_basic_auth`] → 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 an HTTP response via +/// [`crate::app::http_error`] using the error's documented status code. +/// +/// # Errors +/// +/// When [`enforce_basic_auth`] returns an error report, converts it to an HTTP +/// response via [`crate::app::http_error`] (preserving the error's status code) +/// so that [`FinalizeResponseMiddleware`] can still inject standard TS headers +/// before the response reaches the client. +pub struct AuthMiddleware { + settings: Arc, +} + +impl AuthMiddleware { + /// Creates a new [`AuthMiddleware`] with the given settings. + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +#[async_trait(?Send)] +impl Middleware for AuthMiddleware { + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + match enforce_basic_auth(&self.settings, ctx.request()) { + Ok(Some(response)) => return Ok(response), + Ok(None) => {} + Err(report) => { + log::error!("auth check failed: {:?}", report); + return Ok(crate::app::http_error(&report)); + } + } + + next.run(ctx).await + } +} + +// --------------------------------------------------------------------------- +// Shared geo resolution helper +// --------------------------------------------------------------------------- + +/// Resolves geo for a response, skipping the lookup for 401 responses. +/// +/// Returns `None` for authentication rejections (401) without calling `lookup_geo` +/// to avoid unnecessary work and exposing geo data to unauthenticated callers. +/// All other responses call `lookup_geo` and return its result. +/// +/// Used by both [`FinalizeResponseMiddleware`] and the entry-point finalization +/// in `main.rs` so the 401-skip rule is defined in one place. +/// +/// # Parity note +/// +/// The legacy path skips geo only for its own `HandlerOutcome::AuthChallenge` +/// responses; origin-forwarded 401s still receive geo headers there. The `EdgeZero` +/// path skips geo for **all** 401s by status. This is intentionally more +/// conservative: geo data is not sent to any unauthenticated caller regardless of +/// whether the 401 originated from this server or the upstream origin. +pub(crate) fn resolve_geo_for_response( + response: &Response, + client_ip: Option, + lookup_geo: F, +) -> Option +where + F: FnOnce(Option) -> Option, +{ + if response.status() == StatusCode::UNAUTHORIZED { + None + } else { + lookup_geo(client_ip) + } +} + +// --------------------------------------------------------------------------- +// apply_finalize_headers — extracted for unit testing +// --------------------------------------------------------------------------- + +/// Applies all standard Trusted Server response headers to the given response. +/// +/// Mirrors [`crate::finalize_response`] exactly, operating on [`Response`] from +/// `edgezero_core::http` instead of `HttpResponse`. +/// +/// Header write order (last write wins): +/// 1. Geo headers (`x-geo-*`) — or `X-Geo-Info-Available: false` when absent +/// 2. `X-TS-Version` from `FASTLY_SERVICE_VERSION` env var +/// 3. `X-TS-ENV: staging` when `FASTLY_IS_STAGING == "1"` +/// 4. `settings.response_headers` — operator-configured overrides applied last +pub(crate) fn apply_finalize_headers( + settings: &Settings, + geo_info: Option<&GeoInfo>, + response: &mut Response, +) { + if let Some(geo) = geo_info { + geo.set_response_headers(response); + } else { + response.headers_mut().insert( + HEADER_X_GEO_INFO_AVAILABLE, + HeaderValue::from_static("false"), + ); + } + + if let Ok(v) = std::env::var(ENV_FASTLY_SERVICE_VERSION) { + if let Ok(value) = HeaderValue::from_str(&v) { + response.headers_mut().insert(HEADER_X_TS_VERSION, value); + } else { + log::warn!("Skipping invalid FASTLY_SERVICE_VERSION response header value"); + } + } + + if std::env::var(ENV_FASTLY_IS_STAGING).as_deref() == Ok("1") { + response + .headers_mut() + .insert(HEADER_X_TS_ENV, HeaderValue::from_static("staging")); + } + + for (key, value) in &settings.response_headers { + let header_name = HeaderName::from_bytes(key.as_bytes()) + .expect("should be a valid header name: response_headers validated in prepare_runtime"); + let header_value = HeaderValue::from_str(value).expect( + "should be a valid header value: response_headers validated in prepare_runtime", + ); + response.headers_mut().insert(header_name, header_value); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + use std::collections::HashMap; + use std::net::IpAddr; + use std::sync::Arc; + + use edgezero_core::body::Body; + use edgezero_core::context::RequestContext; + use edgezero_core::error::EdgeError; + use edgezero_core::http::{request_builder, response_builder, Method, StatusCode}; + use edgezero_core::middleware::Next; + use edgezero_core::params::PathParams; + use error_stack::Report; + use futures::executor::block_on; + use trusted_server_core::platform::{PlatformError, PlatformGeo}; + + fn empty_response() -> Response { + response_builder() + .body(Body::empty()) + .expect("should build empty test response") + } + + fn empty_ctx() -> RequestContext { + let req = request_builder() + .method(Method::GET) + .uri("/test") + .body(Body::empty()) + .expect("should build test request"); + RequestContext::new(req, PathParams::new(HashMap::new())) + } + + struct FixedGeo(Option); + + impl PlatformGeo for FixedGeo { + fn lookup(&self, _: Option) -> Result, Report> { + Ok(self.0.clone()) + } + } + + fn test_settings() -> Settings { + Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.com" + cookie_domain = ".test-publisher.com" + origin_url = "https://origin.test-publisher.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + passphrase = "test-secret-key-32-bytes-minimum" + + [request_signing] + enabled = false + config_store_id = "test-config-store-id" + secret_store_id = "test-secret-store-id" + "#, + ) + .expect("should parse test settings") + } + + fn settings_with_response_headers(headers: Vec<(&str, &str)>) -> Settings { + let mut s = test_settings(); + s.response_headers = headers + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + s + } + + #[test] + fn operator_response_headers_override_earlier_headers() { + let settings = + settings_with_response_headers(vec![("X-Geo-Info-Available", "operator-override")]); + let mut response = empty_response(); + + // No geo_info → would set "false"; operator header should win instead. + apply_finalize_headers(&settings, None, &mut response); + + assert_eq!( + response + .headers() + .get("x-geo-info-available") + .and_then(|v| v.to_str().ok()), + Some("operator-override"), + "should override the managed geo header with the operator-configured value" + ); + } + + #[test] + fn sets_geo_unavailable_header_when_no_geo_info() { + let settings = settings_with_response_headers(vec![]); + let mut response = empty_response(); + + apply_finalize_headers(&settings, None, &mut response); + + assert_eq!( + response + .headers() + .get("x-geo-info-available") + .and_then(|v| v.to_str().ok()), + Some("false"), + "should set X-Geo-Info-Available: false when no geo info is available" + ); + } + + // --------------------------------------------------------------------------- + // FinalizeResponseMiddleware::handle tests + // --------------------------------------------------------------------------- + + #[test] + fn finalize_handle_injects_geo_unavailable_on_ok_response() { + let settings = settings_with_response_headers(vec![]); + let middleware = + FinalizeResponseMiddleware::new(Arc::new(settings), Arc::new(FixedGeo(None))); + let handler = + Arc::new( + |_ctx: RequestContext| async move { Ok::(empty_response()) }, + ); + + let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should succeed"); + + assert_eq!( + response + .headers() + .get("x-geo-info-available") + .and_then(|v| v.to_str().ok()), + Some("false"), + "should set X-Geo-Info-Available: false when geo returns None" + ); + } + + #[test] + fn finalize_handle_marks_response_as_finalized() { + let settings = settings_with_response_headers(vec![]); + let middleware = + FinalizeResponseMiddleware::new(Arc::new(settings), Arc::new(FixedGeo(None))); + let handler = + Arc::new( + |_ctx: RequestContext| async move { Ok::(empty_response()) }, + ); + + let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should succeed"); + + assert_eq!( + response + .headers() + .get("x-ts-finalized") + .and_then(|v| v.to_str().ok()), + Some("1"), + "middleware-finalized responses should carry the entry-point sentinel" + ); + } + + #[test] + fn finalize_handle_absorbs_handler_error_and_injects_headers() { + let settings = settings_with_response_headers(vec![]); + let middleware = + FinalizeResponseMiddleware::new(Arc::new(settings), Arc::new(FixedGeo(None))); + let handler = Arc::new(|_ctx: RequestContext| async move { + Err::(EdgeError::service_unavailable("test error")) + }); + + let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should absorb handler error into a response"); + + assert!( + response.status().is_server_error(), + "should produce a server-error status for absorbed handler error" + ); + assert!( + response.headers().get("x-geo-info-available").is_some(), + "absorbed error response should still carry geo header" + ); + } + + #[test] + #[allow(clippy::panic)] + fn finalize_handle_skips_geo_lookup_for_401() { + struct PanicGeo; + impl PlatformGeo for PanicGeo { + fn lookup(&self, _: Option) -> Result, Report> { + panic!("should not call geo for 401 responses") + } + } + + let settings = settings_with_response_headers(vec![]); + let middleware = FinalizeResponseMiddleware::new(Arc::new(settings), Arc::new(PanicGeo)); + let handler = Arc::new(|_ctx: RequestContext| async move { + let mut resp = empty_response(); + *resp.status_mut() = StatusCode::UNAUTHORIZED; + Ok::(resp) + }); + + let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should succeed without calling geo"); + + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "should preserve 401 status" + ); + assert_eq!( + response + .headers() + .get("x-geo-info-available") + .and_then(|v| v.to_str().ok()), + Some("false"), + "should set geo-unavailable header without calling geo for 401" + ); + } + + // --------------------------------------------------------------------------- + // AuthMiddleware::handle tests + // --------------------------------------------------------------------------- + + #[test] + fn finalize_handle_preserves_duplicate_set_cookie_headers() { + // Regression guard: FinalizeResponseMiddleware must not drop duplicate + // Set-Cookie headers. The old dispatch_with_config_handle path silently + // collapsed them because fastly::Response uses set_header (last-wins). + // This test verifies the EdgeZero middleware chain is header-transparent. + let settings = settings_with_response_headers(vec![]); + let middleware = + FinalizeResponseMiddleware::new(Arc::new(settings), Arc::new(FixedGeo(None))); + let handler = Arc::new(|_ctx: RequestContext| async move { + let resp = response_builder() + .header("set-cookie", "session=abc; Path=/; HttpOnly") + .header("set-cookie", "tracker=xyz; Path=/; SameSite=Lax") + .body(Body::empty()) + .expect("should build response with two Set-Cookie headers"); + Ok::(resp) + }); + + let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should succeed"); + + let cookie_count = response.headers().get_all("set-cookie").iter().count(); + assert_eq!( + cookie_count, 2, + "FinalizeResponseMiddleware must not drop duplicate Set-Cookie headers" + ); + } + + #[test] + fn auth_handle_passes_through_when_auth_not_configured() { + let settings = test_settings(); + let middleware = AuthMiddleware::new(Arc::new(settings)); + let handler = + Arc::new( + |_ctx: RequestContext| async move { Ok::(empty_response()) }, + ); + + let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should pass through when auth is not configured"); + + assert_eq!( + response.status(), + StatusCode::OK, + "should reach the handler when auth is not required" + ); + } +} diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index d73c43657..929db0f43 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -13,11 +13,10 @@ use bytes::Bytes; use edgezero_adapter_fastly::key_value_store::FastlyKvStore; use edgezero_core::key_value_store::KvError; use error_stack::{Report, ResultExt}; -use fastly::geo::geo_lookup; +use fastly::geo::{geo_lookup, Geo}; use fastly::{ConfigStore, Request, SecretStore}; -use trusted_server_core::backend::BackendConfig; -use trusted_server_core::geo::geo_from_fastly; +use crate::backend::BackendConfig; pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, @@ -35,7 +34,7 @@ use trusted_server_core::platform::{ /// /// Stateless — the store name is supplied per call, matching the trait /// signature. This replaces the store-name-at-construction pattern of -/// [`trusted_server_core::storage::FastlyConfigStore`]. +/// the legacy `FastlyConfigStore` (removed). /// /// # Write cost /// @@ -84,8 +83,8 @@ impl PlatformConfigStore for FastlyPlatformConfigStore { /// Fastly [`SecretStore`]-backed implementation of [`PlatformSecretStore`]. /// /// Stateless — the store name is supplied per call. This replaces the -/// store-name-at-construction pattern of -/// [`trusted_server_core::storage::FastlySecretStore`]. +/// store-name-at-construction pattern of the legacy `FastlySecretStore` +/// (removed). /// /// # Write cost /// @@ -339,8 +338,7 @@ fn edge_request_to_fastly( /// `take_body_bytes()` copies the full origin response into a single /// allocation. This cap prevents oversized origin responses from exhausting /// the WASM address space. The Content-Length pre-check avoids the copy -/// entirely for responses that declare their size. Streaming response support -/// will remove this limit in PR 15. +/// entirely for responses that declare their size. const MAX_PLATFORM_RESPONSE_BODY_BYTES: usize = 10 * 1024 * 1024; // 10 MiB fn fastly_body_to_edge_stream(body: fastly::Body) -> edgezero_core::body::Body { @@ -503,13 +501,10 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { let (ready, failed_backend_name) = match result { Ok(fastly_resp) => { - let backend_name = fastly_resp - .get_backend_name() - .unwrap_or_else(|| { - log::warn!("select: response has no backend name, correlation will fail"); - "" - }) - .to_string(); + let Some(backend_name) = fastly_resp.get_backend_name().map(str::to_string) else { + return Err(Report::new(PlatformError::HttpClient) + .attach("select: response has no backend name; correlation impossible")); + }; ( fastly_response_to_platform(fastly_resp, backend_name, false), None, @@ -538,10 +533,24 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { // FastlyPlatformGeo // --------------------------------------------------------------------------- -/// Fastly geo-lookup implementation of [`PlatformGeo`]. +/// Convert a Fastly [`Geo`] value into a platform-neutral [`GeoInfo`]. /// -/// Uses [`geo_from_fastly`] from `trusted_server_core::geo` to avoid -/// duplicating the field-mapping logic present in `GeoInfo::from_request`. +/// Shared by `FastlyPlatformGeo::lookup` in `trusted-server-adapter-fastly` so +/// that field mapping is never duplicated. +fn geo_from_fastly(geo: &Geo) -> GeoInfo { + GeoInfo { + city: 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(), + region: geo.region().map(str::to_string), + asn: None, + } +} + +/// Fastly geo-lookup implementation of [`PlatformGeo`]. pub struct FastlyPlatformGeo; impl PlatformGeo for FastlyPlatformGeo { @@ -577,25 +586,38 @@ pub fn build_runtime_services( .backend(Arc::new(FastlyPlatformBackend)) .http_client(Arc::new(FastlyPlatformHttpClient)) .geo(Arc::new(FastlyPlatformGeo)) - .client_info(ClientInfo { - client_ip: req.get_client_ip_addr(), - tls_protocol: req.get_tls_protocol().map(str::to_string), - tls_cipher: req.get_tls_cipher_openssl_name().map(str::to_string), - tls_ja4: req.get_tls_ja4().map(str::to_string), - h2_fingerprint: req.get_client_h2_fingerprint().map(str::to_string), - server_hostname: std::env::var("FASTLY_HOSTNAME").ok(), - server_region: std::env::var("FASTLY_REGION").ok(), - }) + .client_info(client_info_from_request(req)) .build() } +/// Extract [`ClientInfo`] from the original Fastly request. +/// +/// Fastly's TLS, JA4, and HTTP/2 fingerprint accessors only return real values +/// on the client request before it is converted to platform HTTP types. This +/// must therefore be called at the adapter entry point, while the original +/// [`fastly::Request`] is still available. Used by both [`build_runtime_services`] +/// (legacy path) and the `EdgeZero` entry point, which stores the result in the +/// request extensions so `build_per_request_services` can read back the same +/// bot-protection metadata the reconstructed request cannot expose. +#[must_use] +pub fn client_info_from_request(req: &Request) -> ClientInfo { + ClientInfo { + client_ip: req.get_client_ip_addr(), + tls_protocol: req.get_tls_protocol().map(str::to_string), + tls_cipher: req.get_tls_cipher_openssl_name().map(str::to_string), + tls_ja4: req.get_tls_ja4().map(str::to_string), + h2_fingerprint: req.get_client_h2_fingerprint().map(str::to_string), + server_hostname: std::env::var("FASTLY_HOSTNAME").ok(), + server_region: std::env::var("FASTLY_REGION").ok(), + } +} + /// Open a named KV store as a [`PlatformKvStore`] implementation. /// /// # Errors /// /// Returns [`KvError::Unavailable`] when the store does not exist, or /// [`KvError::Internal`] when the Fastly SDK fails to open it. -#[allow(dead_code)] pub fn open_kv_store(store_name: &str) -> Result, KvError> { FastlyKvStore::open(store_name).map(|store| Arc::new(store) as Arc) } diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs index 1abe16b63..df076632a 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::net::IpAddr; use std::sync::{Arc, Mutex}; +use crate::compat; use bytes::Bytes; use edgezero_core::body::Body as EdgeBody; use edgezero_core::http::response_builder as edge_response_builder; @@ -14,7 +15,7 @@ use trusted_server_core::auction::{ build_orchestrator, AuctionContext, AuctionOrchestrator, AuctionProvider, AuctionRequest, AuctionResponse, }; -use trusted_server_core::compat; +use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::ec::finalize::ec_finalize_response; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::TrustedServerError; @@ -599,7 +600,6 @@ fn route_result_to_fastly_response( super::finalize_response(settings, geo_info.as_ref(), &mut response); asset_cache_policy.apply_after_route_finalization(&mut response); - let mut fastly_response = compat::to_fastly_response(response); if should_finalize_ec { ec_finalize_response( settings, @@ -608,11 +608,19 @@ fn route_result_to_fastly_response( partner_registry, eids_cookie.as_deref(), sharedid_cookie.as_deref(), - &mut fastly_response, + &mut response, ); } - request_filter_effects.apply_to_fastly_response(&mut fastly_response); - fastly_response + request_filter_effects.apply_to_response(&mut response); + compat::to_fastly_response(response) +} + +fn browser_device_signals() -> DeviceSignals { + DeviceSignals::derive( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15", + Some("t13d1516h2_000000000000_000000000000"), + None, + ) } fn route_auction(settings: &Settings, body: impl Into>) -> fastly::Response { @@ -641,6 +649,7 @@ fn route_auction_with_stack( &partner_registry, &services, compat::from_fastly_request(req), + browser_device_signals(), )) .expect("should route auction request"); route_result_to_fastly_response(settings, &services, &partner_registry, route_result) @@ -664,6 +673,7 @@ fn route_buffered_response( &partner_registry, services, compat::from_fastly_request(req), + browser_device_signals(), )) .expect(expect_message); route_result_to_fastly_response(settings, services, &partner_registry, route_result) @@ -983,6 +993,7 @@ fn routes_use_request_local_consent() { &partner_registry, &discovery_services, compat::from_fastly_request(discovery_fastly_req), + DeviceSignals::derive("", None, None), )) .expect("should route discovery request"); assert_eq!( @@ -1000,6 +1011,7 @@ fn routes_use_request_local_consent() { &partner_registry, &admin_services, compat::from_fastly_request(admin_fastly_req), + DeviceSignals::derive("", None, None), )) .expect("should route admin request"); assert_eq!( @@ -1218,6 +1230,7 @@ fn asset_routes_stream_asset_responses_directly() { &partner_registry, &services, req, + browser_device_signals(), )) .expect("should route streaming asset request"); diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index 95ef3a035..f8f27c79b 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -22,6 +22,10 @@ config = { workspace = true } cookie = { workspace = true } derive_more = { workspace = true } error-stack = { workspace = true } +# Still required by ec/kv.rs (KV Store compare-and-swap via InsertMode + +# generation preconditions, which the EdgeZero KvStore trait does not expose +# yet) and ec/rate_limiter.rs (fastly::erl Edge Rate Limiting). Removal is +# deferred until EdgeZero grows equivalent APIs — tracked in issue #495. fastly = { workspace = true } flate2 = { workspace = true } futures = { workspace = true } @@ -40,7 +44,6 @@ serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } subtle = { workspace = true } -tokio = { workspace = true } toml = { workspace = true } trusted-server-js = { path = "../js" } trusted-server-openrtb = { path = "../openrtb" } @@ -66,6 +69,9 @@ validator = { workspace = true } [features] default = [] +# Exposes test-only constructors (e.g. `IntegrationRegistry::from_request_filters`) +# so downstream crates can build registries with stub integrations in their tests. +test-utils = [] [dev-dependencies] criterion = { workspace = true } diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index f265eb1bf..000ef669c 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -763,42 +763,44 @@ mod tests { ); } - #[tokio::test] - async fn auction_rejects_oversized_body() { - use edgezero_core::body::Body as EdgeBody; - use http::{Method, Request as HttpRequest, StatusCode}; - - use crate::auction::build_orchestrator; - use crate::consent::ConsentContext; - use crate::ec::EcContext; - use crate::platform::test_support::noop_services; - use crate::test_support::tests::create_test_settings; - - let settings = create_test_settings(); - let orchestrator = build_orchestrator(&settings).expect("should build orchestrator"); - let services = noop_services(); - let ec_context = EcContext::new_for_test(None, ConsentContext::default()); - let oversized = vec![b'x'; MAX_AUCTION_BODY_SIZE + 1]; - let req = HttpRequest::builder() - .method(Method::POST) - .uri("https://test.com/auction") - .body(EdgeBody::from(oversized)) - .expect("should build request"); - let response = handle_auction( - &settings, - &orchestrator, - None, - None, - &ec_context, - &services, - req, - ) - .await - .expect("should return 413 response for oversized body"); - assert_eq!( - response.status(), - StatusCode::PAYLOAD_TOO_LARGE, - "should return 413 for auction body over limit" - ); + #[test] + fn auction_rejects_oversized_body() { + futures::executor::block_on(async { + use edgezero_core::body::Body as EdgeBody; + use http::{Method, Request as HttpRequest, StatusCode}; + + use crate::auction::build_orchestrator; + use crate::consent::ConsentContext; + use crate::ec::EcContext; + use crate::platform::test_support::noop_services; + use crate::test_support::tests::create_test_settings; + + let settings = create_test_settings(); + let orchestrator = build_orchestrator(&settings).expect("should build orchestrator"); + let services = noop_services(); + let ec_context = EcContext::new_for_test(None, ConsentContext::default()); + let oversized = vec![b'x'; MAX_AUCTION_BODY_SIZE + 1]; + let req = HttpRequest::builder() + .method(Method::POST) + .uri("https://test.com/auction") + .body(EdgeBody::from(oversized)) + .expect("should build request"); + let response = handle_auction( + &settings, + &orchestrator, + None, + None, + &ec_context, + &services, + req, + ) + .await + .expect("should return 413 response for oversized body"); + assert_eq!( + response.status(), + StatusCode::PAYLOAD_TOO_LARGE, + "should return 413 for auction body over limit" + ); + }); } } diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index ba6aaef7f..451987a1a 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -363,7 +363,7 @@ impl AuctionOrchestrator { // Get the backend name for this provider to map responses back. // Must be computed after effective_timeout since the timeout is // part of the backend name. - let backend_name = match provider.backend_name(effective_timeout) { + let backend_name = match provider.backend_name(context.services, effective_timeout) { Some(name) => name, None => { log::warn!( @@ -798,7 +798,7 @@ mod tests { 2000 } - fn backend_name(&self, _timeout_ms: u32) -> Option { + fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { Some(self.backend.to_string()) } } @@ -882,7 +882,7 @@ mod tests { 2000 } - fn backend_name(&self, _timeout_ms: u32) -> Option { + fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { Some("launch-failing-backend".to_string()) } } @@ -1055,63 +1055,67 @@ mod tests { // of requiring real platform backends. An `#[ignore]` integration test // exercising the full path via Viceroy would also catch regressions. - #[tokio::test] - async fn test_no_providers_configured() { - let config = AuctionConfig { - enabled: true, - providers: vec![], - mediator: None, - timeout_ms: 2000, - creative_store: "creative_store".to_string(), - allowed_context_keys: HashSet::from(["permutive_segments".to_string()]), - }; + #[test] + fn test_no_providers_configured() { + futures::executor::block_on(async { + let config = AuctionConfig { + enabled: true, + providers: vec![], + mediator: None, + timeout_ms: 2000, + creative_store: "creative_store".to_string(), + allowed_context_keys: HashSet::from(["permutive_segments".to_string()]), + }; - let orchestrator = AuctionOrchestrator::new(config); + let orchestrator = AuctionOrchestrator::new(config); - let request = create_test_auction_request(); - let settings = create_test_settings(); - let req = http::Request::builder() - .method(http::Method::GET) - .uri("https://test.com/test") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let context = create_test_auction_context(&settings, &req, 2000); + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://test.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = create_test_auction_context(&settings, &req, 2000); - let result = orchestrator.run_auction(&request, &context).await; + let result = orchestrator.run_auction(&request, &context).await; - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(format!("{}", err).contains("No providers configured")); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(format!("{}", err).contains("No providers configured")); + }); } - #[tokio::test] - async fn provider_launch_failures_error_when_no_requests_launch() { - let config = AuctionConfig { - enabled: true, - providers: vec!["launch-failing".to_string()], - timeout_ms: 2000, - ..Default::default() - }; - let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(LaunchFailingProvider)); - - let request = create_test_auction_request(); - let settings = create_test_settings(); - let req = http::Request::builder() - .method(http::Method::GET) - .uri("https://test.com/test") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let context = create_test_auction_context(&settings, &req, 2000); - - let result = orchestrator.run_auction(&request, &context).await; - - let err = result.expect_err("should fail when every provider launch fails"); - assert!( - err.to_string() - .contains("All 1 configured provider(s) skipped or failed to launch"), - "should explain that no configured provider request launched" - ); + #[test] + fn provider_launch_failures_error_when_no_requests_launch() { + futures::executor::block_on(async { + let config = AuctionConfig { + enabled: true, + providers: vec!["launch-failing".to_string()], + timeout_ms: 2000, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(LaunchFailingProvider)); + + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://test.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = create_test_auction_context(&settings, &req, 2000); + + let result = orchestrator.run_auction(&request, &context).await; + + let err = result.expect_err("should fail when every provider launch fails"); + assert!( + err.to_string() + .contains("All 1 configured provider(s) skipped or failed to launch"), + "should explain that no configured provider request launched" + ); + }); } #[test] @@ -1166,87 +1170,89 @@ mod tests { ); } - #[tokio::test] - async fn select_error_is_attributed_to_correct_provider() { - // Arrange: two stub providers backed by distinct backend names. - // The stub HTTP client injects a select() error for the first request - // that completes (backend-a). backend-b should still produce a success. - let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"{}".to_vec()); // consumed by send_async for backend-a - stub.push_response(200, b"{}".to_vec()); // consumed by send_async for backend-b - stub.push_select_error(); // first select() reports backend-a as failed - - let services = build_services_with_http_client(stub); - // SAFETY: `Box::leak` creates a `'static` reference for test use only. - // The leaked allocation is bounded to the test process lifetime. - let services: &'static RuntimeServices = Box::leak(Box::new(services)); + #[test] + fn select_error_is_attributed_to_correct_provider() { + futures::executor::block_on(async { + // Arrange: two stub providers backed by distinct backend names. + // The stub HTTP client injects a select() error for the first request + // that completes (backend-a). backend-b should still produce a success. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); // consumed by send_async for backend-a + stub.push_response(200, b"{}".to_vec()); // consumed by send_async for backend-b + stub.push_select_error(); // first select() reports backend-a as failed + + let services = build_services_with_http_client(stub); + // SAFETY: `Box::leak` creates a `'static` reference for test use only. + // The leaked allocation is bounded to the test process lifetime. + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + + let config = AuctionConfig { + enabled: true, + providers: vec!["provider-a".to_string(), "provider-b".to_string()], + timeout_ms: 2000, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubAuctionProvider { + name: "provider-a", + backend: "backend-a", + })); + orchestrator.register_provider(Arc::new(StubAuctionProvider { + name: "provider-b", + backend: "backend-b", + })); - let config = AuctionConfig { - enabled: true, - providers: vec!["provider-a".to_string(), "provider-b".to_string()], - timeout_ms: 2000, - mediator: None, - ..Default::default() - }; - let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "backend-a", - })); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", - backend: "backend-b", - })); - - let request = create_test_auction_request(); - let settings = create_test_settings(); - let req = http::Request::builder() - .method(http::Method::GET) - .uri("https://example.com/test") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let context = AuctionContext { - settings: &settings, - request: &req, - timeout_ms: 2000, - provider_responses: None, - services, - }; + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &req, + timeout_ms: 2000, + provider_responses: None, + services, + }; - // Act - let result = orchestrator - .run_auction(&request, &context) - .await - .expect("should complete auction even when one provider errors"); + // Act + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should complete auction even when one provider errors"); - // Assert: exactly two responses — one error, one success. - assert_eq!( - result.provider_responses.len(), - 2, - "should collect responses from both providers" - ); + // Assert: exactly two responses — one error, one success. + assert_eq!( + result.provider_responses.len(), + 2, + "should collect responses from both providers" + ); - let provider_a = result - .provider_responses - .iter() - .find(|r| r.provider == "provider-a") - .expect("should have provider-a response"); - let provider_b = result - .provider_responses - .iter() - .find(|r| r.provider == "provider-b") - .expect("should have provider-b response"); + let provider_a = result + .provider_responses + .iter() + .find(|r| r.provider == "provider-a") + .expect("should have provider-a response"); + let provider_b = result + .provider_responses + .iter() + .find(|r| r.provider == "provider-b") + .expect("should have provider-b response"); - assert_eq!( - provider_a.status, - BidStatus::Error, - "provider-a should be marked error — select() Err was attributed via failed_backend_name" - ); - assert_eq!( - provider_b.status, - BidStatus::Success, - "provider-b should succeed — error was correctly isolated to provider-a" - ); + assert_eq!( + provider_a.status, + BidStatus::Error, + "provider-a should be marked error — select() Err was attributed via failed_backend_name" + ); + assert_eq!( + provider_b.status, + BidStatus::Success, + "provider-b should succeed — error was correctly isolated to provider-a" + ); + }); } #[test] diff --git a/crates/trusted-server-core/src/auction/provider.rs b/crates/trusted-server-core/src/auction/provider.rs index 0d1e43270..f5d235aa0 100644 --- a/crates/trusted-server-core/src/auction/provider.rs +++ b/crates/trusted-server-core/src/auction/provider.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use error_stack::Report; use crate::error::TrustedServerError; -use crate::platform::{PlatformPendingRequest, PlatformResponse}; +use crate::platform::{PlatformPendingRequest, PlatformResponse, RuntimeServices}; use super::types::{AuctionContext, AuctionRequest, AuctionResponse}; @@ -67,9 +67,10 @@ pub trait AuctionProvider: Send + Sync { /// /// `timeout_ms` is the effective timeout that will be used when the backend /// is registered in [`request_bids`](Self::request_bids). It must be - /// forwarded to [`crate::backend::BackendConfig::backend_name_for_url()`] so the predicted - /// name matches the actual registration (the timeout is part of the name). - fn backend_name(&self, _timeout_ms: u32) -> Option { + /// forwarded to [`crate::platform::PlatformBackend::predict_name`] through + /// `services` so the predicted name matches the actual platform backend + /// registration. + fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { None } } diff --git a/crates/trusted-server-core/src/compat.rs b/crates/trusted-server-core/src/compat.rs deleted file mode 100644 index 909120004..000000000 --- a/crates/trusted-server-core/src/compat.rs +++ /dev/null @@ -1,720 +0,0 @@ -//! Compatibility bridge between `fastly` SDK types and `http` crate types. -//! -//! All items in this module are temporary scaffolding created in PR 11 and -//! scheduled for deletion in PR 15. Do not add new callers after PR 13. -//! -//! # PR 15 removal target - -use edgezero_core::body::Body as EdgeBody; -use fastly::http::header; - -use crate::constants::INTERNAL_HEADERS; -use crate::http_util::SPOOFABLE_FORWARDED_HEADERS; - -fn build_http_request(req: &fastly::Request, body: EdgeBody) -> http::Request { - let uri: http::Uri = req.get_url_str().parse().unwrap_or_else(|_| { - log::warn!( - "Failed to parse request URL '{}'; falling back to '/'", - req.get_url_str() - ); - http::Uri::from_static("/") - }); - - let mut builder = http::Request::builder() - .method(req.get_method().clone()) - .uri(uri); - - for (name, value) in req.get_headers() { - builder = builder.header(name.as_str(), value.as_bytes()); - } - - // Cannot fail: URI is always valid (parsed above or the "/" fallback), - // and Fastly pre-validates all method and header values. - builder - .body(body) - .expect("should build http request from fastly request") -} - -/// Convert an owned `fastly::Request` into an `http::Request`. -/// -/// # PR 15 removal target -/// -/// # Panics -/// -/// Does not panic in practice — URL parse failure falls back to `"/"` (logged -/// as a warning), and the subsequent `builder.body()` cannot fail given a valid -/// method and URI. Listed here only because clippy cannot prove it statically. -pub fn from_fastly_request(mut req: fastly::Request) -> http::Request { - let body = EdgeBody::from(req.take_body_bytes()); - build_http_request(&req, body) -} - -/// Convert a borrowed `fastly::Request` into an `http::Request` for reading. -/// -/// Headers are copied; the body is empty. -/// -/// # PR 15 removal target -/// -/// # Panics -/// -/// Does not panic in practice — URL parse failure falls back to `"/"` (logged -/// as a warning), and the subsequent `builder.body()` cannot fail given a valid -/// method and URI. Listed here only because clippy cannot prove it statically. -pub fn from_fastly_headers_ref(req: &fastly::Request) -> http::Request { - build_http_request(req, EdgeBody::empty()) -} - -/// Convert an `http::Request` into a `fastly::Request`. -/// -/// # PR 15 removal target -pub fn to_fastly_request(req: http::Request) -> fastly::Request { - let (parts, body) = req.into_parts(); - let mut fastly_req = fastly::Request::new(parts.method, parts.uri.to_string()); - for (name, value) in &parts.headers { - fastly_req.append_header(name.as_str(), value.as_bytes()); - } - - match body { - EdgeBody::Once(bytes) => { - if !bytes.is_empty() { - // bytes.to_vec() is an O(N) copy at the compat boundary; goes away in PR 15. - fastly_req.set_body(bytes.to_vec()); - } - } - EdgeBody::Stream(_) => { - // Audited call sites: only integration proxy routes, which always carry buffered - // Once bodies from from_fastly_request. No streaming body reaches this path today. - log::warn!("streaming body in compat::to_fastly_request; body will be empty"); - } - } - - fastly_req -} - -/// Convert a borrowed `http::Request` into a `fastly::Request`. -/// -/// Headers, method, and URI are copied; the body is always empty. This function -/// requires that the caller has already consumed or discarded the body — passing -/// a request with a non-empty body is a caller error: the body bytes will be -/// silently lost with no warning or panic. -/// -/// # PR 15 removal target -pub fn to_fastly_request_ref(req: &http::Request) -> fastly::Request { - let mut fastly_req = fastly::Request::new(req.method().clone(), req.uri().to_string()); - for (name, value) in req.headers() { - fastly_req.append_header(name.as_str(), value.as_bytes()); - } - - fastly_req -} - -/// Convert a `fastly::Response` into an `http::Response`. -/// -/// # PR 15 removal target -/// -/// # Panics -/// -/// Panics if the copied Fastly response parts cannot form a valid -/// `http::Response`. -pub fn from_fastly_response(mut resp: fastly::Response) -> http::Response { - let status = resp.get_status(); - let mut builder = http::Response::builder().status(status); - for (name, value) in resp.get_headers() { - builder = builder.header(name.as_str(), value.as_bytes()); - } - - builder - .body(EdgeBody::from(resp.take_body_bytes())) - .expect("should build http response from fastly response") -} - -/// Convert an `http::Response` into a `fastly::Response`. -/// -/// # PR 15 removal target -pub fn to_fastly_response(resp: http::Response) -> 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() { - // bytes.to_vec() is an O(N) copy at the compat boundary; goes away in PR 15. - fastly_resp.set_body(bytes.to_vec()); - } - } - EdgeBody::Stream(_) => { - // Audited call sites: streaming publisher bodies are dispatched via - // to_fastly_response_skeleton + stream_to_client before reaching here. - // No streaming body reaches to_fastly_response in the current adapter. - log::warn!("streaming body in compat::to_fastly_response; body will be empty"); - } - } - - fastly_resp -} - -/// Convert an `http::Response` into a `fastly::Response` with headers only. -/// -/// Body is discarded — use when the caller will stream the body separately via -/// [`fastly::Response::stream_to_client`]. Audited call sites: only the streaming -/// publisher dispatch path in the adapter, which streams the body directly to the -/// client via [`crate::publisher::stream_publisher_body`]. (Removal target: PR 15.) -/// -/// # PR 15 removal target -pub fn to_fastly_response_skeleton(resp: http::Response) -> 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 and internal headers on a `fastly::Request`. -/// -/// Strips spoofable forwarded headers (X-Forwarded-Host etc.) and TS-internal -/// identity headers (x-ts-ec, x-ts-eids, …) that clients must not be able to -/// inject. Both sets are stripped at the edge entry point, before any -/// request-derived context is built or the request is converted to http types. -/// -/// # PR 15 removal target -pub fn sanitize_fastly_forwarded_headers(req: &mut fastly::Request) { - for &name in SPOOFABLE_FORWARDED_HEADERS { - if req.get_header(name).is_some() { - log::debug!("Stripped spoofable header: {name}"); - req.remove_header(name); - } - } - for &name in crate::constants::INTERNAL_HEADERS { - if req.get_header(name).is_some() { - log::debug!("Stripped inbound internal header: {name}"); - req.remove_header(name); - } - } -} - -/// Copy `X-*` custom headers between two `fastly::Request` values. -/// -/// # PR 15 removal target -pub fn copy_fastly_custom_headers(from: &fastly::Request, to: &mut fastly::Request) { - for (name, value) in from.get_headers() { - let name_str = name.as_str(); - if name_str.starts_with("x-") && !INTERNAL_HEADERS.contains(&name_str) { - to.append_header(name_str, value); - } - } -} - -/// Forward the `Cookie` header from one `fastly::Request` to another. -/// -/// # PR 15 removal target -pub fn forward_fastly_cookie_header( - from: &fastly::Request, - to: &mut fastly::Request, - strip_consent: bool, -) { - use crate::cookies::{strip_cookies, CONSENT_COOKIE_NAMES}; - - let Some(cookie_value) = from.get_header(header::COOKIE) else { - return; - }; - - if !strip_consent { - to.set_header(header::COOKIE, cookie_value); - return; - } - - match cookie_value.to_str() { - Ok(value) => { - let stripped = strip_cookies(value, CONSENT_COOKIE_NAMES); - if !stripped.is_empty() { - to.set_header(header::COOKIE, &stripped); - } - } - Err(_) => { - to.set_header(header::COOKIE, cookie_value); - } - } -} - -/// Set the EC ID cookie on a `fastly::Response`. -/// -/// # PR 15 removal target -pub fn set_fastly_ec_cookie( - settings: &crate::settings::Settings, - response: &mut fastly::Response, - ec_id: &str, -) { - crate::ec::cookies::set_ec_cookie(settings, response, ec_id); -} - -/// Expire the EC ID cookie on a `fastly::Response`. -/// -/// # PR 15 removal target -pub fn expire_fastly_ec_cookie( - settings: &crate::settings::Settings, - response: &mut fastly::Response, -) { - crate::ec::cookies::expire_ec_cookie(settings, response); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn assert_once_body_eq(body: EdgeBody, expected: &[u8]) { - match body { - EdgeBody::Once(bytes) => assert_eq!(bytes.as_ref(), expected, "should copy body bytes"), - EdgeBody::Stream(_) => panic!("expected non-streaming body"), - } - } - - #[test] - fn from_fastly_headers_ref_copies_headers() { - let mut fastly_req = - fastly::Request::new(fastly::http::Method::GET, "https://example.com/path"); - fastly_req.set_header("x-custom", "value"); - - let http_req = from_fastly_headers_ref(&fastly_req); - - assert_eq!(http_req.uri().path(), "/path", "should copy path"); - assert_eq!( - http_req - .headers() - .get("x-custom") - .and_then(|v| v.to_str().ok()), - Some("value"), - "should copy custom header" - ); - } - - #[test] - fn from_fastly_headers_ref_preserves_duplicate_headers() { - let mut fastly_req = - fastly::Request::new(fastly::http::Method::GET, "https://example.com/path"); - fastly_req.append_header("x-custom", "first"); - fastly_req.append_header("x-custom", "second"); - - let http_req = from_fastly_headers_ref(&fastly_req); - let values: Vec<_> = http_req - .headers() - .get_all("x-custom") - .iter() - .map(|value| value.to_str().expect("should be valid utf8")) - .collect(); - - assert_eq!( - values, - vec!["first", "second"], - "should preserve duplicates" - ); - } - - #[test] - fn from_fastly_headers_ref_body_is_empty() { - let fastly_req = fastly::Request::new(fastly::http::Method::POST, "https://example.com/"); - - let http_req = from_fastly_headers_ref(&fastly_req); - - assert_eq!(http_req.method(), http::Method::POST, "should copy method"); - assert_once_body_eq(http_req.into_body(), b""); - } - - #[test] - fn from_fastly_request_copies_body() { - let mut fastly_req = - fastly::Request::new(fastly::http::Method::POST, "https://example.com/path"); - fastly_req.set_header("content-type", "application/json"); - fastly_req.set_body(r#"{"ok":true}"#); - - let http_req = from_fastly_request(fastly_req); - let (parts, body) = http_req.into_parts(); - - assert_eq!(parts.method, http::Method::POST, "should copy method"); - assert_eq!(parts.uri.path(), "/path", "should copy uri path"); - assert_eq!( - parts - .headers - .get("content-type") - .and_then(|v| v.to_str().ok()), - Some("application/json"), - "should copy headers" - ); - assert_once_body_eq(body, br#"{"ok":true}"#); - } - - #[test] - fn to_fastly_request_copies_headers_and_body() { - let http_req = http::Request::builder() - .method(http::Method::POST) - .uri("https://example.com/submit") - .header("x-custom", "value") - .body(EdgeBody::from(b"payload".as_ref())) - .expect("should build request"); - - let mut fastly_req = to_fastly_request(http_req); - - assert_eq!( - fastly_req.get_method(), - &fastly::http::Method::POST, - "should copy method" - ); - assert_eq!( - fastly_req - .get_header("x-custom") - .and_then(|v| v.to_str().ok()), - Some("value"), - "should copy headers" - ); - assert_eq!( - fastly_req.take_body_bytes().as_slice(), - b"payload", - "should copy body bytes" - ); - } - - #[test] - fn to_fastly_request_preserves_duplicate_headers() { - let http_req = http::Request::builder() - .method(http::Method::GET) - .uri("https://example.com/") - .header("x-custom", "first") - .header("x-custom", "second") - .body(EdgeBody::empty()) - .expect("should build request"); - - let fastly_req = to_fastly_request(http_req); - - let values: Vec<_> = fastly_req - .get_headers() - .filter(|(name, _)| name.as_str() == "x-custom") - .map(|(_, value)| value.to_str().expect("should be valid utf8")) - .collect(); - assert_eq!( - values, - vec!["first", "second"], - "should preserve duplicate headers" - ); - } - - #[test] - fn from_fastly_response_copies_status_headers_and_body() { - let mut fastly_resp = fastly::Response::from_status(202); - fastly_resp.set_header("content-type", "application/json"); - fastly_resp.set_body(r#"{"ok":true}"#); - - let http_resp = from_fastly_response(fastly_resp); - let (parts, body) = http_resp.into_parts(); - - assert_eq!(parts.status.as_u16(), 202, "should copy status"); - assert_eq!( - parts - .headers - .get("content-type") - .and_then(|v| v.to_str().ok()), - Some("application/json"), - "should copy headers" - ); - assert_once_body_eq(body, br#"{"ok":true}"#); - } - - #[test] - fn to_fastly_response_copies_status_and_headers() { - let http_resp = http::Response::builder() - .status(201) - .header("content-type", "application/json") - .body(EdgeBody::from(b"{}".as_ref())) - .expect("should build response"); - - let fastly_resp = to_fastly_response(http_resp); - - assert_eq!(fastly_resp.get_status().as_u16(), 201, "should copy status"); - assert!( - fastly_resp.get_header("content-type").is_some(), - "should copy content-type header" - ); - } - - #[test] - fn to_fastly_request_ref_copies_method_uri_and_headers_without_body() { - let http_req = http::Request::builder() - .method(http::Method::POST) - .uri("https://example.com/path?q=1") - .header("x-custom", "value") - .body(EdgeBody::from(b"payload".as_ref())) - .expect("should build request"); - - let mut fastly_req = to_fastly_request_ref(&http_req); - - assert_eq!( - fastly_req.get_method(), - &fastly::http::Method::POST, - "should copy method" - ); - assert_eq!( - fastly_req.get_url_str(), - "https://example.com/path?q=1", - "should copy URI" - ); - assert_eq!( - fastly_req - .get_header("x-custom") - .and_then(|v| v.to_str().ok()), - Some("value"), - "should copy headers" - ); - assert!( - fastly_req.take_body_bytes().is_empty(), - "borrowed conversion should not copy body bytes" - ); - } - - #[test] - fn to_fastly_request_ref_preserves_duplicate_headers() { - let http_req = http::Request::builder() - .method(http::Method::GET) - .uri("https://example.com/") - .header("x-custom", "first") - .header("x-custom", "second") - .body(EdgeBody::empty()) - .expect("should build request"); - - let fastly_req = to_fastly_request_ref(&http_req); - - let values: Vec<_> = fastly_req - .get_headers() - .filter(|(name, _)| name.as_str() == "x-custom") - .map(|(_, value)| value.to_str().expect("should be valid utf8")) - .collect(); - assert_eq!( - values, - vec!["first", "second"], - "should preserve duplicate headers" - ); - } - - #[test] - fn sanitize_fastly_forwarded_headers_strips_spoofable() { - let mut req = fastly::Request::new(fastly::http::Method::GET, "https://example.com"); - req.set_header("forwarded", "host=evil.com"); - req.set_header("x-forwarded-host", "evil.com"); - req.set_header("x-forwarded-proto", "https"); - req.set_header("fastly-ssl", "1"); - req.set_header("host", "legit.example.com"); - - sanitize_fastly_forwarded_headers(&mut req); - - assert!( - req.get_header("forwarded").is_none(), - "should strip Forwarded" - ); - assert!( - req.get_header("x-forwarded-host").is_none(), - "should strip X-Forwarded-Host" - ); - assert!( - req.get_header("x-forwarded-proto").is_none(), - "should strip X-Forwarded-Proto" - ); - assert!( - req.get_header("fastly-ssl").is_none(), - "should strip Fastly-SSL" - ); - assert_eq!( - req.get_header("host").and_then(|v| v.to_str().ok()), - Some("legit.example.com"), - "should preserve Host" - ); - } - - #[test] - fn forward_fastly_cookie_header_strips_consent() { - let mut from_req = fastly::Request::new(fastly::http::Method::GET, "https://example.com"); - from_req.set_header(header::COOKIE, "euconsent-v2=BOE; session=abc"); - let mut to_req = fastly::Request::new(fastly::http::Method::GET, "https://partner.com"); - - forward_fastly_cookie_header(&from_req, &mut to_req, true); - - let forwarded = to_req - .get_header(header::COOKIE) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - assert!( - !forwarded.contains("euconsent-v2"), - "should strip consent cookie" - ); - assert!( - forwarded.contains("session=abc"), - "should keep non-consent cookie" - ); - } - - #[test] - fn copy_fastly_custom_headers_filters_internal() { - let mut from_req = fastly::Request::new(fastly::http::Method::GET, "https://example.com"); - from_req.set_header("x-custom-data", "present"); - from_req.set_header("x-ts-ec", "should-not-copy"); - let mut to_req = fastly::Request::new(fastly::http::Method::GET, "https://partner.com"); - - copy_fastly_custom_headers(&from_req, &mut to_req); - - assert_eq!( - to_req - .get_header("x-custom-data") - .and_then(|v| v.to_str().ok()), - Some("present"), - "should copy arbitrary x-header" - ); - assert!( - to_req.get_header("x-ts-ec").is_none(), - "should not copy internal header" - ); - } - - #[test] - fn copy_fastly_custom_headers_preserves_duplicate_values() { - let mut from_req = fastly::Request::new(fastly::http::Method::GET, "https://example.com"); - from_req.append_header("x-custom-data", "first"); - from_req.append_header("x-custom-data", "second"); - let mut to_req = fastly::Request::new(fastly::http::Method::GET, "https://partner.com"); - - copy_fastly_custom_headers(&from_req, &mut to_req); - - let values: Vec<_> = to_req - .get_headers() - .filter(|(name, _)| name.as_str() == "x-custom-data") - .map(|(_, value)| value.to_str().expect("should be valid utf8")) - .collect(); - assert_eq!( - values, - vec!["first", "second"], - "should preserve duplicates" - ); - } - - #[test] - fn set_fastly_ec_cookie_sets_cookie_header() { - let settings = crate::test_support::tests::create_test_settings(); - let mut response = fastly::Response::new(); - - let ec_id = format!("{}.Ab12z9", "a".repeat(64)); - set_fastly_ec_cookie(&settings, &mut response, &ec_id); - - let cookie = response - .get_header(header::SET_COOKIE) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned); - assert_eq!( - cookie, - Some(format!( - "ts-ec={ec_id}; Domain=.{}; Path=/; Secure; SameSite=Lax; Max-Age=31536000; HttpOnly", - settings.publisher.domain - )), - "should set expected EC cookie" - ); - } - - #[test] - fn expire_fastly_ec_cookie_sets_expiry_cookie() { - let settings = crate::test_support::tests::create_test_settings(); - let mut response = fastly::Response::new(); - - expire_fastly_ec_cookie(&settings, &mut response); - - let cookie = response - .get_header(header::SET_COOKIE) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned); - assert_eq!( - cookie, - Some(format!( - "ts-ec=; Domain=.{}; Path=/; Secure; SameSite=Lax; Max-Age=0; HttpOnly", - settings.publisher.domain - )), - "should set expected expiry cookie" - ); - } - - #[test] - fn to_fastly_response_skeleton_copies_status_and_headers_discards_body() { - let http_resp = http::Response::builder() - .status(206) - .header("content-type", "text/html; charset=utf-8") - .header("x-custom", "value") - .body(EdgeBody::from(b"some body bytes".as_ref())) - .expect("should build response"); - - let fastly_resp = to_fastly_response_skeleton(http_resp); - - assert_eq!( - fastly_resp.get_status().as_u16(), - 206, - "should copy status code" - ); - assert_eq!( - fastly_resp - .get_header("content-type") - .and_then(|v| v.to_str().ok()), - Some("text/html; charset=utf-8"), - "should copy content-type header" - ); - assert_eq!( - fastly_resp - .get_header("x-custom") - .and_then(|v| v.to_str().ok()), - Some("value"), - "should copy custom header" - ); - } - - #[test] - fn to_fastly_request_with_streaming_body_produces_empty_body() { - // Stream bodies cannot cross the compat boundary: the Fastly SDK has no - // streaming body API, so the shim drops the stream and logs a warning. - // This test pins that silent-drop behaviour so it cannot become - // accidentally load-bearing. (Removal target: PR 15.) - let body = EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( - b"data", - )])); - let http_req = http::Request::builder() - .method(http::Method::POST) - .uri("https://example.com/") - .body(body) - .expect("should build request"); - - let mut fastly_req = to_fastly_request(http_req); - - assert!( - fastly_req.take_body_bytes().is_empty(), - "streaming body should be silently dropped; compat shim produces empty body" - ); - } - - #[test] - fn to_fastly_response_with_streaming_body_produces_empty_body() { - // Same constraint as to_fastly_request: streaming bodies are dropped at - // the compat boundary. (Removal target: PR 15.) - let body = EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( - b"data", - )])); - let http_resp = http::Response::builder() - .status(200) - .body(body) - .expect("should build response"); - - let mut fastly_resp = to_fastly_response(http_resp); - - assert_eq!( - fastly_resp.get_status().as_u16(), - 200, - "should copy status code" - ); - assert!( - fastly_resp.take_body_bytes().is_empty(), - "streaming body should be silently dropped; compat shim produces empty body" - ); - } -} diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index 5d949b443..f28b44f5b 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -8,9 +8,10 @@ //! auction pipeline and populates `OpenRTB` bid requests. //! //! Consent is interpreted from request cookies, headers, geolocation, and -//! publisher policy defaults. The consent pipeline does not read from or write -//! to KV storage; EC identity lifecycle state is managed separately by the EC -//! identity graph. +//! publisher policy defaults. When the caller supplies an EC ID and a KV +//! store via [`ConsentPipelineInput`], the pipeline also loads persisted +//! consent as a fallback and persists cookie-sourced consent on change. EC +//! identity lifecycle state is managed separately by the EC identity graph. //! //! # Supported signals //! @@ -37,6 +38,7 @@ pub mod tcf; pub mod types; pub mod us_privacy; +pub use crate::storage::kv_store as kv; pub use extraction::extract_consent_signals; pub use types::{ ConsentContext, ConsentSource, PrivacyFlag, RawConsentSignals, TcfConsent, UsPrivacy, @@ -73,6 +75,17 @@ pub struct ConsentPipelineInput<'a> { pub config: &'a ConsentConfig, /// Geolocation data from the request (for jurisdiction detection). pub geo: Option<&'a GeoInfo>, + /// EC ID for KV Store consent persistence. + /// + /// When set along with `kv_store`, enables: + /// - **Read fallback**: loads consent from KV when cookies are absent. + /// - **Write-on-change**: persists cookie-sourced consent to KV. + pub ec_id: Option<&'a str>, + /// KV store for consent persistence. + /// + /// `None` when consent persistence is not configured for this request, or + /// when the caller intentionally skips consent KV access. + pub kv_store: Option<&'a dyn crate::platform::PlatformKvStore>, } /// Extracts, decodes, and normalizes consent signals from a request. @@ -87,9 +100,16 @@ pub struct ConsentPipelineInput<'a> { /// 6. Builds a [`ConsentContext`] with both raw and decoded data. /// 7. Logs a summary for observability. /// -/// The returned context reflects request-local consent signals plus policy -/// defaults only. This function does not load persisted consent from KV and -/// does not persist consent to KV. +/// When [`ConsentPipelineInput::ec_id`] and [`ConsentPipelineInput::kv_store`] +/// are both set, the pipeline also: +/// +/// - **Read fallback**: loads consent persisted in KV for the EC ID when the +/// request carries no consent signals. +/// - **Write-on-change**: persists cookie-sourced consent to KV after the +/// context is built (skipping empty contexts and unchanged fingerprints). +/// +/// Without those inputs the returned context reflects request-local consent +/// signals plus policy defaults only. /// /// Decoding failures are logged and the corresponding decoded field is set to /// `None` — the raw string is still preserved for proxy-mode forwarding. @@ -101,6 +121,20 @@ pub fn build_consent_context(input: &ConsentPipelineInput<'_>) -> ConsentContext log_missing_geo_warning_once(); } + // Read fallback: when the request carries no consent signals, fall back + // to consent persisted in KV for this EC ID (when persistence is wired). + if signals.is_empty() { + if let (Some(ec_id), Some(store)) = (input.ec_id, input.kv_store) { + if let Some(mut ctx) = kv::load_consent_from_kv(store, ec_id) { + // Jurisdiction is request-local: derive it from the current + // geo rather than the value stored with the persisted entry. + ctx.jurisdiction = jurisdiction::detect_jurisdiction(input.geo, input.config); + log_consent_context(&ctx); + return ctx; + } + } + } + // In proxy mode, skip decoding entirely. if input.config.mode == ConsentMode::Proxy { let jur = jurisdiction::detect_jurisdiction(input.geo, input.config); @@ -134,6 +168,13 @@ pub fn build_consent_context(input: &ConsentPipelineInput<'_>) -> ConsentContext apply_expiration_check(&mut ctx, input.config); apply_gpc_us_privacy(&mut ctx, input.config); + // Write-on-change: persist cookie-sourced consent for this EC ID (when + // persistence is wired). The helper skips empty contexts and unchanged + // fingerprints internally. + if let (Some(ec_id), Some(store)) = (input.ec_id, input.kv_store) { + kv::save_consent_to_kv(store, ec_id, &ctx, input.config.max_consent_age_days); + } + log_consent_context(&ctx); ctx } @@ -751,6 +792,8 @@ mod tests { req: &req, config: &config, geo: None, + ec_id: None, + kv_store: None, }); assert_eq!( @@ -778,6 +821,8 @@ mod tests { req: &req, config: &config, geo: None, + ec_id: None, + kv_store: None, }); assert!( @@ -806,6 +851,8 @@ mod tests { req: &req, config: &config, geo: None, + ec_id: None, + kv_store: None, }); assert!( @@ -1314,4 +1361,161 @@ mod tests { "GPP without US section should fall through to us_privacy" ); } + + // ----------------------------------------------------------------------- + // Consent KV read-fallback / write-on-change pipeline tests + // ----------------------------------------------------------------------- + + struct InMemoryKvStore { + entries: std::sync::Mutex>, + } + + impl InMemoryKvStore { + fn new() -> Self { + Self { + entries: std::sync::Mutex::new(std::collections::HashMap::new()), + } + } + } + + #[async_trait::async_trait(?Send)] + impl crate::platform::PlatformKvStore for InMemoryKvStore { + async fn get_bytes( + &self, + key: &str, + ) -> Result, crate::platform::KvError> { + Ok(self + .entries + .lock() + .expect("should lock entries") + .get(key) + .cloned()) + } + + async fn put_bytes( + &self, + key: &str, + value: bytes::Bytes, + ) -> Result<(), crate::platform::KvError> { + self.entries + .lock() + .expect("should lock entries") + .insert(key.to_owned(), value); + Ok(()) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: bytes::Bytes, + _ttl: std::time::Duration, + ) -> Result<(), crate::platform::KvError> { + self.put_bytes(key, value).await + } + + async fn delete(&self, key: &str) -> Result<(), crate::platform::KvError> { + self.entries + .lock() + .expect("should lock entries") + .remove(key); + Ok(()) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(edgezero_core::key_value_store::KvPage::default()) + } + } + + #[test] + fn pipeline_persists_cookie_sourced_consent_to_kv() { + let jar = parse_cookies_to_jar("us_privacy=1YNN"); + let req = build_request(); + let config = ConsentConfig::default(); + let store = InMemoryKvStore::new(); + + let ctx = build_consent_context(&ConsentPipelineInput { + jar: Some(&jar), + req: &req, + config: &config, + geo: None, + ec_id: Some("test-ec-id"), + kv_store: Some(&store), + }); + + assert_eq!( + ctx.raw_us_privacy.as_deref(), + Some("1YNN"), + "should build cookie-sourced consent" + ); + let persisted = crate::consent::kv::load_consent_from_kv(&store, "test-ec-id") + .expect("should persist cookie-sourced consent to KV"); + assert_eq!( + persisted.raw_us_privacy.as_deref(), + Some("1YNN"), + "persisted consent should round-trip the cookie signal" + ); + } + + #[test] + fn pipeline_falls_back_to_kv_consent_when_request_has_no_signals() { + let config = ConsentConfig::default(); + let store = InMemoryKvStore::new(); + + // First request carries a consent cookie — persisted to KV. + let jar = parse_cookies_to_jar("us_privacy=1YNN"); + let req = build_request(); + build_consent_context(&ConsentPipelineInput { + jar: Some(&jar), + req: &req, + config: &config, + geo: None, + ec_id: Some("test-ec-id"), + kv_store: Some(&store), + }); + + // Second request has no consent signals — must fall back to KV. + let bare_req = build_request(); + let ctx = build_consent_context(&ConsentPipelineInput { + jar: None, + req: &bare_req, + config: &config, + geo: None, + ec_id: Some("test-ec-id"), + kv_store: Some(&store), + }); + + assert_eq!( + ctx.raw_us_privacy.as_deref(), + Some("1YNN"), + "should load persisted consent when the request carries no signals" + ); + } + + #[test] + fn pipeline_skips_kv_when_persistence_not_wired() { + let jar = parse_cookies_to_jar("us_privacy=1YNN"); + let req = build_request(); + let config = ConsentConfig::default(); + let store = InMemoryKvStore::new(); + + // ec_id is absent, so the pipeline must not touch the KV store. + build_consent_context(&ConsentPipelineInput { + jar: Some(&jar), + req: &req, + config: &config, + geo: None, + ec_id: None, + kv_store: Some(&store), + }); + + assert!( + crate::consent::kv::load_consent_from_kv(&store, "test-ec-id").is_none(), + "should not persist consent without an EC ID" + ); + } } diff --git a/crates/trusted-server-core/src/consent_config.rs b/crates/trusted-server-core/src/consent_config.rs index e5fed1a9c..b12523fbe 100644 --- a/crates/trusted-server-core/src/consent_config.rs +++ b/crates/trusted-server-core/src/consent_config.rs @@ -72,6 +72,11 @@ pub struct ConsentConfig { /// but disagree on consent status. #[serde(default)] pub conflict_resolution: ConflictResolutionConfig, + /// When set, consent data is persisted per Edge Cookie (EC) ID so that + /// returning users without consent cookies can still have their + /// consent preferences applied. Set to `None` to disable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub consent_store: Option, } impl Default for ConsentConfig { @@ -84,6 +89,7 @@ impl Default for ConsentConfig { us_states: UsStatesConfig::default(), us_privacy_defaults: UsPrivacyDefaultsConfig::default(), conflict_resolution: ConflictResolutionConfig::default(), + consent_store: None, } } } diff --git a/crates/trusted-server-core/src/ec/auth.rs b/crates/trusted-server-core/src/ec/auth.rs index a480853d0..0e609f9a7 100644 --- a/crates/trusted-server-core/src/ec/auth.rs +++ b/crates/trusted-server-core/src/ec/auth.rs @@ -3,7 +3,8 @@ //! Used by both `/_ts/api/v1/identify` and `/_ts/api/v1/batch-sync` so //! authentication hardening stays consistent across endpoints. -use fastly::Request; +use edgezero_core::body::Body as EdgeBody; +use http::Request; use super::partner::hash_api_key; use super::registry::{PartnerConfig, PartnerRegistry}; @@ -11,9 +12,12 @@ use super::registry::{PartnerConfig, PartnerRegistry}; /// Authenticates a request via Bearer token, returning the matching partner. pub(super) fn authenticate_bearer<'r>( registry: &'r PartnerRegistry, - req: &Request, + req: &Request, ) -> Option<&'r PartnerConfig> { - let header_value = req.get_header_str("authorization")?; + let header_value = req + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok())?; let token = parse_bearer_token(header_value)?; let key_hash = hash_api_key(token); registry.find_by_api_key_hash(&key_hash) @@ -77,7 +81,11 @@ mod tests { #[test] fn authenticate_bearer_returns_none_for_missing_header() { let registry = PartnerRegistry::empty(); - let req = Request::new("GET", "https://edge.example.com/_ts/api/v1/identify"); + let req = Request::builder() + .method("GET") + .uri("https://edge.example.com/_ts/api/v1/identify") + .body(EdgeBody::empty()) + .expect("should build test request"); let result = authenticate_bearer(®istry, &req); assert!(result.is_none(), "should return None without auth header"); @@ -86,8 +94,12 @@ mod tests { #[test] fn authenticate_bearer_returns_none_for_malformed_header() { let registry = PartnerRegistry::empty(); - let mut req = Request::new("GET", "https://edge.example.com/_ts/api/v1/identify"); - req.set_header("authorization", "Basic dXNlcjpwYXNz"); + let req = Request::builder() + .method("GET") + .uri("https://edge.example.com/_ts/api/v1/identify") + .header("authorization", "Basic dXNlcjpwYXNz") + .body(EdgeBody::empty()) + .expect("should build test request"); let result = authenticate_bearer(®istry, &req); assert!( @@ -100,8 +112,12 @@ mod tests { fn authenticate_bearer_returns_matching_partner_for_valid_token() { let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); - let mut req = Request::new("GET", "https://edge.example.com/_ts/api/v1/identify"); - req.set_header("authorization", format!("Bearer {VALID_API_TOKEN}")); + let req = Request::builder() + .method("GET") + .uri("https://edge.example.com/_ts/api/v1/identify") + .header("authorization", format!("Bearer {VALID_API_TOKEN}")) + .body(EdgeBody::empty()) + .expect("should build test request"); let result = authenticate_bearer(®istry, &req).expect("should authenticate partner"); assert_eq!( diff --git a/crates/trusted-server-core/src/ec/batch_sync.rs b/crates/trusted-server-core/src/ec/batch_sync.rs index fcd00b270..8888a8472 100644 --- a/crates/trusted-server-core/src/ec/batch_sync.rs +++ b/crates/trusted-server-core/src/ec/batch_sync.rs @@ -12,9 +12,9 @@ //! semantics: unchanged UIDs are accepted without a write; different UIDs //! replace the stored value regardless of timestamp. +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::http::StatusCode; -use fastly::{Request, Response}; +use http::{Request, Response, StatusCode}; use serde::{Deserialize, Serialize}; use crate::error::TrustedServerError; @@ -101,19 +101,19 @@ pub fn handle_batch_sync( kv: &KvIdentityGraph, registry: &PartnerRegistry, rate_limiter: &dyn RateLimiter, - mut req: Request, -) -> Result> { - handle_batch_sync_with_writer(kv, registry, rate_limiter, &mut req) + req: Request, +) -> Result, Report> { + handle_batch_sync_with_writer(kv, registry, rate_limiter, req) } fn handle_batch_sync_with_writer( writer: &dyn BatchSyncWriter, registry: &PartnerRegistry, rate_limiter: &dyn RateLimiter, - req: &mut Request, -) -> Result> { + req: Request, +) -> Result, Report> { // 1. Authenticate - let Some(partner) = authenticate_bearer(registry, req) else { + let Some(partner) = authenticate_bearer(registry, &req) else { return Ok(error_response(StatusCode::UNAUTHORIZED, "invalid_token")); }; @@ -128,14 +128,14 @@ 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) { + if content_length_exceeds_limit(&req, MAX_BODY_SIZE) { return Ok(error_response( StatusCode::PAYLOAD_TOO_LARGE, "body_too_large", )); } - let body_bytes = req.take_body_bytes(); + let body_bytes = req.into_body().into_bytes(); if body_bytes.len() > MAX_BODY_SIZE { return Ok(error_response( StatusCode::PAYLOAD_TOO_LARGE, @@ -171,8 +171,10 @@ fn handle_batch_sync_with_writer( json_response(status, &response_body) } -fn content_length_exceeds_limit(req: &Request, max_body_size: usize) -> bool { - req.get_header_str("content-length") +fn content_length_exceeds_limit(req: &Request, max_body_size: usize) -> bool { + req.headers() + .get("content-length") + .and_then(|v| v.to_str().ok()) .and_then(|value| value.parse::().ok()) .is_some_and(|content_length| content_length > max_body_size) } @@ -239,21 +241,24 @@ fn process_mappings( fn json_response( status: StatusCode, body: &T, -) -> Result> { - let body = serde_json::to_string(body).change_context(TrustedServerError::EdgeCookie { +) -> Result, Report> { + let body_str = serde_json::to_string(body).change_context(TrustedServerError::EdgeCookie { message: "Failed to serialize batch sync response".to_owned(), })?; - - Ok(Response::from_status(status) - .with_content_type(fastly::mime::APPLICATION_JSON) - .with_body(body)) + Ok(Response::builder() + .status(status) + .header(http::header::CONTENT_TYPE, "application/json") + .body(EdgeBody::from(body_str)) + .expect("should build json response")) } -fn error_response(status: StatusCode, reason: &str) -> Response { +fn error_response(status: StatusCode, reason: &str) -> Response { let body = serde_json::json!({ "error": reason }); - Response::from_status(status) - .with_content_type(fastly::mime::APPLICATION_JSON) - .with_body(body.to_string()) + Response::builder() + .status(status) + .header(http::header::CONTENT_TYPE, "application/json") + .body(EdgeBody::from(body.to_string())) + .expect("should build error response") } #[cfg(test)] @@ -347,11 +352,13 @@ mod tests { } } - fn authorized_batch_request(body: &str) -> Request { - let mut req = Request::new("POST", "https://edge.example.com/_ts/api/v1/batch-sync"); - req.set_header("authorization", "Bearer test-token-32-bytes-minimum-value"); - req.set_body(body.to_owned()); - req + fn authorized_batch_request(body: &str) -> Request { + Request::builder() + .method("POST") + .uri("https://edge.example.com/_ts/api/v1/batch-sync") + .header("authorization", "Bearer test-token-32-bytes-minimum-value") + .body(EdgeBody::from(body.to_owned())) + .expect("should build authorized batch request") } fn test_registry() -> PartnerRegistry { @@ -364,8 +371,13 @@ mod tests { #[test] fn content_length_exceeds_limit_detects_oversized_header() { - let mut req = authorized_batch_request("{}"); - req.set_header("content-length", "2097153"); + let req = Request::builder() + .method("POST") + .uri("https://edge.example.com/_ts/api/v1/batch-sync") + .header("authorization", "Bearer test-token-32-bytes-minimum-value") + .header("content-length", "2097153") + .body(EdgeBody::from("{}")) + .expect("should build test request"); assert!( content_length_exceeds_limit(&req, 2 * 1024 * 1024), @@ -376,8 +388,13 @@ mod tests { #[test] fn content_length_exceeds_limit_ignores_missing_or_malformed_header() { let missing = authorized_batch_request("{}"); - let mut malformed = authorized_batch_request("{}"); - malformed.set_header("content-length", "not-a-number"); + let malformed = Request::builder() + .method("POST") + .uri("https://edge.example.com/_ts/api/v1/batch-sync") + .header("authorization", "Bearer test-token-32-bytes-minimum-value") + .header("content-length", "not-a-number") + .body(EdgeBody::from("{}")) + .expect("should build test request"); assert!( !content_length_exceeds_limit(&missing, 2 * 1024 * 1024), @@ -396,14 +413,19 @@ mod tests { let limiter = MockRateLimiter { should_exceed: false, }; - let mut req = authorized_batch_request("not-json"); - req.set_header("content-length", "2097153"); - - let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, &mut req) + let req = Request::builder() + .method("POST") + .uri("https://edge.example.com/_ts/api/v1/batch-sync") + .header("authorization", "Bearer test-token-32-bytes-minimum-value") + .header("content-length", "2097153") + .body(EdgeBody::from("not-json")) + .expect("should build test request"); + + let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, req) .expect("should return oversized response"); assert_eq!( - response.get_status(), + response.status(), StatusCode::PAYLOAD_TOO_LARGE, "should reject from content-length before parsing body" ); @@ -417,14 +439,19 @@ mod tests { should_exceed: false, }; let oversized_body = "{".repeat((2 * 1024 * 1024) + 1); - let mut req = authorized_batch_request(&oversized_body); - req.set_header("content-length", "not-a-number"); - - let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, &mut req) + let req = Request::builder() + .method("POST") + .uri("https://edge.example.com/_ts/api/v1/batch-sync") + .header("authorization", "Bearer test-token-32-bytes-minimum-value") + .header("content-length", "not-a-number") + .body(EdgeBody::from(oversized_body)) + .expect("should build test request"); + + let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, req) .expect("should return oversized response"); assert_eq!( - response.get_status(), + response.status(), StatusCode::PAYLOAD_TOO_LARGE, "should reject oversized body even when content-length is malformed" ); @@ -487,12 +514,16 @@ mod tests { let limiter = MockRateLimiter { should_exceed: false, }; - let req = Request::new("POST", "https://edge.example.com/_ts/api/v1/batch-sync"); + let req = Request::builder() + .method("POST") + .uri("https://edge.example.com/_ts/api/v1/batch-sync") + .body(EdgeBody::empty()) + .expect("should build test request"); let response = handle_batch_sync(&kv, ®istry, &limiter, req).expect("should return response"); assert_eq!( - response.get_status(), + response.status(), StatusCode::UNAUTHORIZED, "should return 401 for missing auth" ); diff --git a/crates/trusted-server-core/src/ec/cookies.rs b/crates/trusted-server-core/src/ec/cookies.rs index 46304852a..2ace7cf3a 100644 --- a/crates/trusted-server-core/src/ec/cookies.rs +++ b/crates/trusted-server-core/src/ec/cookies.rs @@ -15,7 +15,8 @@ use std::borrow::Cow; -use fastly::http::header; +use edgezero_core::body::Body as EdgeBody; +use http::{header, HeaderValue, Response}; use crate::constants::COOKIE_TS_EC; use crate::settings::Settings; @@ -125,7 +126,7 @@ pub(crate) fn create_ec_cookie(settings: &Settings, ec_id: &str) -> String { /// /// Debug-asserts that `ec_id` passes [`super::generation::is_valid_ec_id`] /// as a defense-in-depth check against cookie injection. -pub fn set_ec_cookie(settings: &Settings, response: &mut fastly::Response, ec_id: &str) { +pub fn set_ec_cookie(settings: &Settings, response: &mut Response, ec_id: &str) { if !is_safe_cookie_value(ec_id) { log::warn!( "Rejecting EC ID for Set-Cookie: value of {} bytes contains characters illegal in a cookie value", @@ -139,25 +140,53 @@ pub fn set_ec_cookie(settings: &Settings, response: &mut fastly::Response, ec_id "EC ID must be validated before cookie creation: got '{ec_id}'" ); - response.append_header(header::SET_COOKIE, create_ec_cookie(settings, ec_id)); + match HeaderValue::from_str(&create_ec_cookie(settings, ec_id)) { + Ok(val) => { + response.headers_mut().append(header::SET_COOKIE, val); + } + Err(e) => { + // Unreachable in practice — is_safe_cookie_value and the debug + // assertion above gate the value, and format_set_cookie emits + // only controlled bytes. Logged for defense-in-depth symmetry + // with the rejection logging above. + log::warn!("Skipping EC Set-Cookie: invalid header value: {e}"); + } + } } /// Expires the EC cookie by setting `Max-Age=0`. /// /// Used when a user revokes consent — the browser will delete the cookie /// on receipt of this header. -pub fn expire_ec_cookie(settings: &Settings, response: &mut fastly::Response) { - response.append_header( - header::SET_COOKIE, - format_set_cookie(&settings.publisher.ec_cookie_domain(), "", 0), - ); +pub fn expire_ec_cookie(settings: &Settings, response: &mut Response) { + match HeaderValue::from_str(&format_set_cookie( + &settings.publisher.ec_cookie_domain(), + "", + 0, + )) { + Ok(val) => { + response.headers_mut().append(header::SET_COOKIE, val); + } + Err(e) => { + // Unreachable in practice: format_set_cookie emits only + // controlled bytes from operator-trusted configuration. + log::warn!("Skipping EC cookie expiry Set-Cookie: invalid header value: {e}"); + } + } } #[cfg(test)] mod tests { use super::*; use crate::test_support::tests::create_test_settings; - use fastly::http::header; + use http::header; + + fn empty_response() -> Response { + Response::builder() + .status(200) + .body(EdgeBody::empty()) + .expect("should build test response") + } /// A valid EC ID for use in cookie tests. const TEST_EC_ID: &str = @@ -181,11 +210,12 @@ mod tests { #[test] fn set_ec_cookie_appends_header() { let settings = create_test_settings(); - let mut response = fastly::Response::new(); + let mut response = empty_response(); set_ec_cookie(&settings, &mut response, TEST_EC_ID); let cookie_header = response - .get_header(header::SET_COOKIE) + .headers() + .get(header::SET_COOKIE) .expect("should have Set-Cookie header"); let cookie_str = cookie_header.to_str().expect("should be valid UTF-8"); @@ -227,11 +257,11 @@ mod tests { #[test] fn set_ec_cookie_rejects_semicolon() { let settings = create_test_settings(); - let mut response = fastly::Response::new(); + let mut response = empty_response(); set_ec_cookie(&settings, &mut response, "evil; Domain=.attacker.com"); assert!( - response.get_header(header::SET_COOKIE).is_none(), + response.headers().get(header::SET_COOKIE).is_none(), "should not set Set-Cookie when value contains a semicolon" ); } @@ -239,11 +269,11 @@ mod tests { #[test] fn set_ec_cookie_rejects_crlf() { let settings = create_test_settings(); - let mut response = fastly::Response::new(); + let mut response = empty_response(); set_ec_cookie(&settings, &mut response, "evil\r\nX-Injected: header"); assert!( - response.get_header(header::SET_COOKIE).is_none(), + response.headers().get(header::SET_COOKIE).is_none(), "should not set Set-Cookie when value contains CRLF" ); } @@ -251,11 +281,11 @@ mod tests { #[test] fn set_ec_cookie_rejects_space() { let settings = create_test_settings(); - let mut response = fastly::Response::new(); + let mut response = empty_response(); set_ec_cookie(&settings, &mut response, "bad value"); assert!( - response.get_header(header::SET_COOKIE).is_none(), + response.headers().get(header::SET_COOKIE).is_none(), "should not set Set-Cookie when value contains whitespace" ); } @@ -304,11 +334,12 @@ mod tests { #[test] fn expire_ec_cookie_sets_max_age_zero() { let settings = create_test_settings(); - let mut response = fastly::Response::new(); + let mut response = empty_response(); expire_ec_cookie(&settings, &mut response); let cookie_header = response - .get_header(header::SET_COOKIE) + .headers() + .get(header::SET_COOKIE) .expect("should have Set-Cookie header"); let cookie_str = cookie_header.to_str().expect("should be valid UTF-8"); @@ -329,11 +360,12 @@ mod tests { #[test] fn expire_ec_cookie_matches_security_attributes() { let settings = create_test_settings(); - let mut response = fastly::Response::new(); + let mut response = empty_response(); expire_ec_cookie(&settings, &mut response); let cookie_header = response - .get_header(header::SET_COOKIE) + .headers() + .get(header::SET_COOKIE) .expect("should have Set-Cookie header"); let cookie_str = cookie_header.to_str().expect("should be valid UTF-8"); diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index fd1392bfd..2c10d2df0 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -5,7 +5,8 @@ use std::collections::HashSet; -use fastly::Response; +use edgezero_core::body::Body as EdgeBody; +use http::Response; use super::consent::{ec_consent_granted, ec_consent_withdrawn}; use crate::settings::Settings; @@ -44,7 +45,7 @@ pub fn ec_finalize_response( registry: &PartnerRegistry, eids_cookie: Option<&str>, sharedid_cookie: Option<&str>, - response: &mut Response, + response: &mut Response, ) { let consent_allows_ec = ec_consent_granted(ec_context.consent()); let consent_withdrawn = ec_consent_withdrawn(ec_context.consent()); @@ -110,7 +111,7 @@ pub fn ec_finalize_response( pub fn set_ec_cookie_on_response( settings: &Settings, ec_context: &EcContext, - response: &mut Response, + response: &mut Response, ) { if let Some(ec_id) = ec_context.ec_value() { set_ec_cookie(settings, response, ec_id); @@ -122,14 +123,19 @@ pub fn set_ec_cookie_on_response( /// In addition to the fixed [`EC_RESPONSE_HEADERS`], this also strips dynamic /// `X-ts-` headers for registered partners. Other `x-ts-*` /// headers are intentionally preserved because they may be set by non-EC middleware. -fn clear_ec_headers_on_response(response: &mut Response, registry: Option<&PartnerRegistry>) { +fn clear_ec_headers_on_response( + response: &mut Response, + registry: Option<&PartnerRegistry>, +) { for header in EC_RESPONSE_HEADERS { - response.remove_header(*header); + response.headers_mut().remove(*header); } if let Some(registry) = registry { for partner in registry.all() { - response.remove_header(partner_response_header(&partner.source_domain).as_str()); + response + .headers_mut() + .remove(partner_response_header(&partner.source_domain).as_str()); } } } @@ -141,7 +147,7 @@ fn partner_response_header(source_domain: &str) -> String { /// Clears EC cookie and removes EC-specific response headers. /// /// Used when the request carries an explicit withdrawal signal. -pub fn clear_ec_on_response(settings: &Settings, response: &mut Response) { +pub fn clear_ec_on_response(settings: &Settings, response: &mut Response) { expire_ec_cookie(settings, response); clear_ec_headers_on_response(response, None); } @@ -175,6 +181,8 @@ where #[cfg(test)] mod tests { + use http::HeaderValue; + use super::*; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ConsentContext, ConsentSource}; @@ -182,6 +190,29 @@ mod tests { use crate::settings::EcPartner; use crate::test_support::tests::create_test_settings; + fn empty_response() -> Response { + Response::builder() + .status(200) + .body(EdgeBody::empty()) + .expect("should build test response") + } + + fn set_header(response: &mut Response, name: &str, value: &str) { + response.headers_mut().insert( + http::header::HeaderName::from_bytes(name.as_bytes()) + .expect("should parse header name"), + HeaderValue::from_str(value).expect("should parse header value"), + ); + } + + fn get_header<'a>(response: &'a Response, name: &str) -> Option<&'a HeaderValue> { + response.headers().get(name) + } + + fn get_header_str<'a>(response: &'a Response, name: &str) -> Option<&'a str> { + response.headers().get(name).and_then(|v| v.to_str().ok()) + } + fn make_context( ec_value: Option<&str>, cookie_ec_value: Option<&str>, @@ -328,29 +359,28 @@ mod tests { #[test] fn clear_ec_on_response_removes_headers_and_expires_cookie() { let settings = create_test_settings(); - let mut response = Response::new(); - response.set_header("x-ts-ec", "abc"); - response.set_header("x-ts-eids", "[]"); - response.set_header("x-ts-unrelated", "keep-me"); + let mut response = empty_response(); + set_header(&mut response, "x-ts-ec", "abc"); + set_header(&mut response, "x-ts-eids", "[]"); + set_header(&mut response, "x-ts-unrelated", "keep-me"); clear_ec_on_response(&settings, &mut response); assert!( - response.get_header("x-ts-ec").is_none(), + get_header(&response, "x-ts-ec").is_none(), "should remove x-ts-ec" ); assert!( - response.get_header("x-ts-eids").is_none(), + get_header(&response, "x-ts-eids").is_none(), "should remove x-ts-eids" ); assert_eq!( - response.get_header_str("x-ts-unrelated"), + get_header_str(&response, "x-ts-unrelated"), Some("keep-me"), "should preserve unrelated x-ts headers without a partner registry" ); - let set_cookie = response - .get_header("set-cookie") + let set_cookie = get_header(&response, "set-cookie") .expect("should append Set-Cookie for expiry") .to_str() .expect("should render set-cookie as utf-8"); @@ -373,11 +403,11 @@ mod tests { }; let ec_context = make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent); - let mut response = Response::new(); - response.set_header("x-ts-ec", "stale"); - response.set_header("x-ts-eids", "[]"); - response.set_header("x-ts-ssp.example.com", "partner-uid-123"); - response.set_header("x-ts-unrelated", "keep-me"); + let mut response = empty_response(); + set_header(&mut response, "x-ts-ec", "stale"); + set_header(&mut response, "x-ts-eids", "[]"); + set_header(&mut response, "x-ts-ssp.example.com", "partner-uid-123"); + set_header(&mut response, "x-ts-unrelated", "keep-me"); let partners = vec![make_partner("ssp.example.com")]; let test_registry = PartnerRegistry::from_config(&partners).expect("should build registry"); @@ -392,24 +422,23 @@ mod tests { ); assert!( - response.get_header("x-ts-ec").is_none(), + get_header(&response, "x-ts-ec").is_none(), "withdrawal should clear x-ts-ec header" ); assert!( - response.get_header("x-ts-eids").is_none(), + get_header(&response, "x-ts-eids").is_none(), "withdrawal should clear x-ts-eids header" ); assert!( - response.get_header("x-ts-ssp.example.com").is_none(), + get_header(&response, "x-ts-ssp.example.com").is_none(), "withdrawal should clear registered partner header" ); assert_eq!( - response.get_header_str("x-ts-unrelated"), + get_header_str(&response, "x-ts-unrelated"), Some("keep-me"), "withdrawal should preserve unrelated x-ts header" ); - let set_cookie = response - .get_header("set-cookie") + let set_cookie = get_header(&response, "set-cookie") .expect("withdrawal should expire cookie") .to_str() .expect("set-cookie should be utf-8"); @@ -431,7 +460,7 @@ mod tests { false, Jurisdiction::NonRegulated, ); - let mut response = Response::new(); + let mut response = empty_response(); let test_registry = PartnerRegistry::empty(); ec_finalize_response( @@ -445,11 +474,11 @@ mod tests { ); assert!( - response.get_header("x-ts-ec").is_none(), + get_header(&response, "x-ts-ec").is_none(), "returning user should not set x-ts-ec" ); assert!( - response.get_header("set-cookie").is_none(), + get_header(&response, "set-cookie").is_none(), "returning user should not refresh or repair cookie" ); } @@ -465,7 +494,7 @@ mod tests { false, Jurisdiction::NonRegulated, ); - let mut response = Response::new(); + let mut response = empty_response(); let test_registry = PartnerRegistry::empty(); ec_finalize_response( @@ -479,11 +508,11 @@ mod tests { ); assert!( - response.get_header("x-ts-ec").is_none(), + get_header(&response, "x-ts-ec").is_none(), "returning user should not set x-ts-ec" ); assert!( - response.get_header("set-cookie").is_none(), + get_header(&response, "set-cookie").is_none(), "returning user should not refresh cookie" ); } @@ -499,7 +528,7 @@ mod tests { true, Jurisdiction::NonRegulated, ); - let mut response = Response::new(); + let mut response = empty_response(); let test_registry = PartnerRegistry::empty(); ec_finalize_response( @@ -513,11 +542,11 @@ mod tests { ); assert!( - response.get_header("x-ts-ec").is_none(), + get_header(&response, "x-ts-ec").is_none(), "generated EC without KV should not set response header" ); assert!( - response.get_header("set-cookie").is_none(), + get_header(&response, "set-cookie").is_none(), "generated EC without KV should not set cookie" ); } @@ -526,7 +555,7 @@ mod tests { fn finalize_denied_without_cookie_is_noop() { let settings = create_test_settings(); let ec_context = make_context(None, None, false, false, Jurisdiction::Unknown); - let mut response = Response::new(); + let mut response = empty_response(); let test_registry = PartnerRegistry::empty(); ec_finalize_response( @@ -540,11 +569,11 @@ mod tests { ); assert!( - response.get_header("x-ts-ec").is_none(), + get_header(&response, "x-ts-ec").is_none(), "should not set EC header" ); assert!( - response.get_header("set-cookie").is_none(), + get_header(&response, "set-cookie").is_none(), "should not mutate cookie when there is nothing to revoke" ); } @@ -560,9 +589,9 @@ mod tests { false, Jurisdiction::Unknown, ); - let mut response = Response::new(); - response.set_header("x-ts-ec", &ec_id); - response.set_header("x-ts-eids", "[]"); + let mut response = empty_response(); + set_header(&mut response, "x-ts-ec", &ec_id); + set_header(&mut response, "x-ts-eids", "[]"); let test_registry = PartnerRegistry::empty(); ec_finalize_response( @@ -576,15 +605,15 @@ mod tests { ); assert!( - response.get_header("x-ts-ec").is_none(), + get_header(&response, "x-ts-ec").is_none(), "should strip EC header when consent cannot be verified" ); assert!( - response.get_header("x-ts-eids").is_none(), + get_header(&response, "x-ts-eids").is_none(), "should strip EID header when consent cannot be verified" ); assert!( - response.get_header("set-cookie").is_none(), + get_header(&response, "set-cookie").is_none(), "should not expire the cookie without an explicit withdrawal signal" ); } diff --git a/crates/trusted-server-core/src/ec/generation.rs b/crates/trusted-server-core/src/ec/generation.rs index 480682927..af40c3ff4 100644 --- a/crates/trusted-server-core/src/ec/generation.rs +++ b/crates/trusted-server-core/src/ec/generation.rs @@ -32,7 +32,7 @@ const ALPHANUMERIC_CHARSET: &[u8] = /// - **IPv4:** decimal-dotted notation (e.g. `"192.168.1.1"`) /// - **IPv6:** first 4 segments as zero-padded lowercase hex without /// separators (e.g. `"20010db885a30000"`) -fn normalize_ip(ip: IpAddr) -> String { +pub(crate) fn normalize_ip(ip: IpAddr) -> String { match ip { IpAddr::V4(ipv4) => ipv4.to_string(), IpAddr::V6(ipv6) => { @@ -100,23 +100,6 @@ pub fn generate_ec_id( Ok(ec_id) } -/// Extracts and normalizes the client IP from a request. -/// -/// Returns the normalized IP as a string suitable for HMAC input. -/// -/// # Errors -/// -/// Returns [`TrustedServerError::EdgeCookie`] when the client IP is unavailable -/// (e.g. in certain test or proxy configurations). EC generation requires -/// a valid client IP — there is no fallback. -pub fn extract_client_ip(req: &fastly::Request) -> Result> { - req.get_client_ip_addr().map(normalize_ip).ok_or_else(|| { - Report::new(TrustedServerError::EdgeCookie { - message: "Client IP required for EC generation but unavailable".to_string(), - }) - }) -} - /// Extracts the stable 64-character hex prefix from an EC ID. /// /// Given an EC ID in `{64hex}.{6alnum}` format, returns the `{64hex}` diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index e9da1bd68..44c9f0fbc 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -3,9 +3,10 @@ //! Partners authenticate with a Bearer token and receive only their own //! synced UID for the active EC ID. +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::http::{header, StatusCode}; -use fastly::{Request, Response}; +use http::header::{self, HeaderValue}; +use http::{Request, Response, StatusCode}; use url::Url; use super::auth::authenticate_bearer; @@ -27,18 +28,26 @@ use super::EcContext; /// # Errors /// /// Returns [`TrustedServerError`] for response serialization issues. +/// +/// # Panics +/// +/// Panics if response builder produces an invalid status or body, which cannot +/// happen with the hardcoded values used here. pub fn handle_identify( settings: &Settings, kv: &KvIdentityGraph, registry: &PartnerRegistry, - req: &Request, + req: &Request, ec_context: &EcContext, -) -> Result> { +) -> Result, Report> { let allowed_origin = match classify_origin(req, settings) { CorsDecision::Denied => { - return Ok(apply_identify_cache_headers(Response::from_status( - StatusCode::FORBIDDEN, - ))); + return Ok(apply_identify_cache_headers( + Response::builder() + .status(StatusCode::FORBIDDEN) + .body(EdgeBody::empty()) + .expect("should build forbidden response"), + )); } CorsDecision::NoOrigin => None, CorsDecision::Allowed(origin) => Some(origin), @@ -62,7 +71,12 @@ pub fn handle_identify( } let Some(ec_id) = ec_context.ec_value() else { - let response = apply_identify_cache_headers(Response::from_status(StatusCode::NO_CONTENT)); + let response = apply_identify_cache_headers( + Response::builder() + .status(StatusCode::NO_CONTENT) + .body(EdgeBody::empty()) + .expect("should build no-content response"), + ); return Ok(apply_cors_headers_if_allowed( response, allowed_origin.as_deref(), @@ -137,21 +151,34 @@ pub fn handle_identify( /// # Errors /// /// Returns [`TrustedServerError`] when response construction fails. +/// +/// # Panics +/// +/// Panics if response builder produces an invalid status or body, which cannot +/// happen with the hardcoded values used here. pub fn cors_preflight_identify( settings: &Settings, - req: &Request, -) -> Result> { - let mut response = match classify_origin(req, settings) { - CorsDecision::Denied => Response::from_status(StatusCode::FORBIDDEN), - CorsDecision::NoOrigin => Response::from_status(StatusCode::OK), + req: &Request, +) -> Result, Report> { + let response = match classify_origin(req, settings) { + CorsDecision::Denied => Response::builder() + .status(StatusCode::FORBIDDEN) + .body(EdgeBody::empty()) + .expect("should build forbidden response"), + CorsDecision::NoOrigin => Response::builder() + .status(StatusCode::OK) + .body(EdgeBody::empty()) + .expect("should build ok response"), CorsDecision::Allowed(origin) => { - let mut response = Response::from_status(StatusCode::OK); + let mut response = Response::builder() + .status(StatusCode::OK) + .body(EdgeBody::empty()) + .expect("should build ok response"); apply_cors_headers(&mut response, &origin); response } }; - response.set_body(Vec::new()); Ok(apply_identify_cache_headers(response)) } @@ -173,14 +200,16 @@ fn json_response_with_origin( status: StatusCode, body: &T, allowed_origin: Option<&str>, -) -> Result> { - let body = serde_json::to_string(body).change_context(TrustedServerError::EdgeCookie { +) -> Result, Report> { + let body_str = serde_json::to_string(body).change_context(TrustedServerError::EdgeCookie { message: "Failed to serialize identify response".to_owned(), })?; - let response = Response::from_status(status) - .with_content_type(fastly::mime::APPLICATION_JSON) - .with_body(body); + let response = Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "application/json") + .body(EdgeBody::from(body_str)) + .expect("should build identify response"); let response = apply_identify_cache_headers(response); Ok(apply_cors_headers_if_allowed(response, allowed_origin)) @@ -192,8 +221,12 @@ enum CorsDecision { Denied, } -fn classify_origin(req: &Request, settings: &Settings) -> CorsDecision { - let Some(origin) = req.get_header(header::ORIGIN).and_then(|v| v.to_str().ok()) else { +fn classify_origin(req: &Request, settings: &Settings) -> CorsDecision { + let Some(origin) = req + .headers() + .get(header::ORIGIN) + .and_then(|v| v.to_str().ok()) + else { return CorsDecision::NoOrigin; }; @@ -245,27 +278,55 @@ fn origin_authority_contains_uppercase_host(origin: &str) -> bool { host.bytes().any(|byte| byte.is_ascii_uppercase()) } -fn apply_identify_cache_headers(mut response: Response) -> Response { - response.set_header(header::CACHE_CONTROL, "no-store"); - response.set_header(header::PRAGMA, "no-cache"); - response.set_header(header::VARY, "Origin, Authorization"); +fn apply_identify_cache_headers(mut response: Response) -> Response { + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response + .headers_mut() + .insert(header::PRAGMA, HeaderValue::from_static("no-cache")); + response.headers_mut().insert( + header::VARY, + HeaderValue::from_static("Origin, Authorization"), + ); response } -fn apply_cors_headers_if_allowed(mut response: Response, allowed_origin: Option<&str>) -> Response { +fn apply_cors_headers_if_allowed( + mut response: Response, + allowed_origin: Option<&str>, +) -> Response { if let Some(origin) = allowed_origin { apply_cors_headers(&mut response, origin); } response } -fn apply_cors_headers(response: &mut Response, origin: &str) { - response.set_header(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin); - response.set_header(header::ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"); - response.set_header(header::ACCESS_CONTROL_ALLOW_METHODS, "GET, OPTIONS"); - response.set_header(header::ACCESS_CONTROL_ALLOW_HEADERS, "Authorization"); - response.set_header(header::ACCESS_CONTROL_MAX_AGE, "600"); - response.set_header(header::VARY, "Origin, Authorization"); +fn apply_cors_headers(response: &mut Response, origin: &str) { + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_str(origin).expect("should be valid origin header value"), + ); + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_CREDENTIALS, + HeaderValue::from_static("true"), + ); + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("GET, OPTIONS"), + ); + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_HEADERS, + HeaderValue::from_static("Authorization"), + ); + response.headers_mut().insert( + header::ACCESS_CONTROL_MAX_AGE, + HeaderValue::from_static("600"), + ); + response.headers_mut().insert( + header::VARY, + HeaderValue::from_static("Origin, Authorization"), + ); } #[cfg(test)] @@ -280,9 +341,12 @@ mod tests { const VALID_API_TOKEN: &str = "identify-test-token-32-bytes-min"; - fn assert_no_store(response: &Response) { + fn assert_no_store(response: &Response) { assert_eq!( - response.get_header_str(header::CACHE_CONTROL), + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), Some("no-store"), "identify responses should not be cached" ); @@ -317,8 +381,12 @@ mod tests { #[test] fn classify_origin_accepts_publisher_subdomain() { let settings = create_test_settings(); - let mut req = Request::new("GET", "https://edge.test-publisher.com/identify"); - req.set_header("origin", "https://www.test-publisher.com"); + let req = Request::builder() + .method("GET") + .uri("https://edge.test-publisher.com/identify") + .header("origin", "https://www.test-publisher.com") + .body(EdgeBody::empty()) + .expect("should build test request"); let decision = classify_origin(&req, &settings); assert!( @@ -330,8 +398,12 @@ mod tests { #[test] fn classify_origin_rejects_mismatch() { let settings = create_test_settings(); - let mut req = Request::new("GET", "https://edge.test-publisher.com/identify"); - req.set_header("origin", "https://evil.com"); + let req = Request::builder() + .method("GET") + .uri("https://edge.test-publisher.com/identify") + .header("origin", "https://evil.com") + .body(EdgeBody::empty()) + .expect("should build test request"); let decision = classify_origin(&req, &settings); assert!( @@ -343,8 +415,12 @@ mod tests { #[test] fn classify_origin_rejects_mixed_case_publisher_host() { let settings = create_test_settings(); - let mut req = Request::new("GET", "https://edge.test-publisher.com/identify"); - req.set_header("origin", "https://Foo.test-publisher.com"); + let req = Request::builder() + .method("GET") + .uri("https://edge.test-publisher.com/identify") + .header("origin", "https://Foo.test-publisher.com") + .body(EdgeBody::empty()) + .expect("should build test request"); let decision = classify_origin(&req, &settings); assert!( @@ -356,8 +432,12 @@ mod tests { #[test] fn classify_origin_rejects_http_scheme() { let settings = create_test_settings(); - let mut req = Request::new("GET", "https://edge.test-publisher.com/identify"); - req.set_header("origin", "http://www.test-publisher.com"); + let req = Request::builder() + .method("GET") + .uri("https://edge.test-publisher.com/identify") + .header("origin", "http://www.test-publisher.com") + .body(EdgeBody::empty()) + .expect("should build test request"); let decision = classify_origin(&req, &settings); assert!( @@ -369,7 +449,11 @@ mod tests { #[test] fn classify_origin_allows_absent_origin_header() { let settings = create_test_settings(); - let req = Request::new("GET", "https://edge.test-publisher.com/identify"); + let req = Request::builder() + .method("GET") + .uri("https://edge.test-publisher.com/identify") + .body(EdgeBody::empty()) + .expect("should build test request"); let decision = classify_origin(&req, &settings); assert!( @@ -383,25 +467,32 @@ mod tests { let settings = create_test_settings(); let kv = KvIdentityGraph::new("missing_store"); let registry = PartnerRegistry::empty(); - let req = Request::new("GET", "https://edge.test-publisher.com/identify"); + let req = Request::builder() + .method("GET") + .uri("https://edge.test-publisher.com/identify") + .body(EdgeBody::empty()) + .expect("should build test request"); let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); - let mut response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) + let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct unauthorized response"); assert_eq!( - response.get_header_str(header::ACCESS_CONTROL_ALLOW_ORIGIN), + response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|v| v.to_str().ok()), None, "should omit CORS headers when Origin is absent" ); assert_eq!( - response.get_status(), + response.status(), StatusCode::UNAUTHORIZED, "should return 401 without Bearer token" ); assert_no_store(&response); - let body = serde_json::from_slice::(&response.take_body_bytes()) + let body = serde_json::from_slice::(&response.into_body().into_bytes()) .expect("should decode JSON body"); assert_eq!( body["error"], "invalid_token", @@ -415,15 +506,19 @@ mod tests { let kv = KvIdentityGraph::new("missing_store"); let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); - let mut req = Request::new("GET", "https://edge.test-publisher.com/identify"); - req.set_header("authorization", "Bearer wrong-token"); + let req = Request::builder() + .method("GET") + .uri("https://edge.test-publisher.com/identify") + .header("authorization", "Bearer wrong-token") + .body(EdgeBody::empty()) + .expect("should build test request"); let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct unauthorized response"); assert_eq!( - response.get_status(), + response.status(), StatusCode::UNAUTHORIZED, "should return 401 for invalid Bearer token" ); @@ -436,20 +531,24 @@ mod tests { let kv = KvIdentityGraph::new("missing_store"); let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); - let mut req = Request::new("GET", "https://edge.test-publisher.com/identify"); - req.set_header("authorization", format!("Bearer {VALID_API_TOKEN}")); + let req = Request::builder() + .method("GET") + .uri("https://edge.test-publisher.com/identify") + .header("authorization", format!("Bearer {VALID_API_TOKEN}")) + .body(EdgeBody::empty()) + .expect("should build test request"); let ec_context = make_ec_context(Jurisdiction::Unknown, None); - let mut response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) + let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct denied response"); assert_eq!( - response.get_status(), + response.status(), StatusCode::FORBIDDEN, "should return 403 when consent denies EC" ); assert_no_store(&response); - let body = serde_json::from_slice::(&response.take_body_bytes()) + let body = serde_json::from_slice::(&response.into_body().into_bytes()) .expect("should decode JSON body"); assert_eq!( body, @@ -464,15 +563,19 @@ mod tests { let kv = KvIdentityGraph::new("missing_store"); let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); - let mut req = Request::new("GET", "https://edge.test-publisher.com/identify"); - req.set_header("authorization", format!("Bearer {VALID_API_TOKEN}")); + let req = Request::builder() + .method("GET") + .uri("https://edge.test-publisher.com/identify") + .header("authorization", format!("Bearer {VALID_API_TOKEN}")) + .body(EdgeBody::empty()) + .expect("should build test request"); let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct no-content response"); assert_eq!( - response.get_status(), + response.status(), StatusCode::NO_CONTENT, "should return 204 when EC is unavailable" ); @@ -485,28 +588,32 @@ mod tests { let kv = KvIdentityGraph::new("missing_store"); let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); - let mut req = Request::new("GET", "https://edge.test-publisher.com/identify"); - req.set_header("authorization", format!("Bearer {VALID_API_TOKEN}")); + let req = Request::builder() + .method("GET") + .uri("https://edge.test-publisher.com/identify") + .header("authorization", format!("Bearer {VALID_API_TOKEN}")) + .body(EdgeBody::empty()) + .expect("should build test request"); let ec_id = format!("{}.ABC123", "a".repeat(64)); let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); - let mut response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) + let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct degraded identify response"); assert_eq!( - response.get_status(), + response.status(), StatusCode::OK, "should return 200 on degraded KV read" ); assert_no_store(&response); - let body = serde_json::from_slice::(&response.take_body_bytes()) - .expect("should decode identify response JSON"); - - assert_eq!(body["ec"], ec_id, "should echo EC in body"); assert!( - response.get_header("x-ts-ec").is_none(), + response.headers().get("x-ts-ec").is_none(), "should not emit x-ts-ec header" ); + let body = serde_json::from_slice::(&response.into_body().into_bytes()) + .expect("should decode identify response JSON"); + + assert_eq!(body["ec"], ec_id, "should echo EC in body"); assert_eq!( body["source_domain"], "ssp.example.com", "should echo source domain" @@ -532,16 +639,20 @@ mod tests { let kv = KvIdentityGraph::new("missing_store"); let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); - let mut req = Request::new("GET", "https://edge.test-publisher.com/identify"); - req.set_header("authorization", format!("Bearer {VALID_API_TOKEN}")); - req.set_header("origin", "https://evil.example"); + let req = Request::builder() + .method("GET") + .uri("https://edge.test-publisher.com/identify") + .header("authorization", format!("Bearer {VALID_API_TOKEN}")) + .header("origin", "https://evil.example") + .body(EdgeBody::empty()) + .expect("should build test request"); let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct forbidden response"); assert_eq!( - response.get_status(), + response.status(), StatusCode::FORBIDDEN, "should reject GET from non-publisher origin" ); @@ -554,27 +665,37 @@ mod tests { let kv = KvIdentityGraph::new("missing_store"); let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); - let mut req = Request::new("GET", "https://edge.test-publisher.com/identify"); - req.set_header("authorization", format!("Bearer {VALID_API_TOKEN}")); - req.set_header("origin", "https://www.test-publisher.com"); + let req = Request::builder() + .method("GET") + .uri("https://edge.test-publisher.com/identify") + .header("authorization", format!("Bearer {VALID_API_TOKEN}")) + .header("origin", "https://www.test-publisher.com") + .body(EdgeBody::empty()) + .expect("should build test request"); let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct no-content response with CORS headers"); assert_eq!( - response.get_status(), + response.status(), StatusCode::NO_CONTENT, "should preserve identify response status for allowed browser origin" ); assert_no_store(&response); assert_eq!( - response.get_header_str(header::ACCESS_CONTROL_ALLOW_ORIGIN), + response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|v| v.to_str().ok()), Some("https://www.test-publisher.com"), "should reflect allowed browser origin on GET responses" ); assert_eq!( - response.get_header_str(header::VARY), + response + .headers() + .get(header::VARY) + .and_then(|v| v.to_str().ok()), Some("Origin, Authorization"), "should vary on identity request inputs for browser-direct identify responses" ); @@ -583,14 +704,18 @@ mod tests { #[test] fn identify_preflight_denies_mismatched_origin() { let settings = create_test_settings(); - let mut req = Request::new("OPTIONS", "https://edge.test-publisher.com/identify"); - req.set_header("origin", "https://evil.example"); + let req = Request::builder() + .method("OPTIONS") + .uri("https://edge.test-publisher.com/identify") + .header("origin", "https://evil.example") + .body(EdgeBody::empty()) + .expect("should build test request"); let response = cors_preflight_identify(&settings, &req).expect("should construct preflight response"); assert_eq!( - response.get_status(), + response.status(), StatusCode::FORBIDDEN, "should reject preflight from non-publisher origin" ); @@ -600,20 +725,27 @@ mod tests { #[test] fn identify_preflight_allows_publisher_origin() { let settings = create_test_settings(); - let mut req = Request::new("OPTIONS", "https://edge.test-publisher.com/identify"); - req.set_header("origin", "https://www.test-publisher.com"); + let req = Request::builder() + .method("OPTIONS") + .uri("https://edge.test-publisher.com/identify") + .header("origin", "https://www.test-publisher.com") + .body(EdgeBody::empty()) + .expect("should build test request"); let response = cors_preflight_identify(&settings, &req).expect("should construct preflight response"); assert_eq!( - response.get_status(), + response.status(), StatusCode::OK, "should allow preflight from publisher origin" ); assert_no_store(&response); assert_eq!( - response.get_header_str(header::VARY), + response + .headers() + .get(header::VARY) + .and_then(|v| v.to_str().ok()), Some("Origin, Authorization"), "should vary on identity request inputs for preflight" ); diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index c71b2e61c..f554bec7f 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -58,16 +58,17 @@ pub fn log_id(ec_id: &str) -> String { } use cookie::CookieJar; +use edgezero_core::body::Body as EdgeBody; use error_stack::Report; -use fastly::Request; +use http::Request; -use crate::compat; use crate::consent::{self as consent_mod, ConsentContext, ConsentPipelineInput}; use crate::constants::COOKIE_TS_EC; use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; use crate::error::TrustedServerError; use crate::geo::GeoInfo; +use crate::platform::RuntimeServices; use crate::settings::Settings; use device::DeviceSignals; @@ -91,9 +92,8 @@ struct RequestEc { /// # Errors /// /// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails -fn parse_ec_from_request(req: &Request) -> Result> { - let http_req = compat::from_fastly_headers_ref(req); - let jar = handle_request_cookies(&http_req)?; +fn parse_ec_from_request(req: &Request) -> Result> { + let jar = handle_request_cookies(req)?; let cookie_ec = jar .as_ref() .and_then(|j| j.get(COOKIE_TS_EC)) @@ -121,7 +121,7 @@ fn request_ec_id_if_allowed(value: &str, source: &str) -> Option { /// # Errors /// /// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails -pub fn get_ec_id(req: &fastly::Request) -> Result, Report> { +pub fn get_ec_id(req: &Request) -> Result, Report> { let parsed = parse_ec_from_request(req)?; let ec_id = parsed.cookie_ec.filter(|v| is_valid_ec_id(v)); if let Some(ref id) = ec_id { @@ -135,7 +135,7 @@ pub fn get_ec_id(req: &fastly::Request) -> Result, Report, @@ -175,9 +175,10 @@ impl EcContext { /// Returns an error if cookie parsing fails. pub fn read_from_request( settings: &Settings, - req: &Request, + req: &Request, + services: &RuntimeServices, ) -> Result> { - Self::read_from_request_with_geo(settings, req, None) + Self::read_from_request_with_geo(settings, req, services, None) } /// Reads EC state from an incoming request using pre-extracted geo data. @@ -190,7 +191,8 @@ impl EcContext { /// Returns an error if cookie parsing fails. pub fn read_from_request_with_geo( settings: &Settings, - req: &Request, + req: &Request, + services: &RuntimeServices, geo_info: Option<&GeoInfo>, ) -> Result> { let parsed = parse_ec_from_request(req)?; @@ -202,16 +204,20 @@ impl EcContext { log::trace!("Existing EC ID found: {}", log_id(id)); } - // Capture the client IP now — the request body may be consumed later. - let client_ip = generation::extract_client_ip(req).ok(); - let http_req = compat::from_fastly_headers_ref(req); + // Capture the client IP from platform services (normalized). + let client_ip = services + .client_info() + .client_ip + .map(generation::normalize_ip); // Build consent context from request-local cookies, headers, and geo. let consent = consent_mod::build_consent_context(&ConsentPipelineInput { jar: parsed.jar.as_ref(), - req: &http_req, + req, config: &settings.consent, geo: geo_info, + ec_id: None, + kv_store: None, }); log::info!( @@ -482,18 +488,17 @@ pub(crate) fn current_timestamp() -> u64 { #[cfg(test)] mod tests { use super::*; + use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; - use fastly::http::HeaderValue; - fn create_test_request(headers: &[(&str, &str)]) -> Request { - let mut req = Request::new("GET", "http://example.com"); + fn create_test_request(headers: &[(&str, &str)]) -> Request { + let mut builder = Request::builder().method("GET").uri("http://example.com"); for &(key, value) in headers { - req.set_header( - key, - HeaderValue::from_str(value).expect("should create valid header value"), - ); + builder = builder.header(key, value); } - req + builder + .body(EdgeBody::empty()) + .expect("should build test request") } /// Creates a valid EC ID for testing: `{64hex}.{6alnum}`. @@ -507,7 +512,8 @@ mod tests { let ec_id = valid_ec_id("a", "HdrEc1"); let req = create_test_request(&[("x-ts-ec", &ec_id)]); - let ec = EcContext::read_from_request(&settings, &req).expect("should read EC context"); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); assert!(ec.ec_value().is_none(), "should ignore EC from header"); assert!(!ec.ec_was_present(), "should not detect EC from header"); @@ -522,7 +528,8 @@ mod tests { let cookie = format!("ts-ec={ec_id}"); let req = create_test_request(&[("cookie", &cookie)]); - let ec = EcContext::read_from_request(&settings, &req).expect("should read EC context"); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); assert_eq!(ec.ec_value(), Some(ec_id.as_str())); assert!(ec.ec_was_present(), "should detect EC from cookie"); @@ -538,7 +545,8 @@ mod tests { let cookie = format!("ts-ec={cookie_id}"); let req = create_test_request(&[("x-ts-ec", &header_id), ("cookie", &cookie)]); - let ec = EcContext::read_from_request(&settings, &req).expect("should read EC context"); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); assert_eq!( ec.ec_value(), @@ -553,7 +561,8 @@ mod tests { let settings = create_test_settings(); let req = create_test_request(&[]); - let ec = EcContext::read_from_request(&settings, &req).expect("should read EC context"); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); assert!(ec.ec_value().is_none(), "should have no EC value"); assert!(!ec.ec_was_present(), "should not detect EC"); @@ -567,7 +576,8 @@ mod tests { let cookie = format!("ts-ec={cookie_id}"); let req = create_test_request(&[("x-ts-ec", "malformed-header"), ("cookie", &cookie)]); - let ec = EcContext::read_from_request(&settings, &req).expect("should read EC context"); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); assert_eq!( ec.ec_value(), @@ -582,7 +592,8 @@ mod tests { let settings = create_test_settings(); let req = create_test_request(&[("x-ts-ec", "bad-header"), ("cookie", "ts-ec=bad-cookie")]); - let ec = EcContext::read_from_request(&settings, &req).expect("should read EC context"); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); assert!( ec.ec_value().is_none(), @@ -605,7 +616,8 @@ mod tests { let cookie = format!("ts-ec={ec_id}"); let req = create_test_request(&[("cookie", &cookie)]); - let mut ec = EcContext::read_from_request(&settings, &req).expect("should read EC context"); + let mut ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); ec.generate_if_needed(&settings, None) .expect("should not error when EC already exists"); @@ -625,7 +637,8 @@ mod tests { let cookie_ec = valid_ec_id("e", "CkVal1"); let cookie = format!("ts-ec={cookie_ec}"); let req = create_test_request(&[("cookie", &cookie)]); - let ec = EcContext::read_from_request(&settings, &req).expect("should read EC context"); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); assert_eq!( ec.existing_cookie_ec_id(), Some(cookie_ec.as_str()), @@ -635,7 +648,8 @@ mod tests { // With only header (no cookie) let header_ec = valid_ec_id("f", "HdrVl1"); let req = create_test_request(&[("x-ts-ec", &header_ec)]); - let ec = EcContext::read_from_request(&settings, &req).expect("should read EC context"); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); assert!( ec.existing_cookie_ec_id().is_none(), "should return None when only header is present" @@ -646,7 +660,8 @@ mod tests { let cookie_ec2 = valid_ec_id("b", "Ck0002"); let cookie2 = format!("ts-ec={cookie_ec2}"); let req = create_test_request(&[("x-ts-ec", &header_ec2), ("cookie", &cookie2)]); - let ec = EcContext::read_from_request(&settings, &req).expect("should read EC context"); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); assert_eq!( ec.ec_value(), Some(cookie_ec2.as_str()), diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index 6c935badd..1e4749c4b 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -8,13 +8,15 @@ //! present in the EC identity entry, it is not periodically refreshed because //! the entry no longer stores per-partner sync timestamps. -use fastly::http::request::PendingRequest; -use fastly::http::{header, Method, StatusCode}; -use fastly::Request; +use edgezero_core::body::Body as EdgeBody; +use http::{header, Method, StatusCode}; use serde::Deserialize; use url::Url; -use crate::backend::BackendConfig; +use crate::platform::{ + PlatformBackendSpec, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, + RuntimeServices, DEFAULT_FIRST_BYTE_TIMEOUT, +}; use crate::settings::Settings; use super::generation::{ec_hash, is_valid_ec_id}; @@ -43,7 +45,7 @@ impl PullSyncContext { struct InFlightPull { source_domain: String, - pending: PendingRequest, + pending: PlatformPendingRequest, } #[derive(Debug, Deserialize)] @@ -73,12 +75,18 @@ pub fn build_pull_sync_context(ec_context: &EcContext) -> Option name, - Err(err) => { - log::warn!( - "Pull sync: failed to resolve backend for partner '{}': {err:?}", - partner.source_domain - ); - continue; - } - }; + let scheme = request_url.scheme().to_string(); + let host = request_url.host_str().unwrap_or_default().to_string(); + let port = request_url.port(); + + let backend_name = match services.backend().ensure(&PlatformBackendSpec { + scheme, + host, + port, + host_header_override: None, + certificate_check: settings.proxy.certificate_check, + first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, + }) { + Ok(name) => name, + Err(err) => { + log::warn!( + "Pull sync: failed to resolve backend for partner '{}': {err:?}", + partner.source_domain + ); + continue; + } + }; - let pending = match request.send_async(backend_name) { + let request = http::Request::builder() + .method(Method::GET) + .uri(request_url.as_str()) + .header("authorization", format!("Bearer {}", token.expose())) + .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)), + ) { Ok(pending) => pending, Err(err) => { log::warn!( @@ -186,11 +212,11 @@ pub fn dispatch_pull_sync( }); if in_flight.len() >= max_concurrency { - drain_pull_batch(kv, context.ec_id(), &mut in_flight); + drain_pull_batch(kv, context.ec_id(), &mut in_flight, services); } } - drain_pull_batch(kv, context.ec_id(), &mut in_flight); + drain_pull_batch(kv, context.ec_id(), &mut in_flight, services); } fn is_partner_pull_eligible(partner: &PartnerConfig, kv_entry: Option<&KvEntry>) -> bool { @@ -258,23 +284,27 @@ fn pull_rate_limit_key(source_domain: &str, ec_id: &str) -> String { format!("pull:{source_domain}:{}", ec_hash(ec_id)) } -fn drain_pull_batch(kv: &KvIdentityGraph, ec_id: &str, in_flight: &mut Vec) { +fn drain_pull_batch( + kv: &KvIdentityGraph, + ec_id: &str, + in_flight: &mut Vec, + services: &RuntimeServices, +) { for pending in in_flight.drain(..) { let source_domain = pending.source_domain; - // The Fastly SDK version used by this crate exposes only blocking - // `PendingRequest::wait()` for a single pending request. Pull sync runs - // after `send_to_client()` and relies on the platform compute cap for - // the hard upper bound until a per-request timeout API is available. - let response = match pending.pending.wait() { - Ok(response) => response, - Err(err) => { - log::warn!( - "Pull sync: request failed for partner '{}': {err:?}", - source_domain - ); - continue; - } - }; + // 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 Some(uid) = extract_pull_uid(response, &source_domain) else { continue; @@ -296,8 +326,8 @@ fn drain_pull_batch(kv: &KvIdentityGraph, ec_id: &str, in_flight: &mut Vec bool { - let Some(value) = response.get_header(header::CONTENT_LENGTH) else { +fn response_content_length_exceeds_limit(response: &PlatformResponse, source_domain: &str) -> bool { + let Some(value) = response.response.headers().get(header::CONTENT_LENGTH) else { return false; }; @@ -329,8 +359,8 @@ fn response_content_length_exceeds_limit(response: &fastly::Response, source_dom false } -fn extract_pull_uid(mut response: fastly::Response, source_domain: &str) -> Option { - let status = response.get_status(); +fn extract_pull_uid(response: PlatformResponse, source_domain: &str) -> Option { + let status = response.response.status(); if status == StatusCode::NOT_FOUND { log::debug!( @@ -353,7 +383,7 @@ fn extract_pull_uid(mut response: fastly::Response, source_domain: &str) -> Opti return None; } - let body = response.take_body_bytes(); + let body = response.response.into_body().into_bytes(); if body.len() > MAX_PULL_RESPONSE_BYTES { log::warn!( "Pull sync: partner '{}' returned oversized response ({} bytes), rejecting", @@ -402,8 +432,32 @@ mod tests { use super::*; use crate::consent::types::ConsentContext; use crate::ec::kv_types::KvEntry; + use crate::platform::PlatformResponse; use crate::redacted::Redacted; + fn make_response(status: u16, body: &[u8]) -> PlatformResponse { + PlatformResponse::new( + edgezero_core::http::response_builder() + .status(status) + .body(EdgeBody::from(body.to_vec())) + .expect("should build test response"), + ) + } + + fn make_response_with_content_length( + status: u16, + content_length: usize, + body: &[u8], + ) -> PlatformResponse { + PlatformResponse::new( + edgezero_core::http::response_builder() + .status(status) + .header(header::CONTENT_LENGTH, content_length.to_string()) + .body(EdgeBody::from(body.to_vec())) + .expect("should build test response"), + ) + } + fn pull_partner(ttl_sec: u64) -> PartnerConfig { PartnerConfig { name: "SSP X".to_owned(), @@ -548,7 +602,7 @@ mod tests { #[test] fn extract_pull_uid_treats_404_as_noop() { - let response = fastly::Response::from_status(StatusCode::NOT_FOUND); + let response = make_response(404, b""); let uid = extract_pull_uid(response, "ssp.example.com"); assert!(uid.is_none(), "should treat 404 as no-op"); @@ -556,7 +610,7 @@ mod tests { #[test] fn extract_pull_uid_treats_uid_null_as_noop() { - let response = fastly::Response::from_status(StatusCode::OK).with_body("{\"uid\":null}"); + let response = make_response(200, b"{\"uid\":null}"); let uid = extract_pull_uid(response, "ssp.example.com"); assert!(uid.is_none(), "should treat uid=null as no-op"); @@ -566,7 +620,7 @@ mod tests { fn extract_pull_uid_rejects_oversized_uid() { let long_uid = "x".repeat(513); let body = format!("{{\"uid\":\"{long_uid}\"}}"); - let response = fastly::Response::from_status(StatusCode::OK).with_body(body); + let response = make_response(200, body.as_bytes()); let uid = extract_pull_uid(response, "ssp.example.com"); assert!(uid.is_none(), "should reject uid exceeding 512 bytes"); @@ -574,8 +628,7 @@ mod tests { #[test] fn extract_pull_uid_reads_uid_from_success_body() { - let response = - fastly::Response::from_status(StatusCode::OK).with_body("{\"uid\":\"abc123\"}"); + let response = make_response(200, b"{\"uid\":\"abc123\"}"); let uid = extract_pull_uid(response, "ssp.example.com"); assert_eq!( @@ -587,12 +640,11 @@ mod tests { #[test] fn extract_pull_uid_rejects_oversized_content_length_before_body_read() { - let response = fastly::Response::from_status(StatusCode::OK) - .with_header( - header::CONTENT_LENGTH, - (MAX_PULL_RESPONSE_BYTES + 1).to_string(), - ) - .with_body("{\"uid\":\"abc123\"}"); + let response = make_response_with_content_length( + 200, + MAX_PULL_RESPONSE_BYTES + 1, + b"{\"uid\":\"abc123\"}", + ); let uid = extract_pull_uid(response, "ssp.example.com"); assert!( @@ -603,8 +655,7 @@ mod tests { #[test] fn extract_pull_uid_accepts_small_body_without_content_length() { - let response = - fastly::Response::from_status(StatusCode::OK).with_body("{\"uid\":\"abc123\"}"); + let response = make_response(200, b"{\"uid\":\"abc123\"}"); let uid = extract_pull_uid(response, "ssp.example.com"); assert_eq!( @@ -617,7 +668,7 @@ mod tests { #[test] fn extract_pull_uid_rejects_body_larger_than_limit() { let body = format!("{{\"uid\":\"{}\"}}", "x".repeat(MAX_PULL_RESPONSE_BYTES)); - let response = fastly::Response::from_status(StatusCode::OK).with_body(body); + let response = make_response(200, body.as_bytes()); let uid = extract_pull_uid(response, "ssp.example.com"); assert!(uid.is_none(), "should reject body larger than limit"); diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index e7fd0c180..05eaf310f 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -1,7 +1,7 @@ -//! EC ID extraction from incoming HTTP requests. +//! Edge Cookie (EC) ID generation using HMAC. //! -//! Reads an existing EC ID from the `x-ts-ec` header or `ts-ec` cookie. -//! Generation is handled by [`crate::ec::generation`]. +//! This module provides functionality for generating privacy-preserving EC IDs +//! based on the client IP address and a secret key. use edgezero_core::body::Body as EdgeBody; use error_stack::Report; @@ -10,7 +10,38 @@ use http::Request; use crate::constants::{COOKIE_TS_EC, HEADER_X_TS_EC}; use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; +use crate::ec::generation::{generate_ec_id as generate_canonical_ec_id, normalize_ip}; use crate::error::TrustedServerError; +use crate::platform::RuntimeServices; +use crate::settings::Settings; + +/// Generates a fresh EC ID based on client IP address. +/// +/// Delegates to the canonical generator in [`crate::ec::generation`] so a +/// single normalization + HMAC path produces EC IDs. The canonical +/// `normalize_ip` format is a stable contract — EC hashes stored in KV +/// depend on it, and a divergent normalization would mint non-correlating +/// identities for the same client. +/// +/// # Errors +/// +/// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails +pub fn generate_ec_id( + settings: &Settings, + services: &RuntimeServices, +) -> Result> { + // Fallback to "unknown" when client IP is unavailable (e.g., local testing). + // All such requests share the same HMAC base; the random suffix provides uniqueness. + let client_ip = services + .client_info + .client_ip + .map(normalize_ip) + .unwrap_or_else(|| "unknown".to_string()); + + log::trace!("Generating fresh EC ID from normalized client context"); + + generate_canonical_ec_id(settings, &client_ip) +} /// Gets an existing EC ID from the request. /// @@ -30,7 +61,7 @@ pub fn get_ec_id(req: &Request) -> Result, Report) -> Result, Report) -> Result, Report, +) -> Result> { + if let Some(id) = get_ec_id(req)? { + return Ok(id); + } + + // If no existing EC ID found, generate a fresh one + let ec_id = generate_ec_id(settings, services)?; + log::trace!("No existing EC ID found; generated a fresh EC ID"); + Ok(ec_id) +} + +/// Gets or creates an EC ID from the request. +/// +/// # Errors +/// +/// Returns an error if ID generation fails. +#[cfg(test)] +pub fn get_or_generate_ec_id( + settings: &Settings, + services: &RuntimeServices, + req: &Request, +) -> Result> { + get_or_generate_ec_id_from_http_request(settings, services, req) +} + #[cfg(test)] mod tests { use super::*; + use edgezero_core::body::Body as EdgeBody; use http::{header, HeaderName}; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + use crate::platform::test_support::{noop_services, noop_services_with_client_ip}; + use crate::test_support::tests::create_test_settings; + + #[test] + fn test_generate_ec_id_matches_canonical_generator_for_ipv6() { + // Regression guard: this module must hash the same normalized IP as + // the canonical generator in ec::generation. A divergent IPv6 /64 + // normalization would mint non-correlating identity prefixes for the + // same client depending on which path generated the ID. + let settings = create_test_settings(); + let ip = IpAddr::V6(Ipv6Addr::new( + 0x2001, 0x0db8, 0x85a3, 0x0000, 0x8a2e, 0x0370, 0x7334, 0x1234, + )); + + let id_here = generate_ec_id(&settings, &noop_services_with_client_ip(ip)) + .expect("should generate EC ID via edge_cookie"); + let id_canonical = generate_canonical_ec_id(&settings, &normalize_ip(ip)) + .expect("should generate EC ID via canonical generator"); + + assert_eq!( + crate::ec::ec_hash(&id_here), + crate::ec::ec_hash(&id_canonical), + "should produce the same identity hash prefix as the canonical generator" + ); + } fn create_test_request(headers: &[(HeaderName, &str)]) -> Request { let mut builder = Request::builder().method("GET").uri("http://example.com"); @@ -70,36 +169,184 @@ mod tests { .expect("should build test request") } + fn is_ec_id_format(value: &str) -> bool { + let mut parts = value.split('.'); + let hmac_part = match parts.next() { + Some(part) => part, + None => return false, + }; + let suffix_part = match parts.next() { + Some(part) => part, + None => return false, + }; + if parts.next().is_some() { + return false; + } + if hmac_part.len() != 64 || suffix_part.len() != 6 { + return false; + } + if !hmac_part.chars().all(|c| c.is_ascii_hexdigit()) { + return false; + } + if !suffix_part.chars().all(|c| c.is_ascii_alphanumeric()) { + return false; + } + true + } + + #[test] + fn test_generate_ec_id() { + let settings: Settings = create_test_settings(); + + let ec_id = generate_ec_id(&settings, &noop_services()).expect("should generate EC ID"); + log::debug!("Generated EC ID: {}", ec_id); + assert!( + is_ec_id_format(&ec_id), + "should match EC ID format: {{64hex}}.{{6alnum}}" + ); + } + + #[test] + fn test_generate_ec_id_uses_client_ip() { + let settings = create_test_settings(); + let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)); + + let id_with_ip = generate_ec_id(&settings, &noop_services_with_client_ip(ip)) + .expect("should generate EC ID with client IP"); + let id_without_ip = generate_ec_id(&settings, &noop_services()) + .expect("should generate EC ID without client IP"); + + let hmac_with_ip = id_with_ip.split_once('.').expect("should contain dot").0; + let hmac_without_ip = id_without_ip.split_once('.').expect("should contain dot").0; + + assert_ne!( + hmac_with_ip, hmac_without_ip, + "should produce different HMAC when client IP differs" + ); + } + #[test] - fn get_ec_id_returns_header_value_when_present() { + fn test_is_ec_id_format_accepts_valid_value() { + let value = format!("{}.{}", "a".repeat(64), "Ab12z9"); + assert!( + is_ec_id_format(&value), + "should accept a valid EC ID format" + ); + } + + #[test] + fn test_is_ec_id_format_rejects_invalid_values() { + let missing_suffix = "a".repeat(64); + assert!( + !is_ec_id_format(&missing_suffix), + "should reject missing suffix" + ); + + let invalid_hex = format!("{}.{}", "a".repeat(63) + "g", "Ab12z9"); + assert!( + !is_ec_id_format(&invalid_hex), + "should reject non-hex HMAC content" + ); + + let invalid_suffix = format!("{}.{}", "a".repeat(64), "ab-129"); + assert!( + !is_ec_id_format(&invalid_suffix), + "should reject non-alphanumeric suffix" + ); + + let extra_segment = format!("{}.{}.{}", "a".repeat(64), "Ab12z9", "zz"); + assert!( + !is_ec_id_format(&extra_segment), + "should reject extra segments" + ); + } + + #[test] + fn test_get_ec_id_with_header() { + let settings = create_test_settings(); let req = create_test_request(&[(HEADER_X_TS_EC, "existing_ec_id")]); + let ec_id = get_ec_id(&req).expect("should get EC ID"); assert_eq!(ec_id, Some("existing_ec_id".to_string())); + + let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) + .expect("should reuse header EC ID"); + assert_eq!(ec_id, "existing_ec_id"); } #[test] - fn get_ec_id_returns_cookie_value_when_present() { + fn test_get_ec_id_with_cookie() { + let settings = create_test_settings(); let req = create_test_request(&[( header::COOKIE, &format!("{}=existing_cookie_id", COOKIE_TS_EC), )]); + let ec_id = get_ec_id(&req).expect("should get EC ID"); assert_eq!(ec_id, Some("existing_cookie_id".to_string())); + + let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) + .expect("should reuse cookie EC ID"); + assert_eq!(ec_id, "existing_cookie_id"); } #[test] - fn get_ec_id_returns_none_when_absent() { + fn test_get_ec_id_from_http_request_with_header() { + let req = http::Request::builder() + .method("GET") + .uri("http://example.com") + .header(HEADER_X_TS_EC, "existing_http_ec_id") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + + let ec_id = get_ec_id(&req).expect("should get EC ID from http request"); + + assert_eq!(ec_id, Some("existing_http_ec_id".to_string())); + } + + #[test] + fn test_get_or_generate_ec_id_from_http_request_reuses_cookie() { + let settings = create_test_settings(); + let req = http::Request::builder() + .method("GET") + .uri("http://example.com") + .header( + header::COOKIE, + format!("{}=existing_http_cookie_id", COOKIE_TS_EC), + ) + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + + let ec_id = get_or_generate_ec_id_from_http_request(&settings, &noop_services(), &req) + .expect("should reuse cookie EC ID from http request"); + + assert_eq!(ec_id, "existing_http_cookie_id"); + } + + #[test] + fn test_get_ec_id_none() { let req = create_test_request(&[]); let ec_id = get_ec_id(&req).expect("should handle missing ID"); assert!(ec_id.is_none()); } #[test] - fn get_ec_id_rejects_header_with_disallowed_chars_falls_back_to_cookie() { + fn test_get_or_generate_ec_id_generate_new() { + let settings = create_test_settings(); + let req = create_test_request(&[]); + + let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) + .expect("should get or generate EC ID"); + assert!(!ec_id.is_empty()); + } + + #[test] + fn test_get_ec_id_rejects_invalid_header_and_falls_back_to_cookie() { let req = create_test_request(&[ (HEADER_X_TS_EC, "evil;injected"), (header::COOKIE, &format!("{}=valid_cookie_id", COOKIE_TS_EC)), ]); + let ec_id = get_ec_id(&req).expect("should handle invalid header gracefully"); assert_eq!( ec_id, @@ -109,11 +356,29 @@ mod tests { } #[test] - fn get_ec_id_rejects_cookie_with_disallowed_chars() { + fn test_get_or_generate_ec_id_replaces_invalid_header() { + let settings = create_test_settings(); + let req = create_test_request(&[(HEADER_X_TS_EC, "evil;injected")]); + + let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) + .expect("should generate fresh ID on invalid header"); + assert_ne!( + ec_id, "evil;injected", + "should not use tampered header value" + ); + assert!( + is_ec_id_format(&ec_id), + "should generate a valid EC ID format when header is rejected" + ); + } + + #[test] + fn test_get_ec_id_rejects_invalid_cookie() { let req = create_test_request(&[( header::COOKIE, &format!("{}=bad